import { cborBytes, cborMap, decodeCbor } from "./cbor"; import { timingSafeEqualStrings, toHex } from "./crypto"; import { fromBase64Url, toBase64Url } from "~/lib/base64url"; const COSE_ALG_ES256 = -7; const COSE_ALG_RS256 = -257; const FLAG_USER_PRESENT = 0x01; const FLAG_USER_VERIFIED = 0x04; const FLAG_ATTESTED = 0x40; const RP_ID_HASH_BYTES = 32; const AAGUID_BYTES = 16; const P256_COORDINATE_BYTES = 32; const MAX_CREDENTIAL_ID_BYTES = 1023; const COSE_KTY_EC2 = 2; const COSE_KTY_RSA = 3; const COSE_CRV_P256 = 1; const DER_SEQUENCE = 0x30; const DER_INTEGER = 0x02; const MIN_DER_SIGNATURE_BYTES = 8; export const SUPPORTED_ALGORITHMS = [COSE_ALG_ES256, COSE_ALG_RS256] as const; export type RelyingParty = { id: string; origin: string }; type AuthenticatorData = { rpIdHash: Uint8Array; flags: number; signCount: number; credential: { id: Uint8Array; publicKey: Map } | null; }; export type VerifiedRegistration = { credentialId: string; publicKeyJwk: JsonWebKey; algorithm: number; signCount: number; userVerified: boolean; }; export type StoredPasskey = { publicKeyJwk: JsonWebKey; algorithm: number; signCount: number }; const encoder = new TextEncoder(); async function sha256(bytes: Uint8Array): Promise { return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); } function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { return timingSafeEqualStrings(toHex(left), toHex(right)); } export function parseAuthenticatorData(data: Uint8Array): AuthenticatorData { if (data.byteLength < RP_ID_HASH_BYTES + 5) throw new Error("Authenticator data is too short"); const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const rpIdHash = data.slice(0, RP_ID_HASH_BYTES); const flags = data[RP_ID_HASH_BYTES]; const signCount = view.getUint32(RP_ID_HASH_BYTES + 1); let offset = RP_ID_HASH_BYTES + 5; if (!(flags & FLAG_ATTESTED)) return { rpIdHash, flags, signCount, credential: null }; offset += AAGUID_BYTES; if (data.byteLength < offset + 2) throw new Error("Authenticator data is truncated"); const idLength = view.getUint16(offset); offset += 2; if (idLength > MAX_CREDENTIAL_ID_BYTES || data.byteLength < offset + idLength) { throw new Error("Invalid credential id length"); } const id = data.slice(offset, offset + idLength); offset += idLength; const { value } = decodeCbor(data.subarray(offset)); return { rpIdHash, flags, signCount, credential: { id, publicKey: cborMap(value) } }; } function coordinate(value: unknown): string { if (!(value instanceof Uint8Array)) throw new Error("Invalid COSE key"); return toBase64Url(value); } /** Converts a COSE_Key (ES256 or RS256) into a JWK that WebCrypto can import. */ export function coseToJwk(key: Map): { jwk: JsonWebKey; algorithm: number } { const kty = key.get(1); const algorithm = key.get(3); if (kty === COSE_KTY_EC2 && algorithm === COSE_ALG_ES256 && key.get(-1) === COSE_CRV_P256) { return { jwk: { kty: "EC", crv: "P-256", x: coordinate(key.get(-2)), y: coordinate(key.get(-3)) }, algorithm }; } if (kty === COSE_KTY_RSA && algorithm === COSE_ALG_RS256) { return { jwk: { kty: "RSA", alg: "RS256", n: coordinate(key.get(-1)), e: coordinate(key.get(-2)) }, algorithm }; } throw new Error("Unsupported passkey algorithm; use ES256 or RS256"); } type ClientData = { type: string; challenge: string; origin: string }; function parseClientData(clientDataJSON: Uint8Array): ClientData { const parsed: unknown = JSON.parse(new TextDecoder().decode(clientDataJSON)); if (typeof parsed !== "object" || parsed === null) throw new Error("Invalid client data"); const type = "type" in parsed ? parsed.type : null; const challenge = "challenge" in parsed ? parsed.challenge : null; const origin = "origin" in parsed ? parsed.origin : null; if (typeof type !== "string" || typeof challenge !== "string" || typeof origin !== "string") { throw new Error("Invalid client data"); } return { type, challenge, origin }; } async function checkCeremony( clientDataJSON: Uint8Array, authData: AuthenticatorData, type: "webauthn.create" | "webauthn.get", expectedChallenge: string, rp: RelyingParty, ): Promise { const clientData = parseClientData(clientDataJSON); if (clientData.type !== type) throw new Error("Unexpected ceremony type"); if (!timingSafeEqualStrings(clientData.challenge, expectedChallenge)) throw new Error("Challenge mismatch"); if (clientData.origin !== rp.origin) throw new Error("Origin mismatch"); if (!bytesEqual(authData.rpIdHash, await sha256(encoder.encode(rp.id)))) throw new Error("Relying party mismatch"); if (!(authData.flags & FLAG_USER_PRESENT)) throw new Error("User presence is required"); } export async function verifyRegistration( response: { clientDataJSON: string; attestationObject: string }, expectedChallenge: string, rp: RelyingParty, ): Promise { const clientDataJSON = fromBase64Url(response.clientDataJSON); const attestation = cborMap(decodeCbor(fromBase64Url(response.attestationObject)).value); const authData = parseAuthenticatorData(cborBytes(attestation.get("authData"))); await checkCeremony(clientDataJSON, authData, "webauthn.create", expectedChallenge, rp); if (!authData.credential) throw new Error("No credential in attestation"); const { jwk, algorithm } = coseToJwk(authData.credential.publicKey); await importKey(jwk, algorithm); return { credentialId: toBase64Url(authData.credential.id), publicKeyJwk: jwk, algorithm, signCount: authData.signCount, userVerified: (authData.flags & FLAG_USER_VERIFIED) !== 0, }; } async function importKey(jwk: JsonWebKey, algorithm: number): Promise { if (algorithm === COSE_ALG_ES256) { return crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]); } return crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]); } function readDerInteger(der: Uint8Array, offset: number): { value: Uint8Array; next: number } { if (der[offset] !== DER_INTEGER) throw new Error("Invalid DER signature"); const length = der[offset + 1]; const start = offset + 2; if (length === 0 || length > P256_COORDINATE_BYTES + 1 || start + length > der.byteLength) { throw new Error("Invalid DER signature"); } let value = der.subarray(start, start + length); while (value.length > P256_COORDINATE_BYTES && value[0] === 0) value = value.subarray(1); if (value.length > P256_COORDINATE_BYTES) throw new Error("Invalid DER signature"); const padded = new Uint8Array(P256_COORDINATE_BYTES); padded.set(value, P256_COORDINATE_BYTES - value.length); return { value: padded, next: start + length }; } /** WebAuthn ES256 signatures are ASN.1 DER; WebCrypto expects raw r||s. */ export function derToRawEcdsa(der: Uint8Array): Uint8Array { if (der[0] !== DER_SEQUENCE || der.byteLength < MIN_DER_SIGNATURE_BYTES || der[1] !== der.byteLength - 2) { throw new Error("Invalid DER signature"); } const r = readDerInteger(der, 2); const s = readDerInteger(der, r.next); if (s.next !== der.byteLength) throw new Error("Invalid DER signature"); const out = new Uint8Array(P256_COORDINATE_BYTES * 2); out.set(r.value, 0); out.set(s.value, P256_COORDINATE_BYTES); return out; } export async function verifyAssertion( response: { clientDataJSON: string; authenticatorData: string; signature: string }, stored: StoredPasskey, expectedChallenge: string, rp: RelyingParty, ): Promise<{ signCount: number; userVerified: boolean }> { const clientDataJSON = fromBase64Url(response.clientDataJSON); const authDataBytes = fromBase64Url(response.authenticatorData); const authData = parseAuthenticatorData(authDataBytes); await checkCeremony(clientDataJSON, authData, "webauthn.get", expectedChallenge, rp); const clientHash = await sha256(clientDataJSON); const signed = new Uint8Array(authDataBytes.byteLength + clientHash.byteLength); signed.set(authDataBytes, 0); signed.set(clientHash, authDataBytes.byteLength); const key = await importKey(stored.publicKeyJwk, stored.algorithm); const signature = fromBase64Url(response.signature); const valid = stored.algorithm === COSE_ALG_ES256 ? await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, derToRawEcdsa(signature), signed) : await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, signed); if (!valid) throw new Error("Invalid passkey signature"); if (authData.signCount !== 0 && stored.signCount !== 0 && authData.signCount <= stored.signCount) { throw new Error("Passkey counter regressed; the authenticator may be cloned"); } return { signCount: authData.signCount, userVerified: (authData.flags & FLAG_USER_VERIFIED) !== 0 }; }