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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
191 lines · 4.4 KB Raw History
/**
 * DPoP proof generation for e2e tests
 */

import { base64UrlEncode, computeJwkThumbprint } from '@pdsjs/core/crypto';

/**
 * @typedef {Object} DpopPayload
 * @property {string} jti
 * @property {string} htm
 * @property {string} htu
 * @property {number} iat
 * @property {string} [ath]
 * @property {string} [nonce]
 */

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

/**
 * Generate an ES256 key pair for DPoP
 * @returns {Promise<{privateKey: CryptoKey, publicKey: CryptoKey, jwk: EcJwk}>}
 */
export async function generateKeyPair() {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );

  const jwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
  /** @type {EcJwk} */
  const publicJwk = {
    kty: jwk.kty || '',
    crv: jwk.crv || '',
    x: jwk.x || '',
    y: jwk.y || '',
  };

  return {
    privateKey: keyPair.privateKey,
    publicKey: keyPair.publicKey,
    jwk: publicJwk,
  };
}

// The server's DPoP nonce is global (an HMAC over a time window), so tests
// share one value: seed it once with seedDpopNonce(), and every proof
// includes it automatically unless omitNonce is set.
/** @type {string | null} */
let serverNonce = null;

/**
 * Learn the server's current DPoP nonce by poking the PAR endpoint.
 * @param {string} base - PDS base URL
 */
export async function seedDpopNonce(base) {
  const { privateKey, jwk } = await generateKeyPair();
  const proof = await createDpopProof({
    privateKey,
    jwk,
    method: 'POST',
    url: `${base}/oauth/par`,
    omitNonce: true,
  });
  const res = await fetch(`${base}/oauth/par`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      DPoP: proof,
    },
    body: 'client_id=seed',
  });
  const nonce = res.headers.get('DPoP-Nonce');
  if (nonce) serverNonce = nonce;
}

/**
 * Create a DPoP proof JWT
 * @param {object} params
 * @param {CryptoKey} params.privateKey
 * @param {EcJwk} params.jwk
 * @param {string} params.method
 * @param {string} params.url
 * @param {string} [params.accessToken]
 * @param {boolean} [params.omitNonce] - Skip the shared server nonce
 * @returns {Promise<string>}
 */
export async function createDpopProof({
  privateKey,
  jwk,
  method,
  url,
  accessToken,
  omitNonce,
}) {
  const header = { typ: 'dpop+jwt', alg: 'ES256', jwk };
  /** @type {DpopPayload} */
  const payload = {
    jti: base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))),
    htm: method,
    htu: url,
    iat: Math.floor(Date.now() / 1000),
  };

  if (serverNonce && !omitNonce) {
    payload.nonce = serverNonce;
  }

  if (accessToken) {
    const hash = await crypto.subtle.digest(
      'SHA-256',
      new TextEncoder().encode(accessToken),
    );
    payload.ath = base64UrlEncode(new Uint8Array(hash));
  }

  const headerB64 = base64UrlEncode(
    new TextEncoder().encode(JSON.stringify(header)),
  );
  const payloadB64 = base64UrlEncode(
    new TextEncoder().encode(JSON.stringify(payload)),
  );
  const signingInput = `${headerB64}.${payloadB64}`;

  const signature = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    privateKey,
    new TextEncoder().encode(signingInput),
  );

  return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
}

/**
 * DPoP client helper
 */
export class DpopClient {
  /** @type {CryptoKey} */
  #privateKey;
  /** @type {EcJwk} */
  #jwk;
  /** @type {string|null} */
  #jkt = null;

  /**
   * @param {CryptoKey} privateKey
   * @param {EcJwk} jwk
   */
  constructor(privateKey, jwk) {
    this.#privateKey = privateKey;
    this.#jwk = jwk;
  }

  static async create() {
    const { privateKey, jwk } = await generateKeyPair();
    return new DpopClient(privateKey, jwk);
  }

  async getJkt() {
    if (!this.#jkt) this.#jkt = await computeJwkThumbprint(this.#jwk);
    return this.#jkt;
  }

  getJwk() {
    return this.#jwk;
  }

  /**
   * @param {string} method
   * @param {string} url
   * @param {string} [accessToken]
   * @param {{omitNonce?: boolean}} [options]
   * @returns {Promise<string>}
   */
  async createProof(method, url, accessToken, options = {}) {
    return createDpopProof({
      privateKey: this.#privateKey,
      jwk: this.#jwk,
      method,
      url,
      accessToken,
      omitNonce: options.omitNonce,
    });
  }
}