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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
219 lines · 5.7 KB Raw History
/**
 * Browser DPoP primitives for OAuth and other credentials bound to one key.
 * The key remains non-extractable in IndexedDB.
 */

/**
 * @typedef {Object} DpopClient
 * @property {() => Promise<CryptoKeyPair>} dpopKeyPair
 * @property {() => Promise<void>} forgetDpopKey
 * @property {(bytes: Uint8Array) => string} base64Url
 * @property {(text: string) => Promise<string>} sha256Base64Url
 * @property {(jwk: {crv: string, kty: string, x: string, y: string}) => Promise<string>} thumbprint
 * @property {() => Promise<string>} dpopThumbprint
 * @property {(opts: {keyPair: CryptoKeyPair, method: string, url: string, nonce?: string, token?: string}) => Promise<string>} dpopProof
 * @property {(url: string) => string|undefined} nonceFor
 * @property {(url: string, response: Response) => void} rememberNonce
 */

/**
 * @param {Object} [options]
 * @param {string} [options.databaseName]
 * @returns {DpopClient}
 */
export function createDpopClient({ databaseName = 'pdsjs-oauth-client' } = {}) {
  const STORE = 'keys';
  const KEY_ID = 'dpop';

  /**
   * @returns {Promise<IDBDatabase>}
   */
  function openDb() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(databaseName, 1);
      request.onupgradeneeded = () => {
        request.result.createObjectStore(STORE);
      };
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * @template T
   * @param {IDBTransactionMode} mode
   * @param {(store: IDBObjectStore) => IDBRequest} run
   * @returns {Promise<T>}
   */
  async function withStore(mode, run) {
    const db = await openDb();
    try {
      return await new Promise((resolve, reject) => {
        const request = run(db.transaction(STORE, mode).objectStore(STORE));
        request.onsuccess = () => resolve(request.result);
        request.onerror = () => reject(request.error);
      });
    } finally {
      db.close();
    }
  }

  /**
   * @returns {Promise<CryptoKeyPair>}
   */
  async function dpopKeyPair() {
    const stored = /** @type {CryptoKeyPair|undefined} */ (
      await withStore('readonly', (store) => store.get(KEY_ID))
    );
    if (stored?.privateKey) return stored;

    const pair = await crypto.subtle.generateKey(
      { name: 'ECDSA', namedCurve: 'P-256' },
      false,
      ['sign', 'verify'],
    );
    await withStore('readwrite', (store) => store.put(pair, KEY_ID));
    return pair;
  }

  async function forgetDpopKey() {
    await withStore('readwrite', (store) => store.delete(KEY_ID));
  }

  /**
   * @param {Uint8Array} bytes
   * @returns {string}
   */
  function base64Url(bytes) {
    let binary = '';
    for (const byte of bytes) binary += String.fromCharCode(byte);
    return btoa(binary)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  /**
   * @param {string} text
   * @returns {Promise<string>}
   */
  async function sha256Base64Url(text) {
    const digest = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(text),
    );
    return base64Url(new Uint8Array(digest));
  }

  /**
   * @param {CryptoKey} publicKey
   * @returns {Promise<{crv: string, kty: string, x: string, y: string}>}
   */
  async function publicJwk(publicKey) {
    const jwk = await crypto.subtle.exportKey('jwk', publicKey);
    return {
      crv: String(jwk.crv),
      kty: String(jwk.kty),
      x: String(jwk.x),
      y: String(jwk.y),
    };
  }

  /**
   * @param {{crv: string, kty: string, x: string, y: string}} jwk
   * @returns {Promise<string>}
   */
  function thumbprint(jwk) {
    return sha256Base64Url(
      JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }),
    );
  }

  async function dpopThumbprint() {
    const pair = await dpopKeyPair();
    return thumbprint(await publicJwk(pair.publicKey));
  }

  /**
   * @param {unknown} value
   * @returns {string}
   */
  function encodeSegment(value) {
    return base64Url(new TextEncoder().encode(JSON.stringify(value)));
  }

  /**
   * @param {{keyPair: CryptoKeyPair, method: string, url: string, nonce?: string, token?: string}} opts
   * @returns {Promise<string>}
   */
  async function dpopProof({ keyPair, method, url, nonce, token }) {
    const htu = new URL(url);
    htu.search = '';
    htu.hash = '';

    const header = {
      typ: 'dpop+jwt',
      alg: 'ES256',
      jwk: await publicJwk(keyPair.publicKey),
    };
    /** @type {Record<string, unknown>} */
    const payload = {
      jti: crypto.randomUUID(),
      htm: method.toUpperCase(),
      htu: htu.toString(),
      iat: Math.floor(Date.now() / 1000),
    };
    if (nonce) payload.nonce = nonce;
    if (token) payload.ath = await sha256Base64Url(token);

    const signingInput = `${encodeSegment(header)}.${encodeSegment(payload)}`;
    const signature = await crypto.subtle.sign(
      { name: 'ECDSA', hash: 'SHA-256' },
      keyPair.privateKey,
      new TextEncoder().encode(signingInput),
    );
    return `${signingInput}.${base64Url(new Uint8Array(signature))}`;
  }

  /** @type {Map<string, string>} */
  const nonces = new Map();

  /** @param {string} url */
  function nonceFor(url) {
    return nonces.get(new URL(url).origin);
  }

  /**
   * @param {string} url
   * @param {Response} response
   */
  function rememberNonce(url, response) {
    const nonce = response.headers.get('dpop-nonce');
    if (nonce) nonces.set(new URL(url).origin, nonce);
  }

  return {
    dpopKeyPair,
    forgetDpopKey,
    base64Url,
    sha256Base64Url,
    thumbprint,
    dpopThumbprint,
    dpopProof,
    nonceFor,
    rememberNonce,
  };
}

const defaultDpop = createDpopClient();
export const {
  dpopKeyPair,
  forgetDpopKey,
  base64Url,
  sha256Base64Url,
  thumbprint,
  dpopThumbprint,
  dpopProof,
  nonceFor,
  rememberNonce,
} = defaultDpop;