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