mirror of
https://github.com/Blah-IM/typescript-core.git
synced 2025-06-23 16:31:08 +00:00
feat: Add sign/verify functionality with proof-of-work support
This commit is contained in:
parent
4674565d64
commit
0b178c3df0
7 changed files with 297 additions and 84 deletions
23
crypto/keypair.test.ts
Normal file
23
crypto/keypair.test.ts
Normal 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);
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
178
crypto/signAndVerify.ts
Normal 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;
|
||||
}
|
|
@ -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> {
|
||||
|
|
|
@ -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");
|
||||
|
|
Loading…
Add table
Reference in a new issue