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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
310 lines · 10.1 KB Raw History
// @pdsjs/core/webauthn - Passkey registration and assertion checks
//
// Only the two ceremonies a PDS needs, and no CBOR: the browser hands over the
// credential's public key already in SPKI form (`getPublicKey()`), so nothing
// here has to walk an attestation object or decode a COSE key.
//
// Attestation is not examined. It answers "what kind of authenticator is
// this", which matters to deployments enforcing an authenticator policy and
// not to a personal server: the registering user is already signed in with
// the account password, so they are entitled to add whatever key they hold.
// What must hold is that later assertions are signed by the key registered
// here, and that is what verifyAssertion checks.

import { base64UrlDecode, base64UrlEncode } from './crypto.js';

/**
 * Schema for passkey storage. Defined once and imported by every adapter, so
 * the table cannot drift between SQLite and Durable Objects.
 */
export const PASSKEY_SCHEMA_SQL = `
  CREATE TABLE IF NOT EXISTS passkeys (
    credential_id TEXT PRIMARY KEY,
    did TEXT NOT NULL,
    name TEXT NOT NULL,
    public_key TEXT NOT NULL,
    algorithm INTEGER NOT NULL,
    sign_count INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL,
    last_used_at TEXT
  );
  CREATE INDEX IF NOT EXISTS passkeys_did_idx ON passkeys(did);
`;

/** COSE algorithm identifiers this server accepts. */
export const COSE_ES256 = -7;
export const COSE_RS256 = -257;

/**
 * Map a COSE algorithm to its WebCrypto import and verify parameters.
 * @param {number} algorithm - COSE identifier from getPublicKeyAlgorithm()
 * @returns {{importParams: EcKeyImportParams|RsaHashedImportParams, verifyParams: EcdsaParams|AlgorithmIdentifier}|null}
 */
function algorithmParams(algorithm) {
  if (algorithm === COSE_ES256) {
    return {
      importParams: { name: 'ECDSA', namedCurve: 'P-256' },
      verifyParams: { name: 'ECDSA', hash: 'SHA-256' },
    };
  }
  if (algorithm === COSE_RS256) {
    return {
      importParams: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      verifyParams: { name: 'RSASSA-PKCS1-v1_5' },
    };
  }
  return null;
}

/**
 * True for an algorithm this server can verify.
 * @param {number} algorithm
 * @returns {boolean}
 */
export function isSupportedAlgorithm(algorithm) {
  return algorithmParams(algorithm) !== null;
}

/**
 * Parse and check the clientDataJSON both ceremonies carry.
 * @param {string} clientDataJSON - base64url of the raw bytes
 * @param {{type: string, challenge: string, origin: string}} expected
 * @throws {Error} If the ceremony, challenge or origin does not match
 * @returns {Object} The parsed client data
 */
export function checkClientData(clientDataJSON, expected) {
  let clientData;
  try {
    clientData = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(clientDataJSON)),
    );
  } catch {
    throw new Error('Client data is not valid JSON');
  }

  if (clientData.type !== expected.type) {
    throw new Error(`Expected a ${expected.type} ceremony`);
  }
  // The challenge proves this response answers the request this server just
  // made, rather than one replayed from an earlier ceremony.
  if (clientData.challenge !== expected.challenge) {
    throw new Error('Challenge mismatch');
  }
  // The origin is what binds a passkey to this site: without it, a credential
  // created on another origin could be presented here.
  if (clientData.origin !== expected.origin) {
    throw new Error('Origin mismatch');
  }
  return clientData;
}

/**
 * SHA-256, as bytes.
 * @param {Uint8Array} data
 * @returns {Promise<Uint8Array>}
 */
async function sha256(data) {
  return new Uint8Array(
    await crypto.subtle.digest('SHA-256', /** @type {BufferSource} */ (data)),
  );
}

/**
 * Check the authenticator data's relying-party hash and flags.
 * @param {Uint8Array} authData
 * @param {string} rpId - The relying party id (this server's hostname)
 * @throws {Error} If the data is for another site, or nobody was present
 */
async function checkAuthenticatorData(authData, rpId) {
  if (authData.length < 37) throw new Error('Authenticator data is too short');

  const expectedRpIdHash = await sha256(new TextEncoder().encode(rpId));
  for (let i = 0; i < 32; i++) {
    if (authData[i] !== expectedRpIdHash[i]) {
      throw new Error('Authenticator data is for a different site');
    }
  }

  // Bit 0 is "user present" — the authenticator saw a gesture, not just a
  // process asking it to sign.
  if ((authData[32] & 0x01) !== 0x01) {
    throw new Error('User presence was not confirmed');
  }
}

/**
 * The signature counter an authenticator reports, or 0 when it keeps none.
 * @param {Uint8Array} authData
 * @returns {number}
 */
export function readSignCount(authData) {
  if (authData.length < 37) return 0;
  return (
    authData[33] * 0x1000000 +
    authData[34] * 0x10000 +
    authData[35] * 0x100 +
    authData[36]
  );
}

