@blamejs/core 0.18.7 → 0.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module b.webhookHmac
6
+ * @nav HTTP
7
+ * @title HMAC Webhooks
8
+ * @order 236
9
+ *
10
+ * @intro
11
+ * Inbound verification for the timestamped-HMAC webhook scheme — a single
12
+ * header carrying a Unix timestamp and one or more HMAC signatures:
13
+ *
14
+ * <sig-header>: t=<unix-seconds>,v1=<hmac-sha256-hex>[,v1=<rotated>]
15
+ *
16
+ * The signed payload is <code>&lt;timestamp&gt;.&lt;raw-body&gt;</code>, keyed on the
17
+ * endpoint signing secret. This is the scheme Stripe and Tailscale (among
18
+ * others) use. It is DISTINCT from the StandardWebhooks scheme that
19
+ * <code>b.standardWebhooks</code> verifies, which uses three separate headers
20
+ * and an <code>&lt;id&gt;.&lt;ts&gt;.&lt;body&gt;</code> payload.
21
+ *
22
+ * Verification refuses a timestamp outside the tolerance window (replay
23
+ * defense), checks EVERY signature value under the version field (so a
24
+ * rotated secret verifies with no downtime), compares with
25
+ * <code>b.crypto.timingSafeEqual</code>, and ignores signature versions it
26
+ * does not understand. Always verify against the EXACT received bytes —
27
+ * never a re-serialized JSON body.
28
+ *
29
+ * @card
30
+ * Verify the Stripe-style <code>t=&lt;ts&gt;,v1=&lt;hmac&gt;</code> timestamped-HMAC webhook scheme (Stripe / Tailscale / …) — replay window, key-rotation multi-signature, constant-time compare.
31
+ */
32
+
33
+ var bCrypto = require("./crypto");
34
+ var safeBuffer = require("./safe-buffer");
35
+ var numericBounds = require("./numeric-bounds");
36
+ var validateOpts = require("./validate-opts");
37
+ var { defineClass } = require("./framework-error");
38
+
39
+ var WebhookHmacError = defineClass("WebhookHmacError", { alwaysPermanent: true });
40
+
41
+ var DEFAULT_TOLERANCE_SEC = 300; // 5 minutes — the Stripe/Tailscale default replay window
42
+ var DEFAULT_TS_FIELD = "t";
43
+ var DEFAULT_SIG_FIELD = "v1";
44
+ var DEFAULT_ALG = "hmac-sha256";
45
+
46
+ // alg string → node HMAC name. SHA-2 only; SHA-1 is refused (collision-weak,
47
+ // and no webhook provider using this scheme needs it).
48
+ var ALG_MAP = {
49
+ "hmac-sha256": "sha256",
50
+ "hmac-sha512": "sha512",
51
+ };
52
+
53
+ // Named presets for providers using this exact single-header scheme. Explicit
54
+ // opts always override a profile.
55
+ var PROFILES = {
56
+ stripe: { tsField: "t", sigField: "v1", alg: "hmac-sha256" },
57
+ tailscale: { tsField: "t", sigField: "v1", alg: "hmac-sha256" },
58
+ };
59
+
60
+ function _mkErr(code, message) { return new WebhookHmacError(code, message); }
61
+
62
+ function _requireNonEmptyString(val, name) {
63
+ if (typeof val !== "string" || val.length === 0) {
64
+ throw new WebhookHmacError("webhook-hmac/bad-" + name,
65
+ "verify: opts." + name + " must be a non-empty string");
66
+ }
67
+ return val;
68
+ }
69
+
70
+ /**
71
+ * @primitive b.webhookHmac.verify
72
+ * @signature b.webhookHmac.verify(opts)
73
+ * @since 0.18.8
74
+ * @status stable
75
+ * @related b.standardWebhooks.verify, b.crypto.hmac, b.crypto.timingSafeEqual
76
+ *
77
+ * Verify an inbound webhook signed with the timestamped-HMAC scheme
78
+ * (<code>t=&lt;ts&gt;,v1=&lt;hmac&gt;</code>). Refuses on a missing/garbled header, a
79
+ * timestamp outside the tolerance window (replay), or an HMAC mismatch;
80
+ * returns <code>{ valid: true, timestamp }</code> when a signature matches.
81
+ *
82
+ * The signed payload is <code>&lt;timestamp&gt;.&lt;raw-body&gt;</code> — pass the EXACT
83
+ * bytes received, not a parsed-then-re-serialized JSON body, or the HMAC will
84
+ * not reproduce. Every value under the signature field is checked, so a
85
+ * rotated secret (two <code>v1=</code> values) verifies without downtime.
86
+ * Comparison is constant-time; unrecognized signature versions are ignored.
87
+ *
88
+ * @opts
89
+ * header: string, // the raw signature header value ("t=...,v1=...")
90
+ * rawBody: Buffer | string, // the exact received body bytes
91
+ * secret: Buffer | string, // the endpoint signing secret
92
+ * profile: string, // "stripe" | "tailscale" — sets tsField/sigField/alg
93
+ * tsField: string, // default: "t"
94
+ * sigField: string, // default: "v1"
95
+ * alg: string, // default: "hmac-sha256" (also "hmac-sha512")
96
+ * toleranceSec: number, // default: 300 (5 minutes)
97
+ *
98
+ * @example
99
+ * var v = b.webhookHmac.verify({
100
+ * header: req.headers["stripe-signature"],
101
+ * rawBody: rawBody,
102
+ * secret: process.env.WHSEC,
103
+ * });
104
+ * // → { valid: true, timestamp: 1614556828 }
105
+ */
106
+ function verify(opts) {
107
+ opts = validateOpts.requireObject(opts, "webhookHmac.verify",
108
+ WebhookHmacError, "webhook-hmac/bad-opts");
109
+ validateOpts(opts,
110
+ ["header", "rawBody", "secret", "profile", "tsField", "sigField", "alg", "toleranceSec"],
111
+ "webhookHmac.verify");
112
+
113
+ // Resolve profile → field/alg defaults; explicit opts win.
114
+ var prof = {};
115
+ if (opts.profile !== undefined) {
116
+ if (typeof opts.profile !== "string" || !Object.prototype.hasOwnProperty.call(PROFILES, opts.profile)) {
117
+ throw new WebhookHmacError("webhook-hmac/bad-profile",
118
+ "verify: unknown profile '" + opts.profile + "' (known: " + Object.keys(PROFILES).join(", ") + ")");
119
+ }
120
+ prof = PROFILES[opts.profile];
121
+ }
122
+ var tsField = opts.tsField !== undefined ? opts.tsField : (prof.tsField || DEFAULT_TS_FIELD);
123
+ var sigField = opts.sigField !== undefined ? opts.sigField : (prof.sigField || DEFAULT_SIG_FIELD);
124
+ var algName = opts.alg !== undefined ? opts.alg : (prof.alg || DEFAULT_ALG);
125
+ _requireNonEmptyString(tsField, "tsField");
126
+ _requireNonEmptyString(sigField, "sigField");
127
+ var nodeAlg = Object.prototype.hasOwnProperty.call(ALG_MAP, algName) ? ALG_MAP[algName] : null;
128
+ if (!nodeAlg) {
129
+ throw new WebhookHmacError("webhook-hmac/bad-alg",
130
+ "verify: unsupported alg '" + String(algName) + "' (supported: " + Object.keys(ALG_MAP).join(", ") + ")");
131
+ }
132
+
133
+ _requireNonEmptyString(opts.header, "header");
134
+ var bodyBuf = safeBuffer.toBuffer(opts.rawBody, { typeCode: "webhook-hmac/bad-body", errorFactory: _mkErr });
135
+ var secretBuf = safeBuffer.toBuffer(opts.secret, { typeCode: "webhook-hmac/bad-secret", errorFactory: _mkErr });
136
+ if (secretBuf.length === 0) {
137
+ throw new WebhookHmacError("webhook-hmac/bad-secret", "verify: opts.secret must be non-empty");
138
+ }
139
+
140
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.toleranceSec, "toleranceSec",
141
+ WebhookHmacError, "webhook-hmac/bad-tolerance");
142
+ var tolerance = typeof opts.toleranceSec === "number" ? opts.toleranceSec : DEFAULT_TOLERANCE_SEC;
143
+
144
+ // Parse "t=<ts>,v1=<sig>,v1=<rotated>" — comma-separated k=v. Collect the ts
145
+ // and EVERY sigField value; ignore any other version keys.
146
+ var tsRaw = null;
147
+ var sigs = [];
148
+ var items = opts.header.split(",");
149
+ for (var i = 0; i < items.length; i += 1) {
150
+ var eq = items[i].indexOf("=");
151
+ if (eq < 0) continue;
152
+ var k = items[i].slice(0, eq).trim();
153
+ var v = items[i].slice(eq + 1).trim();
154
+ if (k === tsField) { if (tsRaw === null) tsRaw = v; }
155
+ else if (k === sigField) { sigs.push(v); }
156
+ }
157
+ if (tsRaw === null) {
158
+ throw new WebhookHmacError("webhook-hmac/missing-timestamp",
159
+ "verify: no '" + tsField + "=' field in the signature header");
160
+ }
161
+ if (sigs.length === 0) {
162
+ throw new WebhookHmacError("webhook-hmac/missing-signature",
163
+ "verify: no '" + sigField + "=' field in the signature header");
164
+ }
165
+
166
+ // Strict-integer timestamp (reject "12.3", "0x1", leading zeros, whitespace).
167
+ var ts = parseInt(tsRaw, 10);
168
+ if (!isFinite(ts) || ts <= 0 || String(ts) !== tsRaw) {
169
+ throw new WebhookHmacError("webhook-hmac/bad-timestamp",
170
+ "verify: '" + tsField + "' is not a positive integer");
171
+ }
172
+ var nowSec = Math.floor(Date.now() / 1000);
173
+ var skew = Math.abs(nowSec - ts);
174
+ if (skew > tolerance) {
175
+ throw new WebhookHmacError("webhook-hmac/timestamp-skew",
176
+ "verify: timestamp skew " + skew + "s exceeds tolerance " + tolerance + "s (replay window)");
177
+ }
178
+
179
+ // Signed payload is the raw timestamp string + "." + the exact body bytes.
180
+ var signed = Buffer.concat([Buffer.from(tsRaw + ".", "utf8"), bodyBuf]);
181
+ var expected = bCrypto.hmac(secretBuf, signed, nodeAlg);
182
+ var expectedBuf = Buffer.from(expected, "utf8");
183
+ var matched = false;
184
+ for (var s = 0; s < sigs.length; s += 1) {
185
+ // timingSafeEqual requires equal-length inputs; a wrong-length candidate
186
+ // cannot be the digest (the hex length is fixed by the algorithm, and is
187
+ // not secret), so the length pre-check leaks nothing.
188
+ if (sigs[s].length === expected.length &&
189
+ bCrypto.timingSafeEqual(expectedBuf, Buffer.from(sigs[s], "utf8"))) {
190
+ matched = true;
191
+ break;
192
+ }
193
+ }
194
+ if (!matched) {
195
+ throw new WebhookHmacError("webhook-hmac/bad-signature",
196
+ "verify: no '" + sigField + "' signature matched");
197
+ }
198
+ return { valid: true, timestamp: ts };
199
+ }
200
+
201
+ module.exports = {
202
+ verify: verify,
203
+ PROFILES: PROFILES,
204
+ DEFAULT_TOLERANCE_SEC: DEFAULT_TOLERANCE_SEC,
205
+ WebhookHmacError: WebhookHmacError,
206
+ };
package/lib/webhook.js CHANGED
@@ -236,12 +236,12 @@ function _composeSignedString(algo, kid, timestamp, id, body) {
236
236
  // ---- Sign / verify primitives ----
237
237
 
238
238
  function _hmacSign(key, data) {
239
- return bCrypto.hmacSha3(key, data); // hex string
239
+ return bCrypto.hmac(key, data); // hex string
240
240
  }
241
241
 
242
242
  function _hmacVerify(key, data, expectedHex) {
243
243
  if (!safeBuffer.isHex(expectedHex)) return false;
244
- var actualHex = bCrypto.hmacSha3(key, data);
244
+ var actualHex = bCrypto.hmac(key, data);
245
245
  return bCrypto.timingSafeEqual(actualHex, expectedHex);
246
246
  }
247
247
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.7",
3
+ "version": "0.18.8",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:212a25f7-e069-40b5-9c4b-b7f4658f9a8a",
5
+ "serialNumber": "urn:uuid:58122d84-2312-4802-ade5-95a30059fda2",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-02T04:21:19.772Z",
8
+ "timestamp": "2026-08-02T12:19:34.917Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.18.7",
22
+ "bom-ref": "@blamejs/core@0.18.8",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.7",
25
+ "version": "0.18.8",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.18.7",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.8",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.18.7",
57
+ "ref": "@blamejs/core@0.18.8",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]