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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
160 lines · 4.6 KB Raw History
/**
 * DPoP proof generation (RFC 9449), for an OAuth access token. The token is
 * bound to a key that outlives the token pair. That key is generated
 * extractable, serialized into the session file, and imported back on the next
 * invocation. Space credentials are signed differently: see space-signature.js.
 *
 * Self-contained on WebCrypto because this package installs as a global CLI
 * and keeps its dependency list empty.
 */

/**
 * @typedef {Object} EcJwk
 * @property {string} kty
 * @property {string} crv
 * @property {string} x
 * @property {string} y
 */

/**
 * @typedef {Object} DpopKey
 * @property {CryptoKey} privateKey
 * @property {EcJwk} jwk - public half
 * @property {string} jkt - RFC 7638 thumbprint of the public half
 */

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

/**
 * @param {CryptoKey} publicKey
 * @returns {Promise<{jwk: EcJwk, jkt: string}>}
 */
async function publicHalf(publicKey) {
  const exported = await crypto.subtle.exportKey('jwk', publicKey);
  /** @type {EcJwk} */
  const jwk = {
    kty: exported.kty ?? '',
    crv: exported.crv ?? '',
    x: exported.x ?? '',
    y: exported.y ?? '',
  };
  // RFC 7638: hash over the required members in lexicographic order.
  const thumbprintInput = JSON.stringify({
    crv: jwk.crv,
    kty: jwk.kty,
    x: jwk.x,
    y: jwk.y,
  });
  const hash = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(thumbprintInput),
  );
  return { jwk, jkt: base64UrlEncode(new Uint8Array(hash)) };
}

/**
 * Generate an ES256 key pair for token binding.
 * @param {{extractable?: boolean}} [opts] - extractable allows exportDpopKey,
 *   for a key that has to survive into the next invocation
 * @returns {Promise<DpopKey>}
 */
export async function generateDpopKey({ extractable = false } = {}) {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    extractable,
    ['sign', 'verify'],
  );
  return {
    privateKey: keyPair.privateKey,
    ...(await publicHalf(keyPair.publicKey)),
  };
}

/**
 * Serialize a key generated with extractable set, for the session file.
 * @param {DpopKey} key
 * @returns {Promise<JsonWebKey>} the private key as a JWK
 */
export function exportDpopKey(key) {
  return crypto.subtle.exportKey('jwk', key.privateKey);
}

/**
 * Rebuild a DpopKey from a serialized private JWK.
 * @param {JsonWebKey} privateJwk
 * @returns {Promise<DpopKey>}
 */
export async function importDpopKey(privateJwk) {
  const privateKey = await crypto.subtle.importKey(
    'jwk',
    privateJwk,
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign'],
  );
  const publicKey = await crypto.subtle.importKey(
    'jwk',
    {
      kty: privateJwk.kty,
      crv: privateJwk.crv,
      x: privateJwk.x,
      y: privateJwk.y,
    },
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['verify'],
  );
  return { privateKey, ...(await publicHalf(publicKey)) };
}

/**
 * Create the proof JWT for one request.
 * @param {Object} opts
 * @param {DpopKey} opts.key
 * @param {string} opts.method - HTTP method, the htm claim
 * @param {string} opts.url - request URL, the htu claim
 * @param {string} [opts.credential] - the presented credential or access
 *   token, hashed into ath. Omitted for authorization-server requests, which
 *   present no token.
 * @param {string} [opts.nonce] - server nonce, required by the authorization
 *   server endpoints
 * @returns {Promise<string>}
 */
export async function createDpopProof({ key, method, url, credential, nonce }) {
  const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk };
  /** @type {Record<string, string|number>} */
  const payload = {
    jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
    htm: method,
    htu: url,
    iat: Math.floor(Date.now() / 1000),
  };
  if (credential) {
    const tokenHash = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(credential),
    );
    payload.ath = base64UrlEncode(new Uint8Array(tokenHash));
  }
  if (nonce) payload.nonce = nonce;
  const signingInput = `${base64UrlEncode(
    new TextEncoder().encode(JSON.stringify(header)),
  )}.${base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload)))}`;
  const signature = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    key.privateKey,
    new TextEncoder().encode(signingInput),
  );
  return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
}