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 = {
- 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 = {
+ 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 (
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