chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// Reaching a repo this account does not own.
//
// A space is one dataset spread over the repos of everyone who writes to it,
// and each of those repos is served by its own author's PDS. Reading them is a
// three-step exchange the proposal defines:
//
// own PDS getDelegationToken proves this user delegated to this page
// authority getSpaceCredential exchanges that for a credential for the space
// each host <read endpoint> presents the credential, signed by our key
//
// Each credential is bound to a key made for it alone, which never leaves
// this page. Every read signs the credential together with the account it is
// addressed to, so a copy taken out of this browser cannot be presented
// anywhere. A read of a commit or a record also asks the host for a MAC under
// a secret shared with the author's `#atproto_kem` key, which shows the
// answer is what the author's server holds.
import { kemPublicKey } from '@pdsjs/spaces/authority';
import {
createSpaceSigHeaders,
generateSpaceSigKey,
} from '@pdsjs/spaces/http-signature';
import { encapsulate } from '@pdsjs/spaces/kem';
import { formatWantMacHeader, WANT_MAC_HEADER } from '@pdsjs/spaces/mac';
import { parseSpaceUri } from '@pdsjs/spaces/uri';
import { pdsEndpoint, resolveDid } from '#/lib/identity.js';
import { expiryOf } from '#/lib/jwt.js';
import { authedFetch } from '#/lib/oauth.js';
/** DID documents, for every account whose repo this page has opened. */
const docs = new Map();
/**
* @param {string} did
* @returns {Promise<any>}
*/
async function didDocument(did) {
let doc = docs.get(did);
if (!doc) {
doc = await resolveDid(did);
docs.set(did, doc);
}
return doc;
}
/**
* Where a DID's repo is served from.
* @param {string} did
* @returns {Promise<string>}
*/
export async function repoHost(did) {
return pdsEndpoint(await didDocument(did));
}
/**
* A request for a MAC against an author's KEM key, and the secret the answer
* is MACed under. A fresh key share for every request.
* @param {string} did - the author whose repo is read
* @param {import('@pdsjs/spaces/mac').MacType} type
* @returns {Promise<{headers: Record<string, string>, sharedSecret: Uint8Array}>}
*/
export async function wantMac(did, type) {
const { keyShare, sharedSecret } = await encapsulate(
kemPublicKey(await didDocument(did), did),
);
return {
headers: { [WANT_MAC_HEADER]: formatWantMacHeader(type, keyShare) },
sharedSecret,
};
}
/** @typedef {import('@pdsjs/spaces/http-signature').SpaceSigKey} SpaceSigKey */
/** @type {Map<string, {credential: string, expiresAt: number, key: SpaceSigKey}>} */
const credentials = new Map();
/**
* Ask this account's own PDS for a token saying the user delegated to this
* page. It lives one minute and names the authority's space host as its
* audience, so it is useless anywhere else.
*
* @param {import('#/lib/oauth.js').Session} session
* @param {string} space
* @returns {Promise<{token: string, host: string}>}
*/
export async function requestDelegation(session, space) {
const url = new URL(
`${session.pds}/xrpc/com.atproto.space.getDelegationToken`,
);
url.searchParams.set('space', space);
const response = await authedFetch(session, url.toString());
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
body.message || `Your PDS would not delegate access to ${space}.`,
);
}
return { token: (await response.json()).token, host: session.pds };
}
/**
* Exchange a delegation token at the space's authority for a credential bound
* to a fresh key, which the request names.
*
* @param {string} space
* @param {string} delegation
* @returns {Promise<{credential: string, host: string, keyId: string}>}
*/
export async function exchangeDelegation(space, delegation) {
const { spaceDid } = parseSpaceUri(space);
const host = await repoHost(spaceDid);
const key = await generateSpaceSigKey();
const response = await fetch(
`${host}/xrpc/com.atproto.space.getSpaceCredential`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
space,
credentialKid: key.did,
delegationToken: delegation,
}),
},
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
body.message || `The authority refused a credential for ${space}.`,
);
}
const { credential } = await response.json();
credentials.set(space, {
credential,
// A credential with no readable expiry is held for ten minutes rather than
// forever, so a server that stops honouring it is asked again before long.
expiresAt: expiryOf(credential) || Date.now() + 600_000,
key,
});
return { credential, host, keyId: key.did };
}
/**
* The credential this page holds for a space, or null. Read by the inspector,
* which shows what is in hand before it asks for anything new.
* @param {string} space
* @returns {{credential: string, expiresAt: number, keyId: string}|null}
*/
export function heldCredential(space) {
const held = credentials.get(space);
return held
? {
credential: held.credential,
expiresAt: held.expiresAt,
keyId: held.key.did,
}
: null;
}
/**
* A credential admitting this user to a space, minted on first use and reused
* until it is nearly expired.
*
* @param {import('#/lib/oauth.js').Session} session
* @param {string} space
* @returns {Promise<string>}
*/
export async function spaceCredential(session, space) {
const held = heldCredential(space);
if (held && held.expiresAt - 60_000 > Date.now()) return held.credential;
const { token } = await requestDelegation(session, space);
return (await exchangeDelegation(space, token)).credential;
}
/**
* Call an endpoint with a space credential rather than a session. The request
* is signed for the repo the URL names, or else for the space's authority.
* @param {string} credential - one this page minted
* @param {string} url
* @param {RequestInit} [init]
* @returns {Promise<Response>}
*/
export async function credentialFetch(credential, url, init = {}) {
const held = [...credentials.values()].find(
(entry) => entry.credential === credential,
);
if (!held) throw new Error('This page holds no key for that credential.');
const params = new URL(url).searchParams;
const audience =
params.get('repo') ?? parseSpaceUri(params.get('space') ?? '').spaceDid;
return fetch(url, {
...init,
headers: {
...(init.headers || {}),
...(await createSpaceSigHeaders(held.key, {
authorization: `Atproto-Space-Credential ${credential}`,
audience,
})),
},
});
}
/** Forget every credential, which a sign-out must do. */
export function forgetCredentials() {
credentials.clear();
docs.clear();
}