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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
176 lines · 5.5 KB Raw History
// @pdsjs/spaces/service-auth - verifying inter-service calls.
//
// notifyWrite and notifySpaceDeleted are server-to-server: a repo host tells an
// authority its user wrote, an authority tells syncers a space is gone. Both are
// authenticated with ordinary atproto service auth — a JWT signed by the
// caller's #atproto key, addressed to us, naming the method it is calling.

import { base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
import { atprotoSigningKey } from './authority.js';
import { assertKeyAlg, SpaceTokenError, signerAlg } from './token.js';

const CLOCK_SKEW_SEC = 5;

/**
 * @param {string} b64
 * @returns {any}
 */
function decodePart(b64) {
  const pad = b64.replace(/-/g, '+').replace(/_/g, '/');
  const bytes = Uint8Array.from(
    atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)),
    (c) => c.charCodeAt(0),
  );
  return JSON.parse(new TextDecoder().decode(bytes));
}

/**
 * @param {string} b64
 * @returns {Uint8Array}
 */
function decodeSig(b64) {
  const pad = b64.replace(/-/g, '+').replace(/_/g, '/');
  return Uint8Array.from(
    atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)),
    (c) => c.charCodeAt(0),
  );
}

/**
 * Verify a service-auth JWT presented to us.
 *
 * @param {Object} opts
 * @param {string} opts.jwt
 * @param {string|string[]} opts.aud - who we answer as; the token must be
 *   addressed to one of them
 * @param {string} opts.lxm - the method being called
 * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
 * @returns {Promise<{iss: string}>}
 */
export async function verifyServiceAuth({
  jwt,
  aud,
  lxm,
  resolveDid,
  verifier,
}) {
  const parts = typeof jwt === 'string' ? jwt.split('.') : [];
  if (parts.length !== 3) {
    throw new SpaceTokenError('malformed service token', 'BadJwt');
  }
  const [headerB64, payloadB64, sigB64] = parts;

  /** @type {any} */
  let header;
  /** @type {any} */
  let payload;
  try {
    header = decodePart(headerB64);
    payload = decodePart(payloadB64);
  } catch {
    throw new SpaceTokenError('could not parse service token', 'BadJwt');
  }
  if (typeof header?.alg !== 'string' || !header.alg) {
    throw new SpaceTokenError('missing service token "alg"', 'BadJwt');
  }

  const now = Math.floor(Date.now() / 1000);
  if (typeof payload.exp !== 'number' || now - CLOCK_SKEW_SEC >= payload.exp) {
    throw new SpaceTokenError('service token expired', 'JwtExpired');
  }
  if (!payload.iss) {
    throw new SpaceTokenError('missing service token "iss"', 'BadJwtIss');
  }
  if (!(Array.isArray(aud) ? aud : [aud]).includes(payload.aud)) {
    throw new SpaceTokenError(
      'service token is not addressed to this service',
      'BadJwtAudience',
    );
  }
  // An lxm claim scopes a token to one method, so a token minted for one call
  // cannot be replayed against another. Absent, it would be usable anywhere.
  if (payload.lxm !== lxm) {
    throw new SpaceTokenError(
      `service token is not scoped to ${lxm}`,
      'BadJwtLexiconMethod',
    );
  }

  const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
  const sig = decodeSig(sigB64);

  /**
   * @param {boolean} forceRefresh
   * @returns {Promise<{didKey: string, valid: boolean}>}
   */
  const matchesSignature = async (forceRefresh) => {
    const didDoc = await resolveDid(payload.iss, { forceRefresh });
    if (!didDoc) {
      throw new SpaceTokenError(
        `could not resolve ${payload.iss}`,
        'DidNotFound',
      );
    }
    const didKey = atprotoSigningKey(didDoc, payload.iss);
    assertKeyAlg(didKey, header.alg);
    try {
      return {
        didKey,
        valid: await verifier.verify(didKey, signingInput, sig),
      };
    } catch (err) {
      throw new SpaceTokenError(
        `could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
        'BadJwtSignature',
      );
    }
  };

  const first = await matchesSignature(false);
  if (!first.valid) {
    // A resolver that holds its answers can name a key the caller has rotated
    // away from, and the token is signed by the current one.
    const fresh = await matchesSignature(true);
    if (fresh.didKey === first.didKey || !fresh.valid) {
      throw new SpaceTokenError(
        'invalid service token signature',
        'BadJwtSignature',
      );
    }
  }

  return { iss: payload.iss };
}

/**
 * Mint a service-auth JWT for an outbound call.
 *
 * Core has its own createServiceJwt, but it takes a CryptoKey; @pdsjs/spaces only
 * ever holds a signer port, so that private key material never reaches this
 * package.
 *
 * @param {Object} opts
 * @param {string} opts.iss - our DID
 * @param {string} opts.aud - the service we are calling
 * @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
 * @param {import('./token.js').SpaceSigner} opts.signer
 * @returns {Promise<string>}
 */
export async function createServiceAuth({ iss, aud, lxm, signer }) {
  const now = Math.floor(Date.now() / 1000);
  const header = { typ: 'JWT', alg: signerAlg(signer) };
  const payload = {
    iss,
    aud,
    exp: now + 60,
    iat: now,
    jti: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
    lxm,
  };
  const encode = (/** @type {object} */ o) =>
    base64UrlEncode(new TextEncoder().encode(JSON.stringify(o)));
  const signingInput = `${encode(header)}.${encode(payload)}`;
  const sig = await signer.sign(new TextEncoder().encode(signingInput));
  return `${signingInput}.${base64UrlEncode(sig)}`;
}