// Stateless WebAuthn challenge store backed by HMAC-signed cookies. // // Why not an in-memory Map: Nuxt runs under PM2 cluster mode (multiple // workers). A challenge stored in worker A's memory is invisible to worker B, // so login-options and login-verify can land on different workers and fail // with "Challenge expired or invalid". Signing the challenge into a cookie is // stateless across workers and processes. // // Single-use: the cookie is deleted on every consumeChallenge() call, so a // challenge cannot be replayed within the same browser. Cross-browser replay // is bounded by the 5-minute TTL, same as the previous Map-based store. import { createHmac, timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto' const CHALLENGE_TTL_MS = 5 * 60 * 1000 const COOKIE_PATH = '/api/idempiere-auth/webauthn' export type ChallengeKind = 'register' | 'login' interface SignedPayload { c: string // challenge (the base64url string @simplewebauthn issued) k: ChallengeKind u?: number // userId (only set for register) e: number // expiresAt (ms epoch) } // Domain-separated subkey: don't reuse the AES-GCM key bytes as an HMAC key. function getSigningKey(): Buffer { const hex = process.env.CREDENTIAL_ENCRYPTION_KEY if (!hex) { throw new Error('CREDENTIAL_ENCRYPTION_KEY env var is not set — cannot sign WebAuthn challenges') } return createHmac('sha256', Buffer.from(hex, 'hex')).update('wa-challenge-v1').digest() } function b64urlEncode(buf: Buffer): string { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } function b64urlDecode(s: string): Buffer { const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)) return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64') } function sign(payloadB64: string): string { return b64urlEncode(createHmac('sha256', getSigningKey()).update(payloadB64).digest()) } function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a) const bb = Buffer.from(b) if (ab.length !== bb.length) return false return nodeTimingSafeEqual(ab, bb) } function cookieName(kind: ChallengeKind): string { return kind === 'register' ? 'logship_wa_reg' : 'logship_wa_login' } export function issueChallenge( event: any, challenge: string, opts: { kind: ChallengeKind; userId?: number }, ): void { const payload: SignedPayload = { c: challenge, k: opts.kind, u: opts.userId, e: Date.now() + CHALLENGE_TTL_MS, } const payloadB64 = b64urlEncode(Buffer.from(JSON.stringify(payload), 'utf8')) const value = payloadB64 + '.' + sign(payloadB64) setCookie(event, cookieName(opts.kind), value, { httpOnly: true, sameSite: 'strict', secure: !import.meta.dev, path: COOKIE_PATH, maxAge: Math.floor(CHALLENGE_TTL_MS / 1000), }) } export function consumeChallenge( event: any, kind: ChallengeKind, expectedChallenge: string, ): { userId?: number } | null { const name = cookieName(kind) const raw = getCookie(event, name) // Single-use: clear the cookie regardless of whether verification succeeds. deleteCookie(event, name, { path: COOKIE_PATH }) if (!raw) return null const dot = raw.indexOf('.') if (dot < 0) return null const payloadB64 = raw.slice(0, dot) const sig = raw.slice(dot + 1) if (!safeEqual(sig, sign(payloadB64))) return null let payload: SignedPayload try { payload = JSON.parse(b64urlDecode(payloadB64).toString('utf8')) } catch { return null } if (payload.k !== kind) return null if (payload.e < Date.now()) return null if (payload.c !== expectedChallenge) return null return { userId: payload.u } }