// Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S. // atproto rejects high-S signatures as malleable and raw crypto.subtle.sign // emits them about half the time, so a test signing the raw way would verify // fine under WebCrypto while the reference implementation rejected it. import { sign } from '@pdsjs/core/crypto'; import { beforeEach, describe, expect, it } from 'vitest'; import { verifyCommit } from '../src/commit.js'; import { createMemoryBlobContext, createMemorySpaceStorage, } from '../src/memory-storage.js'; import { createSpaceRoutes } from '../src/routes.js'; import { readStar, verifyRecord, verifyRepoStar } from '../src/star.js'; import { applyWrites } from '../src/writer.js'; import { makeKemKey, responseMac, wantMac } from './helpers/kem.js'; const SPACE = 'at://did:plc:aaaa/space/com.example.forum/general'; const DID = 'did:plc:writer'; const COL = 'com.example.post'; const AUTH = { did: DID, scope: 'atproto' }; /** A real P-256 signer so commits can actually be verified. */ async function makeSigner() { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); return { signer: { /** @param {Uint8Array} bytes */ sign: (bytes) => sign(keys.privateKey, bytes), }, verifier: { /** * @param {string} _didKey @param {Uint8Array} data @param {Uint8Array} sig */ verify: async (_didKey, data, sig) => crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, keys.publicKey, /** @type {BufferSource} */ (sig), /** @type {BufferSource} */ (data), ), }, }; } /** @type {any} */ let routes; /** @type {any} */ let store; /** @type {any} */ let blobContext; /** @type {{privateKey: Uint8Array, didKey: string}} */ let kemKey; beforeEach(async () => { store = createMemorySpaceStorage(); const keys = await makeSigner(); blobContext = createMemoryBlobContext(); kemKey = await makeKemKey(); routes = createSpaceRoutes({ spaceStorage: store, ...blobContext, getDid: async () => DID, getSigner: async () => keys.signer, getKemKey: async () => kemKey, resolveDid: async () => null, verifier: keys.verifier, }); }); /** * @param {string} path * @param {Record} params * @param {Record} [headers] */ function get(path, params = {}, headers = {}) { const url = new URL(`https://pds.example${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); return routes[path].handler(new Request(url, { headers }), url, AUTH); } /** @param {string} s */ const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); /** @param {any} commit */ function fromJsonCommit(commit) { return { ...commit, data: fromB64(commit.data.$bytes) }; } /** @param {Array<[string, string, any]>} entries */ function seed(entries) { return applyWrites(store, { space: SPACE, writes: entries.map(([collection, rkey, record]) => ({ action: /** @type {const} */ ('create'), collection, rkey, record, })), }); } describe('getRecord', () => { it('returns the decoded record', async () => { await seed([[COL, 'a', { $type: COL, text: 'hi' }]]); const res = await get('/xrpc/com.atproto.space.getRecord', { space: SPACE, collection: COL, rkey: 'a', }); expect(res.status).toBe(200); const body = await res.json(); expect(body.uri).toBe(`${SPACE}/${DID}/${COL}/a`); expect(body.value).toEqual({ $type: COL, text: 'hi' }); }); it('404s a missing record', async () => { const res = await get('/xrpc/com.atproto.space.getRecord', { space: SPACE, collection: COL, rkey: 'nope', }); expect(res.status).toBe(404); expect((await res.json()).error).toBe('RecordNotFound'); }); it('404s a repo this server does not host', async () => { const res = await get('/xrpc/com.atproto.space.getRecord', { space: SPACE, repo: 'did:plc:elsewhere', collection: COL, rkey: 'a', }); expect(res.status).toBe(404); expect((await res.json()).error).toBe('RepoNotFound'); }); it('rejects a non-space URI', async () => { const res = await get('/xrpc/com.atproto.space.getRecord', { space: 'at://did:plc:aaaa/com.example.post/a', collection: COL, rkey: 'a', }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('InvalidSpaceUri'); }); }); describe('listRecords', () => { it('lists and paginates, newest first', async () => { await seed([ [COL, 'a', { v: 1 }], [COL, 'b', { v: 2 }], ]); const first = await ( await get('/xrpc/com.atproto.space.listRecords', { space: SPACE, collection: COL, limit: '1', }) ).json(); expect(first.records).toHaveLength(1); expect(first.records[0]).toEqual({ collection: COL, rkey: 'b', cid: expect.any(String), value: { v: 2 }, }); expect(first.cursor).toBeTruthy(); const second = await ( await get('/xrpc/com.atproto.space.listRecords', { space: SPACE, collection: COL, limit: '1', cursor: first.cursor, }) ).json(); expect(second.records[0].value).toEqual({ v: 1 }); }); it('gives the last record AT-URI as the cursor', async () => { await seed([ [COL, 'a', { v: 1 }], [COL, 'b', { v: 2 }], ]); const { cursor } = await ( await get('/xrpc/com.atproto.space.listRecords', { space: SPACE, collection: COL, limit: '1', }) ).json(); expect(cursor).toBe(`${SPACE}/${DID}/${COL}/b`); }); it('pages from a cursor the caller builds', async () => { await seed([ [COL, 'k1.x', { v: 1 }], [COL, 'k2.x', { v: 2 }], [COL, 'k2.y', { v: 3 }], [COL, 'k3.x', { v: 4 }], ]); /** @param {Record} params */ const rkeys = async (params) => { const { records } = await ( await get('/xrpc/com.atproto.space.listRecords', { space: SPACE, collection: COL, ...params, }) ).json(); return records.map((/** @type {any} */ r) => r.rkey); }; const uri = `${SPACE}/${DID}/${COL}/`; expect(await rkeys({ cursor: `${uri}k2/` })).toEqual([ 'k2.y', 'k2.x', 'k1.x', ]); expect(await rkeys({ cursor: `${uri}k2.x`, reverse: 'true' })).toEqual([ 'k2.y', 'k3.x', ]); }); it('puts every row or none past a cursor outside the repo', async () => { await seed([[COL, 'a', { v: 1 }]]); /** @param {string} cursor @param {boolean} reverse */ const count = async (cursor, reverse) => { const { records } = await ( await get('/xrpc/com.atproto.space.listRecords', { space: SPACE, cursor, ...(reverse ? { reverse: 'true' } : {}), }) ).json(); return records.length; }; expect(await count('at://zzz', false)).toBe(1); expect(await count('at://zzz', true)).toBe(0); expect(await count('at://a', false)).toBe(0); expect(await count('at://a', true)).toBe(1); }); it('lists across collections when none is named', async () => { await seed([ [COL, 'a', { v: 1 }], ['com.example.other', 'b', { v: 2 }], ]); const { records } = await ( await get('/xrpc/com.atproto.space.listRecords', { space: SPACE }) ).json(); expect(records.map((/** @type {any} */ r) => r.collection).sort()).toEqual([ 'com.example.other', COL, ]); }); it('omits values on request', async () => { await seed([[COL, 'a', { v: 1 }]]); const { records } = await ( await get('/xrpc/com.atproto.space.listRecords', { space: SPACE, excludeValues: 'true', }) ).json(); expect(records[0].value).toBeUndefined(); expect(records[0].cid).toEqual(expect.any(String)); }); it('rejects an out-of-range limit', async () => { const res = await get('/xrpc/com.atproto.space.listRecords', { space: SPACE, collection: COL, limit: '1001', }); expect(res.status).toBe(400); }); }); describe('getLatestCommit', () => { it('returns nothing before any write', async () => { const res = await get('/xrpc/com.atproto.space.getLatestCommit', { space: SPACE, }); expect(await res.json()).toEqual({}); }); it('answers the same commit under the getRepoState name', async () => { await seed([[COL, 'a', { v: 1 }]]); const canonical = await ( await get('/xrpc/com.atproto.space.getLatestCommit', { space: SPACE }) ).json(); const alias = await ( await get('/xrpc/com.atproto.space.getRepoState', { space: SPACE }) ).json(); expect(alias).toEqual(canonical); }); it('returns a plain commit, with no MAC for the own session that asks for none', async () => { const { rev } = await seed([[COL, 'a', { v: 1 }]]); const res = await get('/xrpc/com.atproto.space.getLatestCommit', { space: SPACE, }); const { commit } = await res.json(); expect(Object.keys(commit).sort()).toEqual([ 'data', 'repo', 'rev', 'space', 'ver', ]); expect(commit).toMatchObject({ ver: 1, repo: DID, space: SPACE, rev }); // Bytes travel as atproto's $bytes JSON shape. expect(typeof commit.data.$bytes).toBe('string'); expect(res.headers.get('atproto-space-mac')).toBeNull(); expect(res.headers.get('vary')).toContain('atproto-space-want-mac'); }); it('MACs the commit under the secret the Want-Mac key share derives', async () => { await seed([[COL, 'a', { v: 1 }]]); const want = await wantMac('atproto-space-repo', kemKey.didKey); const res = await get( '/xrpc/com.atproto.space.getLatestCommit', { space: SPACE }, { 'atproto-space-want-mac': want.header }, ); expect(res.status).toBe(200); expect(res.headers.get('access-control-expose-headers')).toContain( 'atproto-space-mac', ); const { commit } = await res.json(); const params = { space: SPACE, repo: DID, sharedSecret: want.sharedSecret, mac: responseMac(res, 'atproto-space-repo'), }; expect(await verifyCommit(fromJsonCommit(commit), params)).toBe(true); // Another syncer's secret does not verify it. const other = await wantMac('atproto-space-repo', kemKey.didKey); expect( await verifyCommit(fromJsonCommit(commit), { ...params, sharedSecret: other.sharedSecret, }), ).toBe(false); }); it('refuses a malformed Want-Mac header', async () => { await seed([[COL, 'a', { v: 1 }]]); for (const header of [ 'type="atproto-space-record", ver=1, key_share="did:key:zDnae"', 'type="atproto-space-repo", ver=2, key_share="did:key:zDnae"', 'type="atproto-space-repo", ver=1', 'type="atproto-space-repo", ver=1, key_share="did:key:z6Mkhello"', 'not a dictionary (', ]) { const res = await get( '/xrpc/com.atproto.space.getLatestCommit', { space: SPACE }, { 'atproto-space-want-mac': header }, ); expect(res.status, header).toBe(400); expect((await res.json()).error).toBe('BadSpaceMac'); } }); }); describe('getRecord MAC', () => { it('MACs the record uri and cid', async () => { await seed([[COL, 'a', { $type: COL, text: 'hi' }]]); const want = await wantMac('atproto-space-record', kemKey.didKey); const res = await get( '/xrpc/com.atproto.space.getRecord', { space: SPACE, collection: COL, rkey: 'a' }, { 'atproto-space-want-mac': want.header }, ); const record = await res.json(); const uri = `${SPACE}/${DID}/${COL}/a`; const params = { uri, sharedSecret: want.sharedSecret, mac: responseMac(res, 'atproto-space-record'), }; expect(await verifyRecord(record, params)).toBe(true); expect( await verifyRecord({ ...record, value: { text: 'changed' } }, params), ).toBe(false); expect( await verifyRecord(record, { ...params, uri: `${SPACE}/${DID}/${COL}/b`, }), ).toBe(false); }); // A blob ref is a CID link, so the record's CID is the one any other // implementation computes from the JSON it is served. it('serves a record with a blob ref under the CID of its JSON', async () => { const record = { $type: COL, image: { $type: 'blob', ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy', }, mimeType: 'image/png', size: 3, }, }; await seed([[COL, 'a', record]]); const want = await wantMac('atproto-space-record', kemKey.didKey); const res = await get( '/xrpc/com.atproto.space.getRecord', { space: SPACE, collection: COL, rkey: 'a' }, { 'atproto-space-want-mac': want.header }, ); const body = await res.json(); expect(body.value).toEqual(record); expect( await verifyRecord(body, { uri: `${SPACE}/${DID}/${COL}/a`, sharedSecret: want.sharedSecret, mac: responseMac(res, 'atproto-space-record'), }), ).toBe(true); }); it('refuses a repo MAC type on getRecord', async () => { await seed([[COL, 'a', { v: 1 }]]); const want = await wantMac('atproto-space-repo', kemKey.didKey); const res = await get( '/xrpc/com.atproto.space.getRecord', { space: SPACE, collection: COL, rkey: 'a' }, { 'atproto-space-want-mac': want.header }, ); expect(res.status).toBe(400); expect((await res.json()).error).toBe('BadSpaceMac'); }); }); describe('listRepoOps', () => { it('inlines current record values and carries the head commit', async () => { await seed([[COL, 'a', { v: 1 }]]); const body = await ( await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE }) ).json(); expect(body.ops).toHaveLength(1); expect(body.ops[0].value).toEqual({ v: 1 }); expect(body.ops[0].prev).toBeNull(); expect(body.commit).toBeTruthy(); }); it('MACs the head commit it carries', async () => { await seed([[COL, 'a', { v: 1 }]]); const want = await wantMac('atproto-space-repo', kemKey.didKey); const res = await get( '/xrpc/com.atproto.space.listRepoOps', { space: SPACE }, { 'atproto-space-want-mac': want.header }, ); const body = await res.json(); expect( await verifyCommit(fromJsonCommit(body.commit), { space: SPACE, repo: DID, sharedSecret: want.sharedSecret, mac: responseMac(res, 'atproto-space-repo'), }), ).toBe(true); }); it('omits values when excludeValues is set', async () => { await seed([[COL, 'a', { v: 1 }]]); const body = await ( await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE, excludeValues: 'true', }) ).json(); expect(body.ops[0].value).toBeUndefined(); }); it('omits a superseded value but keeps the op', async () => { await seed([[COL, 'a', { v: 1 }]]); await applyWrites(store, { space: SPACE, writes: [ { action: 'update', collection: COL, rkey: 'a', record: { v: 2 } }, ], }); const body = await ( await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE }) ).json(); expect(body.ops).toHaveLength(2); // The first op's value is stale: the stored record is the second's. expect(body.ops[0].value).toBeUndefined(); expect(body.ops[1].value).toEqual({ v: 2 }); }); it('gives a cursor and no commit or MAC when more ops remain', async () => { await seed([ [COL, 'a', { v: 1 }], [COL, 'b', { v: 2 }], ]); const want = await wantMac('atproto-space-repo', kemKey.didKey); const res = await get( '/xrpc/com.atproto.space.listRepoOps', { space: SPACE, limit: '1' }, { 'atproto-space-want-mac': want.header }, ); const body = await res.json(); expect(body.ops).toHaveLength(1); expect(body.cursor).toBeTruthy(); // The commit describes the head, so it must not appear on a backfill page. expect(body.commit).toBeUndefined(); expect(res.headers.get('atproto-space-mac')).toBeNull(); }); it('deletes carry a null cid and no value', async () => { await seed([[COL, 'a', { v: 1 }]]); const { rev } = await applyWrites(store, { space: SPACE, writes: [{ action: 'delete', collection: COL, rkey: 'a' }], }); const body = await ( await get('/xrpc/com.atproto.space.listRepoOps', { space: SPACE, since: '', }) ).json(); const del = body.ops.find((/** @type {any} */ o) => o.rev === rev); expect(del.cid).toBeNull(); expect(del.value).toBeUndefined(); }); }); describe('getRepo', () => { it('404s a repo that has never been written to', async () => { const res = await get('/xrpc/com.atproto.space.getRepo', { space: SPACE }); expect(res.status).toBe(404); expect((await res.json()).error).toBe('RepoNotFound'); }); it('serialises the commit then records in byte order of their paths', async () => { // 'com.e.p/a' sorts before 'com.example.post/a' byte for byte. await seed([ [COL, 'a', { v: 1 }], ['com.e.p', 'a', { v: 2 }], ]); const res = await get('/xrpc/com.atproto.space.getRepo', { space: SPACE }); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('application/octet-stream'); const { meta, pairs } = readStar(new Uint8Array(await res.arrayBuffer())); expect(meta.$type).toBe('atproto-space-repo'); const decoder = new TextDecoder(); expect(pairs.map((p) => decoder.decode(p.key))).toEqual([ 'com.e.p/a', `${COL}/a`, ]); }); it('serves a repo that verifies against the MAC it carries', async () => { const { rev } = await seed([ [COL, 'a', { v: 1 }], [COL, 'b', { v: 2 }], ]); const want = await wantMac('atproto-space-repo', kemKey.didKey); const res = await get( '/xrpc/com.atproto.space.getRepo', { space: SPACE }, { 'atproto-space-want-mac': want.header }, ); const { commit, records } = await verifyRepoStar( new Uint8Array(await res.arrayBuffer()), { space: SPACE, repo: DID, sharedSecret: want.sharedSecret, mac: responseMac(res, 'atproto-space-repo'), }, ); expect(commit.rev).toBe(rev); expect(records.map((r) => r.record)).toEqual([{ v: 1 }, { v: 2 }]); }); });