121 lines
3.2 KiB
TypeScript
121 lines
3.2 KiB
TypeScript
// 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<ArrayBuffer> {
|
|
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<QrPayload, "iat" | "exp">,
|
|
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<QrPayload> {
|
|
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);
|
|
}
|