/** * HMAC signing + replay protection for build-bus webhooks. * * The signing layer is intentionally small — HMAC-SHA256 over a * canonical-JSON encoding of the PublishEvent, with a per-subscriber * shared secret. No asymmetric crypto, no key rotation infrastructure. * That's fine for the first cut; promotion to asymmetric signing (if * we ever want untrusted intermediaries) is a v2-of-v2 problem. * * All exports here are pure functions taking primitive inputs — * no I/O, no globals. The wire-level signing concerns live in * `fan-out.ts`; this file is the algorithm. */ import { createHmac, timingSafeEqual } from 'node:crypto'; import type { PublishEvent } from './types'; const SIGNATURE_PREFIX = 'sha256='; const DEFAULT_MAX_AGE_MS = 6 * 60 * 60 * 1000; // 6 hours /** * Canonical JSON encoding for signing — recursively sorts object * keys so {a:1,b:2} and {b:2,a:1} produce the same bytes (and the * same signature). Arrays preserve order (their indices ARE meaning). * * Why we need this: HTTP and JSON serializers are free to reorder * keys. If we signed `JSON.stringify(event)` directly, a proxy that * re-parsed and re-serialized the payload would invalidate the * signature. Canonical form lets receiver and sender agree even * after a round-trip through any conformant JSON tool. */ export function canonicalJsonStringify(value: unknown): string { return JSON.stringify(sortKeysDeep(value)); } function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeysDeep); if (value && typeof value === 'object') { const obj = value as Record; const sorted: Record = {}; for (const key of Object.keys(obj).sort()) { sorted[key] = sortKeysDeep(obj[key]); } return sorted; } return value; } /** * Compute the signature for a PublishEvent. Returns the full * header value (`sha256=`), ready to drop into * `X-Celilo-Signature`. */ export function signEvent(event: PublishEvent, secret: string): string { const body = canonicalJsonStringify(event); const hmac = createHmac('sha256', secret); hmac.update(body); return `${SIGNATURE_PREFIX}${hmac.digest('hex')}`; } /** * Constant-time verify. Returns true only if `signature` matches * what we'd compute from `event` + `secret`. Defends against * timing-attack-based secret recovery — important because secrets * are HMAC-shared across the network. * * Handles the well-known timingSafeEqual length-mismatch gotcha: if * the candidate signature is a different length than expected, the * comparison itself can't even start (would throw); we return false * early to keep the "any failure → false" contract. */ export function verifyEvent(event: PublishEvent, signature: string, secret: string): boolean { if (!signature.startsWith(SIGNATURE_PREFIX)) return false; const expected = signEvent(event, secret); if (signature.length !== expected.length) return false; return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } /** * Replay-protection check. Rejects events whose timestamp is more * than `maxAgeMs` away from "now" (default 6 hours either direction * — generous enough to tolerate clock skew, tight enough to make * captured-payload replay impractical). * * Pure: takes `now` as input so tests can pin time without mocking * Date. Returns `true` when the event is fresh. */ export function isFresh( event: PublishEvent, opts: { now?: number; maxAgeMs?: number } = {}, ): boolean { const now = opts.now ?? Date.now(); const maxAge = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS; const eventTime = Date.parse(event.timestamp); if (Number.isNaN(eventTime)) return false; return Math.abs(now - eventTime) <= maxAge; }