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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
212 lines · 6.5 KB Raw History
// @pdsjs/spaces/http-signature - the HTTP message signature a space credential
// travels with (RFC 9421).
//
// Every request presenting a credential signs it together with the DID the
// request is addressed to, `Atproto-Space-Audience`, under the label and tag
// `atproto-space-credential`, ecdsa-p256-sha256, by the P-256 key the
// credential names in `cnf.kid`. A host given the credential to serve one
// account cannot present it to another.
//
// A signature covers no method, URL or nonce, so it may be reused for one
// credential and one audience while the credential is valid.

import { compressPublicKey, sign } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { parseDidKey } from '@pdsjs/core/verify';
import {
  parseDictionary,
  serializeInnerList,
  toPaddedBase64,
} from './structured-fields.js';
import { SpaceTokenError } from './token.js';

const LABEL = 'atproto-space-credential';
const TAG = 'atproto-space-credential';
const ALG = 'ecdsa-p256-sha256';
const COMPONENTS = ['authorization', 'atproto-space-audience'];
const SIGNATURE_INPUT = `(${COMPONENTS.map((c) => `"${c}"`).join(' ')});tag="${TAG}"`;

/**
 * @typedef {Object} SpaceSigKey
 * @property {string} did - the public key as a P-256 did:key
 * @property {(data: Uint8Array) => Promise<Uint8Array>} sign - 64-byte r||s
 */

/**
 * A fresh P-256 key for one credential. The private key is not extractable.
 * @returns {Promise<SpaceSigKey>}
 */
export async function generateSpaceSigKey() {
  const pair = /** @type {CryptoKeyPair} */ (
    await crypto.subtle.generateKey(
      { name: 'ECDSA', namedCurve: 'P-256' },
      false,
      ['sign', 'verify'],
    )
  );
  const raw = new Uint8Array(
    await crypto.subtle.exportKey('raw', pair.publicKey),
  );
  return {
    did: publicKeyToDidKey(compressPublicKey(raw), 'p256'),
    sign: (data) => sign(pair.privateKey, data),
  };
}

/**
 * Whether `keyId` is a P-256 did:key, the only kind a credential binds to.
 * @param {unknown} keyId
 * @returns {boolean}
 */
export function isP256DidKey(keyId) {
  if (typeof keyId !== 'string' || !keyId.startsWith('did:key:')) return false;
  try {
    return parseDidKey(keyId).curve === 'p256';
  } catch {
    return false;
  }
}

/**
 * The authorization, audience and signature headers for a request presenting
 * a space credential.
 *
 * @param {SpaceSigKey} key
 * @param {{authorization: string, audience: string}} opts
 * @returns {Promise<Record<string, string>>}
 */
export async function createSpaceSigHeaders(key, { authorization, audience }) {
  const signature = await key.sign(
    signatureBase(authorization, audience, SIGNATURE_INPUT),
  );
  return {
    authorization,
    'atproto-space-audience': audience,
    'signature-input': `${LABEL}=${SIGNATURE_INPUT}`,
    signature: `${LABEL}=:${toPaddedBase64(signature)}:`,
  };
}

/**
 * Verify the `atproto-space-credential` signature on a request against the key
 * the credential is bound to (its `cnf.kid`). A `keyid` parameter, when
 * present, must name that key.
 *
 * @param {Headers} headers
 * @param {Object} opts
 * @param {string} opts.keyId
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
 * @returns {Promise<{audience: string}>}
 * @throws {SpaceTokenError} code BadSpaceSignature
 */
export async function verifyCredentialSig(headers, { keyId, verifier }) {
  const authorization = single(headers, 'authorization');
  const audience = single(headers, 'atproto-space-audience');
  const input = memberOf(headers.get('signature-input'), 'Signature-Input');
  const signature = memberOf(headers.get('signature'), 'Signature');
  if (input.kind !== 'innerList') fail('Signature-Input is not an inner list');
  if (
    signature.kind !== 'item' ||
    !(signature.value instanceof Uint8Array) ||
    signature.params.size !== 0
  ) {
    fail('Signature is not a byte sequence');
  }

  if (
    input.items.length !== COMPONENTS.length ||
    input.items.some(
      (item, i) => item.value !== COMPONENTS[i] || item.params.size !== 0,
    )
  ) {
    fail(
      `signature must cover exactly ${COMPONENTS.map((c) => `"${c}"`).join(' ')}, in order`,
    );
  }
  if (input.params.get('tag') !== TAG) fail(`signature tag must be "${TAG}"`);
  const alg = input.params.get('alg');
  if (alg !== undefined && alg !== ALG) {
    fail(`signature algorithm must be ${ALG}`);
  }
  const named = input.params.get('keyid');
  if (named !== undefined && named !== keyId) {
    fail('signature keyid does not match the credential key');
  }
  if (!isP256DidKey(keyId)) fail('signature key must be a P-256 did:key');

  const bytes = /** @type {Uint8Array} */ (signature.value);
  const base = signatureBase(
    authorization,
    audience,
    serializeInnerList(input.items, input.params),
  );
  let valid = false;
  if (bytes.length === 64) {
    try {
      valid = await verifier.verify(keyId, base, bytes);
    } catch {
      valid = false;
    }
  }
  if (!valid) fail('invalid HTTP message signature');
  return { audience };
}

/**
 * @param {string} authorization
 * @param {string} audience
 * @param {string} params - the serialized signature parameters
 * @returns {Uint8Array}
 */
function signatureBase(authorization, audience, params) {
  return new TextEncoder().encode(
    [
      `"authorization": ${authorization.trim()}`,
      `"atproto-space-audience": ${audience.trim()}`,
      `"@signature-params": ${params}`,
    ].join('\n'),
  );
}

/**
 * One field value. Fetch joins repeated fields with ", ", and neither a token
 * nor a DID contains a comma, so a comma means the field was sent twice.
 *
 * @param {Headers} headers
 * @param {string} name
 * @returns {string}
 */
function single(headers, name) {
  const value = headers.get(name);
  if (!value || value.includes(',')) {
    fail(`request requires exactly one "${name}" field`);
  }
  return /** @type {string} */ (value);
}

/**
 * @param {string} message
 * @returns {never}
 */
function fail(message) {
  throw new SpaceTokenError(message, 'BadSpaceSignature');
}

/**
 * @param {string|null} header
 * @param {string} name
 * @returns {import('./structured-fields.js').SfMember}
 */
function memberOf(header, name) {
  if (!header) fail(`missing ${name} field`);
  /** @type {Map<string, import('./structured-fields.js').SfMember>} */
  let dict;
  try {
    dict = parseDictionary(/** @type {string} */ (header));
  } catch {
    fail(`malformed ${name} field`);
  }
  const member = dict.get(LABEL);
  if (!member) fail(`no ${LABEL} member in ${name}`);
  return /** @type {import('./structured-fields.js').SfMember} */ (member);
}