import { base64UrlToBytes, bytesToArrayBuffer, bytesToBase64Url, bytesToHex, utf8ToBytes, } from "../crypto/encoding"; import type { CborValue } from "./cbor"; import { parseAuthenticatorData, parseClientData } from "./parser"; export type CheckStatus = "pass" | "fail" | "warning" | "information"; export interface VerificationCheck { name: string; status: CheckStatus; detail: string; } export interface AssertionVerificationInput { clientDataJSON: string; authenticatorData: string; signature: string; credentialPublicKey: string | Map; expectedChallenge: string; expectedOrigin: string; expectedRpId: string; requireUserVerification?: boolean; previousSignCount?: number; } export interface AssertionVerificationResult { verified: boolean; checks: VerificationCheck[]; signCount: number; } function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { if (left.byteLength !== right.byteLength) return false; let difference = 0; for (let index = 0; index < left.byteLength; index += 1) difference |= left[index]! ^ right[index]!; return difference === 0; } async function digest(bytes: Uint8Array): Promise { return new Uint8Array( await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(bytes)), ); } function coseNumber(map: Map, key: number): number { const value = map.get(key); if (typeof value !== "number") throw new Error(`COSE key parameter ${key} is missing.`); return value; } function coseBytes(map: Map, key: number): Uint8Array { const value = map.get(key); if (!(value instanceof Uint8Array)) throw new Error(`COSE key parameter ${key} is missing.`); return value; } function decodeCoseJson(input: string): Map { let value: unknown; try { value = JSON.parse(input); } catch { throw new Error("Credential public key JSON is invalid."); } if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Credential public key must be a JSON object."); } const result = new Map(); for (const [rawKey, item] of Object.entries(value)) { const key = Number(rawKey); if (!Number.isInteger(key)) throw new Error("COSE key labels must be integers."); if (typeof item === "string" && /^[-_A-Za-z0-9]+=*$/u.test(item)) { result.set(key, base64UrlToBytes(item)); } else if (typeof item === "number") result.set(key, item); else throw new Error(`Unsupported COSE value for label ${rawKey}.`); } return result; } export function coseKeyToJson( map: Map, ): Record { const result: Record = {}; for (const [key, value] of map) { if (typeof key !== "number") continue; if (typeof value === "number") result[String(key)] = value; else if (value instanceof Uint8Array) result[String(key)] = bytesToBase64Url(value); } return result; } function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array { if (signature[0] !== 0x30) throw new Error("ECDSA signature is not a DER sequence."); let offset = 1; let sequenceLength = signature[offset++]!; if (sequenceLength & 0x80) { const lengthBytes = sequenceLength & 0x7f; if ( lengthBytes === 0 || lengthBytes > 2 || offset + lengthBytes > signature.length ) throw new Error("ECDSA DER length is invalid."); sequenceLength = 0; for (let index = 0; index < lengthBytes; index += 1) sequenceLength = sequenceLength * 256 + signature[offset++]!; } if ( offset + sequenceLength !== signature.length || signature[offset++] !== 0x02 ) throw new Error("ECDSA DER sequence is malformed."); const rLength = signature[offset++]!; const r = signature.slice(offset, offset + rLength); offset += rLength; if (signature[offset++] !== 0x02) throw new Error("ECDSA DER sequence lacks s."); const sLength = signature[offset++]!; const s = signature.slice(offset, offset + sLength); if (offset + sLength !== signature.length) throw new Error("ECDSA DER signature has trailing data."); const normalize = (integer: Uint8Array): Uint8Array => { if (!integer.length || (integer[0]! & 0x80) !== 0) throw new Error("ECDSA DER integer is negative or empty."); if (integer.length > 1 && integer[0] === 0 && (integer[1]! & 0x80) === 0) throw new Error("ECDSA DER integer is not minimally encoded."); let start = 0; while (start < integer.length - 1 && integer[start] === 0) start += 1; const stripped = integer.slice(start); if (stripped.length > size) throw new Error("ECDSA integer is too large."); const output = new Uint8Array(size); output.set(stripped, size - stripped.length); return output; }; const output = new Uint8Array(size * 2); output.set(normalize(r)); output.set(normalize(s), size); return output; } async function importCoseKey(map: Map): Promise<{ key: CryptoKey; algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams; normalizeSignature: (signature: Uint8Array) => Uint8Array; }> { const kty = coseNumber(map, 1); const alg = coseNumber(map, 3); if (kty === 2 && alg === -7 && coseNumber(map, -1) === 1) { const jwk: JsonWebKey = { kty: "EC", crv: "P-256", x: bytesToBase64Url(coseBytes(map, -2)), y: bytesToBase64Url(coseBytes(map, -3)), ext: true, }; return { key: await crypto.subtle.importKey( "jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"], ), algorithm: { name: "ECDSA", hash: "SHA-256" }, normalizeSignature: derEcdsaToRaw, }; } if (kty === 3 && (alg === -257 || alg === -37)) { const jwk: JsonWebKey = { kty: "RSA", n: bytesToBase64Url(coseBytes(map, -1)), e: bytesToBase64Url(coseBytes(map, -2)), ext: true, }; const name = alg === -37 ? "RSA-PSS" : "RSASSA-PKCS1-v1_5"; return { key: await crypto.subtle.importKey( "jwk", jwk, { name, hash: "SHA-256" }, false, ["verify"], ), algorithm: alg === -37 ? { name, saltLength: 32 } : name, normalizeSignature: (signature) => signature, }; } if (kty === 1 && alg === -8 && coseNumber(map, -1) === 6) { const jwk: JsonWebKey = { kty: "OKP", crv: "Ed25519", x: bytesToBase64Url(coseBytes(map, -2)), ext: true, }; return { key: await crypto.subtle.importKey("jwk", jwk, "Ed25519", false, [ "verify", ]), algorithm: "Ed25519", normalizeSignature: (signature) => signature, }; } throw new Error( `Unsupported COSE key type/algorithm combination: kty ${kty}, alg ${alg}.`, ); } function addCheck( checks: VerificationCheck[], name: string, pass: boolean, detail: string, ): void { checks.push({ name, status: pass ? "pass" : "fail", detail }); } export async function verifyAssertion( input: AssertionVerificationInput, ): Promise { const checks: VerificationCheck[] = []; const clientBytes = base64UrlToBytes(input.clientDataJSON); const authBytes = base64UrlToBytes(input.authenticatorData); const client = parseClientData(clientBytes); const authenticator = parseAuthenticatorData(authBytes); addCheck( checks, "Ceremony type", client.type === "webauthn.get", `Received ${client.type}.`, ); const challengeMatches = (() => { try { return bytesEqual( base64UrlToBytes(client.challenge), base64UrlToBytes(input.expectedChallenge), ); } catch { return false; } })(); addCheck( checks, "Challenge", challengeMatches, challengeMatches ? "Challenge matches exactly." : "Challenge does not match.", ); let receivedOriginValid = false; let expectedOriginValid = false; try { receivedOriginValid = new URL(client.origin).origin === client.origin; } catch { // Reported as a failed syntax check below. } try { expectedOriginValid = new URL(input.expectedOrigin).origin === input.expectedOrigin; } catch { // Reported as a failed syntax check below. } addCheck( checks, "Origin syntax", receivedOriginValid && expectedOriginValid, "Origins must be serialized, absolute URL origins without a path.", ); addCheck( checks, "Origin", receivedOriginValid && expectedOriginValid && client.origin === input.expectedOrigin, `Received ${client.origin}.`, ); addCheck( checks, "Cross-origin", client.crossOrigin !== true, client.crossOrigin === true ? "Client data marks this ceremony cross-origin." : "Ceremony is not marked cross-origin.", ); const expectedRpHash = await digest(utf8ToBytes(input.expectedRpId)); addCheck( checks, "RP ID hash", bytesToHex(expectedRpHash) === authenticator.rpIdHash, `Expected SHA-256(${input.expectedRpId}).`, ); addCheck( checks, "User presence", authenticator.flags.userPresent, "Authenticator UP flag must be set.", ); if (input.requireUserVerification) addCheck( checks, "User verification", authenticator.flags.userVerified, "User verification was required.", ); else checks.push({ name: "User verification", status: "information", detail: authenticator.flags.userVerified ? "UV flag is set." : "UV was not required and is not set.", }); if ( input.previousSignCount !== undefined && input.previousSignCount > 0 && authenticator.signCount > 0 ) { addCheck( checks, "Signature counter", authenticator.signCount > input.previousSignCount, `Previous ${input.previousSignCount}; received ${authenticator.signCount}.`, ); } else { checks.push({ name: "Signature counter", status: "information", detail: `Received ${authenticator.signCount}; zero/non-incrementing counters can be valid for multi-device credentials.`, }); } try { const cose = typeof input.credentialPublicKey === "string" ? decodeCoseJson(input.credentialPublicKey) : input.credentialPublicKey; const imported = await importCoseKey(cose); const clientHash = await digest(clientBytes); const signed = new Uint8Array(authBytes.byteLength + clientHash.byteLength); signed.set(authBytes); signed.set(clientHash, authBytes.byteLength); const signature = imported.normalizeSignature( base64UrlToBytes(input.signature), ); const valid = await crypto.subtle.verify( imported.algorithm, imported.key, bytesToArrayBuffer(signature), bytesToArrayBuffer(signed), ); addCheck( checks, "Cryptographic signature", valid, valid ? "Signature verifies with the supplied credential key." : "Signature verification failed.", ); } catch (error) { checks.push({ name: "Cryptographic signature", status: "fail", detail: error instanceof Error ? error.message : "Signature verification failed.", }); } return { verified: checks.every((check) => check.status !== "fail"), checks, signCount: authenticator.signCount, }; }