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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
203 lines · 6.5 KB Raw History
// The syncer: how an application assembles a permissioned space.
//
// This is the role pds.js itself does not play. The app holds a space
// credential, discovers the writer set from the space authority, pulls each
// member's repo from that member's own PDS, and verifies everything before
// trusting a byte of it: the commit against a MAC from the member's KEM key,
// and every record, folded into a set hash, against the commit's data.

import { resolveDidDocument } from '@pdsjs/lexicon-resolver';
import { kemPublicKey, spaceHostEndpoint } from '@pdsjs/spaces/authority';
import {
  createSpaceSigHeaders,
  generateSpaceSigKey,
} from '@pdsjs/spaces/http-signature';
import { encapsulate } from '@pdsjs/spaces/kem';
import {
  COMMIT_MAC_TYPE,
  formatWantMacHeader,
  MAC_HEADER,
  parseMacHeader,
  WANT_MAC_HEADER,
} from '@pdsjs/spaces/mac';
import { verifyRepoStar } from '@pdsjs/spaces/star';
import { parseSpaceUri } from '@pdsjs/spaces/uri';

/**
 * A space credential and the key it is bound to.
 * @typedef {Object} HeldCredential
 * @property {string} credential
 * @property {import('@pdsjs/spaces/http-signature').SpaceSigKey} key
 */

/**
 * GET an XRPC query presenting the credential, signed for the account the
 * read is addressed to.
 * @param {string} base
 * @param {string} nsid
 * @param {Record<string, string>} params
 * @param {HeldCredential} held
 * @param {string} audience - the DID the read is addressed to
 * @param {Record<string, string>} [headers]
 */
async function credentialGet(base, nsid, params, held, audience, headers = {}) {
  const url = new URL(`${base}/xrpc/${nsid}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, {
    headers: {
      ...headers,
      ...(await createSpaceSigHeaders(held.key, {
        authorization: `Atproto-Space-Credential ${held.credential}`,
        audience,
      })),
    },
  });
  if (!res.ok) {
    throw new Error(`${nsid} failed: ${res.status} ${await res.text()}`);
  }
  return res;
}

/**
 * Exchange a member's session for a space credential.
 *
 * The chain: the member's own PDS mints a short-lived delegation token proving
 * the user delegated to this app; the space authority verifies it against the
 * member's DID document and, if the member is authorized, issues a credential
 * signed with its atproto key and bound to a key this app made for it.
 *
 * @param {Object} opts
 * @param {string} opts.space - space AT-URI
 * @param {string} opts.memberPds - the member's own PDS base URL
 * @param {string} opts.sessionJwt - the member's session with their PDS
 * @param {string} opts.plcUrl
 * @returns {Promise<HeldCredential>}
 */
export async function getSpaceCredential({
  space,
  memberPds,
  sessionJwt,
  plcUrl,
}) {
  const url = new URL(`${memberPds}/xrpc/com.atproto.space.getDelegationToken`);
  url.searchParams.set('space', space);
  const delegation = await fetch(url, {
    headers: { authorization: `Bearer ${sessionJwt}` },
  });
  if (!delegation.ok) {
    throw new Error(
      `getDelegationToken failed: ${delegation.status} ${await delegation.text()}`,
    );
  }
  const { token } = await delegation.json();

  const { spaceDid } = parseSpaceUri(space);
  const authorityDoc = await resolveDidDocument(spaceDid, { plcUrl });
  if (!authorityDoc) throw new Error(`could not resolve ${spaceDid}`);
  const host = spaceHostEndpoint(authorityDoc, spaceDid);

  const key = await generateSpaceSigKey();
  const res = await fetch(`${host}/xrpc/com.atproto.space.getSpaceCredential`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      space,
      credentialKid: key.did,
      delegationToken: token,
    }),
  });
  if (!res.ok) {
    throw new Error(
      `getSpaceCredential failed: ${res.status} ${await res.text()}`,
    );
  }
  return { credential: (await res.json()).credential, key };
}

/**
 * Pull and verify one member's repo in the space.
 *
 * @param {Object} opts
 * @param {string} opts.space
 * @param {string} opts.did - the member whose repo to pull
 * @param {HeldCredential} opts.held
 * @param {string} opts.plcUrl
 * @returns {Promise<{did: string, rev: string, hashHex: string, records: Array<{collection: string, rkey: string, value: any}>}>}
 */
export async function pullRepo({ space, did, held, plcUrl }) {
  // The member's repo lives on the member's own PDS, not the authority's.
  const didDoc = await resolveDidDocument(did, { plcUrl });
  if (!didDoc) throw new Error(`could not resolve ${did}`);
  const pds = spaceHostEndpoint(didDoc, did);

  // A fresh key share against the member's KEM key. The host answers with a
  // MAC under the secret it shares, which only the member's key can derive.
  const { keyShare, sharedSecret } = await encapsulate(
    kemPublicKey(didDoc, did),
  );
  const res = await credentialGet(
    pds,
    'com.atproto.space.getRepo',
    { space, repo: did },
    held,
    did,
    { [WANT_MAC_HEADER]: formatWantMacHeader(COMMIT_MAC_TYPE, keyShare) },
  );
  const star = new Uint8Array(await res.arrayBuffer());
  const mac = parseMacHeader(res.headers.get(MAC_HEADER), COMMIT_MAC_TYPE);

  // The commit must carry the member's MAC, and the records, folded into a
  // set hash, must reproduce its data: only then is the repo whole and theirs.
  const { commit, records } = await verifyRepoStar(star, {
    space,
    repo: did,
    sharedSecret,
    mac,
  });

  const hashHex = [...commit.data]
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
  return {
    did,
    rev: commit.rev,
    hashHex,
    records: records.map(({ collection, rkey, record }) => ({
      collection,
      rkey,
      value: record,
    })),
  };
}

/**
 * Assemble the whole space: writer set from the authority, then each repo
 * from its own host, verified.
 *
 * @param {Object} opts
 * @param {string} opts.space
 * @param {HeldCredential} opts.held
 * @param {string} opts.plcUrl
 */
export async function syncSpace({ space, held, plcUrl }) {
  const { spaceDid } = parseSpaceUri(space);
  const authorityDoc = await resolveDidDocument(spaceDid, { plcUrl });
  if (!authorityDoc) throw new Error(`could not resolve ${spaceDid}`);
  const host = spaceHostEndpoint(authorityDoc, spaceDid);

  const { repos } = await (
    await credentialGet(
      host,
      'com.atproto.space.listRepos',
      { space },
      held,
      spaceDid,
    )
  ).json();

  return Promise.all(
    repos.map((/** @type {{did: string}} */ r) =>
      pullRepo({ space, did: r.did, held, plcUrl }),
    ),
  );
}