// 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); }); });