// 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 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} */ 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} */ 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, 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} */ 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} */ 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} */ 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(); }