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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
337 lines · 10.1 KB Raw History
// WebAuthn ceremony checks, driven by a synthetic authenticator: a P-256 key
// pair plus the byte layouts a real one would produce.
import { base64UrlEncode } from '@pdsjs/core/crypto';
import {
  COSE_ES256,
  checkClientData,
  derToRawEcdsaSignature,
  isSupportedAlgorithm,
  readSignCount,
  verifyAssertion,
  verifyRegistration,
} from '@pdsjs/core/webauthn';
import { describe, expect, test } from 'vitest';

const ORIGIN = 'https://pds.example.com';
const RP_ID = 'pds.example.com';
const CHALLENGE = 'Y2hhbGxlbmdlLWZvci10ZXN0aW5n';

/** @param {object} data */
function encodeClientData(data) {
  return base64UrlEncode(new TextEncoder().encode(JSON.stringify(data)));
}

/** A key pair standing in for the authenticator's credential. */
async function createCredential() {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
  return {
    privateKey: keyPair.privateKey,
    publicKey: base64UrlEncode(new Uint8Array(spki)),
  };
}

/**
 * The 37-byte prefix a real authenticator emits: rpIdHash, flags, counter.
 * @param {{rpId?: string, userPresent?: boolean, signCount?: number}} [options]
 */
async function makeAuthenticatorData({
  rpId = RP_ID,
  userPresent = true,
  signCount = 0,
} = {}) {
  const rpIdHash = new Uint8Array(
    await crypto.subtle.digest('SHA-256', new TextEncoder().encode(rpId)),
  );
  const authData = new Uint8Array(37);
  authData.set(rpIdHash, 0);
  authData[32] = userPresent ? 0x01 : 0x00;
  authData[33] = (signCount >>> 24) & 0xff;
  authData[34] = (signCount >>> 16) & 0xff;
  authData[35] = (signCount >>> 8) & 0xff;
  authData[36] = signCount & 0xff;
  return authData;
}

/**
 * Sign an assertion the way an authenticator does: over authData followed by
 * the hash of the client data.
 */
async function signAssertion(
  /** @type {CryptoKey} */ privateKey,
  /** @type {Uint8Array} */ authData,
  /** @type {string} */ clientDataJSON,
) {
  const clientDataHash = new Uint8Array(
    await crypto.subtle.digest(
      'SHA-256',
      Uint8Array.from(
        atob(clientDataJSON.replace(/-/g, '+').replace(/_/g, '/')),
        (c) => c.charCodeAt(0),
      ),
    ),
  );
  const signed = new Uint8Array(authData.length + clientDataHash.length);
  signed.set(authData, 0);
  signed.set(clientDataHash, authData.length);

  const raw = new Uint8Array(
    await crypto.subtle.sign(
      { name: 'ECDSA', hash: 'SHA-256' },
      privateKey,
      signed,
    ),
  );
  return base64UrlEncode(rawToDerEcdsaSignature(raw));
}

/** WebCrypto signs raw r||s; a browser hands the server DER. */
function rawToDerEcdsaSignature(/** @type {Uint8Array} */ raw) {
  const encodeInt = (/** @type {Uint8Array} */ bytes) => {
    let start = 0;
    while (start < bytes.length - 1 && bytes[start] === 0) start++;
    let value = bytes.slice(start);
    // DER integers are signed: a leading high bit needs a zero byte
    if (value[0] & 0x80) value = Uint8Array.from([0, ...value]);
    return Uint8Array.from([0x02, value.length, ...value]);
  };
  const r = encodeInt(raw.slice(0, 32));
  const s = encodeInt(raw.slice(32));
  return Uint8Array.from([0x30, r.length + s.length, ...r, ...s]);
}

describe('client data', () => {
  test('accepts a matching ceremony', () => {
    const clientDataJSON = encodeClientData({
      type: 'webauthn.create',
      challenge: CHALLENGE,
      origin: ORIGIN,
    });
    expect(() =>
      checkClientData(clientDataJSON, {
        type: 'webauthn.create',
        challenge: CHALLENGE,
        origin: ORIGIN,
      }),
    ).not.toThrow();
  });

  test('refuses a challenge from another request', () => {
    const clientDataJSON = encodeClientData({
      type: 'webauthn.get',
      challenge: 'some-other-challenge',
      origin: ORIGIN,
    });
    expect(() =>
      checkClientData(clientDataJSON, {
        type: 'webauthn.get',
        challenge: CHALLENGE,
        origin: ORIGIN,
      }),
    ).toThrow(/Challenge mismatch/);
  });

  test('refuses a credential created for another origin', () => {
    const clientDataJSON = encodeClientData({
      type: 'webauthn.get',
      challenge: CHALLENGE,
      origin: 'https://evil.example',
    });
    expect(() =>
      checkClientData(clientDataJSON, {
        type: 'webauthn.get',
        challenge: CHALLENGE,
        origin: ORIGIN,
      }),
    ).toThrow(/Origin mismatch/);
  });

  test('refuses a registration response to a sign-in request', () => {
    const clientDataJSON = encodeClientData({
      type: 'webauthn.create',
      challenge: CHALLENGE,
      origin: ORIGIN,
    });
    expect(() =>
      checkClientData(clientDataJSON, {
        type: 'webauthn.get',
        challenge: CHALLENGE,
        origin: ORIGIN,
      }),
    ).toThrow(/webauthn.get/);
  });
});

