import { createHmac, timingSafeEqual } from 'node:crypto'; export const SIGNATURE_MAX_SKEW_SECONDS = 300; export function computeSignature(secret: string, canonicalRequest: string): string { return createHmac('sha256', secret).update(canonicalRequest, 'utf8').digest('hex'); } /** Canonical request string covered by the HMAC: method, path, timestamp, * nonce, and the raw body, newline-separated. */ export function buildCanonicalRequest( method: string, path: string, timestamp: string, nonce: string, body: string ): string { return [method, path, timestamp, nonce, body].join('\n'); } export function safeEqual(a: string, b: string): boolean { const bufA = Buffer.from(a, 'utf8'); const bufB = Buffer.from(b, 'utf8'); if (bufA.length !== bufB.length) return false; return timingSafeEqual(bufA, bufB); } // In-memory nonce store. Prunes entries older than twice the allowed skew. const seenNonces = new Map(); export function isNonceReplayed(nonce: string, now = Date.now()): boolean { const seenAt = seenNonces.get(nonce); if (seenAt === undefined) return false; // Keep treating it as replayed only while within the replay window; beyond // that the entry is pruned and the nonce could in theory be reused, but the // timestamp skew check still bounds it. return now - seenAt < SIGNATURE_MAX_SKEW_SECONDS * 2 * 1000; } export function recordNonce(nonce: string, now = Date.now()): void { seenNonces.set(nonce, now); // Opportunistically prune stale entries. const cutoff = now - SIGNATURE_MAX_SKEW_SECONDS * 2 * 1000; for (const [key, seenAt] of seenNonces) { if (seenAt < cutoff) seenNonces.delete(key); } } export type SignatureVerification = { ok: true } | { ok: false; reason: string }; export function verifyRequestSignature(opts: { method: string; path: string; headers: Headers; rawBody: string; secret: string; now?: Date; }): SignatureVerification { const { method, path, headers, rawBody, secret } = opts; const timestamp = headers.get('x-timestamp'); const nonce = headers.get('x-nonce'); const signature = headers.get('x-signature'); if (!timestamp || !nonce || !signature) { return { ok: false, reason: 'missing signature headers' }; } const ts = Number(timestamp); if (!Number.isFinite(ts)) { return { ok: false, reason: 'invalid timestamp' }; } const now = (opts.now ?? new Date()).getTime() / 1000; if (Math.abs(now - ts) > SIGNATURE_MAX_SKEW_SECONDS) { return { ok: false, reason: 'expired timestamp' }; } if (isNonceReplayed(nonce)) { return { ok: false, reason: 'replayed nonce' }; } const canonical = buildCanonicalRequest(method, path, timestamp, nonce, rawBody); const expected = computeSignature(secret, canonical); if (!safeEqual(expected, signature)) { return { ok: false, reason: 'invalid signature' }; } recordNonce(nonce); return { ok: true }; } /** Test hook: clears the in-memory nonce store. */ export function _clearNonces(): void { seenNonces.clear(); }