import { base64UrlToBytes, bytesToBase64Url, bytesToHex, bytesToUtf8, } from "../crypto/encoding"; import { cborMap, decodeCbor, decodeCompleteCbor, type CborValue, } from "./cbor"; export interface AuthenticatorFlags { userPresent: boolean; userVerified: boolean; backupEligible: boolean; backupState: boolean; attestedCredentialData: boolean; extensionData: boolean; raw: number; } export interface AttestedCredentialData { aaguid: string; credentialId: string; credentialPublicKey: Map; } export interface ParsedAuthenticatorData { rpIdHash: string; flags: AuthenticatorFlags; signCount: number; attestedCredential?: AttestedCredentialData; extensions?: CborValue; bytesRead: number; raw: Uint8Array; } export interface ParsedAttestation { format: string; statement: Map; authenticator: ParsedAuthenticatorData; } export interface CollectedClientData { type: string; challenge: string; origin: string; crossOrigin?: boolean; tokenBinding?: unknown; [key: string]: unknown; } function readU16(bytes: Uint8Array, offset: number): number { if (offset + 2 > bytes.byteLength) throw new Error("Authenticator data ends unexpectedly."); return (bytes[offset]! << 8) | bytes[offset + 1]!; } function readU32(bytes: Uint8Array, offset: number): number { if (offset + 4 > bytes.byteLength) throw new Error("Authenticator data ends unexpectedly."); return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32( 0, false, ); } export function parseAuthenticatorData( bytes: Uint8Array, ): ParsedAuthenticatorData { if (bytes.byteLength < 37) throw new Error("Authenticator data must contain at least 37 bytes."); const flagByte = bytes[32]!; const flags: AuthenticatorFlags = { userPresent: Boolean(flagByte & 0x01), userVerified: Boolean(flagByte & 0x04), backupEligible: Boolean(flagByte & 0x08), backupState: Boolean(flagByte & 0x10), attestedCredentialData: Boolean(flagByte & 0x40), extensionData: Boolean(flagByte & 0x80), raw: flagByte, }; if (flags.backupState && !flags.backupEligible) { throw new Error( "Authenticator flags set backup state without backup eligibility.", ); } let offset = 37; let attestedCredential: AttestedCredentialData | undefined; if (flags.attestedCredentialData) { if (offset + 18 > bytes.byteLength) throw new Error("Attested credential data is truncated."); const aaguidBytes = bytes.slice(offset, offset + 16); offset += 16; const credentialLength = readU16(bytes, offset); offset += 2; if ( credentialLength === 0 || offset + credentialLength > bytes.byteLength ) { throw new Error("Attested credential ID length is invalid."); } const credentialId = bytes.slice(offset, offset + credentialLength); offset += credentialLength; const decodedKey = decodeCbor(bytes.slice(offset)); const credentialPublicKey = cborMap( decodedKey.value, "Credential public key", ); offset += decodedKey.bytesRead; const hex = bytesToHex(aaguidBytes); attestedCredential = { aaguid: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`, credentialId: bytesToBase64Url(credentialId), credentialPublicKey, }; } let extensions: CborValue | undefined; if (flags.extensionData) { const decoded = decodeCbor(bytes.slice(offset)); extensions = decoded.value; offset += decoded.bytesRead; } if (offset !== bytes.byteLength) { throw new Error( "Authenticator data contains bytes not described by its flags.", ); } return { rpIdHash: bytesToHex(bytes.slice(0, 32)), flags, signCount: readU32(bytes, 33), ...(attestedCredential ? { attestedCredential } : {}), ...(extensions !== undefined ? { extensions } : {}), bytesRead: offset, raw: bytes, }; } export function parseAttestationObject( input: string | Uint8Array, ): ParsedAttestation { const bytes = typeof input === "string" ? base64UrlToBytes(input) : input; const map = cborMap(decodeCompleteCbor(bytes), "Attestation object"); const format = map.get("fmt"); const authData = map.get("authData"); const statement = map.get("attStmt"); if (typeof format !== "string") throw new Error("Attestation format is missing."); if (!(authData instanceof Uint8Array)) throw new Error("Attestation authData is missing."); return { format, statement: cborMap(statement ?? null, "Attestation statement"), authenticator: parseAuthenticatorData(authData), }; } export function parseClientData( input: string | Uint8Array, ): CollectedClientData { const bytes = typeof input === "string" ? base64UrlToBytes(input) : input; let value: unknown; try { value = JSON.parse(bytesToUtf8(bytes)); } catch { throw new Error("Client data is not valid UTF-8 JSON."); } if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Client data must be a JSON object."); } const object = value as Record; if ( typeof object.type !== "string" || typeof object.challenge !== "string" || typeof object.origin !== "string" ) { throw new Error("Client data requires type, challenge and origin strings."); } if ( object.crossOrigin !== undefined && typeof object.crossOrigin !== "boolean" ) { throw new Error("Client data crossOrigin must be boolean when present."); } return object as CollectedClientData; } export function cborDiagnostic(value: CborValue): unknown { if (value instanceof Uint8Array) { return { base64url: bytesToBase64Url(value), bytes: value.byteLength }; } if (Array.isArray(value)) return value.map(cborDiagnostic); if (value instanceof Map) { return Object.fromEntries( [...value].map(([key, item]) => [String(key), cborDiagnostic(item)]), ); } return typeof value === "bigint" ? `${value.toString()}n` : value; }