Build API v2
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user