diff --git a/.github/workflows/tests-w3id.yml b/.github/workflows/tests-w3id.yml new file mode 100644 index 00000000..8c60b42e --- /dev/null +++ b/.github/workflows/tests-w3id.yml @@ -0,0 +1,34 @@ +name: Tests [W3ID] + +on: + push: + branches: [main] + paths: + - 'infrastructure/w3id/**' + pull_request: + branches: [main] + paths: + - 'infrastructure/w3id/**' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm -F=w3id test + diff --git a/infrastructure/w3id/src/errors/errors.ts b/infrastructure/w3id/src/errors/errors.ts index 4a314b08..31bc90ef 100644 --- a/infrastructure/w3id/src/errors/errors.ts +++ b/infrastructure/w3id/src/errors/errors.ts @@ -1,33 +1,33 @@ export class MalformedIndexChainError extends Error { - constructor(message: string = "Malformed index chain detected") { + constructor(message = "Malformed index chain detected") { super(message); this.name = "MalformedIndexChainError"; } } export class MalformedHashChainError extends Error { - constructor(message: string = "Malformed hash chain detected") { + constructor(message = "Malformed hash chain detected") { super(message); this.name = "MalformedHashChainError"; } } export class BadSignatureError extends Error { - constructor(message: string = "Bad signature detected") { + constructor(message = "Bad signature detected") { super(message); this.name = "BadSignatureError"; } } export class BadNextKeySpecifiedError extends Error { - constructor(message: string = "Bad next key specified") { + constructor(message = "Bad next key specified") { super(message); this.name = "BadNextKeySpecifiedError"; } } export class BadOptionsSpecifiedError extends Error { - constructor(message: string = "Bad options specified") { + constructor(message = "Bad options specified") { super(message); this.name = "BadOptionsSpecifiedError"; } diff --git a/infrastructure/w3id/src/index.ts b/infrastructure/w3id/src/index.ts index ff8b4c56..5e7cb997 100644 --- a/infrastructure/w3id/src/index.ts +++ b/infrastructure/w3id/src/index.ts @@ -1 +1,121 @@ -export default {}; +import { IDLogManager } from "./logs/log-manager"; +import type { LogEvent, Signer } from "./logs/log.types"; +import type { StorageSpec } from "./logs/storage/storage-spec"; +import { generateRandomAlphaNum } from "./utils/rand"; +import { v4 as uuidv4 } from "uuid"; +import { generateUuid } from "./utils/uuid"; + +export class W3ID { + constructor( + public id: string, + public logs?: IDLogManager, + ) {} +} + +export class W3IDBuilder { + private signer?: Signer; + private repository?: StorageSpec; + private entropy?: string; + private namespace?: string; + private nextKeyHash?: string; + private global?: boolean = false; + + /** + * Specify entropy to create the identity with + * + * @param {string} str + */ + public withEntropy(str: string): W3IDBuilder { + this.entropy = str; + return this; + } + + /** + * Specify namespace to use to generate the UUIDv5 + * + * @param {string} uuid + */ + public withNamespace(uuid: string): W3IDBuilder { + this.namespace = uuid; + return this; + } + + /** + * Specify whether to create a global identifier or a local identifer + * + * According to the project specification there are supposed to be 2 main types of + * W3ID's ones which are tied to more permanent entities + * + * A global identifer is expected to live at the registry and starts with an \`@\` + * + * @param {boolean} isGlobal + */ + public withGlobal(isGlobal: boolean): W3IDBuilder { + this.global = isGlobal; + return this; + } + + /** + * Add a logs repository to the W3ID, a rotateble key attached W3ID would need a + * repository in which the logs would be stored + * + * @param {StorageSpec} storage + */ + public withRepository(storage: StorageSpec): W3IDBuilder { + this.repository = storage; + return this; + } + + /** + * Attach a keypair to the W3ID, a key attached W3ID would also need a repository + * to be added. + * + * @param {Signer} signer + */ + public withSigner(signer: Signer): W3IDBuilder { + this.signer = signer; + return this; + } + + /** + * Specify the SHA256 hash of the next key which will sign the next log entry after + * rotation of keys + * + * @param {string} hash + */ + public withNextKeyHash(hash: string): W3IDBuilder { + this.nextKeyHash = hash; + return this; + } + + /** + * Build the W3ID with provided builder options + * + * @returns Promise + */ + public async build(): Promise { + this.entropy = this.entropy ?? generateRandomAlphaNum(); + this.namespace = this.namespace ?? uuidv4(); + const id = `${ + this.global ? "@" : "" + }${generateUuid(this.entropy, this.namespace)}`; + if (!this.signer) { + return new W3ID(id); + } + if (!this.repository) + throw new Error( + "Repository is required, pass with `withRepository` method", + ); + + if (!this.nextKeyHash) + throw new Error( + "NextKeyHash is required pass with `withNextKeyHash` method", + ); + const logs = new IDLogManager(this.repository, this.signer); + await logs.createLogEvent({ + id, + nextKeyHashes: [this.nextKeyHash], + }); + return new W3ID(id, logs); + } +} diff --git a/infrastructure/w3id/src/logs/log-manager.ts b/infrastructure/w3id/src/logs/log-manager.ts index b69e3dc2..97a7d90f 100644 --- a/infrastructure/w3id/src/logs/log-manager.ts +++ b/infrastructure/w3id/src/logs/log-manager.ts @@ -11,6 +11,7 @@ import { hash } from "../utils/hash"; import { isGenesisOptions, isRotationOptions, + type Signer, type CreateLogEventOptions, type GenesisLogOptions, type LogEvent, @@ -28,15 +29,25 @@ import type { StorageSpec } from "./storage/storage-spec"; export class IDLogManager { repository: StorageSpec; + signer: Signer; - constructor(repository: StorageSpec) { + constructor(repository: StorageSpec, signer: Signer) { this.repository = repository; + this.signer = signer; } + /** + * Validate a chain of W3ID logs + * + * @param {LogEvent[]} log + * @param {VerifierCallback} verifyCallback + * @returns {Promise} + */ + static async validateLogChain( log: LogEvent[], verifyCallback: VerifierCallback, - ) { + ): Promise { let currIndex = 0; let currentNextKeyHashesSeen: string[] = []; let lastUpdateKeysSeen: string[] = []; @@ -71,11 +82,19 @@ export class IDLogManager { return true; } + /** + * Validate cryptographic signature on a single LogEvent + * + * @param {LogEvent} e + * @param {string[]} currentUpdateKeys + * @param {VerifierCallback} verifyCallback + * @returns {Promise} + */ private static async verifyLogEventProof( e: LogEvent, currentUpdateKeys: string[], verifyCallback: VerifierCallback, - ) { + ): Promise { const proof = e.proof; const copy = JSON.parse(JSON.stringify(e)); // biome-ignore lint/performance/noDelete: we need to delete proof completely @@ -94,8 +113,15 @@ export class IDLogManager { if (!verified) throw new BadSignatureError(); } + /** + * Append a new log entry for a W3ID + * + * @param {LogEvent[]} entries + * @param {RotationLogOptions} options + * @returns Promise + */ private async appendEntry(entries: LogEvent[], options: RotationLogOptions) { - const { signer, nextKeyHashes, nextKeySigner } = options; + const { nextKeyHashes, nextKeySigner } = options; const latestEntry = entries[entries.length - 1]; const logHash = await hash(latestEntry); const index = Number(latestEntry.versionId.split("-")[0]) + 1; @@ -113,30 +139,44 @@ export class IDLogManager { method: "w3id:v0.0.0", }; - const proof = await signer.sign(canonicalize(logEvent) as string); + const proof = await this.signer.sign(canonicalize(logEvent) as string); logEvent.proof = proof; await this.repository.create(logEvent); + this.signer = nextKeySigner; return logEvent; } + /** + * Create genesis entry for a W3ID log + * + * @param {GenesisLogOptions} options + * @returns Promise + */ private async createGenesisEntry(options: GenesisLogOptions) { - const { id, nextKeyHashes, signer } = options; + const { id, nextKeyHashes } = options; + const idTag = id.includes("@") ? id.split("@")[1] : id; const logEvent: LogEvent = { id, - versionId: `0-${id.split("@")[1]}`, + versionId: `0-${idTag}`, versionTime: new Date(Date.now()), - updateKeys: [signer.pubKey], + updateKeys: [this.signer.pubKey], nextKeyHashes: nextKeyHashes, method: "w3id:v0.0.0", }; - const proof = await signer.sign(canonicalize(logEvent) as string); + const proof = await this.signer.sign(canonicalize(logEvent) as string); logEvent.proof = proof; await this.repository.create(logEvent); return logEvent; } - async createLogEvent(options: CreateLogEventOptions) { + /** + * Create a log event and save it to the repository + * + * @param {CreateLogEventOptions} options + * @returns Promise + */ + async createLogEvent(options: CreateLogEventOptions): Promise { const entries = await this.repository.findMany({}); if (entries.length > 0) { if (!isRotationOptions(options)) throw new BadOptionsSpecifiedError(); diff --git a/infrastructure/w3id/src/logs/log.types.ts b/infrastructure/w3id/src/logs/log.types.ts index 2f03b311..a5c1636d 100644 --- a/infrastructure/w3id/src/logs/log.types.ts +++ b/infrastructure/w3id/src/logs/log.types.ts @@ -21,14 +21,12 @@ export type Signer = { export type RotationLogOptions = { nextKeyHashes: string[]; - signer: Signer; nextKeySigner: Signer; }; export type GenesisLogOptions = { nextKeyHashes: string[]; id: string; - signer: Signer; }; export function isGenesisOptions( diff --git a/infrastructure/w3id/src/utils/rand.ts b/infrastructure/w3id/src/utils/rand.ts new file mode 100644 index 00000000..f9710fe0 --- /dev/null +++ b/infrastructure/w3id/src/utils/rand.ts @@ -0,0 +1,22 @@ +/** + * Generate a random alphanumeric sequence with set length + * + * @param {number} length length of the alphanumeric string you want + * @returns {string} + */ + +export function generateRandomAlphaNum(length = 16): string { + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const charsLength = chars.length; + const randomValues = new Uint32Array(length); + + crypto.getRandomValues(randomValues); + + for (let i = 0; i < length; i++) { + result += chars.charAt(randomValues[i] % charsLength); + } + + return result; +} diff --git a/infrastructure/w3id/tests/logs/log.test.ts b/infrastructure/w3id/tests/logs/log.test.ts index 90db3bbc..ad3e8701 100644 --- a/infrastructure/w3id/tests/logs/log.test.ts +++ b/infrastructure/w3id/tests/logs/log.test.ts @@ -1,20 +1,10 @@ -import { StorageSpec } from "../../src/logs/storage/storage-spec.ts"; -import { - LogEvent, - Signer, - VerifierCallback, -} from "../../src/logs/log.types.ts"; +import { LogEvent } 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 { uint8ArrayToHex } from "../../src/utils/codec"; import falso from "@ngneat/falso"; import { BadNextKeySpecifiedError, @@ -23,79 +13,16 @@ import { 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")}`; +import { InMemoryStorage } from "../utils/store.ts"; +import { createSigner, verifierCallback } from "../utils/crypto.ts"; 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; -} +const signer = createSigner(keyPair); + +const logManager = new IDLogManager(InMemoryStorage.build(), signer); +const w3id = `@${generateUuid("asdfa")}`; describe("LogManager", async () => { test("GenesisEvent: [Throw at Bad Options]", async () => { @@ -104,18 +31,15 @@ describe("LogManager", async () => { 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(); }); @@ -124,10 +48,8 @@ describe("LogManager", 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()}`, }); @@ -138,11 +60,9 @@ describe("LogManager", 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, }); @@ -153,11 +73,9 @@ describe("LogManager", 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, }); diff --git a/infrastructure/w3id/tests/utils/crypto.ts b/infrastructure/w3id/tests/utils/crypto.ts new file mode 100644 index 00000000..6c9de1fb --- /dev/null +++ b/infrastructure/w3id/tests/utils/crypto.ts @@ -0,0 +1,38 @@ +import { base58btc } from "multiformats/bases/base58"; +import { Signer, VerifierCallback } from "../../src/logs/log.types"; +import { + hexToUint8Array, + stringToUint8Array, + uint8ArrayToHex, +} from "../../src/utils/codec"; +import nacl from "tweetnacl"; + +export 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; +}; + +export 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; +} diff --git a/infrastructure/w3id/tests/utils/store.ts b/infrastructure/w3id/tests/utils/store.ts new file mode 100644 index 00000000..f13541f4 --- /dev/null +++ b/infrastructure/w3id/tests/utils/store.ts @@ -0,0 +1,40 @@ +import { LogEvent } from "../../src/logs/log.types"; +import { StorageSpec } from "../../src/logs/storage/storage-spec.ts"; + +export 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, + ), + ); + } +} diff --git a/infrastructure/w3id/tests/w3id.test.ts b/infrastructure/w3id/tests/w3id.test.ts new file mode 100644 index 00000000..9266674c --- /dev/null +++ b/infrastructure/w3id/tests/w3id.test.ts @@ -0,0 +1,81 @@ +import { W3ID, W3IDBuilder } from "../src"; +import { describe, test, expect } from "vitest"; +import falso from "@ngneat/falso"; +import nacl from "tweetnacl"; +import { createSigner, verifierCallback } from "./utils/crypto"; +import { InMemoryStorage } from "./utils/store"; +import { IDLogManager } from "../src/logs/log-manager"; +import { hash } from "../src/utils/hash"; +import { uint8ArrayToHex } from "../src/utils/codec"; +import { LogEvent } from "../src/logs/log.types"; + +const keyPair = nacl.sign.keyPair(); + +describe("W3IDBuilder", () => { + test("ID Generation: Create Basic ID", async () => { + const id = await new W3IDBuilder().build(); + expect(id).toBeInstanceOf(W3ID); + expect(id.logs).toBeUndefined(); + }); + + test("ID Generation: Global ID begins with `@`", async () => { + const id = await new W3IDBuilder().withGlobal(true).build(); + expect(id.id.startsWith("@")).toBe(true); + }); + + test("ID Generation: Local ID begins doesn't begin with `@`", async () => { + const id = await new W3IDBuilder().build(); + expect(id.id.startsWith("@")).toBe(false); + }); + + test("ID Generation: UUID is Deterministic", async () => { + const namespace = falso.randUuid(); + const entropy = falso.randText(); + + const id1 = await new W3IDBuilder() + .withEntropy(entropy) + .withNamespace(namespace) + .build(); + + const id2 = await new W3IDBuilder() + .withEntropy(entropy) + .withNamespace(namespace) + .build(); + expect(id1.id).toEqual(id2.id); + expect(id1.logs).toBeUndefined(); + }); + + test("ID Generation: Creates IDLogManager", async () => { + const id = await new W3IDBuilder() + .withRepository(InMemoryStorage.build()) + .withSigner(createSigner(keyPair)) + .withNextKeyHash(falso.randText()) + .build(); + expect(id.logs).toBeInstanceOf(IDLogManager); + const genesisLog = (await id.logs?.repository.findMany({}))[0]; + expect(genesisLog).toBeDefined(); + }); + + test("ID Mutation: Key Rotation Works", async () => { + const nextKeyPair = nacl.sign.keyPair(); + const nextKeyHash = await hash(uint8ArrayToHex(nextKeyPair.publicKey)); + + const id = await new W3IDBuilder() + .withRepository(InMemoryStorage.build()) + .withSigner(createSigner(keyPair)) + .withNextKeyHash(nextKeyHash) + .build(); + + await id.logs?.createLogEvent({ + nextKeySigner: createSigner(nextKeyPair), + nextKeyHashes: [falso.randText()], + }); + + const logs = await id.logs?.repository.findMany({}); + const result = await IDLogManager.validateLogChain( + logs as LogEvent[], + verifierCallback, + ); + expect(result).toBe(true); + }); +});