Files
accounted/lib/webhooks/signing.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

77 lines
2.8 KiB
TypeScript

/**
* Webhook signature generation + verification.
*
* Signature header (Stripe-style):
* X-Gnubok-Signature: t=<unix>,v1=<hex-HMAC-SHA256>
*
* Where the signed payload is:
* `${t}.${rawBody}`
*
* The `t` (unix timestamp in seconds) is included in the signed payload
* so receivers can implement replay-window checks. The receiver-side
* verification (parse the header, recompute the HMAC, apply a 5-minute
* tolerance) is documented in the docs cookbook (lib/docs/content/webhooks.ts);
* receivers can pick their own tolerance.
*
* Why HMAC-SHA256 (not Ed25519): every Node/Python/Go/Ruby stdlib has it,
* receivers can verify without adding a dep. Asymmetric signing buys nothing
* for outbound webhooks where the receiver has no use for verifying the
* signer's identity beyond "this is the secret you set on creation".
*/
import crypto from 'crypto'
const ALGORITHM = 'sha256'
export interface SignedHeaderParts {
/** Unix seconds. */
t: number
/** Hex-encoded HMAC-SHA256(t + "." + body, secret). */
v1: string
}
/**
* Generate the value of the `X-Gnubok-Signature` header for an outbound
* delivery.
*/
export function signPayload(args: {
body: string
secret: string
/** Override for tests. Defaults to current unix-seconds. */
timestamp?: number
}): { header: string; parts: SignedHeaderParts } {
const t = args.timestamp ?? Math.floor(Date.now() / 1000)
const v1 = crypto
.createHmac(ALGORITHM, args.secret)
.update(`${t}.${args.body}`)
.digest('hex')
return {
header: `t=${t},v1=${v1}`,
parts: { t, v1 },
}
}
/**
* Generate a fresh webhook secret. 32 bytes of crypto-random hex (256 bits
* of entropy, 64-character output). Returned to the caller exactly once on
* webhook creation; we do not store the plaintext anywhere except the
* `webhooks.secret` column (used for signing on every outbound delivery).
*
* **Documented Security Decision (OWASP V14.2 / ISO 27001:2022 A.8.24):**
* `webhooks.secret` is stored in plaintext rather than hashed. This is
* unavoidable for outbound HMAC signing: the signing operation needs the
* original byte sequence on every delivery, so a one-way hash would
* preclude signing. Stripe, GitHub, Slack, and Twilio all follow the same
* pattern for the same reason. Defense-in-depth comes from the
* service-role-only INSERT/UPDATE/DELETE on `webhooks` (no anon/auth
* write path), the column-level select projection on every read endpoint
* (the row never includes `secret` outside the create response), and
* Supabase encryption-at-rest. Re-evaluate if/when KMS-backed signing
* becomes available without per-call latency cost.
*
* Receivers use this same value verbatim when verifying signatures.
*/
export function generateWebhookSecret(): string {
return crypto.randomBytes(32).toString('hex')
}