diff --git a/.changeset/did-ckb-advanced.md b/.changeset/did-ckb-advanced.md new file mode 100644 index 000000000..26c4d5eb6 --- /dev/null +++ b/.changeset/did-ckb-advanced.md @@ -0,0 +1,13 @@ +--- +"@ckb-ccc/did-ckb": minor +--- + +feat(did-ckb): identifier helpers, resolver, history walk, and did:plc migration + +Layered on top of the basic create/transfer/destroy operations: + +- `argsToDid`, `didToArgs`, `isDidCkb`, plus RFC 4648 base32 helpers for converting between Type ID args and the human readable `did:ckb:` URI form (WIP-01 §2.2) +- `findDidCkbCell`, `resolveDidCkb`, `listDidCkbsByLock` for resolving a DID by id or by owning lock +- `getDidCkbHistory` walks the cell chain backwards to produce an ordered list of CREATE / UPDATE / MIGRATE entries with tx hash, block number, capacity, and decoded data +- `migrateDidCkb` + `buildMigrationWitness` for importing a `did:plc` into `did:ckb` (WIP-02 §3.1.1) +- `@ckb-ccc/did-ckb/plc` subpath with `fetchPlcLog`, `parseDidKey`, `signRotationHash`, `verifyPrivateKeyMatch` so the curve code only ships to consumers that need it diff --git a/packages/did-ckb/package.json b/packages/did-ckb/package.json index e61651208..d8a210895 100644 --- a/packages/did-ckb/package.json +++ b/packages/did-ckb/package.json @@ -21,6 +21,10 @@ "require": "./dist.commonjs/barrel.js", "import": "./dist/barrel.mjs" }, + "./plc": { + "require": "./dist.commonjs/plc.js", + "import": "./dist/plc.mjs" + }, "./package.json": "./package.json" }, "scripts": { @@ -49,7 +53,8 @@ "dependencies": { "@ckb-ccc/core": "workspace:*", "@ckb-ccc/type-id": "workspace:*", - "@ipld/dag-cbor": "^9.2.5" + "@ipld/dag-cbor": "^9.2.5", + "@noble/curves": "^1.9.7" }, "packageManager": "pnpm@10.8.1", "types": "./dist.commonjs/index.d.ts" diff --git a/packages/did-ckb/src/barrel.ts b/packages/did-ckb/src/barrel.ts index 6112c76ce..f3d44ae35 100644 --- a/packages/did-ckb/src/barrel.ts +++ b/packages/did-ckb/src/barrel.ts @@ -1,2 +1,7 @@ export * from "./codec.js"; export * from "./didCkb.js"; +export * from "./history.js"; +export * from "./identifier.js"; +export * from "./migrate.js"; +export * as plc from "./plc/index.js"; +export * from "./resolver.js"; diff --git a/packages/did-ckb/src/history.test.ts b/packages/did-ckb/src/history.test.ts new file mode 100644 index 000000000..499fa5ad6 --- /dev/null +++ b/packages/did-ckb/src/history.test.ts @@ -0,0 +1,197 @@ +import { ccc } from "@ckb-ccc/core"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { DidCkbData } from "./codec.js"; +import { getDidCkbHistory } from "./history.js"; + +describe("getDidCkbHistory", () => { + let client: ccc.Client; + + const codeHash = + "0x510150477b10d6ab551a509b71265f3164e9fd4137fcb5a4322f49f03092c7c5"; + const id = ("0x" + "ab".repeat(20)) as ccc.Hex; + + const fundingLock = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }); + const didTypeScript = ccc.Script.from({ + codeHash, + hashType: "type", + args: id, + }); + + // Build a cell + the tx that produced it. Each transferDidCkb consumes the + // prior DID cell as an input and emits a new one with the same Type ID. + function didCell( + txHash: ccc.Hex, + document: object, + localId?: string, + ): ccc.Cell { + const data = DidCkbData.fromV1({ document, localId }); + return ccc.Cell.from({ + outPoint: { txHash, index: 0 }, + cellOutput: { + capacity: ccc.fixedPointFrom(300), + lock: fundingLock, + type: didTypeScript, + }, + outputData: ccc.hexFrom(data.toBytes()), + }); + } + + const txGenesis = ("0x" + "11".repeat(32)) as ccc.Hex; + const txUpdate1 = ("0x" + "22".repeat(32)) as ccc.Hex; + const txUpdate2 = ("0x" + "33".repeat(32)) as ccc.Hex; + const txFunding = ("0x" + "ff".repeat(32)) as ccc.Hex; + + const genesisCell = didCell(txGenesis, { v: 1 }); + const update1Cell = didCell(txUpdate1, { v: 2 }); + const update2Cell = didCell(txUpdate2, { v: 3 }); + + beforeEach(() => { + client = { + getKnownScript: vi.fn(), + findSingletonCellByType: vi.fn(), + getTransaction: vi.fn(), + } as unknown as ccc.Client; + + (client.getKnownScript as Mock).mockResolvedValue({ + codeHash, + hashType: "type", + cellDeps: [], + }); + }); + + function txResponse( + tx: ccc.TransactionLike, + blockNumber?: ccc.NumLike, + ): { transaction: ccc.Transaction; blockNumber?: ccc.Num } { + return { + transaction: ccc.Transaction.from(tx), + blockNumber: + blockNumber !== undefined ? ccc.numFrom(blockNumber) : undefined, + }; + } + + it("returns CREATE, UPDATE entries newest-first for a normal mint + two transfers", async () => { + // Funding tx that produced the input used to create the genesis. Not a + // DID cell, so the walk should stop at the genesis tx. + const fundingPrevOut = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock: fundingLock, + }); + + (client.getTransaction as Mock).mockImplementation( + async (hash: ccc.HexLike) => { + const h = ccc.hexFrom(hash); + if (h === txUpdate2) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txUpdate1, index: 0 }, since: 0 }, + ], + outputs: [update2Cell.cellOutput], + outputsData: [update2Cell.outputData], + }, + 300, + ); + } + if (h === txUpdate1) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txGenesis, index: 0 }, since: 0 }, + ], + outputs: [update1Cell.cellOutput], + outputsData: [update1Cell.outputData], + }, + 200, + ); + } + if (h === txGenesis) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txFunding, index: 0 }, since: 0 }, + ], + outputs: [genesisCell.cellOutput], + outputsData: [genesisCell.outputData], + }, + 100, + ); + } + if (h === txFunding) { + return txResponse({ + inputs: [], + outputs: [fundingPrevOut], + outputsData: ["0x"], + }); + } + return undefined; + }, + ); + + const history = await getDidCkbHistory({ + client, + id, + liveCell: update2Cell, + }); + + expect(history.map((h) => h.action)).toEqual([ + "UPDATE", + "UPDATE", + "CREATE", + ]); + expect(history[0].txHash).toBe(txUpdate2); + expect(history[0].blockNumber).toBe(300n); + expect(history[2].txHash).toBe(txGenesis); + expect(history[2].data.value.document).toEqual({ v: 1 }); + }); + + it("flags the genesis as MIGRATE when localId is set", async () => { + const migrated = didCell(txGenesis, { v: 1 }, "did:plc:abc"); + + (client.getTransaction as Mock).mockImplementation( + async (hash: ccc.HexLike) => { + const h = ccc.hexFrom(hash); + if (h === txGenesis) { + return txResponse( + { + inputs: [ + { previousOutput: { txHash: txFunding, index: 0 }, since: 0 }, + ], + outputs: [migrated.cellOutput], + outputsData: [migrated.outputData], + }, + 50, + ); + } + if (h === txFunding) { + return txResponse({ + inputs: [], + outputs: [ + ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock: fundingLock, + }), + ], + outputsData: ["0x"], + }); + } + return undefined; + }, + ); + + const history = await getDidCkbHistory({ client, id, liveCell: migrated }); + expect(history.length).toBe(1); + expect(history[0].action).toBe("MIGRATE"); + expect(history[0].data.value.localId).toBe("did:plc:abc"); + }); + + it("returns an empty array when no live cell exists", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(undefined); + const history = await getDidCkbHistory({ client, id }); + expect(history).toEqual([]); + }); +}); diff --git a/packages/did-ckb/src/history.ts b/packages/did-ckb/src/history.ts new file mode 100644 index 000000000..fdacb0866 --- /dev/null +++ b/packages/did-ckb/src/history.ts @@ -0,0 +1,146 @@ +import { ccc } from "@ckb-ccc/core"; +import { DidCkbData } from "./codec"; +import { findDidCkbCell } from "./resolver"; + +export type HistoryAction = "CREATE" | "UPDATE" | "MIGRATE"; + +export type HistoryEntry = { + /** + * `CREATE` for a fresh mint, `MIGRATE` for a did:plc import (genesis cell + * with a `localId` set), `UPDATE` for every subsequent transfer. + */ + action: HistoryAction; + txHash: ccc.Hex; + outputIndex: ccc.Num; + blockNumber?: ccc.Num; + capacity: ccc.Num; + data: DidCkbData; +}; + +const DEFAULT_MAX_STEPS = 50; + +/** + * Walk the DID cell chain backwards to produce the ordered list of operations + * applied to a DID. + * + * Each `transferDidCkb` consumes the previous DID cell as an input and creates + * a new one with the same Type ID args; the genesis (`createDidCkb` / + * `createDidCkb` with localId) has no DID input. We start from the live cell, + * read its tx, look for the prior DID cell among the inputs, and repeat. The + * first entry returned is the newest (most recent transfer); the last is the + * genesis. + * + * Cost: roughly one `getTransaction` call per step, plus up to one call per + * non-DID input on each step to verify it isn't the prior DID cell. For typical + * DIDs with a handful of updates that's a small handful of RPC calls. + */ +export async function getDidCkbHistory(props: { + client: ccc.Client; + id: ccc.HexLike; + /** Pre-resolved live cell; if omitted, we fetch it. */ + liveCell?: ccc.Cell; + /** Safety bound to prevent runaway walks. Default 50. */ + maxSteps?: number; +}): Promise { + const id = ccc.hexFrom(props.id); + const scriptInfo = await props.client.getKnownScript(ccc.KnownScript.DidCkb); + const codeHash = scriptInfo.codeHash.toLowerCase(); + const normalizedId = id.toLowerCase(); + + let cell: ccc.Cell | undefined = + props.liveCell ?? + (await findDidCkbCell({ client: props.client, id }))?.cell; + if (!cell) { + return []; + } + + const history: HistoryEntry[] = []; + const maxSteps = props.maxSteps ?? DEFAULT_MAX_STEPS; + let steps = 0; + + while (cell && steps < maxSteps) { + steps++; + const tx = await props.client.getTransaction(cell.outPoint.txHash); + if (!tx) { + break; + } + + const entry = decodeEntry(cell, tx.blockNumber); + if (!entry) { + break; + } + + const prior = await findPriorDidCell( + props.client, + tx.transaction, + codeHash, + normalizedId, + ); + + if (!prior) { + // No DID input means this tx is the genesis. If the genesis carries a + // `localId` it's a did:plc migration; otherwise a plain CREATE. + entry.action = entry.data.value.localId ? "MIGRATE" : "CREATE"; + history.push(entry); + break; + } + + entry.action = "UPDATE"; + history.push(entry); + cell = prior; + } + + return history; +} + +async function findPriorDidCell( + client: ccc.Client, + tx: ccc.Transaction, + codeHash: string, + id: string, +): Promise { + for (const input of tx.inputs) { + const prevHash = input.previousOutput.txHash; + const prevIdx = input.previousOutput.index; + const prevTx = await client.getTransaction(prevHash); + if (!prevTx) { + continue; + } + const prevOutput = prevTx.transaction.outputs[Number(prevIdx)]; + if (!prevOutput?.type) { + continue; + } + if (prevOutput.type.codeHash.toLowerCase() !== codeHash) { + continue; + } + if (ccc.hexFrom(prevOutput.type.args).toLowerCase() !== id) { + continue; + } + const data = prevTx.transaction.outputsData[Number(prevIdx)]; + return ccc.Cell.from({ + outPoint: { txHash: prevHash, index: prevIdx }, + cellOutput: prevOutput, + outputData: data, + }); + } + return undefined; +} + +function decodeEntry( + cell: ccc.Cell, + blockNumber?: ccc.Num, +): HistoryEntry | undefined { + try { + const data = DidCkbData.decode(cell.outputData); + return { + action: "UPDATE", + txHash: cell.outPoint.txHash, + outputIndex: cell.outPoint.index, + blockNumber, + capacity: cell.cellOutput.capacity, + data, + }; + } catch { + return undefined; + } +} diff --git a/packages/did-ckb/src/identifier.test.ts b/packages/did-ckb/src/identifier.test.ts new file mode 100644 index 000000000..39ab24d71 --- /dev/null +++ b/packages/did-ckb/src/identifier.test.ts @@ -0,0 +1,78 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + argsToDid, + base32Decode, + base32Encode, + didToArgs, + isDidCkb, +} from "./identifier.js"; + +describe("base32", () => { + it("round-trips known vectors", () => { + const cases: [string, string][] = [ + ["", ""], + ["f", "my"], + ["fo", "mzxq"], + ["foo", "mzxw6"], + ["foob", "mzxw6yq"], + ["fooba", "mzxw6ytb"], + ["foobar", "mzxw6ytboi"], + ]; + for (const [input, expected] of cases) { + const bytes = new TextEncoder().encode(input); + expect(base32Encode(bytes)).toBe(expected); + expect(new TextDecoder().decode(base32Decode(expected))).toBe(input); + } + }); + + it("rejects invalid characters", () => { + expect(() => base32Decode("!!!")).toThrow(/Invalid base32 character/); + }); + + it("accepts hex input via ccc.bytesFrom", () => { + expect(base32Encode("0xdeadbeef")).toBe( + base32Encode(ccc.bytesFrom("0xdeadbeef")), + ); + }); +}); + +describe("did:ckb identifier", () => { + // 20 zero bytes -> 32 'a's + const zeros = "0x" + "00".repeat(20); + const zerosDid = "did:ckb:" + "a".repeat(32); + + it("converts args <-> did", () => { + expect(argsToDid(zeros)).toBe(zerosDid); + expect(didToArgs(zerosDid)).toBe(zeros); + }); + + it("rejects args with wrong length", () => { + expect(() => argsToDid("0x" + "00".repeat(19))).toThrow(); + expect(() => argsToDid("0x" + "00".repeat(21))).toThrow(); + }); + + it("rejects did without prefix", () => { + expect(() => didToArgs("ckb:" + "a".repeat(32))).toThrow(); + }); + + it("rejects did with wrong body length", () => { + expect(() => didToArgs("did:ckb:" + "a".repeat(31))).toThrow(); + expect(() => didToArgs("did:ckb:" + "a".repeat(33))).toThrow(); + }); + + it("isDidCkb is true only for well-formed values", () => { + expect(isDidCkb(zerosDid)).toBe(true); + expect(isDidCkb("did:plc:xyz")).toBe(false); + expect(isDidCkb("did:ckb:tooShort")).toBe(false); + expect(isDidCkb("did:ckb:" + "!".repeat(32))).toBe(false); + }); + + it("round-trips a random-looking args vector", () => { + const args = "0x0123456789abcdef0123456789abcdef01234567"; + const did = argsToDid(args); + expect(did.startsWith("did:ckb:")).toBe(true); + expect(did.length).toBe("did:ckb:".length + 32); + expect(didToArgs(did)).toBe(args); + }); +}); diff --git a/packages/did-ckb/src/identifier.ts b/packages/did-ckb/src/identifier.ts new file mode 100644 index 000000000..67b8bce48 --- /dev/null +++ b/packages/did-ckb/src/identifier.ts @@ -0,0 +1,105 @@ +import { ccc } from "@ckb-ccc/core"; + +const DID_PREFIX = "did:ckb:"; +const ARGS_LEN_BYTES = 20; +const DID_BODY_LEN = 32; + +// RFC 4648 base32, lowercase, no padding (WIP-01 §2.2.3). +const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; +const REVERSE = (() => { + const map: Record = {}; + for (let i = 0; i < ALPHABET.length; i++) { + map[ALPHABET[i]] = i; + } + return map; +})(); + +export function base32Encode(bytes: ccc.BytesLike): string { + const buf = ccc.bytesFrom(bytes); + let bits = 0; + let value = 0; + let output = ""; + for (let i = 0; i < buf.length; i++) { + value = (value << 8) | buf[i]; + bits += 8; + while (bits >= 5) { + output += ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + output += ALPHABET[(value << (5 - bits)) & 31]; + } + return output; +} + +export function base32Decode(input: string): ccc.Bytes { + const cleaned = input.toLowerCase().replace(/=+$/g, ""); + let bits = 0; + let value = 0; + const output: number[] = []; + for (const char of cleaned) { + const v = REVERSE[char]; + if (v === undefined) { + throw new Error(`Invalid base32 character "${char}" in input`); + } + value = (value << 5) | v; + bits += 5; + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Uint8Array.from(output); +} + +/** + * Convert 20-byte Type ID args (the `id` returned from `createDidCkb`) to a + * `did:ckb:` identifier string per WIP-01 §2.2. + */ +export function argsToDid(args: ccc.HexLike): string { + const bytes = ccc.bytesFrom(args); + if (bytes.length !== ARGS_LEN_BYTES) { + throw new Error( + `did:ckb args must be ${ARGS_LEN_BYTES} bytes, got ${bytes.length}`, + ); + } + return DID_PREFIX + base32Encode(bytes); +} + +/** + * Reverse of `argsToDid`. Validates the prefix, base32 length, and decoded + * byte count. + */ +export function didToArgs(did: string): ccc.Hex { + if (!did.startsWith(DID_PREFIX)) { + throw new Error(`Expected did:ckb:..., got "${did}"`); + } + const body = did.slice(DID_PREFIX.length); + // 20 bytes encode to exactly 32 base32 chars without padding. Any other + // length silently truncates leftover bits, so reject it up front. + if (body.length !== DID_BODY_LEN) { + throw new Error( + `did:ckb identifier must be ${DID_BODY_LEN} base32 chars, got ${body.length}`, + ); + } + const bytes = base32Decode(body); + if (bytes.length !== ARGS_LEN_BYTES) { + throw new Error( + `did:ckb identifier must decode to ${ARGS_LEN_BYTES} bytes, got ${bytes.length}`, + ); + } + return ccc.hexFrom(bytes); +} + +export function isDidCkb(value: string): boolean { + if (!value.startsWith(DID_PREFIX)) { + return false; + } + try { + didToArgs(value); + return true; + } catch { + return false; + } +} diff --git a/packages/did-ckb/src/migrate.ts b/packages/did-ckb/src/migrate.ts new file mode 100644 index 000000000..5414d17c7 --- /dev/null +++ b/packages/did-ckb/src/migrate.ts @@ -0,0 +1,94 @@ +import { ccc } from "@ckb-ccc/core"; +import { DidCkbDataLike, DidCkbWitness } from "./codec"; +import { createDidCkb } from "./didCkb"; +import { + getRotationKeys, + signRotationHash, + type Curve, + type PlcOperation, +} from "./plc"; + +/** + * Build the migration witness payload that authorizes a did:plc -> did:ckb + * import. Pure: takes a tx hash + signing material, returns a typed + * `DidCkbWitness` that the caller wraps in a `WitnessArgs.outputType` field. + * + * Per WIP-02 §3.1.1 we send only the genesis operation in `history`. The + * contract recomputes the genesis CID over the CBOR-encoded op, verifies the + * self-signature inside it, then verifies `sig` against + * `history[0].rotationKeys[rotationKeyIndex]`. + * + * `selfSigIndex` defaults to 0 because PLC genesis ops are conventionally + * self-signed by the first rotation key; override if the genesis you're + * migrating used a different one. + */ +export function buildMigrationWitness(props: { + txHash: ccc.HexLike; + genesisOperation: PlcOperation; + rotationKeyIndex: number; + rotationPrivateKey: ccc.BytesLike; + curve?: Curve; + selfSigIndex?: number; +}): DidCkbWitness { + const rotationKeys = getRotationKeys(props.genesisOperation); + if (!rotationKeys[props.rotationKeyIndex]) { + throw new Error( + `rotationKeyIndex ${props.rotationKeyIndex} out of range (genesis has ${rotationKeys.length} keys)`, + ); + } + const curve = props.curve ?? rotationKeys[props.rotationKeyIndex].curve; + const sig = signRotationHash( + props.rotationPrivateKey, + ccc.bytesFrom(props.txHash), + curve, + ); + return DidCkbWitness.from({ + localIdAuthorization: { + history: [props.genesisOperation as unknown as object], + sig: ccc.hexFrom(sig), + rotationKeyIndices: [props.selfSigIndex ?? 0, props.rotationKeyIndex], + }, + }); +} + +/** + * Build a create tx whose output declares an imported did:plc identifier in + * its `localId` field. Equivalent to calling `createDidCkb` with + * `data.value.localId = sourceDid`; provided as a named helper for symmetry + * with the other DID operations and so `did:plc:` migrations have an + * obvious entry point. + * + * The caller is responsible for: completing inputs + fee, building the + * migration witness with `buildMigrationWitness`, and setting it at the + * witness slot of input 0. See `packages/examples/src/migrateDid.ts` for the + * full flow. + */ +export async function migrateDidCkb(props: { + signer: ccc.Signer; + sourceDid: string; + data?: DidCkbDataLike | null; + receiver?: ccc.ScriptLike | null; + tx?: ccc.TransactionLike | null; +}): Promise<{ + tx: ccc.Transaction; + id: ccc.Hex; + index: number; +}> { + if (!props.sourceDid.startsWith("did:plc:")) { + throw new Error(`sourceDid must be did:plc:..., got "${props.sourceDid}"`); + } + const document = props.data?.value?.document ?? {}; + const data: DidCkbDataLike = { + type: props.data?.type ?? "v1", + value: { + document, + localId: props.sourceDid, + }, + }; + return createDidCkb({ + signer: props.signer, + data, + receiver: props.receiver, + tx: props.tx, + }); +} diff --git a/packages/did-ckb/src/plc/index.ts b/packages/did-ckb/src/plc/index.ts new file mode 100644 index 000000000..d5d86805f --- /dev/null +++ b/packages/did-ckb/src/plc/index.ts @@ -0,0 +1,225 @@ +import { ccc } from "@ckb-ccc/core"; +import { p256 } from "@noble/curves/p256"; +import { secp256k1 } from "@noble/curves/secp256k1"; + +/** + * Minimal did:plc helpers, scoped to what a did:ckb migration needs: + * fetch the op log from the public PLC directory, parse rotation keys, and + * sign a 32-byte CKB transaction hash with a rotation private key. + * + * The on-chain contract is happy with a history of length 1 (WIP-02 §3.1.1 + * RECOMMENDs the genesis-only form), so we don't walk the full op chain + * client-side. + */ + +const PLC_DIRECTORY = "https://plc.directory"; + +export type Curve = "secp256k1" | "p256"; + +export type PlcRotationKey = { + didKey: string; + curve: Curve; + compressedPubkey: ccc.Bytes; +}; + +export type PlcOperation = { + type: string; + rotationKeys?: string[]; + verificationMethods?: Record; + alsoKnownAs?: string[]; + services?: Record; + prev: string | null; + sig: string; + // Legacy `create` op fields: + signingKey?: string; + recoveryKey?: string; + handle?: string; + service?: string; + [key: string]: unknown; +}; + +/** + * Fetch the operation log for a did:plc identifier from the public directory. + * Pass a custom directory URL when targeting a staging environment. + */ +export async function fetchPlcLog( + did: string, + directory: string = PLC_DIRECTORY, +): Promise { + if (!did.startsWith("did:plc:")) { + throw new Error(`Expected did:plc identifier, got "${did}"`); + } + const res = await fetch(`${directory}/${did}/log`); + if (!res.ok) { + throw new Error(`PLC log fetch failed (${res.status} ${res.statusText})`); + } + const data: unknown = await res.json(); + if (!Array.isArray(data) || data.length === 0) { + throw new Error("PLC log empty or malformed"); + } + return data as PlcOperation[]; +} + +export function getGenesisOperation(log: PlcOperation[]): PlcOperation { + if (log.length === 0) { + throw new Error("Empty PLC log"); + } + return log[0]; +} + +/** + * Surface the rotation keys declared in a genesis op as a uniform list. PLC + * genesis comes in two shapes: the modern `plc_operation` (with + * `rotationKeys`) and the deprecated `create` (with `signingKey` + + * `recoveryKey`). The on-chain contract treats the legacy form as + * `[signingKey, recoveryKey]`, so we expose the same order. + */ +export function getRotationKeys(op: PlcOperation): PlcRotationKey[] { + const keys: string[] = []; + if (op.rotationKeys?.length) { + keys.push(...op.rotationKeys); + } else if (op.signingKey || op.recoveryKey) { + if (op.signingKey) { + keys.push(op.signingKey); + } + if (op.recoveryKey) { + keys.push(op.recoveryKey); + } + } + return keys.map((didKey) => { + const parsed = parseDidKey(didKey); + return { + didKey, + curve: parsed.curve, + compressedPubkey: parsed.compressedPubkey, + }; + }); +} + +export function parseDidKey(didKey: string): { + curve: Curve; + compressedPubkey: ccc.Bytes; +} { + if (!didKey.startsWith("did:key:z")) { + throw new Error(`Expected did:key:z..., got "${didKey}"`); + } + const raw = base58btcDecode(didKey.slice("did:key:z".length)); + if (raw.length !== 35) { + throw new Error( + `did:key payload must be 35 bytes (2-byte multicodec tag + 33-byte compressed pubkey), got ${raw.length}`, + ); + } + const tag1 = raw[0]; + const tag2 = raw[1]; + let curve: Curve; + if (tag1 === 0xe7 && tag2 === 0x01) { + curve = "secp256k1"; + } else if (tag1 === 0x80 && tag2 === 0x24) { + curve = "p256"; + } else { + throw new Error( + `Unrecognised did:key multicodec tag: 0x${tag1.toString(16).padStart(2, "0")} 0x${tag2.toString(16).padStart(2, "0")}`, + ); + } + return { curve, compressedPubkey: raw.slice(2) }; +} + +/** + * Sign a 32-byte CKB tx hash with a PLC rotation private key. + * + * The on-chain validator runs k256/p256's `Verifier::verify` which internally + * SHA-256 hashes the message before checking the ECDSA signature. Noble's + * default is the opposite (treat input as already-hashed), so we pass + * `prehash: true` to make noble apply SHA-256 internally and match the + * contract. Output is canonical low-s 64-byte compact form, identical to + * what @atproto/crypto's Keypair.sign produces. + */ +export function signRotationHash( + privateKey: ccc.BytesLike, + txHash: ccc.BytesLike, + curve: Curve, +): ccc.Bytes { + const hash = ccc.bytesFrom(txHash); + if (hash.length !== 32) { + throw new Error(`Expected 32-byte tx hash, got ${hash.length}`); + } + const priv = ccc.bytesFrom(privateKey); + if (priv.length !== 32) { + throw new Error(`Expected 32-byte private key, got ${priv.length}`); + } + if (curve === "secp256k1") { + return secp256k1 + .sign(hash, priv, { prehash: true, lowS: true }) + .toCompactRawBytes(); + } + return p256 + .sign(hash, priv, { prehash: true, lowS: true }) + .toCompactRawBytes(); +} + +/** + * Quick sanity check: derive the public key from `privateKey` and compare + * against `expectedPubkey`. Lets a UI tell the user "wrong key for this + * rotation slot" without waiting for an on-chain rejection. + */ +export function verifyPrivateKeyMatch( + privateKey: ccc.BytesLike, + expectedPubkey: ccc.BytesLike, + curve: Curve, +): boolean { + const priv = ccc.bytesFrom(privateKey); + if (priv.length !== 32) { + return false; + } + const expected = ccc.bytesFrom(expectedPubkey); + const pub = + curve === "secp256k1" + ? secp256k1.getPublicKey(priv, true) + : p256.getPublicKey(priv, true); + if (pub.length !== expected.length) { + return false; + } + for (let i = 0; i < pub.length; i++) { + if (pub[i] !== expected[i]) { + return false; + } + } + return true; +} + +// Inline base58btc decode (Bitcoin alphabet) so we don't pull in multiformats +// as a direct dependency. Audited by the multicodec tag round-trip tests. +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const BASE58_REVERSE: Record = (() => { + const m: Record = {}; + for (let i = 0; i < BASE58_ALPHABET.length; i++) { + m[BASE58_ALPHABET[i]] = i; + } + return m; +})(); + +function base58btcDecode(input: string): ccc.Bytes { + if (input.length === 0) { + return new Uint8Array(0); + } + let leadingOnes = 0; + while (leadingOnes < input.length && input[leadingOnes] === "1") { + leadingOnes++; + } + let value = 0n; + for (let i = leadingOnes; i < input.length; i++) { + const c = input[i]; + const v = BASE58_REVERSE[c]; + if (v === undefined) { + throw new Error(`Invalid base58 character "${c}"`); + } + value = value * 58n + BigInt(v); + } + const tail: number[] = []; + while (value > 0n) { + tail.unshift(Number(value & 0xffn)); + value >>= 8n; + } + return Uint8Array.from([...Array(leadingOnes).fill(0), ...tail]); +} diff --git a/packages/did-ckb/src/plc/plc.test.ts b/packages/did-ckb/src/plc/plc.test.ts new file mode 100644 index 000000000..5614245cf --- /dev/null +++ b/packages/did-ckb/src/plc/plc.test.ts @@ -0,0 +1,100 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { describe, expect, it } from "vitest"; +import { + getGenesisOperation, + getRotationKeys, + parseDidKey, + signRotationHash, + verifyPrivateKeyMatch, + type PlcOperation, +} from "./index.js"; + +describe("parseDidKey", () => { + it("recognises secp256k1 multicodec tag (0xe7 0x01)", () => { + // Real did:key from the bluesky network — secp256k1 public key. + const didKey = "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD"; + const { curve, compressedPubkey } = parseDidKey(didKey); + expect(curve).toBe("secp256k1"); + expect(compressedPubkey.length).toBe(33); + }); + + it("recognises p256 multicodec tag (0x80 0x24)", () => { + const didKey = "did:key:zDnaefn5fMKvoZ1n4vyxJ9npjWE5P3D8GkM9zNqaGbLqdDrtX"; + const { curve, compressedPubkey } = parseDidKey(didKey); + expect(curve).toBe("p256"); + expect(compressedPubkey.length).toBe(33); + }); + + it("rejects unknown prefixes", () => { + expect(() => parseDidKey("did:key:abc")).toThrow(); + expect(() => parseDidKey("did:plc:abc")).toThrow(); + }); +}); + +describe("getRotationKeys", () => { + it("returns rotationKeys from modern plc_operation", () => { + const op: PlcOperation = { + type: "plc_operation", + rotationKeys: [ + "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD", + "did:key:zDnaefn5fMKvoZ1n4vyxJ9npjWE5P3D8GkM9zNqaGbLqdDrtX", + ], + prev: null, + sig: "x", + }; + const keys = getRotationKeys(op); + expect(keys.map((k) => k.curve)).toEqual(["secp256k1", "p256"]); + }); + + it("falls back to signingKey + recoveryKey from legacy create op", () => { + const op: PlcOperation = { + type: "create", + signingKey: "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD", + recoveryKey: "did:key:zQ3shqtXEdagupBhLzL2vFUACfdVjDEvciip79uY8iHBuu7FD", + prev: null, + sig: "x", + }; + const keys = getRotationKeys(op); + expect(keys.length).toBe(2); + }); +}); + +describe("getGenesisOperation", () => { + it("returns the first op", () => { + const op: PlcOperation = { type: "x", prev: null, sig: "y" }; + expect(getGenesisOperation([op])).toBe(op); + }); + it("throws on empty log", () => { + expect(() => getGenesisOperation([])).toThrow(); + }); +}); + +describe("signRotationHash + verifyPrivateKeyMatch", () => { + it("round-trips: signature verifies, private key matches its public", () => { + const priv = secp256k1.utils.randomSecretKey(); + const pub = secp256k1.getPublicKey(priv, true); + expect(verifyPrivateKeyMatch(priv, pub, "secp256k1")).toBe(true); + + const txHash = new Uint8Array(32).fill(7); + const sig = signRotationHash(priv, txHash, "secp256k1"); + expect(sig.length).toBe(64); + expect( + secp256k1.verify(sig, txHash, pub, { prehash: true, lowS: true }), + ).toBe(true); + }); + + it("rejects a private key whose pubkey doesn't match", () => { + const a = secp256k1.utils.randomSecretKey(); + const b = secp256k1.utils.randomSecretKey(); + expect( + verifyPrivateKeyMatch(a, secp256k1.getPublicKey(b, true), "secp256k1"), + ).toBe(false); + }); + + it("throws on wrong tx hash length", () => { + const priv = secp256k1.utils.randomSecretKey(); + expect(() => + signRotationHash(priv, new Uint8Array(31), "secp256k1"), + ).toThrow(); + }); +}); diff --git a/packages/did-ckb/src/resolver.test.ts b/packages/did-ckb/src/resolver.test.ts new file mode 100644 index 000000000..0dff1bef5 --- /dev/null +++ b/packages/did-ckb/src/resolver.test.ts @@ -0,0 +1,138 @@ +import { ccc } from "@ckb-ccc/core"; +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { DidCkbData } from "./codec.js"; +import { argsToDid } from "./identifier.js"; +import { + findDidCkbCell, + listDidCkbsByLock, + resolveDidCkb, +} from "./resolver.js"; + +describe("resolver", () => { + let client: ccc.Client; + + const codeHash = + "0x510150477b10d6ab551a509b71265f3164e9fd4137fcb5a4322f49f03092c7c5"; + const id = ("0x" + "ab".repeat(20)) as ccc.Hex; + const did = argsToDid(id); + + const sampleData = DidCkbData.fromV1({ + document: { hello: "world" }, + localId: undefined, + }); + + function fakeCell(args: ccc.Hex = id, data = sampleData): ccc.Cell { + return ccc.Cell.from({ + outPoint: { + txHash: + "0x1111111111111111111111111111111111111111111111111111111111111111", + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(300), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }), + type: ccc.Script.from({ + codeHash, + hashType: "type", + args, + }), + }, + outputData: ccc.hexFrom(data.toBytes()), + }); + } + + beforeEach(() => { + client = { + getKnownScript: vi.fn(), + findSingletonCellByType: vi.fn(), + findCells: vi.fn(), + } as unknown as ccc.Client; + + (client.getKnownScript as Mock).mockResolvedValue({ + codeHash, + hashType: "type", + cellDeps: [], + }); + }); + + it("findDidCkbCell returns a decoded record when a live cell exists", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(fakeCell()); + + const record = await findDidCkbCell({ client, id }); + expect(record?.did).toBe(did); + expect(record?.id).toBe(id); + expect(record?.data.value.document).toEqual({ hello: "world" }); + }); + + it("findDidCkbCell returns undefined when no live cell exists", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(undefined); + expect(await findDidCkbCell({ client, id })).toBeUndefined(); + }); + + it("resolveDidCkb rejects non-did:ckb strings", async () => { + await expect( + resolveDidCkb({ client, did: "did:plc:abc" }), + ).rejects.toThrow(); + }); + + it("resolveDidCkb resolves the same record as findDidCkbCell", async () => { + (client.findSingletonCellByType as Mock).mockResolvedValue(fakeCell()); + const record = await resolveDidCkb({ client, did }); + expect(record?.did).toBe(did); + }); + + it("listDidCkbsByLock decodes every cell yielded by findCells", async () => { + const other = ("0x" + "cd".repeat(20)) as ccc.Hex; + (client.findCells as Mock).mockImplementation(async function* () { + yield fakeCell(id); + yield fakeCell(other); + }); + + const lock = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }); + + const records = await listDidCkbsByLock({ client, lock }); + expect(records.map((r) => r.id)).toEqual([id, other]); + expect(records.map((r) => r.did)).toEqual([did, argsToDid(other)]); + }); + + it("listDidCkbsByLock skips cells without a type script", async () => { + const cellWithoutType = ccc.Cell.from({ + outPoint: { + txHash: + "0x2222222222222222222222222222222222222222222222222222222222222222", + index: 0, + }, + cellOutput: { + capacity: ccc.fixedPointFrom(100), + lock: ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0x", + }), + }, + outputData: "0x", + }); + (client.findCells as Mock).mockImplementation(async function* () { + yield cellWithoutType; + yield fakeCell(id); + }); + + const lock = ccc.Script.from({ + codeHash: "0x" + "0".repeat(64), + hashType: "type", + args: "0xdeadbeef", + }); + + const records = await listDidCkbsByLock({ client, lock }); + expect(records.length).toBe(1); + expect(records[0].id).toBe(id); + }); +}); diff --git a/packages/did-ckb/src/resolver.ts b/packages/did-ckb/src/resolver.ts new file mode 100644 index 000000000..16a41023b --- /dev/null +++ b/packages/did-ckb/src/resolver.ts @@ -0,0 +1,123 @@ +import { ccc } from "@ckb-ccc/core"; +import { DidCkbData } from "./codec"; +import { argsToDid, didToArgs, isDidCkb } from "./identifier"; + +export type DidCkbRecord = { + /** The `did:ckb:` string identifying this cell. */ + did: string; + /** 20-byte Type ID args (same value `createDidCkb` returned as `id`). */ + id: ccc.Hex; + /** Decoded DidCkbData; `.value.document` is the CBOR-decoded document. */ + data: DidCkbData; + /** The live DID Metadata Cell itself. */ + cell: ccc.Cell; +}; + +async function didCkbTypeScript( + client: ccc.Client, + id: ccc.HexLike, +): Promise { + const scriptInfo = await client.getKnownScript(ccc.KnownScript.DidCkb); + return ccc.Script.from({ + codeHash: scriptInfo.codeHash, + hashType: scriptInfo.hashType, + args: ccc.hexFrom(id), + }); +} + +function decodeRecord(cell: ccc.Cell, id: ccc.Hex): DidCkbRecord { + return { + did: argsToDid(id), + id, + data: DidCkbData.decode(cell.outputData), + cell, + }; +} + +/** + * Find the live DID Metadata Cell for a given Type ID and decode its data. + * + * Returns `undefined` when no live cell exists (the DID was never created or + * has been destroyed). + */ +export async function findDidCkbCell(props: { + client: ccc.Client; + id: ccc.HexLike; +}): Promise { + const id = ccc.hexFrom(props.id); + const type = await didCkbTypeScript(props.client, id); + const cell = await props.client.findSingletonCellByType(type, true); + if (!cell) { + return undefined; + } + return decodeRecord(cell, id); +} + +/** + * Resolve a `did:ckb:` string to its live cell + decoded document. + * + * Throws if `did` is not a syntactically valid did:ckb identifier. Returns + * `undefined` if the DID was created and then destroyed, or never existed. + */ +export async function resolveDidCkb(props: { + client: ccc.Client; + did: string; +}): Promise { + if (!isDidCkb(props.did)) { + throw new Error(`Not a did:ckb identifier: ${props.did}`); + } + return findDidCkbCell({ client: props.client, id: didToArgs(props.did) }); +} + +/** + * List every live DID Metadata Cell owned by the given lock. Useful for + * dashboards that want to enumerate the DIDs an address controls. + * + * Cells whose data fails to decode (e.g. a future on-chain schema version) are + * skipped, not thrown, so a single bad cell can't break the whole listing. + */ +export async function listDidCkbsByLock(props: { + client: ccc.Client; + lock: ccc.ScriptLike; + limit?: number; + order?: "asc" | "desc"; +}): Promise { + const scriptInfo = await props.client.getKnownScript(ccc.KnownScript.DidCkb); + const records: DidCkbRecord[] = []; + + // Filter by type.codeHash + hashType (args is prefix-matched against "0x", + // which matches any args). This is the standard indexer pattern for + // "any cell of this type, regardless of identifier". + for await (const cell of props.client.findCells( + { + script: ccc.Script.from(props.lock), + scriptType: "lock", + scriptSearchMode: "exact", + filter: { + script: ccc.Script.from({ + codeHash: scriptInfo.codeHash, + hashType: scriptInfo.hashType, + args: "0x", + }), + }, + withData: true, + }, + props.order, + props.limit, + )) { + const type = cell.cellOutput.type; + if (!type) { + continue; + } + const argsBytes = ccc.bytesFrom(type.args); + if (argsBytes.length !== 20) { + continue; + } + try { + records.push(decodeRecord(cell, ccc.hexFrom(type.args))); + } catch { + // Skip unparseable cells rather than fail the whole listing. + } + } + return records; +} diff --git a/packages/did-ckb/tsdown.config.mts b/packages/did-ckb/tsdown.config.mts index d952699a0..a8e2efae5 100644 --- a/packages/did-ckb/tsdown.config.mts +++ b/packages/did-ckb/tsdown.config.mts @@ -14,6 +14,7 @@ export default defineConfig( entry: { index: "src/index.ts", barrel: "src/barrel.ts", + plc: "src/plc/index.ts", }, format: "esm", copy: "./misc/basedirs/dist/*", @@ -22,6 +23,7 @@ export default defineConfig( entry: { index: "src/index.ts", barrel: "src/barrel.ts", + plc: "src/plc/index.ts", }, noExternal: ["@ipld/dag-cbor"] as string[], format: "cjs", diff --git a/packages/examples/src/didHistory.ts b/packages/examples/src/didHistory.ts new file mode 100644 index 000000000..7a4cb0cd0 --- /dev/null +++ b/packages/examples/src/didHistory.ts @@ -0,0 +1,26 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { signer } from "@ckb-ccc/playground"; + +// Walk the cell chain for an existing did:ckb back to its genesis. Newest +// entry is first; the last entry is CREATE (fresh mint) or MIGRATE (did:plc +// import). +const did = "did:ckb:qq2m72a2vas4e5ovcpxoedscguuu4nba"; + +const history = await ccc.didCkb.getDidCkbHistory({ + client: signer.client, + id: ccc.didCkb.didToArgs(did), +}); + +if (history.length === 0) { + console.log(`No history for ${did}; DID does not exist on this network.`); +} else { + console.log(`History of ${did}:`); + for (const entry of history) { + console.log( + ` [${entry.action}] tx=${entry.txHash} out=${entry.outputIndex} block=${entry.blockNumber ?? "?"}`, + ); + if (entry.data.value.localId) { + console.log(` localId: ${entry.data.value.localId}`); + } + } +} diff --git a/packages/examples/src/migrateDid.ts b/packages/examples/src/migrateDid.ts new file mode 100644 index 000000000..65781e1da --- /dev/null +++ b/packages/examples/src/migrateDid.ts @@ -0,0 +1,58 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// Import an existing did:plc into did:ckb. The on-chain contract requires a +// single PLC genesis operation in the witness plus a signature over the CKB +// transaction hash made by one of the genesis rotation keys. +const sourceDid = "did:plc:yunkr6vorfgzmvzeoofbkhq5"; +const rotationKeyIndex = 0; +const rotationPrivateKey = + "0x806d1925698097c64bc70f629e25b91b48a15eee4e492bb239402cee85356a10"; + +// Pull the genesis op from the public PLC directory. If you've already cached +// it locally, skip the fetch and pass the JSON object directly. +const log = await ccc.didCkb.plc.fetchPlcLog(sourceDid); +const genesisOperation = ccc.didCkb.plc.getGenesisOperation(log); + +// Quick sanity check before we burn capacity: does the private key match the +// rotation slot we intend to use? +const rotationKeys = ccc.didCkb.plc.getRotationKeys(genesisOperation); +const slot = rotationKeys[rotationKeyIndex]; +if ( + !ccc.didCkb.plc.verifyPrivateKeyMatch( + rotationPrivateKey, + slot.compressedPubkey, + slot.curve, + ) +) { + throw new Error( + `Private key does not match rotation key #${rotationKeyIndex} (${slot.curve})`, + ); +} + +// Build a create tx with localId stamped to sourceDid. +const { tx } = await ccc.didCkb.migrateDidCkb({ + signer, + sourceDid, + data: { value: { document: {} } }, +}); + +await tx.completeInputsByCapacity(signer); +await render(tx); +await tx.completeFeeBy(signer); +await render(tx); + +// Sign over the final tx hash and attach the migration witness on input 0. +const witness = ccc.didCkb.buildMigrationWitness({ + txHash: tx.hash(), + genesisOperation, + rotationKeyIndex, + rotationPrivateKey, +}); +tx.setWitnessArgsAt( + 0, + ccc.WitnessArgs.from({ outputType: ccc.hexFrom(witness.toBytes()) }), +); + +const txHash = await signer.sendTransaction(tx); +console.log(`Migration tx ${txHash} sent`); diff --git a/packages/examples/src/resolveDid.ts b/packages/examples/src/resolveDid.ts new file mode 100644 index 000000000..83190014b --- /dev/null +++ b/packages/examples/src/resolveDid.ts @@ -0,0 +1,33 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { signer } from "@ckb-ccc/playground"; + +// Resolve a did:ckb identifier to its live cell + decoded document. +// Replace the value below with an existing identifier on whichever network +// the signer is configured for. +const did = "did:ckb:qq2m72a2vas4e5ovcpxoedscguuu4nba"; + +const record = await ccc.didCkb.resolveDidCkb({ + client: signer.client, + did, +}); +if (!record) { + console.log(`No live cell for ${did}`); +} else { + console.log(`Resolved ${record.did}`); + console.log(` Type ID args: ${record.id}`); + console.log(` Document:`, record.data.value.document); + if (record.data.value.localId) { + console.log(` Imported from: ${record.data.value.localId}`); + } +} + +// You can also enumerate every DID owned by an address. +const owner = (await signer.getRecommendedAddressObj()).script; +const owned = await ccc.didCkb.listDidCkbsByLock({ + client: signer.client, + lock: owner, +}); +console.log(`Address controls ${owned.length} DID(s):`); +for (const entry of owned) { + console.log(` ${entry.did}`); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bab1068e1..1e7d89fec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: '@ipld/dag-cbor': specifier: ^9.2.5 version: 9.2.5 + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 devDependencies: '@eslint/js': specifier: ^9.34.0