/**
 * Check a registration response and return what should be stored.
 * @param {Object} params
 * @param {string} params.credentialId - base64url of the credential's raw id
 * @param {string} params.publicKey - base64url SPKI from getPublicKey()
 * @param {number} params.algorithm - COSE id from getPublicKeyAlgorithm()
 * @param {string} params.clientDataJSON - base64url of the raw bytes
 * @param {string} params.expectedChallenge
 * @param {string} params.expectedOrigin
 * @returns {Promise<{credentialId: string, publicKey: string, algorithm: number}>}
 * @throws {Error} If the response does not answer this server's request
 */
export async function verifyRegistration({
  credentialId,
  publicKey,
  algorithm,
  clientDataJSON,
  expectedChallenge,
  expectedOrigin,
}) {
  if (!credentialId) throw new Error('Missing credential id');
  if (!publicKey) throw new Error('Missing public key');
  if (!isSupportedAlgorithm(algorithm)) {
    throw new Error('Unsupported key algorithm');
  }

  checkClientData(clientDataJSON, {
    type: 'webauthn.create',
    challenge: expectedChallenge,
    origin: expectedOrigin,
  });

  // Import once here so a key this server could never verify with is refused
  // at registration rather than at the first sign-in attempt.
  await importCredentialKey(publicKey, algorithm);

  return { credentialId, publicKey, algorithm };
}

/**
 * Import a stored SPKI public key for verification.
 * @param {string} publicKey - base64url SPKI
 * @param {number} algorithm - COSE identifier
 * @returns {Promise<CryptoKey>}
 */
async function importCredentialKey(publicKey, algorithm) {
  const params = algorithmParams(algorithm);
  if (!params) throw new Error('Unsupported key algorithm');
  return crypto.subtle.importKey(
    'spki',
    /** @type {BufferSource} */ (base64UrlDecode(publicKey)),
    params.importParams,
    false,
    ['verify'],
  );
}

/**
 * Check an authentication response against a stored credential.
 * @param {Object} params
 * @param {string} params.publicKey - base64url SPKI of the stored credential
 * @param {number} params.algorithm - COSE identifier of the stored credential
 * @param {string} params.authenticatorData - base64url of the raw bytes
 * @param {string} params.clientDataJSON - base64url of the raw bytes
 * @param {string} params.signature - base64url of the signature
 * @param {string} params.expectedChallenge
 * @param {string} params.expectedOrigin
 * @param {string} params.rpId
 * @returns {Promise<{signCount: number}>}
 * @throws {Error} If anything about the assertion fails to check out
 */
export async function verifyAssertion({
  publicKey,
  algorithm,
  authenticatorData,
  clientDataJSON,
  signature,
  expectedChallenge,
  expectedOrigin,
  rpId,
}) {
  checkClientData(clientDataJSON, {
    type: 'webauthn.get',
    challenge: expectedChallenge,
    origin: expectedOrigin,
  });

  const authData = base64UrlDecode(authenticatorData);
  await checkAuthenticatorData(authData, rpId);

  // The authenticator signs its own data concatenated with a hash of the
  // client data, which is what ties the signature to this challenge.
  const clientDataHash = await sha256(base64UrlDecode(clientDataJSON));
  const signedData = new Uint8Array(authData.length + clientDataHash.length);
  signedData.set(authData, 0);
  signedData.set(clientDataHash, authData.length);

  const key = await importCredentialKey(publicKey, algorithm);
  const params =
    /** @type {NonNullable<ReturnType<typeof algorithmParams>>} */ (
      algorithmParams(algorithm)
    );

  let sig = base64UrlDecode(signature);
  if (algorithm === COSE_ES256) {
    // WebAuthn signs ECDSA in DER; WebCrypto verifies raw r||s.
    sig = derToRawEcdsaSignature(sig);
  }

  const valid = await crypto.subtle.verify(
    params.verifyParams,
    key,
    /** @type {BufferSource} */ (sig),
    /** @type {BufferSource} */ (signedData),
  );
  if (!valid) throw new Error('Signature is invalid');

  return { signCount: readSignCount(authData) };
}

/**
 * Convert a DER-encoded ECDSA signature to the raw r||s WebCrypto expects.
 * @param {Uint8Array} der
 * @returns {Uint8Array} 64 bytes
 * @throws {Error} If the encoding is not a two-integer SEQUENCE
 */
export function derToRawEcdsaSignature(der) {
  if (der[0] !== 0x30) throw new Error('Malformed ECDSA signature');

  // SEQUENCE header, then INTEGER r, then INTEGER s. Lengths here are always
  // short-form: r and s are at most 33 bytes for P-256.
  let offset = 2;
  const readInt = () => {
    if (der[offset++] !== 0x02) throw new Error('Malformed ECDSA signature');
    const length = der[offset++];
    let value = der.slice(offset, offset + length);
    offset += length;
    // Strip the sign byte DER adds to a value whose high bit is set, then pad
    // back to the fixed 32 the raw form wants.
    while (value.length > 32 && value[0] === 0x00) value = value.slice(1);
    if (value.length > 32) throw new Error('Malformed ECDSA signature');
    const padded = new Uint8Array(32);
    padded.set(value, 32 - value.length);
    return padded;
  };

  const r = readInt();
  const s = readInt();
  const raw = new Uint8Array(64);
  raw.set(r, 0);
  raw.set(s, 32);
  return raw;
}

/**
 * A fresh challenge for either ceremony.
 * @returns {string} base64url
 */
export function generateChallenge() {
  return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
}