describe('registration', () => {
  test('accepts a well-formed response', async () => {
    const credential = await createCredential();
    const result = await verifyRegistration({
      credentialId: 'credential-one',
      publicKey: credential.publicKey,
      algorithm: COSE_ES256,
      clientDataJSON: encodeClientData({
        type: 'webauthn.create',
        challenge: CHALLENGE,
        origin: ORIGIN,
      }),
      expectedChallenge: CHALLENGE,
      expectedOrigin: ORIGIN,
    });
    expect(result.credentialId).toBe('credential-one');
    expect(result.algorithm).toBe(COSE_ES256);
  });

  test('refuses an algorithm this server cannot verify', async () => {
    const credential = await createCredential();
    await expect(
      verifyRegistration({
        credentialId: 'credential-one',
        publicKey: credential.publicKey,
        algorithm: -8, // EdDSA
        clientDataJSON: encodeClientData({
          type: 'webauthn.create',
          challenge: CHALLENGE,
          origin: ORIGIN,
        }),
        expectedChallenge: CHALLENGE,
        expectedOrigin: ORIGIN,
      }),
    ).rejects.toThrow(/Unsupported key algorithm/);
    expect(isSupportedAlgorithm(-8)).toBe(false);
  });

  test('refuses a key that will not import', async () => {
    await expect(
      verifyRegistration({
        credentialId: 'credential-one',
        publicKey: base64UrlEncode(new Uint8Array([1, 2, 3])),
        algorithm: COSE_ES256,
        clientDataJSON: encodeClientData({
          type: 'webauthn.create',
          challenge: CHALLENGE,
          origin: ORIGIN,
        }),
        expectedChallenge: CHALLENGE,
        expectedOrigin: ORIGIN,
      }),
    ).rejects.toThrow();
  });
});

describe('assertion', () => {
  /** Build a complete, valid assertion, then let a test spoil one part. */
  async function makeAssertion(/** @type {any} */ overrides = {}) {
    const credential = await createCredential();
    const authData = await makeAuthenticatorData(overrides.authenticator);
    const clientDataJSON = encodeClientData({
      type: 'webauthn.get',
      challenge: overrides.challenge ?? CHALLENGE,
      origin: overrides.origin ?? ORIGIN,
      ...overrides.clientData,
    });
    const signature = await signAssertion(
      overrides.signWith ?? credential.privateKey,
      authData,
      clientDataJSON,
    );
    return {
      publicKey: credential.publicKey,
      algorithm: COSE_ES256,
      authenticatorData: base64UrlEncode(authData),
      clientDataJSON,
      signature,
      expectedChallenge: CHALLENGE,
      expectedOrigin: ORIGIN,
      rpId: RP_ID,
    };
  }

  test('accepts a signature from the registered key', async () => {
    const result = await verifyAssertion(await makeAssertion());
    expect(result.signCount).toBe(0);
  });

  test('reports the authenticator counter', async () => {
    const assertion = await makeAssertion({
      authenticator: { signCount: 41 },
    });
    expect((await verifyAssertion(assertion)).signCount).toBe(41);
  });

  test('refuses a signature from a different key', async () => {
    const assertion = await makeAssertion();
    const impostor = await createCredential();
    await expect(
      verifyAssertion({ ...assertion, publicKey: impostor.publicKey }),
    ).rejects.toThrow(/Signature is invalid/);
  });

  test('refuses authenticator data for another site', async () => {
    const assertion = await makeAssertion({
      authenticator: { rpId: 'evil.example' },
    });
    await expect(verifyAssertion(assertion)).rejects.toThrow(/different site/);
  });

  test('refuses an assertion with no user present', async () => {
    const assertion = await makeAssertion({
      authenticator: { userPresent: false },
    });
    await expect(verifyAssertion(assertion)).rejects.toThrow(/User presence/);
  });

  test('refuses a replayed challenge', async () => {
    const assertion = await makeAssertion({ challenge: 'an-older-challenge' });
    await expect(verifyAssertion(assertion)).rejects.toThrow(
      /Challenge mismatch/,
    );
  });

  test('refuses a tampered signature', async () => {
    const assertion = await makeAssertion();
    const bytes = Uint8Array.from(
      atob(assertion.signature.replace(/-/g, '+').replace(/_/g, '/')),
      (c) => c.charCodeAt(0),
    );
    bytes[bytes.length - 1] ^= 0xff;
    await expect(
      verifyAssertion({ ...assertion, signature: base64UrlEncode(bytes) }),
    ).rejects.toThrow();
  });
});

describe('signature conversion', () => {
  test('unpacks DER into fixed-width r and s', () => {
    // r has a high bit set, so DER carries the extra sign byte
    const der = Uint8Array.from([
      0x30, 0x08, 0x02, 0x02, 0x00, 0x81, 0x02, 0x02, 0x00, 0x7f,
    ]);
    const raw = derToRawEcdsaSignature(der);
    expect(raw.length).toBe(64);
    expect(raw[31]).toBe(0x81);
    expect(raw[63]).toBe(0x7f);
    expect(raw.slice(0, 31).every((b) => b === 0)).toBe(true);
  });

  test('rejects a non-sequence', () => {
    expect(() =>
      derToRawEcdsaSignature(Uint8Array.from([0x02, 0x01, 0x00])),
    ).toThrow(/Malformed/);
  });
});

describe('sign counters', () => {
  test('reads a big-endian counter', async () => {
    expect(
      readSignCount(await makeAuthenticatorData({ signCount: 70000 })),
    ).toBe(70000);
  });

  test('returns zero for truncated data', () => {
    expect(readSignCount(new Uint8Array(10))).toBe(0);
  });
});