// QR token utilities for Drinkkaart using Web Crypto API // Compatible with Cloudflare Workers (no node:crypto dependency). const QR_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes interface QrPayload { drinkkaartId: string; userId: string; iat: number; exp: number; } function toBase64Url(buffer: ArrayBuffer): string { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); } function base64UrlToBuffer(str: string): Uint8Array { const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); // Ensure the underlying buffer is a plain ArrayBuffer (required by crypto.subtle) return new Uint8Array(bytes.buffer.slice(0)); } /** Generate a cryptographically secure 32-byte hex secret for a new Drinkkaart. */ export function generateQrSecret(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } /** * Sign a QR token for the given drinkkaart / user. * Returns the compact token string and the expiry date. */ export async function signQrToken( payload: Omit, qrSecret: string, authSecret: string, ): Promise<{ token: string; expiresAt: Date }> { const now = Date.now(); const exp = now + QR_TOKEN_TTL_MS; const full: QrPayload = { ...payload, iat: now, exp }; const encodedPayload = btoa(JSON.stringify(full)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=/g, ""); const keyData = new TextEncoder().encode(qrSecret + authSecret); const key = await crypto.subtle.importKey( "raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sigBuffer = await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(encodedPayload), ); const sig = toBase64Url(sigBuffer); return { token: `${encodedPayload}.${sig}`, expiresAt: new Date(exp) }; } /** * Verify a QR token. Throws if the signature is invalid or the token is expired. * Returns the decoded payload. */ export async function verifyQrToken( token: string, qrSecret: string, authSecret: string, ): Promise { const parts = token.split("."); if (parts.length !== 2) throw new Error("MALFORMED"); const [encodedPayload, providedSig] = parts as [string, string]; const keyData = new TextEncoder().encode(qrSecret + authSecret); const key = await crypto.subtle.importKey( "raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ); const sigBytes = base64UrlToBuffer(providedSig); const valid = await crypto.subtle.verify( "HMAC", key, sigBytes, new TextEncoder().encode(encodedPayload), ); if (!valid) throw new Error("INVALID_SIGNATURE"); const payload: QrPayload = JSON.parse( atob(encodedPayload.replace(/-/g, "+").replace(/_/g, "/")), ); if (Date.now() > payload.exp) throw new Error("EXPIRED"); return payload; } /** Format a cents integer as a Belgian euro string, e.g. 1250 → "€ 12,50". */ export function formatCents(cents: number): string { return new Intl.NumberFormat("nl-BE", { style: "currency", currency: "EUR", }).format(cents / 100); }