diff --git a/infrastructure/w3id/package.json b/infrastructure/w3id/package.json index 8c415e51..a278bb85 100644 --- a/infrastructure/w3id/package.json +++ b/infrastructure/w3id/package.json @@ -19,6 +19,9 @@ "author": "", "license": "ISC", "dependencies": { + "canonicalize": "^2.1.0", + "multiformats": "^13.3.2", + "tweetnacl": "^1.0.3", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/infrastructure/w3id/src/errors/errors.ts b/infrastructure/w3id/src/errors/errors.ts new file mode 100644 index 00000000..4a314b08 --- /dev/null +++ b/infrastructure/w3id/src/errors/errors.ts @@ -0,0 +1,34 @@ +export class MalformedIndexChainError extends Error { + constructor(message: string = "Malformed index chain detected") { + super(message); + this.name = "MalformedIndexChainError"; + } +} + +export class MalformedHashChainError extends Error { + constructor(message: string = "Malformed hash chain detected") { + super(message); + this.name = "MalformedHashChainError"; + } +} + +export class BadSignatureError extends Error { + constructor(message: string = "Bad signature detected") { + super(message); + this.name = "BadSignatureError"; + } +} + +export class BadNextKeySpecifiedError extends Error { + constructor(message: string = "Bad next key specified") { + super(message); + this.name = "BadNextKeySpecifiedError"; + } +} + +export class BadOptionsSpecifiedError extends Error { + constructor(message: string = "Bad options specified") { + super(message); + this.name = "BadOptionsSpecifiedError"; + } +} diff --git a/infrastructure/w3id/src/logs/log-manager.ts b/infrastructure/w3id/src/logs/log-manager.ts new file mode 100644 index 00000000..b69e3dc2 --- /dev/null +++ b/infrastructure/w3id/src/logs/log-manager.ts @@ -0,0 +1,148 @@ +import canonicalize from "canonicalize"; +import { + BadNextKeySpecifiedError, + BadOptionsSpecifiedError, + BadSignatureError, + MalformedHashChainError, + MalformedIndexChainError, +} from "../errors/errors"; +import { isSubsetOf } from "../utils/array"; +import { hash } from "../utils/hash"; +import { + isGenesisOptions, + isRotationOptions, + type CreateLogEventOptions, + type GenesisLogOptions, + type LogEvent, + type RotationLogOptions, + type VerifierCallback, +} from "./log.types"; +import type { StorageSpec } from "./storage/storage-spec"; + +/** + * Class to generate historic event logs for all historic events for an Identifier + * starting with generating it's first log entry + */ + +// TODO: Create a specification link inside our docs for how generation of identifier works + +export class IDLogManager { + repository: StorageSpec; + + constructor(repository: StorageSpec) { + this.repository = repository; + } + + static async validateLogChain( + log: LogEvent[], + verifyCallback: VerifierCallback, + ) { + let currIndex = 0; + let currentNextKeyHashesSeen: string[] = []; + let lastUpdateKeysSeen: string[] = []; + let lastHash: string | null = null; + + for (const e of log) { + const [_index, _hash] = e.versionId.split("-"); + const index = Number(_index); + if (currIndex !== index) throw new MalformedIndexChainError(); + const hashedUpdateKeys = await Promise.all( + e.updateKeys.map(async (k) => await hash(k)), + ); + if (index > 0) { + const updateKeysSeen = isSubsetOf( + hashedUpdateKeys, + currentNextKeyHashesSeen, + ); + if (!updateKeysSeen || lastHash !== _hash) + throw new MalformedHashChainError(); + } + + currentNextKeyHashesSeen = e.nextKeyHashes; + await IDLogManager.verifyLogEventProof( + e, + lastUpdateKeysSeen.length > 0 ? lastUpdateKeysSeen : e.updateKeys, + verifyCallback, + ); + lastUpdateKeysSeen = e.updateKeys; + currIndex++; + lastHash = await hash(canonicalize(e) as string); + } + return true; + } + + private static async verifyLogEventProof( + e: LogEvent, + currentUpdateKeys: string[], + verifyCallback: VerifierCallback, + ) { + const proof = e.proof; + const copy = JSON.parse(JSON.stringify(e)); + // biome-ignore lint/performance/noDelete: we need to delete proof completely + delete copy.proof; + const canonicalJson = canonicalize(copy); + let verified = false; + if (!proof) throw new BadSignatureError("No proof found in the log event."); + for (const key of currentUpdateKeys) { + const signValidates = await verifyCallback( + canonicalJson as string, + proof, + key, + ); + if (signValidates) verified = true; + } + if (!verified) throw new BadSignatureError(); + } + + private async appendEntry(entries: LogEvent[], options: RotationLogOptions) { + const { signer, nextKeyHashes, nextKeySigner } = options; + const latestEntry = entries[entries.length - 1]; + const logHash = await hash(latestEntry); + const index = Number(latestEntry.versionId.split("-")[0]) + 1; + + const currKeyHash = await hash(nextKeySigner.pubKey); + if (!latestEntry.nextKeyHashes.includes(currKeyHash)) + throw new BadNextKeySpecifiedError(); + + const logEvent: LogEvent = { + id: latestEntry.id, + versionTime: new Date(Date.now()), + versionId: `${index}-${logHash}`, + updateKeys: [nextKeySigner.pubKey], + nextKeyHashes: nextKeyHashes, + method: "w3id:v0.0.0", + }; + + const proof = await signer.sign(canonicalize(logEvent) as string); + logEvent.proof = proof; + + await this.repository.create(logEvent); + return logEvent; + } + + private async createGenesisEntry(options: GenesisLogOptions) { + const { id, nextKeyHashes, signer } = options; + const logEvent: LogEvent = { + id, + versionId: `0-${id.split("@")[1]}`, + versionTime: new Date(Date.now()), + updateKeys: [signer.pubKey], + nextKeyHashes: nextKeyHashes, + method: "w3id:v0.0.0", + }; + const proof = await signer.sign(canonicalize(logEvent) as string); + logEvent.proof = proof; + await this.repository.create(logEvent); + return logEvent; + } + + async createLogEvent(options: CreateLogEventOptions) { + const entries = await this.repository.findMany({}); + if (entries.length > 0) { + if (!isRotationOptions(options)) throw new BadOptionsSpecifiedError(); + return this.appendEntry(entries, options); + } + if (!isGenesisOptions(options)) throw new BadOptionsSpecifiedError(); + return this.createGenesisEntry(options); + } +} diff --git a/infrastructure/w3id/src/logs/log.types.ts b/infrastructure/w3id/src/logs/log.types.ts new file mode 100644 index 00000000..2f03b311 --- /dev/null +++ b/infrastructure/w3id/src/logs/log.types.ts @@ -0,0 +1,45 @@ +export type LogEvent = { + id: string; + versionId: string; + versionTime: Date; + updateKeys: string[]; + nextKeyHashes: string[]; + method: `w3id:v${string}`; + proof?: string; +}; + +export type VerifierCallback = ( + message: string, + signature: string, + pubKey: string, +) => Promise; + +export type Signer = { + sign: (message: string) => Promise | string; + pubKey: string; +}; + +export type RotationLogOptions = { + nextKeyHashes: string[]; + signer: Signer; + nextKeySigner: Signer; +}; + +export type GenesisLogOptions = { + nextKeyHashes: string[]; + id: string; + signer: Signer; +}; + +export function isGenesisOptions( + options: CreateLogEventOptions, +): options is GenesisLogOptions { + return "id" in options; +} +export function isRotationOptions( + options: CreateLogEventOptions, +): options is RotationLogOptions { + return "nextKeySigner" in options; +} + +export type CreateLogEventOptions = GenesisLogOptions | RotationLogOptions; diff --git a/infrastructure/w3id/src/logs/store.ts b/infrastructure/w3id/src/logs/store.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/infrastructure/w3id/src/utils/array.ts b/infrastructure/w3id/src/utils/array.ts new file mode 100644 index 00000000..19224fec --- /dev/null +++ b/infrastructure/w3id/src/utils/array.ts @@ -0,0 +1,28 @@ +/** + * Utility function to check if A is subset of B + * + * @param a Array to check if it's a subset + * @param b Array to check against + * @returns true if every element in 'a' is present in 'b' with at least the same frequency + * @example + * isSubsetOf([1, 2], [1, 2, 3]) // returns true + * isSubsetOf([1, 1, 2], [1, 2, 3]) // returns false (not enough 1's in b) + * isSubsetOf([], [1, 2]) // returns true (empty set is a subset of any set) + */ + +export function isSubsetOf(a: unknown[], b: unknown[]) { + const map = new Map(); + + for (const el of b) { + map.set(el, (map.get(el) || 0) + 1); + } + + for (const el of a) { + if (!map.has(el) || map.get(el) === 0) { + return false; + } + map.set(el, map.get(el) - 1); + } + + return true; +} diff --git a/infrastructure/w3id/src/utils/codec.ts b/infrastructure/w3id/src/utils/codec.ts new file mode 100644 index 00000000..f05ce212 --- /dev/null +++ b/infrastructure/w3id/src/utils/codec.ts @@ -0,0 +1,20 @@ +export function uint8ArrayToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function hexToUint8Array(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error("Hex string must have an even length"); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} + +export function stringToUint8Array(str: string): Uint8Array { + return new TextEncoder().encode(str); +} diff --git a/infrastructure/w3id/src/utils/hash.ts b/infrastructure/w3id/src/utils/hash.ts new file mode 100644 index 00000000..d7e8c852 --- /dev/null +++ b/infrastructure/w3id/src/utils/hash.ts @@ -0,0 +1,26 @@ +import canonicalize from "canonicalize"; +import { uint8ArrayToHex } from "./codec"; + +export async function hash( + input: string | Record, +): Promise { + let dataToHash: string; + + if (typeof input === "string") { + dataToHash = input; + } else { + const canonical = canonicalize(input); + if (!canonical) { + throw new Error( + `Failed to canonicalize object: ${JSON.stringify(input).substring(0, 100)}...`, + ); + } + dataToHash = canonical; + } + + const buffer = new TextEncoder().encode(dataToHash); + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); + const hashHex = uint8ArrayToHex(new Uint8Array(hashBuffer)); + + return hashHex; +} diff --git a/infrastructure/w3id/tests/logs/log.test.ts b/infrastructure/w3id/tests/logs/log.test.ts new file mode 100644 index 00000000..90db3bbc --- /dev/null +++ b/infrastructure/w3id/tests/logs/log.test.ts @@ -0,0 +1,206 @@ +import { StorageSpec } from "../../src/logs/storage/storage-spec.ts"; +import { + LogEvent, + Signer, + VerifierCallback, +} from "../../src/logs/log.types.ts"; +import { IDLogManager } from "../../src/logs/log-manager"; +import { generateUuid } from "../../src/utils/uuid"; +import { describe, expect, test, expectTypeOf } from "vitest"; +import { hash } from "../../src/utils/hash"; +import nacl from "tweetnacl"; +import { + uint8ArrayToHex, + stringToUint8Array, + hexToUint8Array, +} from "../../src/utils/codec"; +import { base58btc } from "multiformats/bases/base58"; +import falso from "@ngneat/falso"; +import { + BadNextKeySpecifiedError, + BadOptionsSpecifiedError, + BadSignatureError, + MalformedHashChainError, + MalformedIndexChainError, +} from "../../src/errors/errors.ts"; + +class InMemoryStorage + implements StorageSpec +{ + private data: K[] = []; + + public static build(): StorageSpec< + T, + K + > { + return new InMemoryStorage(); + } + + public async create(body: T): Promise { + const entry = body as unknown as K; + this.data.push(entry); + return entry; + } + + public async findOne(options: Partial): Promise { + const result = this.data.find((item) => + Object.entries(options).every( + ([key, value]) => item[key as keyof K] === value, + ), + ); + + if (!result) throw new Error("Not found"); + return result; + } + + public async findMany(options: Partial): Promise { + return this.data.filter((item) => + Object.entries(options).every( + ([key, value]) => item[key as keyof K] === value, + ), + ); + } +} +const logManager = new IDLogManager(InMemoryStorage.build()); +const w3id = `@${generateUuid("asdfa")}`; + +const keyPair = nacl.sign.keyPair(); +let currNextKey = nacl.sign.keyPair(); + +const verifierCallback: VerifierCallback = async ( + message: string, + signature: string, + pubKey: string, +) => { + const signatureBuffer = base58btc.decode(signature); + const messageBuffer = stringToUint8Array(message); + const publicKey = hexToUint8Array(pubKey); + const isValid = nacl.sign.detached.verify( + messageBuffer, + signatureBuffer, + publicKey, + ); + + return isValid; +}; + +function createSigner(keyPair: nacl.SignKeyPair): Signer { + const publicKey = uint8ArrayToHex(keyPair.publicKey); + const signer: Signer = { + pubKey: publicKey, + sign: (str: string) => { + const buffer = stringToUint8Array(str); + const signature = nacl.sign.detached(buffer, keyPair.secretKey); + return base58btc.encode(signature); + }, + }; + return signer; +} + +describe("LogManager", async () => { + test("GenesisEvent: [Throw at Bad Options]", async () => { + const nextKeyHash = await hash(uint8ArrayToHex(currNextKey.publicKey)); + const signer = createSigner(keyPair); + const logEvent = logManager.createLogEvent({ + nextKeySigner: signer, + nextKeyHashes: [nextKeyHash], + signer, + }); + await expect(logEvent).rejects.toThrow(BadOptionsSpecifiedError); + }); + + test("GenesisEvent: [Creates Entry]", async () => { + const nextKeyHash = await hash(uint8ArrayToHex(currNextKey.publicKey)); + const signer = createSigner(keyPair); + const logEvent = await logManager.createLogEvent({ + id: w3id, + nextKeyHashes: [nextKeyHash], + signer, + }); + expectTypeOf(logEvent).toMatchObjectType(); + }); + + test("KeyRotation: [Throw At Bad Options]", async () => { + const nextKeyPair = nacl.sign.keyPair(); + const nextKeyHash = await hash(uint8ArrayToHex(nextKeyPair.publicKey)); + + const signer = createSigner(nextKeyPair); + const logEvent = logManager.createLogEvent({ + nextKeyHashes: [nextKeyHash], + signer, + id: `@{falso.randUuid()}`, + }); + + await expect(logEvent).rejects.toThrow(BadOptionsSpecifiedError); + }); + + test("KeyRotation: [Error At Wrong Next Key]", async () => { + const nextKeyPair = nacl.sign.keyPair(); + const nextKeyHash = await hash(uint8ArrayToHex(nextKeyPair.publicKey)); + + const signer = createSigner(nextKeyPair); + const nextKeySigner = createSigner(nextKeyPair); + const logEvent = logManager.createLogEvent({ + nextKeyHashes: [nextKeyHash], + signer, + nextKeySigner, + }); + + await expect(logEvent).rejects.toThrow(BadNextKeySpecifiedError); + }); + + test("KeyRotation: [Creates Entry]", async () => { + const nextKeyPair = nacl.sign.keyPair(); + const nextKeyHash = await hash(uint8ArrayToHex(nextKeyPair.publicKey)); + + const signer = createSigner(keyPair); + const nextKeySigner = createSigner(currNextKey); + const logEvent = await logManager.createLogEvent({ + nextKeyHashes: [nextKeyHash], + signer, + nextKeySigner, + }); + + expectTypeOf(logEvent).toMatchObjectType(); + }); + + test("Verification: [Verifies Correct Chain]", async () => { + const events = await logManager.repository.findMany({}); + const result = await IDLogManager.validateLogChain( + events, + verifierCallback, + ); + expect(result).toBe(true); + }); + + test("Verification: [Throws on Malformed Index Chain]", async () => { + const _events = await logManager.repository.findMany({}); + const events = JSON.parse(JSON.stringify(_events)); + events[1].versionId = `2-${falso.randUuid()}`; + const result = IDLogManager.validateLogChain(events, verifierCallback); + + await expect(result).rejects.toThrow(MalformedIndexChainError); + }); + + test("Verification: [Throws on Malformed Hash Chain]", async () => { + const _events = await logManager.repository.findMany({}); + const events = JSON.parse(JSON.stringify(_events)); + events[1].versionId = `1-${falso.randUuid()}`; + const result = IDLogManager.validateLogChain(events, verifierCallback); + + await expect(result).rejects.toThrow(MalformedHashChainError); + }); + + test("Verification: [Throws on Wrong Signature]", async () => { + const _events = await logManager.repository.findMany({}); + const events = JSON.parse(JSON.stringify(_events)); + const newKeyPair = nacl.sign.keyPair(); + const signer = createSigner(newKeyPair); + + delete events[1].proof; + events[1].proof = await signer.sign(events[1]); + const result = IDLogManager.validateLogChain(events, verifierCallback); + + await expect(result).rejects.toThrow(BadSignatureError); + }); +}); diff --git a/infrastructure/w3id/tests/utils/codec.test.ts b/infrastructure/w3id/tests/utils/codec.test.ts new file mode 100644 index 00000000..b0bd388b --- /dev/null +++ b/infrastructure/w3id/tests/utils/codec.test.ts @@ -0,0 +1,33 @@ +import { + uint8ArrayToHex, + hexToUint8Array, + stringToUint8Array, +} from "../../src/utils/codec"; +import { describe, test, expect } from "vitest"; + +describe("Codec", () => { + test("uint8ArrayToHex", () => { + const input = new Uint8Array([1, 2, 3, 4]); + const expected = "01020304"; + expect(uint8ArrayToHex(input)).toBe(expected); + }); + + test("hexToUint8Array", () => { + const input = "01020304"; + const expected = new Uint8Array([1, 2, 3, 4]); + expect(hexToUint8Array(input)).toEqual(expected); + }); + + test("hexToUint8Array (Odd Length)", () => { + const input = "010203045"; + expect(() => hexToUint8Array(input)).toThrow( + "Hex string must have an even length", + ); + }); + + test("stringToUint8Array", () => { + const input = "hello"; + const expected = new Uint8Array([104, 101, 108, 108, 111]); + expect(stringToUint8Array(input)).toEqual(expected); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d9d6bf3..6ec4c707 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,15 @@ importers: infrastructure/w3id: dependencies: + canonicalize: + specifier: ^2.1.0 + version: 2.1.0 + multiformats: + specifier: ^13.3.2 + version: 13.3.2 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -1497,6 +1506,10 @@ packages: caniuse-lite@1.0.30001706: resolution: {integrity: sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==} + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} + hasBin: true + chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -2504,6 +2517,9 @@ packages: typescript: optional: true + multiformats@13.3.2: + resolution: {integrity: sha512-qbB0CQDt3QKfiAzZ5ZYjLFOs+zW43vA4uyM8g27PeEuXZybUOFyjrVdP93HPBHMoglibwfkdVwbzfUq8qGcH6g==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3127,6 +3143,9 @@ packages: tween-functions@1.2.0: resolution: {integrity: sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4832,6 +4851,8 @@ snapshots: caniuse-lite@1.0.30001706: {} + canonicalize@2.1.0: {} + chai@5.2.0: dependencies: assertion-error: 2.0.1 @@ -6005,6 +6026,8 @@ snapshots: - '@types/node' optional: true + multiformats@13.3.2: {} + mute-stream@2.0.0: {} nanoid@3.3.11: {} @@ -6652,6 +6675,8 @@ snapshots: tween-functions@1.2.0: {} + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1