// Drives the space handlers to build a repo and issue a credential, and writes // what they answer out for the reference implementation to verify. // // node scripts/space-reference/gen-artifacts.mjs // // This script plays the syncer: it asks every read for a MAC under a fresh // key share against the author's KEM key, and records the secret it derived. // Writes only. Every assertion lives on the reference side, so a failure there // is a disagreement about the protocol rather than about this script. import { mkdirSync, writeFileSync } from 'node:fs'; import { compressPublicKey, sign } from '@pdsjs/core/crypto'; import { publicKeyToDidKey } from '@pdsjs/core/plc'; import { createSpaceSigHeaders, generateSpaceSigKey, } from '@pdsjs/spaces/http-signature'; import { encapsulate } from '@pdsjs/spaces/kem'; import { formatWantMacHeader } from '@pdsjs/spaces/mac'; import { createMemoryBlobContext, createMemorySpaceStorage, } from '@pdsjs/spaces/memory-storage'; import { createSpaceRoutes } from '@pdsjs/spaces/routes'; import { makeSpaceRow } from '@pdsjs/spaces/space-row'; import { createVerifier } from '@pdsjs/spaces/verifier'; const outDir = process.argv[2]; if (!outDir) { console.error( 'usage: node scripts/space-reference/gen-artifacts.mjs ', ); process.exit(1); } mkdirSync(outDir, { recursive: true }); const AUTHORITY = 'did:plc:conformauthority'; const USER = 'did:plc:conformuser'; const SPACE = `at://${AUTHORITY}/space/com.example.forum/general`; const COL = 'com.example.post'; const BLOB = 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy'; async function identity() { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const raw = new Uint8Array( await crypto.subtle.exportKey('raw', keys.publicKey), ); return { didKey: publicKeyToDidKey(compressPublicKey(raw)), // sign() normalizes to low-S, which atproto requires; crypto.subtle.sign // alone emits the high-S variant about half the time. signer: { sign: (bytes) => sign(keys.privateKey, bytes) }, }; } /** A KEM key as the PDS holds it: the raw scalar, and its did:key. */ async function kemKey() { const pair = await crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits'], ); const { d } = await crypto.subtle.exportKey('jwk', pair.privateKey); const raw = new Uint8Array( await crypto.subtle.exportKey('raw', pair.publicKey), ); return { privateKey: new Uint8Array(Buffer.from(d, 'base64url')), didKey: publicKeyToDidKey(compressPublicKey(raw), 'p256'), }; } const hex = (/** @type {Uint8Array} */ b) => Buffer.from(b).toString('hex'); const user = await identity(); const authority = await identity(); const userKem = await kemKey(); const didDocs = { [USER]: { id: USER, verificationMethod: [ { id: '#atproto', type: 'Multikey', publicKeyMultibase: user.didKey.slice(8), }, ], }, [AUTHORITY]: { id: AUTHORITY, verificationMethod: [ { id: '#atproto', type: 'Multikey', publicKeyMultibase: authority.didKey.slice(8), }, ], }, }; /** One account's routes, on its own PDS. */ function routesFor(did, identity, kem) { const storage = createMemorySpaceStorage(); return { storage, routes: createSpaceRoutes({ spaceStorage: storage, ...createMemoryBlobContext(), getDid: async () => did, getSigner: async () => identity.signer, getKemKey: async () => kem, resolveDid: async (d) => didDocs[d] ?? null, verifier: createVerifier(), }), }; } const userPds = routesFor(USER, user, userKem); const authorityPds = routesFor(AUTHORITY, authority, null); const SCOPE = 'space:*?authority=*&collection=*'; const auth = { did: USER, scope: SCOPE }; async function post(routes, path, body, as = auth) { const url = new URL(`https://pds.example${path}`); return routes[path].handler( new Request(url, { method: 'POST', body: JSON.stringify(body) }), url, as, ); } async function get(routes, path, params, headers = {}, as = auth) { 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, as); } async function ok(res, what) { if (res.status !== 200) { throw new Error(`${what} failed: ${res.status} ${await res.text()}`); } return res; } // `com.e.p/x` sorts before `com.example.post/aaa` byte for byte, so the // archive's record order is exercised rather than incidentally correct. The // blob ref is a CID link, so the reference recomputing the record's CID from // its JSON shows pds.js stores it as one. const records = [ { collection: COL, rkey: 'aaa', record: { $type: COL, text: 'first' } }, { collection: COL, rkey: 'bbb', record: { $type: COL, text: 'second' } }, { collection: 'com.e.p', rkey: 'x', record: { $type: 'com.e.p', n: 42 } }, { collection: 'com.example.reply', rkey: 'zz', record: { $type: 'com.example.reply', deep: { nested: [1, 2, 3] } }, }, { collection: COL, rkey: 'ccc', record: { $type: COL, text: 'with a picture', image: { $type: 'blob', ref: { $link: BLOB }, mimeType: 'image/png', size: 1234, }, }, }, ]; for (const r of records) { await ok( await post(userPds.routes, '/xrpc/com.atproto.space.createRecord', { space: SPACE, repo: USER, ...r, }), 'createRecord', ); } // One encapsulation per request, as a syncer does. async function wantMac(type) { const { keyShare, sharedSecret } = await encapsulate(userKem.didKey); return { header: formatWantMacHeader(type, keyShare), keyShare, sharedSecretHex: hex(sharedSecret), }; } const repoMac = await wantMac('atproto-space-repo'); const repoRes = await ok( await get( userPds.routes, '/xrpc/com.atproto.space.getRepo', { space: SPACE }, { 'atproto-space-want-mac': repoMac.header }, ), 'getRepo', ); const star = new Uint8Array(await repoRes.arrayBuffer()); writeFileSync(`${outDir}/repo.star`, star); const commitMac = await wantMac('atproto-space-repo'); const commitRes = await ok( await get( userPds.routes, '/xrpc/com.atproto.space.getLatestCommit', { space: SPACE }, { 'atproto-space-want-mac': commitMac.header }, ), 'getLatestCommit', ); const { commit } = await commitRes.json(); const recordMac = await wantMac('atproto-space-record'); const recordRes = await ok( await get( userPds.routes, '/xrpc/com.atproto.space.getRecord', { space: SPACE, collection: COL, rkey: 'ccc' }, { 'atproto-space-want-mac': recordMac.header }, ), 'getRecord', ); const record = await recordRes.json(); // The credential chain: the user's PDS mints a delegation token, and the // authority's issues a credential bound to the holder's key. await authorityPds.storage.putSpace(makeSpaceRow(SPACE, { isOwner: true })); await authorityPds.storage.putMember(SPACE, USER, { read: true, write: true }); const tokenRes = await ok( await get(userPds.routes, '/xrpc/com.atproto.space.getDelegationToken', { space: SPACE, }), 'getDelegationToken', ); const { token: delegationToken } = await tokenRes.json(); const holder = await generateSpaceSigKey(); const credentialRes = await ok( await post( authorityPds.routes, '/xrpc/com.atproto.space.getSpaceCredential', { space: SPACE, credentialKid: holder.did, delegationToken }, null, ), 'getSpaceCredential', ); const { credential } = await credentialRes.json(); const useSignature = await createSpaceSigHeaders(holder, { authorization: `Atproto-Space-Credential ${credential}`, audience: USER, }); const repoState = await userPds.storage.getSpaceRepo(SPACE); writeFileSync( `${outDir}/manifest.json`, `${JSON.stringify( { producer: 'pds.js', space: SPACE, author: USER, authority: AUTHORITY, userDidKey: user.didKey, authorityDidKey: authority.didKey, // Published so the reference can decapsulate each key share itself. kemPrivateKeyHex: hex(userKem.privateKey), kemDidKey: userKem.didKey, rev: repoState.rev, recordCount: records.length, getRepo: { ...repoMac, mac: repoRes.headers.get('atproto-space-mac'), }, getLatestCommit: { ...commitMac, commit, mac: commitRes.headers.get('atproto-space-mac'), }, getRecord: { ...recordMac, record, mac: recordRes.headers.get('atproto-space-mac'), }, delegationToken, delegationAud: `${AUTHORITY}#atproto_pds`, credential, credentialKeyId: holder.did, useSignature, }, null, 2, )}\n`, ); console.log(`wrote ${outDir}/repo.star (${star.length} bytes)`); console.log(`wrote ${outDir}/manifest.json`); console.log(`space=${SPACE}`); console.log(`author=${USER} kem=${userKem.didKey}`); console.log(`rev=${repoState.rev} records=${records.length}`);