# Permissioned Data — Phase 1: Primitives Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the cryptographic and addressing primitives for AT Protocol permissioned data — vendored BLAKE3 XOF, LtHash set hashing, signed commits, and space AT-URIs — in a new dependency-free `@pdsjs/spaces` package, verified byte-for-byte against vectors generated from the reference implementation. **Architecture:** New workspace package `packages/spaces` with no external dependencies. BLAKE3 is hand-vendored (WebCrypto has no BLAKE3); everything else builds on `crypto.subtle`. Signature verification is a port — P-256 ships built in, secp256k1 is injected by the deployment. Nothing in this phase touches `@pdsjs/core` except adding JSDoc typedefs to `ports.js`. **Tech Stack:** plain ESM JS + JSDoc types, vitest, biome (single quotes, 2-space indent), TDD per task, one commit per task. ## Global Constraints - Spec: `docs/superpowers/specs/2026-07-30-permissioned-data-design.md`. Reference behavior is authoritative. - Reference pin: `bluesky-social/atproto` PR #5187, head `24a81bf5ab8d39557da35a4c4c0978c9aa62c45a`. Fetched locally as branch `pr-5187` in `~/code/atproto`. - `@pdsjs/spaces` has **zero external dependencies**. Workspace-only dependency on `@pdsjs/core`. Do not add anything to `package.json` `dependencies` beyond `"@pdsjs/core": "workspace:*"`. - `@pdsjs/core` must never import `@pdsjs/spaces`. Dependency direction is one-way. - All crypto except BLAKE3 uses `crypto.subtle`. Do not vendor a second hash. - Plain ESM JS with JSDoc type annotations. No TypeScript source files. - Tests live at `packages/spaces/test/*.test.js` (vitest `include` already covers `packages/*/test/**/*.test.js`). - Every task: failing test first, verify RED, minimal implementation, verify GREEN, `npx biome check --write .`, commit. - Branch: `permissioned-data`. --- ### Task 1: Scaffold `@pdsjs/spaces` and vendor BLAKE3 XOF **Files:** - Create: `packages/spaces/package.json` - Create: `packages/spaces/src/blake3.js` - Create: `packages/spaces/src/index.js` - Create: `packages/spaces/test/blake3.test.js` - Create: `test/fixtures/blake3-vectors.json` (downloaded) **Interfaces:** - Consumes: nothing. - Produces: `blake3(input: Uint8Array, outLen?: number) => Uint8Array` — unkeyed BLAKE3 with extendable output. `outLen` defaults to 32. This is the only export of `blake3.js`. - [ ] **Step 1: Create the package manifest** Create `packages/spaces/package.json`: ```json { "name": "@pdsjs/spaces", "version": "0.8.0", "type": "module", "main": "./src/index.js", "types": "./src/index.d.ts", "exports": { ".": "./src/index.js", "./blake3": "./src/blake3.js", "./lthash": "./src/lthash.js", "./path": "./src/path.js", "./uri": "./src/uri.js", "./mac": "./src/mac.js", "./verifier": "./src/verifier.js", "./commit": "./src/commit.js" }, "dependencies": { "@pdsjs/core": "workspace:*" } } ``` Create `packages/spaces/src/index.js` with a single line so the package resolves: ```js // @pdsjs/spaces - AT Protocol permissioned data (proposal 0016) export { blake3 } from './blake3.js'; ``` Run `pnpm install` to link the workspace package. - [ ] **Step 2: Download the official BLAKE3 test vectors** ```bash mkdir -p test/fixtures curl -sSL -o test/fixtures/blake3-vectors.json \ https://raw.githubusercontent.com/BLAKE3-team/BLAKE3/master/test_vectors/test_vectors.json ``` The file has shape `{ key, context_string, cases: [{ input_len, hash, keyed_hash, derive_key }] }`. Only `input_len` and `hash` matter here — `hash` is 131 bytes of extended output as hex (262 hex chars), which exercises the XOF path. The input for a case of length `n` is the byte sequence `i % 251` for `i` in `[0, n)`. This is stated in the BLAKE3 repo's `test_vectors/README.md`. Verify the download is non-empty and has cases: ```bash node -e "const v=require('./test/fixtures/blake3-vectors.json'); console.log(v.cases.length, 'cases, lens:', v.cases.map(c=>c.input_len).join(','))" ``` Expected: a list including `0,1,2,3,4,5,6,7,8,63,64,65,127,128,129,1023,1024,1025,2048,2049,3072,3073,4096,4097,5120,6144,7168,8192,16384,31744,102400`. - [ ] **Step 3: Write the failing test** Create `packages/spaces/test/blake3.test.js`: ```js import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { blake3 } from '../src/blake3.js'; const vectors = JSON.parse( readFileSync(new URL('../../../test/fixtures/blake3-vectors.json', import.meta.url), 'utf8'), ); /** Official BLAKE3 vector inputs are the repeating byte pattern i % 251. */ function vectorInput(len) { const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) bytes[i] = i % 251; return bytes; } function toHex(bytes) { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } describe('blake3', () => { it('matches the official vectors at 32-byte output', () => { for (const c of vectors.cases) { const out = blake3(vectorInput(c.input_len), 32); expect(toHex(out), `input_len=${c.input_len}`).toBe(c.hash.slice(0, 64)); } }); it('matches the official vectors at extended output length', () => { for (const c of vectors.cases) { const outLen = c.hash.length / 2; const out = blake3(vectorInput(c.input_len), outLen); expect(toHex(out), `input_len=${c.input_len} outLen=${outLen}`).toBe(c.hash); } }); it('produces 2048 bytes for LtHash expansion', () => { const out = blake3(new TextEncoder().encode('a'), 2048); expect(out.length).toBe(2048); }); it('defaults to 32 bytes', () => { expect(blake3(new Uint8Array(0)).length).toBe(32); }); }); ``` - [ ] **Step 4: Run test to verify it fails** Run: `npx vitest run packages/spaces/test/blake3.test.js` Expected: FAIL — `Failed to resolve import "../src/blake3.js"`. - [ ] **Step 5: Implement BLAKE3** Create `packages/spaces/src/blake3.js`. This is the only hand-rolled crypto in the tree; the file must open with a comment saying so and naming the vectors that gate it. These constants are the error-prone part and must be exact: ```js // @pdsjs/spaces/blake3 - vendored BLAKE3 with extendable output. // // This is the only hand-written cryptographic primitive in pds.js. WebCrypto // has no BLAKE3, and LtHash (proposal 0016) is defined in terms of it. Verified // against the official BLAKE3 test vectors in test/fixtures/blake3-vectors.json; // do not modify without re-running packages/spaces/test/blake3.test.js. const IV = new Uint32Array([ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, ]); const MSG_PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8]; const CHUNK_START = 1 << 0; const CHUNK_END = 1 << 1; const PARENT = 1 << 2; const ROOT = 1 << 3; const BLOCK_LEN = 64; const CHUNK_LEN = 1024; ``` The rest of the file: ```js const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0; /** The quarter-round. Rotations are 16, 12, 8, 7. */ function g(s, a, b, c, d, mx, my) { s[a] = (s[a] + s[b] + mx) >>> 0; s[d] = rotr(s[d] ^ s[a], 16); s[c] = (s[c] + s[d]) >>> 0; s[b] = rotr(s[b] ^ s[c], 12); s[a] = (s[a] + s[b] + my) >>> 0; s[d] = rotr(s[d] ^ s[a], 8); s[c] = (s[c] + s[d]) >>> 0; s[b] = rotr(s[b] ^ s[c], 7); } /** Four column mixes then four diagonal mixes. */ function round(s, m) { g(s, 0, 4, 8, 12, m[0], m[1]); g(s, 1, 5, 9, 13, m[2], m[3]); g(s, 2, 6, 10, 14, m[4], m[5]); g(s, 3, 7, 11, 15, m[6], m[7]); g(s, 0, 5, 10, 15, m[8], m[9]); g(s, 1, 6, 11, 12, m[10], m[11]); g(s, 2, 7, 8, 13, m[12], m[13]); g(s, 3, 4, 9, 14, m[14], m[15]); } function permute(m) { const out = new Uint32Array(16); for (let i = 0; i < 16; i++) out[i] = m[MSG_PERMUTATION[i]]; return out; } /** * @returns {Uint32Array} all 16 state words — the first 8 are the chaining * value, all 16 are needed for extended output. */ function compress(cv, block, counter, blockLen, flags) { const s = new Uint32Array(16); s.set(cv, 0); s[8] = IV[0]; s[9] = IV[1]; s[10] = IV[2]; s[11] = IV[3]; s[12] = counter >>> 0; s[13] = Math.floor(counter / 2 ** 32) >>> 0; s[14] = blockLen; s[15] = flags; let m = block; for (let i = 0; i < 7; i++) { round(s, m); if (i < 6) m = permute(m); } for (let i = 0; i < 8; i++) { s[i] = (s[i] ^ s[i + 8]) >>> 0; s[i + 8] = (s[i + 8] ^ cv[i]) >>> 0; } return s; } /** Read 16 little-endian uint32s from a 64-byte block. */ function wordsFromBlock(bytes, offset) { const m = new Uint32Array(16); for (let i = 0; i < 16; i++) { const o = offset + i * 4; m[i] = (bytes[o] | (bytes[o + 1] << 8) | (bytes[o + 2] << 16) | (bytes[o + 3] << 24)) >>> 0; } return m; } // An Output is a deferred compression: the final block of a chunk or parent, // not yet compressed, so the ROOT flag can still be applied to the last one. function outputChainingValue(o) { return compress(o.cv, o.block, o.counter, o.blockLen, o.flags).slice(0, 8); } function rootOutputBytes(o, outLen) { const out = new Uint8Array(outLen); let i = 0; let counter = 0; while (i < outLen) { const words = compress(o.cv, o.block, counter, o.blockLen, o.flags | ROOT); for (let w = 0; w < 16 && i < outLen; w++) { for (let b = 0; b < 4 && i < outLen; b++) { out[i++] = (words[w] >>> (8 * b)) & 0xff; } } counter++; } return out; } function chunkOutput(input, start, len, chunkCounter) { let cv = IV.slice(0, 8); let blockFlags = CHUNK_START; let pos = 0; while (len - pos > BLOCK_LEN) { const m = wordsFromBlock(input, start + pos); cv = compress(cv, m, chunkCounter, BLOCK_LEN, blockFlags).slice(0, 8); pos += BLOCK_LEN; blockFlags = 0; } const blockLen = len - pos; const padded = new Uint8Array(BLOCK_LEN); padded.set(input.subarray(start + pos, start + len)); return { cv, block: wordsFromBlock(padded, 0), counter: chunkCounter, blockLen, flags: blockFlags | CHUNK_END, }; } function parentOutput(leftCv, rightCv) { const block = new Uint32Array(16); block.set(leftCv, 0); block.set(rightCv, 8); return { cv: IV.slice(0, 8), block, counter: 0, blockLen: BLOCK_LEN, flags: PARENT, }; } /** * Unkeyed BLAKE3 with extendable output. * @param {Uint8Array} input * @param {number} [outLen] - output length in bytes, default 32 * @returns {Uint8Array} */ export function blake3(input, outLen = 32) { const chunks = Math.max(1, Math.ceil(input.length / CHUNK_LEN)); const stack = []; let output; for (let i = 0; i < chunks; i++) { const start = i * CHUNK_LEN; const len = Math.min(CHUNK_LEN, Math.max(0, input.length - start)); const chunk = chunkOutput(input, start, len, i); // The last chunk stays an Output so the ROOT flag can reach it. if (i === chunks - 1) { output = chunk; break; } // Merge while the completed-chunk count has a trailing zero bit. let cv = outputChainingValue(chunk); let n = i + 1; while ((n & 1) === 0) { cv = outputChainingValue(parentOutput(stack.pop(), cv)); n >>= 1; } stack.push(cv); } while (stack.length > 0) { output = parentOutput(stack.pop(), outputChainingValue(output)); } return rootOutputBytes(output, outLen); } ``` The single-chunk shortcut is not sufficient: the vectors include inputs up to 102400 bytes, so the tree path must work. - [ ] **Step 6: Run test to verify it passes** Run: `npx vitest run packages/spaces/test/blake3.test.js` Expected: PASS, 4 tests. If the 32-byte test passes but the extended-output test fails, the bug is in the root output-block counter (step 7). If short inputs pass and inputs ≥ 1024 fail, the bug is in the CV stack merge (step 6). - [ ] **Step 7: Format and commit** ```bash npx biome check --write packages/spaces test/fixtures git add packages/spaces test/fixtures/blake3-vectors.json pnpm-lock.yaml git commit -m "feat(spaces): scaffold @pdsjs/spaces with vendored BLAKE3 XOF" ``` --- ### Task 2: Generate reference vectors **Files:** - Create: `scripts/gen-space-vectors.md` (documented procedure) - Create: `test/fixtures/space-vectors.json` (generated, committed) **Interfaces:** - Consumes: nothing from earlier tasks. - Produces: `test/fixtures/space-vectors.json` with this exact shape, consumed by Tasks 3, 6, and 7: ```json { "generatedFrom": { "repo": "bluesky-social/atproto", "pr": 5187, "sha": "24a81bf5ab8d39557da35a4c4c0978c9aa62c45a" }, "lthash": { "empty": { "stateHex": "…", "digestHex": "…" }, "single": [{ "element": "…", "stateHex": "…", "digestHex": "…" }], "sets": [{ "elements": ["…"], "digestHex": "…" }] }, "commitCtx": [ { "space": "…", "author": "…", "rev": "…", "ikmHex": "…", "ctxHex": "…" } ], "signedCommits": [ { "didKey": "did:key:z…", "ctx": { "space": "…", "author": "…", "rev": "…" }, "records": [{ "collection": "…", "rkey": "…", "cid": "…" }], "commit": { "ver": 1, "hashHex": "…", "ikmHex": "…", "sigHex": "…", "macHex": "…", "rev": "…" } } ] } ``` - [ ] **Step 1: Create an isolated worktree of the reference** The `~/code/atproto` checkout has uncommitted work on another branch. Do **not** check out `pr-5187` in place. ```bash cd ~/code/atproto git worktree add ~/code/atproto-space-ref pr-5187 cd ~/code/atproto-space-ref pnpm install --frozen-lockfile ``` Verify the space package is present: ```bash ls packages/space/src ``` Expected: `index.ts`, `lthash.ts`, `repo-commit.ts`, `types.ts`, `util.ts`, and others. - [ ] **Step 2: Write the generator as a vitest test inside the worktree** `packages/space` is a registered vitest project, so a test file runs the TypeScript sources directly with no build step. Create `~/code/atproto-space-ref/packages/space/tests/gen-vectors.test.ts` (untracked scratch file in the worktree — it is never committed to atproto): ```ts import { writeFileSync } from 'node:fs' import { P256Keypair } from '@atproto/crypto' import { parseCid } from '@atproto/lex-data' import { it } from 'vitest' import { LtHash, RepoCommit, encodeCommitCtx } from '../src/index.js' const OUT = process.env.VECTORS_OUT const hex = (b: Uint8Array) => Buffer.from(b).toString('hex') // Substitute two real CIDs — see Step 2a. Placeholder strings will fail to // parse and the generator will throw rather than emit bad vectors. const CID_A = process.env.CID_A! const CID_B = process.env.CID_B! const ELEMENTS = [ 'a', 'app.example.post/3kabcdefghi/' + CID_A, 'com.atmoboards.thread/self/' + CID_B, ] it('generates vectors', async () => { const lthashSingle = ELEMENTS.map((element) => { const h = new LtHash().add(element) return { element, stateHex: hex(h.state()), digestHex: hex(h.digest()) } }) const sets = [ { elements: [ELEMENTS[0], ELEMENTS[1]] }, { elements: [ELEMENTS[1], ELEMENTS[0]] }, { elements: ELEMENTS }, ].map(({ elements }) => { const h = new LtHash() for (const e of elements) h.add(e) return { elements, digestHex: hex(h.digest()) } }) const empty = new LtHash() const ctxCases = [ { space: 'at://did:plc:aaaa/space/com.example.type/key1', author: 'did:plc:bbbb', rev: '3lkabcdefgh2x' }, { space: 'at://did:plc:cccc/space/com.atmoboards.forum/general', author: 'did:plc:dddd', rev: '3lkzzzzzzzz2x' }, ] const commitCtx = ctxCases.map((c, i) => { const ikm = new Uint8Array(32).fill(i + 1) return { ...c, ikmHex: hex(ikm), ctxHex: hex(encodeCommitCtx(c, ikm)) } }) const keypair = await P256Keypair.create({ exportable: true }) const records = [ { collection: 'app.example.post', rkey: '3kabcdefghi', cid: CID_A }, { collection: 'com.atmoboards.thread', rkey: 'self', cid: CID_B }, ] const repo = RepoCommit.fromRecords( records.map((r) => ({ ...r, cid: parseCid(r.cid) })), ) const ctx = ctxCases[0] const signed = await repo.sign(ctx, keypair) const out = { generatedFrom: { repo: 'bluesky-social/atproto', pr: 5187, sha: process.env.REF_SHA, }, lthash: { empty: { stateHex: hex(empty.state()), digestHex: hex(empty.digest()) }, single: lthashSingle, sets, }, commitCtx, signedCommits: [ { didKey: keypair.did(), ctx, records, commit: { ver: signed.ver, hashHex: hex(signed.hash), ikmHex: hex(signed.ikm), sigHex: hex(signed.sig), macHex: hex(signed.mac), rev: signed.rev, }, }, ], } writeFileSync(OUT!, JSON.stringify(out, null, 2) + '\n') }) ``` If `parseCid` is not the correct export name in `@atproto/lex-data`, check `packages/lex/lex-data/src/index.ts` in the worktree and use the exported CID parser. The generator must fail loudly rather than emit placeholder CIDs. - [ ] **Step 2a: Source two real CIDs** Do not invent CID strings — an invalid CID either throws in the generator or, worse, produces vectors pds.js can reproduce but the reference cannot. Take two real ones from the reference's own fixtures: ```bash cd ~/code/atproto-space-ref grep -rhoE 'bafyrei[a-z2-7]{52}' packages --include='*.ts' --include='*.json' | sort -u | head -2 ``` If that yields fewer than two, generate them in the worktree instead: ```bash npx vitest run --reporter=basic --testNamePattern='__cids__' \ --root . packages/space 2>/dev/null || true ``` and failing that, add a throwaway test in the worktree that CBOR-encodes two small objects with the reference's own helpers and prints the resulting CIDs. Whatever the source, the two values must round-trip through the reference's CID parser before use. Export them for the next step: ```bash export CID_A= export CID_B= ``` - [ ] **Step 3: Run the generator** ```bash cd ~/code/atproto-space-ref REF_SHA=$(git rev-parse HEAD) \ CID_A=$CID_A CID_B=$CID_B \ VECTORS_OUT=~/code/pds.js/test/fixtures/space-vectors.json \ npx vitest run packages/space/tests/gen-vectors.test.ts ``` Expected: PASS, and the fixture written. - [ ] **Step 4: Verify the fixture** ```bash cd ~/code/pds.js node -e " const v = require('./test/fixtures/space-vectors.json'); console.log('sha', v.generatedFrom.sha); console.log('empty state all-zero:', /^0+$/.test(v.lthash.empty.stateHex)); console.log('state bytes:', v.lthash.single[0].stateHex.length / 2); console.log('order-independent:', v.lthash.sets[0].digestHex === v.lthash.sets[1].digestHex); console.log('ctx cases:', v.commitCtx.length, 'commits:', v.signedCommits.length); " ``` Expected: sha is the pinned SHA, empty state is all zeros, state is 2048 bytes, the two reordered sets share a digest, 2 ctx cases, 1 signed commit. If order-independence is false, the generator is wrong — stop and fix it before proceeding, because every downstream task trusts this file. - [ ] **Step 5: Document the regeneration procedure** Create `scripts/gen-space-vectors.md` recording: why the worktree exists, the exact commands from Steps 1–3, and the rule that regenerating this fixture is how pds.js resyncs with upstream. State that `generatedFrom.sha` must be updated in the spec's §0 pin whenever the fixture is regenerated. - [ ] **Step 6: Commit** ```bash npx biome check --write scripts test/fixtures git add test/fixtures/space-vectors.json scripts/gen-space-vectors.md git commit -m "test(spaces): reference vectors generated from atproto PR #5187" ``` --- ### Task 3: LtHash **Files:** - Create: `packages/spaces/src/lthash.js` - Create: `packages/spaces/test/lthash.test.js` - Modify: `packages/spaces/src/index.js` **Interfaces:** - Consumes: `blake3(input, outLen)` from Task 1; `test/fixtures/space-vectors.json` from Task 2. - Produces: - `LTHASH_STATE_BYTES = 2048` - `class LtHash` with `constructor(state?: Uint8Array | null)`, `add(element: string): this`, `remove(element: string): this`, `state(): Uint8Array`, `digest(): Promise`, `isEmpty(): boolean`, `equals(other: LtHash): boolean`. - **`digest()` is async** — it uses `crypto.subtle.digest('SHA-256', …)`. This differs from the reference's synchronous `digest()` and is deliberate (spec §2). - [ ] **Step 1: Write the failing test** Create `packages/spaces/test/lthash.test.js`: ```js import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { LTHASH_STATE_BYTES, LtHash } from '../src/lthash.js'; const vectors = JSON.parse( readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'), ); const toHex = (bytes) => Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); describe('LtHash', () => { it('starts as 2048 zero bytes', () => { const h = new LtHash(); expect(h.state()).toEqual(new Uint8Array(LTHASH_STATE_BYTES)); expect(h.isEmpty()).toBe(true); }); it('rejects a state of the wrong length', () => { expect(() => new LtHash(new Uint8Array(10))).toThrow(/2048/); }); it('add then remove returns to the zero state', () => { const h = new LtHash().add('a'); expect(h.isEmpty()).toBe(false); h.remove('a'); expect(h.isEmpty()).toBe(true); }); it('is order-independent', async () => { const a = new LtHash().add('a').add('b'); const b = new LtHash().add('b').add('a'); expect(a.equals(b)).toBe(true); expect(await a.digest()).toEqual(await b.digest()); }); it('distinguishes different elements', () => { expect(new LtHash().add('a').equals(new LtHash().add('b'))).toBe(false); }); it('round-trips through state()', () => { const h = new LtHash().add('a').add('b'); expect(new LtHash(h.state()).equals(h)).toBe(true); }); it('matches the reference empty state', async () => { const h = new LtHash(); expect(toHex(h.state())).toBe(vectors.lthash.empty.stateHex); expect(toHex(await h.digest())).toBe(vectors.lthash.empty.digestHex); }); it('matches the reference for single elements', async () => { for (const c of vectors.lthash.single) { const h = new LtHash().add(c.element); expect(toHex(h.state()), c.element).toBe(c.stateHex); expect(toHex(await h.digest()), c.element).toBe(c.digestHex); } }); it('matches the reference for element sets', async () => { for (const c of vectors.lthash.sets) { const h = new LtHash(); for (const e of c.elements) h.add(e); expect(toHex(await h.digest()), c.elements.join(',')).toBe(c.digestHex); } }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/spaces/test/lthash.test.js` Expected: FAIL — `Failed to resolve import "../src/lthash.js"`. - [ ] **Step 3: Implement LtHash** Create `packages/spaces/src/lthash.js`: ```js // @pdsjs/spaces/lthash - homomorphic set hash for permissioned repo commits. import { blake3 } from './blake3.js'; const LANES = 1024; const LANE_BYTES = 2; // cannot change without reworking the Uint16Array impl export const LTHASH_STATE_BYTES = LANES * LANE_BYTES; // 2048 /** * A homomorphic set hash. Each element expands to 1024 little-endian u16 lanes * summed into the state mod 2^16. Addition and subtraction commute, so the state * depends only on the current set, not on insertion order. */ export class LtHash { /** @param {Uint8Array|null} [state] */ constructor(state) { if (state && state.length !== LTHASH_STATE_BYTES) { throw new Error( `LtHash state must be ${LTHASH_STATE_BYTES} bytes, got ${state.length}`, ); } const buffer = new ArrayBuffer(LTHASH_STATE_BYTES); this.bytes = new Uint8Array(buffer); this.lanes = new Uint16Array(buffer); if (state) this.bytes.set(state); } /** @param {string} element @returns {this} */ add(element) { const lanes = expand(element); for (let i = 0; i < LANES; i++) this.lanes[i] += lanes[i]; return this; } /** @param {string} element @returns {this} */ remove(element) { const lanes = expand(element); for (let i = 0; i < LANES; i++) this.lanes[i] -= lanes[i]; return this; } /** @returns {Uint8Array} the full state, for persistence */ state() { return new Uint8Array(this.bytes); } /** @returns {Promise} */ async digest() { const hash = await crypto.subtle.digest('SHA-256', this.bytes); return new Uint8Array(hash); } /** @returns {boolean} */ isEmpty() { for (let i = 0; i < LANES; i++) if (this.lanes[i] !== 0) return false; return true; } /** @param {LtHash} other @returns {boolean} */ equals(other) { for (let i = 0; i < LANES; i++) { if (this.lanes[i] !== other.lanes[i]) return false; } return true; } } /** * Expand an element to 1024 little-endian u16 lanes with BLAKE3 in XOF mode. * @param {string} element * @returns {Uint16Array} */ function expand(element) { const expanded = blake3(new TextEncoder().encode(element), LTHASH_STATE_BYTES); const buffer = new ArrayBuffer(LTHASH_STATE_BYTES); new Uint8Array(buffer).set(expanded); return new Uint16Array(buffer); } ``` `Uint16Array` on a little-endian host reads the buffer little-endian, which matches the spec. Do not add byte-swapping. - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/spaces/test/lthash.test.js` Expected: PASS, 9 tests. If the structural tests pass but the reference-vector tests fail, the bug is in `blake3`, not here — the lane arithmetic has no other degrees of freedom. - [ ] **Step 5: Export from the package index** Modify `packages/spaces/src/index.js`: ```js // @pdsjs/spaces - AT Protocol permissioned data (proposal 0016) export { blake3 } from './blake3.js'; export { LTHASH_STATE_BYTES, LtHash } from './lthash.js'; ``` - [ ] **Step 6: Format and commit** ```bash npx biome check --write packages/spaces git add packages/spaces git commit -m "feat(spaces): LtHash homomorphic set hash" ``` --- ### Task 4: Record path and set-hash element helpers **Files:** - Create: `packages/spaces/src/path.js` - Create: `packages/spaces/test/path.test.js` - Modify: `packages/spaces/src/index.js` **Interfaces:** - Consumes: nothing. - Produces: - `formatRecordPath(collection: string, rkey: string) => string` — `"{collection}/{rkey}"` - `parseRecordPath(path: string) => { collection: string, rkey: string }` — throws on a path that is not exactly two segments - `formatSetHashElement(collection: string, rkey: string, cid: string) => string` — `"{collection}/{rkey}/{cid}"`, the element folded into LtHash - [ ] **Step 1: Write the failing test** Create `packages/spaces/test/path.test.js`: ```js import { describe, expect, it } from 'vitest'; import { formatRecordPath, formatSetHashElement, parseRecordPath, } from '../src/path.js'; describe('record paths', () => { it('formats a record path', () => { expect(formatRecordPath('app.example.post', '3kabc')).toBe( 'app.example.post/3kabc', ); }); it('parses a record path', () => { expect(parseRecordPath('app.example.post/3kabc')).toEqual({ collection: 'app.example.post', rkey: '3kabc', }); }); it('round-trips', () => { const path = formatRecordPath('com.atmoboards.thread', 'self'); expect(formatRecordPath(...Object.values(parseRecordPath(path)))).toBe(path); }); it('rejects paths without exactly two segments', () => { expect(() => parseRecordPath('only-one')).toThrow(/invalid record path/); expect(() => parseRecordPath('a/b/c')).toThrow(/invalid record path/); expect(() => parseRecordPath('')).toThrow(/invalid record path/); }); it('formats a set hash element', () => { expect( formatSetHashElement('app.example.post', '3kabc', 'bafyreiabc'), ).toBe('app.example.post/3kabc/bafyreiabc'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/spaces/test/path.test.js` Expected: FAIL — `Failed to resolve import "../src/path.js"`. - [ ] **Step 3: Implement** Create `packages/spaces/src/path.js`: ```js // @pdsjs/spaces/path - record path and set hash element formatting. /** * @param {string} collection * @param {string} rkey * @returns {string} */ export function formatRecordPath(collection, rkey) { return `${collection}/${rkey}`; } /** * @param {string} path * @returns {{collection: string, rkey: string}} */ export function parseRecordPath(path) { const parts = path.split('/'); if (parts.length !== 2 || !parts[0] || !parts[1]) { throw new Error(`invalid record path: ${path}`); } return { collection: parts[0], rkey: parts[1] }; } /** * The element a record contributes to a repo's set hash. * @param {string} collection * @param {string} rkey * @param {string} cid * @returns {string} */ export function formatSetHashElement(collection, rkey, cid) { return `${collection}/${rkey}/${cid}`; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/spaces/test/path.test.js` Expected: PASS, 5 tests. - [ ] **Step 5: Export and commit** Add to `packages/spaces/src/index.js`: ```js export { formatRecordPath, formatSetHashElement, parseRecordPath, } from './path.js'; ``` ```bash npx biome check --write packages/spaces git add packages/spaces git commit -m "feat(spaces): record path and set hash element helpers" ``` --- ### Task 5: Space AT-URIs **Files:** - Create: `packages/spaces/src/uri.js` - Create: `packages/spaces/test/uri.test.js` - Modify: `packages/spaces/src/index.js` **Interfaces:** - Consumes: nothing. - Produces: - `SPACE_MARKER = 'space'` - `formatSpaceUri({ spaceDid, spaceType, skey, authorDid?, collection?, rkey? }) => string` - `parseSpaceUri(uri: string) => { spaceDid, spaceType, skey, authorDid: string|null, collection: string|null, rkey: string|null }` — throws `Error` on a non-space or malformed URI - `isSpaceUri(uri: string) => boolean` - [ ] **Step 1: Write the failing test** Create `packages/spaces/test/uri.test.js`: ```js import { describe, expect, it } from 'vitest'; import { formatSpaceUri, isSpaceUri, parseSpaceUri } from '../src/uri.js'; const SPACE = 'at://did:plc:aaaa/space/com.atmoboards.forum/general'; const FULL = `${SPACE}/did:plc:bbbb/com.atmoboards.thread/3kabc`; describe('space URIs', () => { it('formats a bare space URI', () => { expect( formatSpaceUri({ spaceDid: 'did:plc:aaaa', spaceType: 'com.atmoboards.forum', skey: 'general', }), ).toBe(SPACE); }); it('formats a full record URI', () => { expect( formatSpaceUri({ spaceDid: 'did:plc:aaaa', spaceType: 'com.atmoboards.forum', skey: 'general', authorDid: 'did:plc:bbbb', collection: 'com.atmoboards.thread', rkey: '3kabc', }), ).toBe(FULL); }); it('parses a bare space URI', () => { expect(parseSpaceUri(SPACE)).toEqual({ spaceDid: 'did:plc:aaaa', spaceType: 'com.atmoboards.forum', skey: 'general', authorDid: null, collection: null, rkey: null, }); }); it('parses a full record URI', () => { expect(parseSpaceUri(FULL)).toEqual({ spaceDid: 'did:plc:aaaa', spaceType: 'com.atmoboards.forum', skey: 'general', authorDid: 'did:plc:bbbb', collection: 'com.atmoboards.thread', rkey: '3kabc', }); }); it('round-trips', () => { expect(formatSpaceUri(parseSpaceUri(FULL))).toBe(FULL); expect(formatSpaceUri(parseSpaceUri(SPACE))).toBe(SPACE); }); it('recognises space URIs by the marker segment', () => { expect(isSpaceUri(SPACE)).toBe(true); expect(isSpaceUri(FULL)).toBe(true); // Public repo URI: the first path segment is a collection NSID, which // always contains dots. The space marker never does. expect(isSpaceUri('at://did:plc:aaaa/app.bsky.feed.post/3kabc')).toBe(false); expect(isSpaceUri('at://did:plc:aaaa')).toBe(false); }); it('rejects malformed space URIs', () => { expect(() => parseSpaceUri('at://did:plc:aaaa/app.bsky.feed.post/3k')).toThrow(); // missing skey expect(() => parseSpaceUri('at://did:plc:aaaa/space/com.example.t')).toThrow(); // author present but collection/rkey truncated expect(() => parseSpaceUri(`${SPACE}/did:plc:bbbb/com.example.c`)).toThrow(); // non-DID authority expect(() => parseSpaceUri('at://alice.test/space/com.example.t/k')).toThrow(); }); it('rejects a space type that is not an NSID', () => { expect(() => parseSpaceUri('at://did:plc:aaaa/space/nodots/k')).toThrow(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/spaces/test/uri.test.js` Expected: FAIL — `Failed to resolve import "../src/uri.js"`. - [ ] **Step 3: Implement** Create `packages/spaces/src/uri.js`. The addressing scheme is: ``` at://{spaceDid}/space/{spaceType}/{skey}[/{authorDid}/{collection}/{rkey}] ``` Valid segment counts after the host are exactly 3 (bare space) or 6 (full record). Anything else is malformed. Validate that `spaceDid` and `authorDid` start with `did:`, and that `spaceType` and `collection` contain at least one dot (the NSID property the marker relies on). ```js // @pdsjs/spaces/uri - permissioned space AT-URI addressing. // // at://{spaceDid}/space/{spaceType}/{skey}[/{authorDid}/{collection}/{rkey}] // // Space URIs are distinguished from public ones by the literal `space` segment, // which contains no dots, whereas a collection NSID always contains at least two. export const SPACE_MARKER = 'space'; /** * @param {{spaceDid: string, spaceType: string, skey: string, * authorDid?: string|null, collection?: string|null, rkey?: string|null}} parts * @returns {string} */ export function formatSpaceUri(parts) { const { spaceDid, spaceType, skey, authorDid, collection, rkey } = parts; let uri = `at://${spaceDid}/${SPACE_MARKER}/${spaceType}/${skey}`; if (authorDid) uri += `/${authorDid}`; if (collection) uri += `/${collection}`; if (rkey) uri += `/${rkey}`; return uri; } /** * @param {string} uri * @returns {boolean} */ export function isSpaceUri(uri) { const segments = splitUri(uri); return segments !== null && segments[1] === SPACE_MARKER; } /** * @param {string} uri * @returns {{spaceDid: string, spaceType: string, skey: string, * authorDid: string|null, collection: string|null, rkey: string|null}} */ export function parseSpaceUri(uri) { const segments = splitUri(uri); if (segments === null || segments[1] !== SPACE_MARKER) { throw new Error(`not a space URI: ${uri}`); } const [spaceDid, , spaceType, skey, authorDid, collection, rkey] = segments; if (segments.length !== 4 && segments.length !== 7) { throw new Error(`invalid space URI: ${uri}`); } if (!spaceDid.startsWith('did:')) { throw new Error(`space authority must be a DID: ${uri}`); } if (!spaceType.includes('.')) { throw new Error(`space type must be an NSID: ${uri}`); } if (!skey) throw new Error(`invalid space URI: ${uri}`); if (segments.length === 4) { return { spaceDid, spaceType, skey, authorDid: null, collection: null, rkey: null }; } if (!authorDid.startsWith('did:')) { throw new Error(`space record author must be a DID: ${uri}`); } if (!collection.includes('.')) { throw new Error(`collection must be an NSID: ${uri}`); } if (!rkey) throw new Error(`invalid space URI: ${uri}`); return { spaceDid, spaceType, skey, authorDid, collection, rkey }; } /** * @param {string} uri * @returns {string[]|null} host followed by path segments, or null if not an at:// URI */ function splitUri(uri) { if (!uri.startsWith('at://')) return null; const segments = uri.slice('at://'.length).split('/'); if (segments.length < 2 || segments.some((s) => s === '')) return null; return segments; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/spaces/test/uri.test.js` Expected: PASS, 8 tests. - [ ] **Step 5: Export and commit** Add to `packages/spaces/src/index.js`: ```js export { SPACE_MARKER, formatSpaceUri, isSpaceUri, parseSpaceUri } from './uri.js'; ``` ```bash npx biome check --write packages/spaces git add packages/spaces git commit -m "feat(spaces): space AT-URI parsing and formatting" ``` --- ### Task 6: Commit context encoding and MAC **Files:** - Create: `packages/spaces/src/mac.js` - Create: `packages/spaces/test/mac.test.js` - Modify: `packages/spaces/src/index.js` **Interfaces:** - Consumes: `test/fixtures/space-vectors.json` from Task 2. - Produces: - `COMMIT_VERSION = 1` - `encodeCommitCtx(ctx: {space, author, rev}, ikm: Uint8Array) => Uint8Array` - `hmacSha256(key: Uint8Array, data: Uint8Array) => Promise` - `hkdfExpandSha256(ikm: Uint8Array, info: Uint8Array) => Promise` — 32 bytes - `computeMac(ikm: Uint8Array, ctxBytes: Uint8Array, hash: Uint8Array) => Promise` - `bytesEqual(a: Uint8Array, b: Uint8Array) => boolean` — length-checked **Critical detail:** the reference's `hkdfSha256(ikm, info)` is `expand(sha256, ikm, info, 32)` — HKDF-**Expand only**, with `ikm` used directly as the PRK. `crypto.subtle.deriveBits({name:'HKDF'})` performs extract **and** expand and will not match. Because the output length equals the hash length, expand collapses to a single block: ``` hkdfExpandSha256(ikm, info) = HMAC-SHA256(ikm, info || 0x01) ``` - [ ] **Step 1: Write the failing test** Create `packages/spaces/test/mac.test.js`: ```js import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { COMMIT_VERSION, bytesEqual, computeMac, encodeCommitCtx, hkdfExpandSha256, hmacSha256, } from '../src/mac.js'; const vectors = JSON.parse( readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'), ); const toHex = (b) => Array.from(b) .map((x) => x.toString(16).padStart(2, '0')) .join(''); const fromHex = (h) => new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16))); describe('encodeCommitCtx', () => { it('matches the reference vectors byte for byte', () => { for (const c of vectors.commitCtx) { const out = encodeCommitCtx( { space: c.space, author: c.author, rev: c.rev }, fromHex(c.ikmHex), ); expect(toHex(out), c.space).toBe(c.ctxHex); } }); it('starts with the domain prefix', () => { const out = encodeCommitCtx( { space: 'at://did:plc:a/space/com.e.t/k', author: 'did:plc:b', rev: '3l' }, new Uint8Array(32), ); expect(new TextDecoder().decode(out.slice(0, 16))).toBe('atproto-space-v1'); }); it('uses big-endian uint16 length prefixes', () => { const out = encodeCommitCtx( { space: 'ab', author: 'c', rev: 'd' }, new Uint8Array(1), ); // prefix(16) | 0x0000 0x0002 'ab' | 0x0000 0x0001 'c' | ... expect(out[16]).toBe(0x00); expect(out[17]).toBe(0x02); expect(String.fromCharCode(out[18], out[19])).toBe('ab'); }); it('rejects a field longer than uint16', () => { expect(() => encodeCommitCtx( { space: 'x'.repeat(0x10000), author: 'a', rev: 'b' }, new Uint8Array(0), ), ).toThrow(/uint16/); }); }); describe('mac', () => { it('hkdfExpandSha256 is a single HMAC block', async () => { const ikm = new Uint8Array(32).fill(7); const info = new TextEncoder().encode('info'); const expected = await hmacSha256( ikm, new Uint8Array([...info, 0x01]), ); expect(await hkdfExpandSha256(ikm, info)).toEqual(expected); }); it('reproduces the reference commit MAC', async () => { for (const c of vectors.signedCommits) { const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex)); const mac = await computeMac( fromHex(c.commit.ikmHex), ctxBytes, fromHex(c.commit.hashHex), ); expect(toHex(mac)).toBe(c.commit.macHex); } }); }); describe('bytesEqual', () => { it('compares by length and content', () => { expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false); expect(bytesEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); }); }); describe('COMMIT_VERSION', () => { it('is 1', () => { expect(COMMIT_VERSION).toBe(1); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/spaces/test/mac.test.js` Expected: FAIL — `Failed to resolve import "../src/mac.js"`. - [ ] **Step 3: Implement** Create `packages/spaces/src/mac.js`: ```js // @pdsjs/spaces/mac - commit context encoding and deniable-signature MAC. export const COMMIT_VERSION = 1; const DOMAIN_PREFIX = new TextEncoder().encode('atproto-space-v1'); /** * ctx = "atproto-space-v1" * || uint16be(len(space)) || space * || uint16be(len(author)) || author * || uint16be(len(rev)) || rev * || uint16be(len(ikm)) || ikm * * Length prefixes are big-endian per the TLS variable-length vector convention, * deliberately the opposite byte order from the set hash's lanes. * * @param {{space: string, author: string, rev: string}} ctx * @param {Uint8Array} ikm * @returns {Uint8Array} */ export function encodeCommitCtx(ctx, ikm) { const encoder = new TextEncoder(); const fields = [ encoder.encode(ctx.space), encoder.encode(ctx.author), encoder.encode(ctx.rev), ikm, ]; let size = DOMAIN_PREFIX.length; for (const field of fields) { if (field.length > 0xffff) { throw new Error('commit ctx field exceeds uint16 length prefix'); } size += 2 + field.length; } const out = new Uint8Array(size); out.set(DOMAIN_PREFIX); let offset = DOMAIN_PREFIX.length; for (const field of fields) { out[offset++] = (field.length >>> 8) & 0xff; out[offset++] = field.length & 0xff; out.set(field, offset); offset += field.length; } return out; } /** * @param {Uint8Array} key * @param {Uint8Array} data * @returns {Promise} */ export async function hmacSha256(key, data) { const cryptoKey = await crypto.subtle.importKey( 'raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('HMAC', cryptoKey, data); return new Uint8Array(sig); } /** * HKDF-Expand (RFC 5869 §2.3) with SHA-256 and L = 32, matching the reference's * `expand(sha256, ikm, info, 32)`. `ikm` is used directly as the PRK — there is * no extract step, which is why crypto.subtle's HKDF cannot be used here. With * L equal to the hash length this is exactly one block. * * @param {Uint8Array} ikm * @param {Uint8Array} info * @returns {Promise} */ export function hkdfExpandSha256(ikm, info) { const block = new Uint8Array(info.length + 1); block.set(info); block[info.length] = 0x01; return hmacSha256(ikm, block); } /** * @param {Uint8Array} ikm * @param {Uint8Array} ctxBytes * @param {Uint8Array} hash * @returns {Promise} */ export async function computeMac(ikm, ctxBytes, hash) { return hmacSha256(await hkdfExpandSha256(ikm, ctxBytes), hash); } /** * @param {Uint8Array} a * @param {Uint8Array} b * @returns {boolean} */ export function bytesEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/spaces/test/mac.test.js` Expected: PASS, 8 tests. If `encodeCommitCtx` matches but the MAC does not, the HKDF interpretation is wrong — re-read `packages/crypto/src/hmac.ts` in the worktree. - [ ] **Step 5: Export and commit** Add to `packages/spaces/src/index.js`: ```js export { COMMIT_VERSION, bytesEqual, computeMac, encodeCommitCtx, hkdfExpandSha256, hmacSha256, } from './mac.js'; ``` ```bash npx biome check --write packages/spaces git add packages/spaces git commit -m "feat(spaces): commit ctx encoding and deniable-signature MAC" ``` --- ### Task 7: Signature verifier port **Files:** - Create: `packages/spaces/src/verifier.js` - Create: `packages/spaces/test/verifier.test.js` - Modify: `packages/core/src/ports.js` - Modify: `packages/spaces/src/index.js` **Interfaces:** - Consumes: `bytesEqual` from Task 6 (not required, but available). - Produces: - `createVerifier({ secp256k1 } = {}) => { verify(didKey: string, data: Uint8Array, sig: Uint8Array): Promise }` - `parseDidKey(didKey: string) => { curve: 'p256'|'secp256k1', publicKey: Uint8Array }` — throws on unsupported multicodec - Typedefs in `packages/core/src/ports.js`: `SignatureVerifierPort`, `DidResolverPort` The multicodec prefixes are: `0x1200` (varint `0x80 0x24`) for P-256, `0xe7` (varint `0xe7 0x01`) for secp256k1. did:key values are `did:key:z`. `@pdsjs/core/crypto` already exports `base58btcDecode`-equivalent helpers used by `plc.js`; reuse them rather than writing new base58 code. Check `packages/core/src/crypto.js` and `packages/core/src/plc.js:47` for the encode path and add a decode counterpart there if one does not exist. - [ ] **Step 1: Write the failing test** Create `packages/spaces/test/verifier.test.js`: ```js import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { encodeCommitCtx } from '../src/mac.js'; import { createVerifier, parseDidKey } from '../src/verifier.js'; const vectors = JSON.parse( readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'), ); const fromHex = (h) => new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16))); describe('parseDidKey', () => { it('recognises the P-256 multicodec', () => { const { didKey } = vectors.signedCommits[0]; const parsed = parseDidKey(didKey); expect(parsed.curve).toBe('p256'); expect(parsed.publicKey.length).toBe(33); }); it('rejects a non-did:key string', () => { expect(() => parseDidKey('did:plc:aaaa')).toThrow(/did:key/); }); }); describe('createVerifier', () => { it('verifies a reference P-256 commit signature', async () => { const verifier = createVerifier(); const c = vectors.signedCommits[0]; const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex)); expect( await verifier.verify(c.didKey, ctxBytes, fromHex(c.commit.sigHex)), ).toBe(true); }); it('rejects a tampered signature', async () => { const verifier = createVerifier(); const c = vectors.signedCommits[0]; const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex)); const sig = fromHex(c.commit.sigHex); sig[0] ^= 0xff; expect(await verifier.verify(c.didKey, ctxBytes, sig)).toBe(false); }); it('rejects tampered data', async () => { const verifier = createVerifier(); const c = vectors.signedCommits[0]; const ctxBytes = encodeCommitCtx(c.ctx, fromHex(c.commit.ikmHex)); ctxBytes[0] ^= 0xff; expect( await verifier.verify(c.didKey, ctxBytes, fromHex(c.commit.sigHex)), ).toBe(false); }); it('rejects secp256k1 when no verifier is injected', async () => { const verifier = createVerifier(); // did:key with the secp256k1 multicodec (zQ3s… prefix) await expect( verifier.verify( 'zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme', new Uint8Array(1), new Uint8Array(64), ), ).rejects.toThrow(/secp256k1/); }); it('delegates secp256k1 to an injected verifier', async () => { let called = null; const verifier = createVerifier({ secp256k1: async (publicKey, data, sig) => { called = { publicKey, data, sig }; return true; }, }); const didKey = 'did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme'; const data = new Uint8Array([1, 2, 3]); const sig = new Uint8Array(64); expect(await verifier.verify(didKey, data, sig)).toBe(true); expect(called.data).toEqual(data); expect(called.publicKey.length).toBe(33); }); }); ``` The secp256k1 did:key in the last two cases is the well-known example from the did:key spec. If it does not decode to a 33-byte key, substitute any `did:key:zQ3s…` value produced by `Secp256k1Keypair.create().did()` in the reference worktree and record it in the test. - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/spaces/test/verifier.test.js` Expected: FAIL — `Failed to resolve import "../src/verifier.js"`. - [ ] **Step 3: Implement** Create `packages/spaces/src/verifier.js`: ```js // @pdsjs/spaces/verifier - signature verification port. // // P-256 ships built in on crypto.subtle. secp256k1 is injected: hand-rolling // ECDSA verification is a different risk class from hand-rolling a hash, since // a subtle bug means accepting a forged credential rather than a failing test. // Without an injected verifier, spaces interoperate only with P-256 accounts. import { base58btcDecode } from '@pdsjs/core/crypto'; const P256_MULTICODEC = [0x80, 0x24]; const SECP256K1_MULTICODEC = [0xe7, 0x01]; /** * @param {string} didKey - did:key:z… (the did: prefix is optional) * @returns {{curve: 'p256'|'secp256k1', publicKey: Uint8Array}} */ export function parseDidKey(didKey) { const value = didKey.startsWith('did:key:') ? didKey.slice(8) : didKey; if (!value.startsWith('z')) { throw new Error(`expected a did:key multibase value, got: ${didKey}`); } const bytes = base58btcDecode(value.slice(1)); if (bytes[0] === P256_MULTICODEC[0] && bytes[1] === P256_MULTICODEC[1]) { return { curve: 'p256', publicKey: bytes.slice(2) }; } if ( bytes[0] === SECP256K1_MULTICODEC[0] && bytes[1] === SECP256K1_MULTICODEC[1] ) { return { curve: 'secp256k1', publicKey: bytes.slice(2) }; } throw new Error(`unsupported did:key multicodec: ${didKey}`); } /** * @param {{secp256k1?: (publicKey: Uint8Array, data: Uint8Array, sig: Uint8Array) => Promise}} [opts] * @returns {{verify: (didKey: string, data: Uint8Array, sig: Uint8Array) => Promise}} */ export function createVerifier(opts = {}) { return { async verify(didKey, data, sig) { const { curve, publicKey } = parseDidKey(didKey); if (curve === 'p256') return verifyP256(publicKey, data, sig); if (!opts.secp256k1) { throw new Error( 'secp256k1 verification requires an injected verifier; ' + 'this deployment supports P-256 keys only', ); } return opts.secp256k1(publicKey, data, sig); }, }; } /** * @param {Uint8Array} publicKey - 33-byte compressed point * @param {Uint8Array} data * @param {Uint8Array} sig - 64-byte raw r||s * @returns {Promise} */ async function verifyP256(publicKey, data, sig) { const key = await crypto.subtle.importKey( 'raw', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'], ); return crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, key, sig, data, ); } ``` If `@pdsjs/core/crypto` has no `base58btcDecode`, add one next to the existing `base58btcEncode` used by `plc.js:57` and export it. Verify the encode/decode pair round-trips before relying on it. If `crypto.subtle.importKey('raw', …)` rejects a 33-byte compressed point on the target runtime, decompress to the 65-byte uncompressed form first. Node 22+, Deno, and workerd all accept compressed points; confirm rather than assume. - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/spaces/test/verifier.test.js` Expected: PASS, 7 tests. - [ ] **Step 5: Declare the ports in core** Modify `packages/core/src/ports.js`, adding these typedefs next to the existing `LexiconResolverPort`. This file is pure JSDoc ending in `export {}` — it gains type declarations and no runtime coupling. ```js /** * Verifies signatures against did:key identifiers. P-256 is always supported; * secp256k1 support depends on an injected implementation. * * @typedef {Object} SignatureVerifierPort * @property {(didKey: string, data: Uint8Array, sig: Uint8Array) => Promise} verify */ /** * Resolves a DID to its DID document. Injected rather than imported so that * @pdsjs/spaces stays dependency-free. * * @typedef {Object} DidResolverPort * @property {(did: string) => Promise} resolveDid */ ``` - [ ] **Step 6: Export and commit** Add to `packages/spaces/src/index.js`: ```js export { createVerifier, parseDidKey } from './verifier.js'; ``` ```bash npx biome check --write packages/spaces packages/core git add packages/spaces packages/core/src/ports.js git commit -m "feat(spaces): signature verifier port with injectable secp256k1" ``` --- ### Task 8: RepoCommit **Files:** - Create: `packages/spaces/src/commit.js` - Create: `packages/spaces/test/commit.test.js` - Modify: `packages/spaces/src/index.js` **Interfaces:** - Consumes: `LtHash` (Task 3), `formatSetHashElement` (Task 4), `COMMIT_VERSION`/`encodeCommitCtx`/`computeMac`/`bytesEqual` (Task 6), verifier port (Task 7). - Produces: - `class RepoCommit` with: - `constructor(setHash?: LtHash)` - `static fromState(state: Uint8Array|null|undefined): RepoCommit` - `static fromRecords(records: Iterable<{collection, rkey, cid}>): RepoCommit` - `static fromIndex(index: Record): RepoCommit` — index maps `"{collection}/{rkey}"` to CID string - `add(collection, rkey, cid): this` - `remove(collection, rkey, cid): this` - `applyOp(op: {collection, rkey, cid: string|null, prev: string|null}): this` - `applyOps(ops): this` - `matches(commit: SignedCommit): Promise` - `sign(ctx: {space, author, rev}, signer: {sign(bytes): Promise}): Promise` - `state(): Uint8Array` - `verifyCommit(commit, ctx, didKey, verifier): Promise` - `SignedCommit` shape: `{ ver: number, hash: Uint8Array, ikm: Uint8Array, sig: Uint8Array, mac: Uint8Array, rev: string }` Note `sign` takes a **signer object** (`{ sign(bytes) }`) rather than raw key material, so `@pdsjs/spaces` never handles private keys. `randomBytes(32)` comes from `crypto.getRandomValues`. - [ ] **Step 1: Write the failing test** Create `packages/spaces/test/commit.test.js`: ```js import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { RepoCommit, verifyCommit } from '../src/commit.js'; import { createVerifier } from '../src/verifier.js'; const vectors = JSON.parse( readFileSync(new URL('../../../test/fixtures/space-vectors.json', import.meta.url), 'utf8'), ); const fromHex = (h) => new Uint8Array(h.match(/../g).map((x) => parseInt(x, 16))); const toHex = (b) => Array.from(b) .map((x) => x.toString(16).padStart(2, '0')) .join(''); function referenceCommit() { const c = vectors.signedCommits[0]; return { ...c, signed: { ver: c.commit.ver, hash: fromHex(c.commit.hashHex), ikm: fromHex(c.commit.ikmHex), sig: fromHex(c.commit.sigHex), mac: fromHex(c.commit.macHex), rev: c.commit.rev, }, }; } describe('RepoCommit', () => { it('reproduces the reference set hash from the same records', async () => { const { records, signed } = referenceCommit(); const repo = RepoCommit.fromRecords(records); expect(toHex(await repo.setHash.digest())).toBe(toHex(signed.hash)); }); it('matches() compares against a signed commit', async () => { const { records, signed } = referenceCommit(); expect(await RepoCommit.fromRecords(records).matches(signed)).toBe(true); expect(await new RepoCommit().matches(signed)).toBe(false); }); it('fromIndex folds in the same records as fromRecords', async () => { const { records } = referenceCommit(); const index = Object.fromEntries( records.map((r) => [`${r.collection}/${r.rkey}`, r.cid]), ); const a = RepoCommit.fromRecords(records); const b = RepoCommit.fromIndex(index); expect(toHex(await a.setHash.digest())).toBe(toHex(await b.setHash.digest())); }); it('fromState round-trips', async () => { const { records } = referenceCommit(); const repo = RepoCommit.fromRecords(records); const restored = RepoCommit.fromState(repo.state()); expect(toHex(await restored.setHash.digest())).toBe( toHex(await repo.setHash.digest()), ); }); it('fromState treats null as an empty repo', () => { expect(RepoCommit.fromState(null).setHash.isEmpty()).toBe(true); }); it('applyOp handles create, update, and delete', async () => { const { records } = referenceCommit(); const [a, b] = records; const repo = new RepoCommit(); repo.applyOp({ collection: a.collection, rkey: a.rkey, cid: a.cid, prev: null }); repo.applyOp({ collection: b.collection, rkey: b.rkey, cid: b.cid, prev: null }); const full = RepoCommit.fromRecords(records); expect(repo.setHash.equals(full.setHash)).toBe(true); // delete removes it again repo.applyOp({ collection: b.collection, rkey: b.rkey, cid: null, prev: b.cid }); expect(repo.setHash.equals(RepoCommit.fromRecords([a]).setHash)).toBe(true); }); it('an update is remove-then-add', async () => { const { records } = referenceCommit(); const [a, b] = records; const repo = RepoCommit.fromRecords([a]); repo.applyOp({ collection: a.collection, rkey: a.rkey, cid: b.cid, prev: a.cid }); const expected = RepoCommit.fromRecords([ { collection: a.collection, rkey: a.rkey, cid: b.cid }, ]); expect(repo.setHash.equals(expected.setHash)).toBe(true); }); }); describe('verifyCommit', () => { const verifier = createVerifier(); it('accepts the reference commit', async () => { const { signed, ctx, didKey } = referenceCommit(); expect(await verifyCommit(signed, ctx, didKey, verifier)).toBe(true); }); it('rejects a wrong version', async () => { const { signed, ctx, didKey } = referenceCommit(); expect( await verifyCommit({ ...signed, ver: 2 }, ctx, didKey, verifier), ).toBe(false); }); it('rejects a rev mismatch between commit and ctx', async () => { const { signed, ctx, didKey } = referenceCommit(); expect( await verifyCommit(signed, { ...ctx, rev: 'different' }, didKey, verifier), ).toBe(false); }); it('rejects a tampered hash', async () => { const { signed, ctx, didKey } = referenceCommit(); const hash = new Uint8Array(signed.hash); hash[0] ^= 0xff; expect( await verifyCommit({ ...signed, hash }, ctx, didKey, verifier), ).toBe(false); }); it('rejects a tampered mac', async () => { const { signed, ctx, didKey } = referenceCommit(); const mac = new Uint8Array(signed.mac); mac[0] ^= 0xff; expect(await verifyCommit({ ...signed, mac }, ctx, didKey, verifier)).toBe( false, ); }); }); describe('sign', () => { it('produces a commit that verifies against our own verifier', async () => { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const signer = { sign: async (bytes) => new Uint8Array( await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, keys.privateKey, bytes, ), ), }; const verifier = { verify: async (_didKey, data, sig) => crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, keys.publicKey, sig, data, ), }; const { records } = referenceCommit(); const ctx = { space: 'at://did:plc:aaaa/space/com.example.type/k', author: 'did:plc:bbbb', rev: '3lkabcdefgh2x', }; const repo = RepoCommit.fromRecords(records); const signed = await repo.sign(ctx, signer); expect(signed.ver).toBe(1); expect(signed.rev).toBe(ctx.rev); expect(signed.ikm.length).toBe(32); expect(await verifyCommit(signed, ctx, 'ignored', verifier)).toBe(true); }); it('uses a fresh ikm per signature', async () => { const signer = { sign: async () => new Uint8Array(64) }; const ctx = { space: 'at://did:plc:a/space/com.e.t/k', author: 'did:plc:b', rev: '3l' }; const repo = new RepoCommit(); const a = await repo.sign(ctx, signer); const b = await repo.sign(ctx, signer); expect(toHex(a.ikm)).not.toBe(toHex(b.ikm)); expect(toHex(a.mac)).not.toBe(toHex(b.mac)); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run packages/spaces/test/commit.test.js` Expected: FAIL — `Failed to resolve import "../src/commit.js"`. - [ ] **Step 3: Implement** Create `packages/spaces/src/commit.js`: ```js // @pdsjs/spaces/commit - permissioned repo commits with deniable signatures. import { LtHash } from './lthash.js'; import { COMMIT_VERSION, bytesEqual, computeMac, encodeCommitCtx, } from './mac.js'; import { formatSetHashElement } from './path.js'; /** * @typedef {Object} SignedCommit * @property {number} ver * @property {Uint8Array} hash * @property {Uint8Array} ikm * @property {Uint8Array} sig * @property {Uint8Array} mac * @property {string} rev */ export class RepoCommit { /** @param {LtHash} [setHash] */ constructor(setHash = new LtHash()) { this.setHash = setHash; } /** @param {Uint8Array|null|undefined} state */ static fromState(state) { return new RepoCommit(new LtHash(state ?? null)); } /** @param {Iterable<{collection: string, rkey: string, cid: string}>} records */ static fromRecords(records) { const commit = new RepoCommit(); for (const { collection, rkey, cid } of records) { commit.add(collection, rkey, cid); } return commit; } /** * Fold in every record an index describes, to compare against a commit. * @param {Record} index - "{collection}/{rkey}" to CID */ static fromIndex(index) { const commit = new RepoCommit(); for (const [path, cid] of Object.entries(index)) { commit.setHash.add(`${path}/${cid}`); } return commit; } add(collection, rkey, cid) { this.setHash.add(formatSetHashElement(collection, rkey, cid)); return this; } remove(collection, rkey, cid) { this.setHash.remove(formatSetHashElement(collection, rkey, cid)); return this; } /** @param {{collection: string, rkey: string, cid: string|null, prev: string|null}} op */ applyOp(op) { if (op.prev) this.remove(op.collection, op.rkey, op.prev); if (op.cid) this.add(op.collection, op.rkey, op.cid); return this; } applyOps(ops) { for (const op of ops) this.applyOp(op); return this; } state() { return this.setHash.state(); } /** * Whether this repo's contents match a signed commit. Verify the commit first — * on its own this says nothing about authenticity. * @param {SignedCommit} commit */ async matches(commit) { return bytesEqual(await this.setHash.digest(), commit.hash); } /** * Sign a commit over the current contents. * * The signature covers only the ctx, never the digest, so a leaked commit * proves nothing about what the author wrote. The digest is bound to the ctx * by a symmetric MAC instead: readers get integrity, third parties get * nothing. A fresh ikm per commit means each reader receives a distinct one. * * @param {{space: string, author: string, rev: string}} ctx * @param {{sign: (bytes: Uint8Array) => Promise}} signer * @returns {Promise} */ async sign(ctx, signer) { const hash = await this.setHash.digest(); const ikm = crypto.getRandomValues(new Uint8Array(32)); const ctxBytes = encodeCommitCtx(ctx, ikm); return { ver: COMMIT_VERSION, hash, ikm, mac: await computeMac(ikm, ctxBytes, hash), sig: await signer.sign(ctxBytes), rev: ctx.rev, }; } } /** * Verify a commit's signature (authenticity) and MAC (integrity). Once this * passes, `hash` is trusted as the author's claim about their repo, which is * what makes RepoCommit#matches meaningful. * * @param {SignedCommit} commit * @param {{space: string, author: string, rev: string}} ctx * @param {string} didKey * @param {import('@pdsjs/core/ports').SignatureVerifierPort} verifier * @returns {Promise} */ export async function verifyCommit(commit, ctx, didKey, verifier) { if (commit.ver !== COMMIT_VERSION) return false; if (commit.rev !== ctx.rev) return false; const ctxBytes = encodeCommitCtx(ctx, commit.ikm); const mac = await computeMac(commit.ikm, ctxBytes, commit.hash); if (!bytesEqual(mac, commit.mac)) return false; return verifier.verify(didKey, ctxBytes, commit.sig); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run packages/spaces/test/commit.test.js` Expected: PASS, 14 tests. - [ ] **Step 5: Run the whole suite** Run: `npx vitest run packages/spaces` Expected: PASS across all six space test files. Then confirm nothing else regressed: Run: `npx vitest run packages/core` Expected: PASS — Task 7 touched only JSDoc in `ports.js`, plus a possible `base58btcDecode` addition to `crypto.js`. - [ ] **Step 6: Export and commit** Add to `packages/spaces/src/index.js`: ```js export { RepoCommit, verifyCommit } from './commit.js'; ``` Run the type build to confirm the JSDoc types are coherent: ```bash npm run build npm run typecheck ``` Expected: no errors. `packages/spaces/src/*.d.ts` files are generated. ```bash npx biome check --write packages/spaces git add packages/spaces git commit -m "feat(spaces): RepoCommit signing and verification" ``` --- ## Phase 1 exit criteria - [ ] `npx vitest run packages/spaces` passes - [ ] `npx vitest run` passes (no regressions) - [ ] `npm run typecheck` introduces no NEW errors relative to the branch point (`6a1b716`). `npm run typecheck` failing outright was never a realistic bar: the repo carried 91 pre-existing `error TS` lines at that commit (measured via `npx tsc --noEmit 2>&1 | grep -c "error TS"` in a clean worktree of `6a1b716`), none of which this phase is responsible for fixing. - [ ] `packages/spaces/package.json` `dependencies` contains only `@pdsjs/core` - [ ] `packages/core` does not import `@pdsjs/spaces` anywhere: `grep -rnE "(^|[^*])\s*import .*@pdsjs/spaces|require\(['\"]@pdsjs/spaces" packages/core/src | wc -l` returns 0. (A plain `grep -rn "@pdsjs/spaces" packages/core/src` returns 2, both inside JSDoc comment prose in `ports.js` rather than real imports, so the check must target import/require syntax specifically.) - [ ] `test/fixtures/space-vectors.json` `generatedFrom.sha` matches the pin in the spec's §0