chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# 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=<first cid>
export CID_B=<second cid>
```
- [ ] **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<Uint8Array>`, `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<Uint8Array>} */
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<Uint8Array>`
- `hkdfExpandSha256(ikm: Uint8Array, info: Uint8Array) => Promise<Uint8Array>` — 32 bytes
- `computeMac(ikm: Uint8Array, ctxBytes: Uint8Array, hash: Uint8Array) => Promise<Uint8Array>`
- `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<Uint8Array>}
*/
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<Uint8Array>}
*/
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<Uint8Array>}
*/
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<boolean> }`
- `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<base58btc(multicodec || compressedPublicKey)>`. `@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<boolean>}} [opts]
* @returns {{verify: (didKey: string, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}}
*/
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<boolean>}
*/
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<boolean>} 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<Object>} 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<string, string>): 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<boolean>`
- `sign(ctx: {space, author, rev}, signer: {sign(bytes): Promise<Uint8Array>}): Promise<SignedCommit>`
- `state(): Uint8Array`
- `verifyCommit(commit, ctx, didKey, verifier): Promise<boolean>`
- `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<string, string>} 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<Uint8Array>}} signer
* @returns {Promise<SignedCommit>}
*/
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<boolean>}
*/
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