import { constantTimeEqual, fromBase64Url } from "@suaveplan/crypto-utils"; import { WEBAUTHN_CODES, WebAuthnError } from "../errors/error-codes.js"; import { type ClientDataJSON, ClientDataJSONSchema, } from "../schemas/schemas.js"; const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); /** Discriminated `type` field accepted by the WebAuthn registration ceremony. */ export const CLIENT_DATA_TYPE_CREATE = "webauthn.create"; /** Discriminated `type` field accepted by the WebAuthn assertion ceremony. */ export const CLIENT_DATA_TYPE_GET = "webauthn.get"; /** * Parse a raw `clientDataJSON` byte string into a typed * {@link ClientDataJSON} object. Throws `WEBAUTHN.MALFORMED_RESPONSE` * when the bytes are not valid UTF-8 JSON or do not conform to the * `ClientDataJSONSchema`. */ export function parseClientDataJSON(bytes: Uint8Array): ClientDataJSON { let text: string; try { text = TEXT_DECODER.decode(bytes); } catch (cause) { throw new WebAuthnError( WEBAUTHN_CODES.MALFORMED_RESPONSE, "error.webauthn.client-data.invalid-utf8", { cause } ); } let json: unknown; try { json = JSON.parse(text); } catch (cause) { throw new WebAuthnError( WEBAUTHN_CODES.MALFORMED_RESPONSE, "error.webauthn.client-data.invalid-json", { cause } ); } const parsed = ClientDataJSONSchema.safeParse(json); if (!parsed.success) { throw new WebAuthnError( WEBAUTHN_CODES.MALFORMED_RESPONSE, "error.webauthn.client-data.invalid-shape", { context: { issues: parsed.error.issues }, cause: parsed.error } ); } return parsed.data; } /** Validation context shared between register and authenticate. */ export interface ValidateClientDataInput { readonly clientDataJSON: Uint8Array; readonly expectedType: | typeof CLIENT_DATA_TYPE_CREATE | typeof CLIENT_DATA_TYPE_GET; readonly expectedOrigin: string; readonly expectedChallenge: Uint8Array; } /** * Validate a parsed `clientDataJSON` against the caller's expected * type, origin, and challenge. Mismatches throw a typed * {@link WebAuthnError} with the appropriate W3C-aligned code. */ export function validateClientDataJSON( input: ValidateClientDataInput ): ClientDataJSON { const data = parseClientDataJSON(input.clientDataJSON); if (data.type !== input.expectedType) { throw new WebAuthnError( WEBAUTHN_CODES.MALFORMED_RESPONSE, "error.webauthn.client-data.type-mismatch", { context: { expected: input.expectedType, got: data.type, }, } ); } if (!constantTimeEqual(utf8(data.origin), utf8(input.expectedOrigin))) { throw new WebAuthnError( WEBAUTHN_CODES.ORIGIN_MISMATCH, "error.webauthn.client-data.origin-mismatch", { context: { expected: input.expectedOrigin, got: data.origin, }, } ); } let decodedChallenge: Uint8Array; try { decodedChallenge = fromBase64Url(data.challenge); } catch (cause) { throw new WebAuthnError( WEBAUTHN_CODES.MALFORMED_RESPONSE, "error.webauthn.client-data.challenge-not-base64url", { cause } ); } if (!constantTimeEqual(decodedChallenge, input.expectedChallenge)) { throw new WebAuthnError( WEBAUTHN_CODES.CHALLENGE_MISMATCH, "error.webauthn.client-data.challenge-mismatch" ); } return data; } const ENCODER = new TextEncoder(); function utf8(s: string): Uint8Array { return ENCODER.encode(s); }