/** * T7 (v0.10) — thin `navigator.credentials` wrapper: base64url ↔ ArrayBuffer plumbing + error * mapping, kept small and pure-ish. No credential material is ever logged (INV9). * * The server (P5) is authoritative for `rpId` — it lives in the challenge options this module * passes straight through to the authenticator; the client NEVER derives `rpId` from * `location.hostname` (see §8 Open Question #5). This module only encodes/decodes and invokes the * ceremony; the account is established server-side from the resulting assertion (INV3). */ import { decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts' /** WebAuthn ceremony error, kind-tagged so callers can map cancellation → 'rejected' cleanly. */ export class WebAuthnError extends Error { readonly kind: 'cancelled' | 'unsupported' | 'malformed' | 'failed' constructor(kind: WebAuthnError['kind'], message: string) { super(message) this.name = 'WebAuthnError' this.kind = kind } } export interface RegistrationResult { readonly id: string readonly rawId: string // base64url readonly type: string readonly response: { readonly clientDataJSON: string // base64url readonly attestationObject: string // base64url } } export interface AuthenticationResult { readonly id: string readonly rawId: string // base64url readonly type: string readonly response: { readonly clientDataJSON: string // base64url readonly authenticatorData: string // base64url readonly signature: string // base64url readonly userHandle: string | null // base64url or null } } export interface WebAuthnClient { register(challenge: PublicKeyCredentialCreationOptions): Promise authenticate(challenge: PublicKeyCredentialRequestOptions): Promise } /** ArrayBuffer → base64url (lossless). */ export function bufferToBase64Url(buf: ArrayBuffer): string { return encodeBase64UrlBytes(new Uint8Array(buf)) } /** base64url → ArrayBuffer (lossless inverse of {@link bufferToBase64Url}). */ export function base64UrlToBuffer(value: string): ArrayBuffer { const bytes = decodeBase64UrlBytes(value) return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer } /** Map a thrown ceremony error to a typed WebAuthnError (user cancel → 'cancelled'). */ function mapCeremonyError(err: unknown): WebAuthnError { if (err instanceof WebAuthnError) return err const name = err instanceof Error ? err.name : '' if (name === 'NotAllowedError' || name === 'AbortError') { return new WebAuthnError('cancelled', 'the passkey prompt was dismissed') } return new WebAuthnError('failed', err instanceof Error ? err.message : 'webauthn ceremony failed') } export function createWebAuthnClient(creds?: CredentialsContainer): WebAuthnClient { const container = creds ?? (typeof navigator !== 'undefined' ? navigator.credentials : undefined) if (!container) { // Fail fast at construction so the UI can offer a fallback (never a silent no-op). throw new WebAuthnError('unsupported', 'WebAuthn is not available in this environment') } return { async register(challenge: PublicKeyCredentialCreationOptions): Promise { let cred: Credential | null try { cred = await container.create({ publicKey: challenge }) } catch (err) { throw mapCeremonyError(err) } if (!cred) throw new WebAuthnError('failed', 'no credential returned') const pk = cred as PublicKeyCredential const resp = pk.response as AuthenticatorAttestationResponse return { id: pk.id, rawId: bufferToBase64Url(pk.rawId), type: pk.type, response: { clientDataJSON: bufferToBase64Url(resp.clientDataJSON), attestationObject: bufferToBase64Url(resp.attestationObject), }, } }, async authenticate( challenge: PublicKeyCredentialRequestOptions, ): Promise { let cred: Credential | null try { cred = await container.get({ publicKey: challenge }) } catch (err) { throw mapCeremonyError(err) } if (!cred) throw new WebAuthnError('failed', 'no assertion returned') const pk = cred as PublicKeyCredential const resp = pk.response as AuthenticatorAssertionResponse return { id: pk.id, rawId: bufferToBase64Url(pk.rawId), type: pk.type, response: { clientDataJSON: bufferToBase64Url(resp.clientDataJSON), authenticatorData: bufferToBase64Url(resp.authenticatorData), signature: bufferToBase64Url(resp.signature), userHandle: resp.userHandle ? bufferToBase64Url(resp.userHandle) : null, }, } }, } }