diff --git a/crypto/keypair.test.ts b/crypto/keypair.test.ts new file mode 100644 index 0000000..a869a7e --- /dev/null +++ b/crypto/keypair.test.ts @@ -0,0 +1,23 @@ +import { expect } from "@std/expect"; +import { BlahKeyPair } from "./mod.ts"; + +let keypair: BlahKeyPair; + +Deno.test("generate keypair", async () => { + keypair = await BlahKeyPair.generate(); +}); + +Deno.test("encode & decode keypair", async () => { + const encoded = await keypair.encode(); + const decoded = await BlahKeyPair.fromEncoded(encoded); + + expect(decoded.id).toBe(keypair.id); +}); + +Deno.test("encode & decode keypair w/ password", async () => { + const password = "password"; + const encoded = await keypair.encode(password); + const decoded = await BlahKeyPair.fromEncoded(encoded, password); + + expect(decoded.id).toBe(keypair.id); +}); diff --git a/crypto/keypair.ts b/crypto/keypair.ts index 0baf6c3..12f6793 100644 --- a/crypto/keypair.ts +++ b/crypto/keypair.ts @@ -1,8 +1,8 @@ -import canonicalize from "./canonicalize.ts"; import { hexToBuf } from "./mod.ts"; import { pbkdf2Key } from "./pbkdf2.ts"; import { BlahPublicKey } from "./publicKey.ts"; -import type { BlahPayloadSignee, BlahSignedPayload } from "./signedPayload.ts"; +import { type SignOptions, signPayload } from "./signAndVerify.ts"; +import type { BlahSignedPayload } from "./signedPayload.ts"; import { bufToHex, ed25519PKCS8ToRawPrivateKey, @@ -163,34 +163,15 @@ export class BlahKeyPair { } } - async signPayload

