~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
311 lines · 9.0 KB Raw History
// 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}`);