feat: Add sign/verify functionality with proof-of-work support

This commit is contained in:
Shibo Lyu 2025-05-11 02:05:32 +08:00
parent 4674565d64
commit 0b178c3df0
7 changed files with 297 additions and 84 deletions

23
crypto/keypair.test.ts Normal file
View file

@ -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);
});

View file

@ -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<P>(
/**
* Sign a payload with the private key.
*
* This is a convenience method of {@link signPayload}.
*/
signPayload<P>(
payload: P,
date: Date = new Date(),
identityKeyId?: string,
options: SignOptions = {},
): Promise<BlahSignedPayload<P>> {
const nonceBuf = new Uint32Array(1);
crypto.getRandomValues(nonceBuf);
const timestamp = Math.floor(date.getTime() / 1000);
const signee: BlahPayloadSignee<P> = {
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);
}
}

View file

@ -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<P>(
signedPayload: BlahSignedPayload<P>,
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<P extends z.ZodTypeAny>(
schema: P,
signedPayload: unknown,
options: SignOrVerifyOptions = {},
): Promise<{ payload: z.infer<P>; key: BlahPublicKey }> {
const signedPayloadSchema = blahSignedPayloadSchemaOf(schema);
const parsed = signedPayloadSchema.parse(signedPayload) as z.infer<P>;
return await BlahPublicKey.verifyPayload(parsed);
return await BlahPublicKey.verifyPayload(parsed, options);
}
async verifyPayload<P>(signedPayload: BlahSignedPayload<P>): Promise<P> {
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<P>(
signedPayload: BlahSignedPayload<P>,
options: SignOrVerifyOptions = {},
): Promise<P> {
return verifyPayload(this, signedPayload, options);
}
}

View file

@ -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,

178
crypto/signAndVerify.ts Normal file
View file

@ -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<P>(
keyPair: BlahKeyPair,
payload: P,
options: SignOptions = {},
): Promise<BlahSignedPayload<P>> {
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<P> = {
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<P>(
publicKey: BlahPublicKey,
signedPayload: BlahSignedPayload<P>,
options: SignOrVerifyOptions = {},
): Promise<P> {
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;
}

View file

@ -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<P>(
payload: P,
date: Date = new Date(),
options: Omit<SignOrVerifyOptions, "identityKeyId"> = {},
): Promise<BlahSignedPayload<P>> {
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<P>(
payload: BlahSignedPayload<P>,
): Promise<P> {
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<void> {

View file

@ -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");