Files
portal/src/lib/server/v2-signature.ts
T
2026-08-07 14:06:20 -05:00

97 lines
2.9 KiB
TypeScript

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<string, number>();
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();
}