chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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 <outDir>
//
// 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 <outDir>',
);
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}`);