97 lines
2.9 KiB
TypeScript
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();
|
|
}
|