( + /** + * Sign a payload with the private key. + * + * This is a convenience method of {@link signPayload}. + */ + signPayload

( payload: P, - date: Date = new Date(), - identityKeyId?: string, + options: SignOptions = {}, ): Promise> { - const nonceBuf = new Uint32Array(1); - crypto.getRandomValues(nonceBuf); - - const timestamp = Math.floor(date.getTime() / 1000); - - const signee: BlahPayloadSignee

= { - nonce: nonceBuf[0], - payload, - timestamp, - id_key: identityKeyId ?? this.id, - act_key: this.id, - }; - - const signeeBytes = new TextEncoder().encode(canonicalize(signee)); - - const rawSig = await crypto.subtle.sign( - "Ed25519", - this.internalPrivateKey, - signeeBytes, - ); - return { - sig: bufToHex(rawSig), - signee, - }; + return signPayload(this, payload, options); } } diff --git a/crypto/publicKey.ts b/crypto/publicKey.ts index 0328855..1d9fa78 100644 --- a/crypto/publicKey.ts +++ b/crypto/publicKey.ts @@ -1,18 +1,22 @@ import type z from "zod"; -import canonicalize from "./canonicalize.ts"; import { type BlahSignedPayload, blahSignedPayloadSchemaOf, } from "./signedPayload.ts"; import { bufToHex, hexToBuf } from "./utils.ts"; +import { type SignOrVerifyOptions, verifyPayload } from "./signAndVerify.ts"; export class BlahPublicKey { - private publicKey: CryptoKey; + private internalPublicKey: CryptoKey; id: string; name: string; + get publicKey(): CryptoKey { + return this.internalPublicKey; + } + private constructor(publicKey: CryptoKey, id: string) { - this.publicKey = publicKey; + this.internalPublicKey = publicKey; this.id = id; // First 4 and last 4 characters of the id this.name = id.slice(0, 4) + "..." + id.slice(-4); @@ -38,41 +42,44 @@ export class BlahPublicKey { return new BlahPublicKey(publicKey, id); } + /** + * Verify a signed payload with the act key in the signed payload. + * + * This method is a convenience method that calls {@link verifyPayload} with the act key in the signed payload. + */ static async verifyPayload

( signedPayload: BlahSignedPayload

, + options: SignOrVerifyOptions = {}, ): Promise<{ payload: P; key: BlahPublicKey }> { const { signee } = signedPayload; - const key = await BlahPublicKey.fromID(signee.act_key ?? signee.id_key); - return { payload: await key.verifyPayload(signedPayload), key }; + const key = await BlahPublicKey.fromID(signee.act_key); + return { payload: await key.verifyPayload(signedPayload, options), key }; } + /** + * Parse and verify a signed payload with the given schema, against the act key in the signed payload. + * + * This method is a convenience method that calls {@link verifyPayload} with the act key in the signed payload. + */ static async parseAndVerifyPayload

( schema: P, signedPayload: unknown, + options: SignOrVerifyOptions = {}, ): Promise<{ payload: z.infer

; key: BlahPublicKey }> { const signedPayloadSchema = blahSignedPayloadSchemaOf(schema); const parsed = signedPayloadSchema.parse(signedPayload) as z.infer

; - return await BlahPublicKey.verifyPayload(parsed); + return await BlahPublicKey.verifyPayload(parsed, options); } - async verifyPayload

(signedPayload: BlahSignedPayload

): Promise

{ - const { sig, signee } = signedPayload; - const signingKey = signee.act_key; - if (signingKey !== this.id) { - throw new Error( - `Payload is not signed by this public key. Was signed by ${signingKey}.`, - ); - } - const signeeBytes = new TextEncoder().encode(canonicalize(signee)); - const result = await crypto.subtle.verify( - "Ed25519", - this.publicKey, - hexToBuf(sig), - signeeBytes, - ); - if (!result) { - throw new Error("Invalid signature"); - } - return signee.payload; + /** + * Verify a signed payload with this public key. + * + * This method is a convenience method that calls {@link verifyPayload} with this public key. + */ + verifyPayload

( + signedPayload: BlahSignedPayload

, + options: SignOrVerifyOptions = {}, + ): Promise

{ + return verifyPayload(this, signedPayload, options); } } diff --git a/crypto/crypto.test.ts b/crypto/signAndVerify.test.ts similarity index 69% rename from crypto/crypto.test.ts rename to crypto/signAndVerify.test.ts index f6ef6c3..25a99a4 100644 --- a/crypto/crypto.test.ts +++ b/crypto/signAndVerify.test.ts @@ -1,27 +1,10 @@ import { expect } from "@std/expect"; -import { BlahKeyPair, BlahPublicKey } from "./mod.ts"; -import z from "zod"; +import { BlahKeyPair } from "./keypair.ts"; +import { z } from "zod"; +import { BlahPublicKey } from "./publicKey.ts"; +import type { SignOrVerifyOptions } from "./signAndVerify.ts"; -let keypair: BlahKeyPair; - -Deno.test("generate keypair", async () => { - keypair = await BlahKeyPair.generate(); -}); - -Deno.test("encode & decode keypair", async () => { - const encoded = await keypair.encode(); - const decoded = await BlahKeyPair.fromEncoded(encoded); - - expect(decoded.id).toBe(keypair.id); -}); - -Deno.test("encode & decode keypair w/ password", async () => { - const password = "password"; - const encoded = await keypair.encode(password); - const decoded = await BlahKeyPair.fromEncoded(encoded, password); - - expect(decoded.id).toBe(keypair.id); -}); +const keypair = await BlahKeyPair.generate(); Deno.test("sign & verify payload", async () => { const payload = { foo: "bar", baz: 123 }; @@ -33,6 +16,30 @@ Deno.test("sign & verify payload", async () => { expect(verifiedPayload).toEqual(payload); }); +Deno.test("sign and verify with POW", async () => { + const payload = { foo: "bar-pow", baz: 123 }; + const options: SignOrVerifyOptions = { powDifficulty: 1 }; + const signedPayload = await keypair.signPayload(payload, options); + const verifiedPayload = await keypair.publicKey.verifyPayload( + signedPayload, + options, + ); + + expect(verifiedPayload).toEqual(payload); +}); + +Deno.test("sign and verify with unmet POW", async () => { + const payload = { foo: "bar", baz: 123 }; + const signedPayload = await keypair.signPayload(payload, { + powDifficulty: 1, + }); + + await expect(keypair.publicKey.verifyPayload( + signedPayload, + { powDifficulty: 6 }, + )).rejects.toMatch(/proof-of-work/); +}); + Deno.test("parse and verify payload", async () => { const payloadSchema = z.object({ foo: z.string(), @@ -58,7 +65,7 @@ Deno.test("parse and verify corrupted payload", async () => { const payload = { foo: "bar", baz: 123, qux: "quux" }; const signedPayload = await keypair.signPayload(payload); - expect(BlahPublicKey + await expect(BlahPublicKey .parseAndVerifyPayload( payloadSchema, signedPayload, diff --git a/crypto/signAndVerify.ts b/crypto/signAndVerify.ts new file mode 100644 index 0000000..ce8f629 --- /dev/null +++ b/crypto/signAndVerify.ts @@ -0,0 +1,178 @@ +import canonicalize from "./canonicalize.ts"; +import type { BlahKeyPair } from "./keypair.ts"; +import type { BlahPublicKey } from "./publicKey.ts"; +import type { BlahPayloadSignee, BlahSignedPayload } from "./signedPayload.ts"; +import { bufToHex, hexToBuf } from "./utils.ts"; + +/** + * Options for signing or verifying a payload. + */ +export interface SignOrVerifyOptions { + /** + * The identity key ID to include in the signed payload, or in case of verification, + * the expected identity key ID. + * + * If not provided during signing, the key ID of the key pair will be used. + * + * If not provided during verification, the identity key ID in the payload must be the same as + * the act key ID. + */ + identityKeyId?: string; + /** + * The proof-of-work difficulty for signing or verifying the payload. + * + * If not provided, no proof-of-work or verification of it will be performed. + * + * If provided during signing, proof-of-work will be performed to meet the specified difficulty. + * + * If provided during verification, the payload will be verified to see if the proof-of-work + * difficulty is met. If not, the verification will fail. + */ + powDifficulty?: number; +} + +/** + * Options for signing a payload with a date. + * + * This is a superset of the options for signing or verifying a payload. + * + * @see {@link SignOrVerifyOptions} + */ +export interface SignOptions extends SignOrVerifyOptions { + /** + * The date to use for signing the payload. + * + * When is signing a payload, this date is used to set the timestamp. If not provided, + * the current date and time will be used. + */ + date?: Date; +} + +async function verifyPoWIsMet(signeeBytes: Uint8Array, difficulty: number) { + const zeroBytes = difficulty >> 3; + const nonzeroByteMax = 1 << (8 - (difficulty & 7)); + + const h = new Uint8Array(await crypto.subtle.digest("SHA-256", signeeBytes)); + let passed = h[zeroBytes] < nonzeroByteMax; + for (let j = 0; j < zeroBytes; j++) passed &&= h[j] === 0; + console.log( + `POW: ${ + Array.from(h) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + }`, + `(${difficulty}): ${passed}`, + ); + return passed; +} + +/** + * Sign a payload with the given key pair as act key. + * + * @param keyPair The key pair to sign the payload with. + * @param payload The payload to sign. + * @param options Options for signing. + * @returns The signed payload. + */ +export async function signPayload

( + keyPair: BlahKeyPair, + payload: P, + options: SignOptions = {}, +): Promise> { + const { date = new Date(), identityKeyId, powDifficulty } = options; + + const nonceBuf = new Uint32Array(1); + crypto.getRandomValues(nonceBuf); + + const timestamp = Math.floor(date.getTime() / 1000); + + const signee: BlahPayloadSignee

= { + nonce: nonceBuf[0], + payload, + timestamp, + id_key: identityKeyId ?? keyPair.id, + act_key: keyPair.id, + }; + + const textEncoder = new TextEncoder(); + let signeeBytes = textEncoder.encode(canonicalize(signee)); + + if (powDifficulty && powDifficulty !== 0) { + while (!await verifyPoWIsMet(signeeBytes, powDifficulty)) { + signee.nonce = (signee.nonce + 1) & 0x7FFFFFFF; + signeeBytes = textEncoder.encode(canonicalize(signee)); + } + } + + const rawSig = await crypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + signeeBytes, + ); + return { + sig: bufToHex(rawSig), + signee, + }; +} + +/** + * Verify a signed payload with the given public key. + * + * @param publicKey The public key to verify the payload with. + * @param signedPayload The signed payload to verify. + * @param options Options for verification. + * @returns The payload if the signature is valid. + * @throws If the signature is invalid or the payload is not signed by the expected key. + */ +export async function verifyPayload

( + publicKey: BlahPublicKey, + signedPayload: BlahSignedPayload

, + options: SignOrVerifyOptions = {}, +): Promise

{ + const { identityKeyId, powDifficulty } = options; + + const { sig, signee } = signedPayload; + + if (identityKeyId) { + if (identityKeyId !== signee.id_key) { + throw new Error( + `Payload is not signed by the expected identity key. Expected ${identityKeyId}, but was ${signee.id_key}.`, + ); + } + } else { + if (signee.id_key !== signee.act_key) { + throw new Error( + `Payload's identity key (${signee.id_key}) is not the same as the act key (${signee.act_key}).`, + ); + } + } + + const signingKey = signee.act_key; + if (signingKey !== publicKey.id) { + throw new Error( + `Payload is not signed by this public key. Was signed by ${signingKey}.`, + ); + } + + const signeeBytes = new TextEncoder().encode(canonicalize(signee)); + + if ( + powDifficulty && powDifficulty !== 0 && + !await verifyPoWIsMet(signeeBytes, powDifficulty) + ) { + throw new Error( + `Payload's proof-of-work does not meet the required difficulty of ${powDifficulty}.`, + ); + } + + const result = await crypto.subtle.verify( + "Ed25519", + publicKey.publicKey, + hexToBuf(sig), + signeeBytes, + ); + if (!result) { + throw new Error("Invalid signature"); + } + return signee.payload; +} diff --git a/identity/actKey.ts b/identity/actKey.ts index 082d3fd..de02190 100644 --- a/identity/actKey.ts +++ b/identity/actKey.ts @@ -2,6 +2,7 @@ import z from "zod"; import { BlahPublicKey } from "../crypto/publicKey.ts"; import { BlahKeyPair } from "../crypto/keypair.ts"; import type { BlahSignedPayload } from "../crypto/signedPayload.ts"; +import type { SignOrVerifyOptions } from "../crypto/signAndVerify.ts"; export const blahActKeyRecordSchema = z.object({ typ: z.literal("user_act_key"), @@ -126,29 +127,44 @@ export class BlahActKey { } } + /** + * Sign a payload with this act key. + * + * This method is a convenience method that calls {@link signPayload} with this act key. + * Correct identity key ID is automatically set. + * + * @param payload The payload to sign. + * @param options Options for signing. + */ async signPayload

( payload: P, - date: Date = new Date(), + options: Omit = {}, ): Promise> { if (!this.canSign) throw new Error("Cannot sign without a private key"); return await (this.internalKey as BlahKeyPair).signPayload( payload, - date, - this.internalIdKeyPublic.id, + { ...options, identityKeyId: this.internalIdKeyPublic.id }, ); } + /** + * Verify a signed payload with this act key. + * + * This method is a convenience method that calls {@link verifyPayload} with this act key. + * But this method also checks if the key was expired at the time of signing. + * + * @param payload The signed payload to verify. + */ async verifyPayload

( payload: BlahSignedPayload

, ): Promise

{ - if (payload.signee.id_key !== this.internalIdKeyPublic.id) { - throw new Error("Payload signed with a different ID key"); - } if (new Date(payload.signee.timestamp * 1000) > this.internalExpiresAt) { throw new Error("Key was expired at the time of signing"); } - return await this.internalKey.verifyPayload(payload); + return await this.internalKey.verifyPayload(payload, { + identityKeyId: this.internalIdKeyPublic.id, + }); } async update(update: ActKeyUpdate, idKeyPair: BlahKeyPair): Promise { diff --git a/identity/identity.test.ts b/identity/identity.test.ts index 8d2acc5..4e5d85c 100644 --- a/identity/identity.test.ts +++ b/identity/identity.test.ts @@ -40,6 +40,7 @@ Deno.test("created identity act key signed correctly", async () => { Deno.test("created identity profile signed correctly", async () => { const record = await actKeyPair.publicKey.verifyPayload( identityDesc.profile, + { identityKeyId: identityDesc.id_key }, ); expect(record.typ).toBe("profile"); expect(record.name).toBe("Shibo Lyu");