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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
207 lines · 6.8 KB Raw History
// 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();
}