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