// 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} params * @param {HeldCredential} held * @param {string} audience - the DID the read is addressed to * @param {Record} [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} */ 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 }), ), ); }