@blamejs/core 0.11.24 → 0.11.25

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.
package/lib/webhook.js CHANGED
@@ -735,6 +735,232 @@ function _writeError(res, status, code, message) {
735
735
  res.end(JSON.stringify({ error: code, message: message }));
736
736
  }
737
737
 
738
+ // ---- Stripe-compatible inbound HMAC-SHA-256 verifier (v0.11.25) ----
739
+ //
740
+ // Stripe (+ Stripe-shaped: Paddle, Shopify) ships inbound webhooks with
741
+ // `Stripe-Signature: t=<unix>,v1=<hex>[,v0=<hex>...]`. The signed payload
742
+ // is the literal string `<t>.<rawBody>` and the MAC is HMAC-SHA-256 keyed
743
+ // by the operator's `whsec_...` secret bytes (the `whsec_` prefix IS the
744
+ // key — Stripe's spec preserves it; do NOT strip).
745
+ //
746
+ // Spec: https://docs.stripe.com/webhooks/signature
747
+ // Paddle: https://developer.paddle.com/webhooks/signature-verification
748
+ // Shopify: https://shopify.dev/docs/apps/webhooks/configuration/https
749
+ //
750
+ // RFC 2104 (HMAC) + RFC 6234 (SHA-256). Constant-time compare via
751
+ // b.crypto.timingSafeEqual — never `===`.
752
+
753
+ var STRIPE_HMAC_ALG = "hmac-sha256-stripe";
754
+ var STRIPE_SIG_MAX_HEX = 256; // allow:raw-byte-literal — hex-char anti-DoS cap, not bytes
755
+ var STRIPE_DEFAULT_TOLERANCE_MS = C.TIME.minutes(5); // RFC 3161-ish 5 minute window default
756
+ var STRIPE_MIN_TOLERANCE_MS = C.TIME.seconds(30); // refuse below 30s
757
+
758
+ function _hmacSha256Hex(keyBytes, dataString) {
759
+ return nodeCrypto.createHmac("sha256", keyBytes).update(dataString, "utf8").digest("hex");
760
+ }
761
+
762
+ function _parseStripeSignatureHeader(header) {
763
+ if (typeof header !== "string" || header.length === 0) {
764
+ throw new WebhookError("webhook/bad-stripe-header",
765
+ "verify: Stripe-Signature header must be a non-empty string");
766
+ }
767
+ if (header.length > 4096) { // allow:raw-byte-literal — anti-DoS header cap
768
+ throw new WebhookError("webhook/bad-stripe-header",
769
+ "verify: Stripe-Signature header exceeds 4096 bytes");
770
+ }
771
+ var parts = header.split(",");
772
+ var ts = null;
773
+ var v1 = [];
774
+ for (var i = 0; i < parts.length; i += 1) {
775
+ var p = parts[i].trim();
776
+ var eq = p.indexOf("=");
777
+ if (eq <= 0) continue;
778
+ var k = p.slice(0, eq);
779
+ var v = p.slice(eq + 1);
780
+ if (k === "t") ts = v;
781
+ else if (k === "v1") {
782
+ if (v.length > STRIPE_SIG_MAX_HEX) {
783
+ throw new WebhookError("webhook/bad-stripe-header",
784
+ "verify: Stripe-Signature v1 entry exceeds " + STRIPE_SIG_MAX_HEX + " hex chars");
785
+ }
786
+ if (!/^[0-9a-f]+$/i.test(v)) continue;
787
+ v1.push(v.toLowerCase());
788
+ }
789
+ }
790
+ if (ts === null || !/^\d+$/.test(ts)) {
791
+ throw new WebhookError("webhook/bad-stripe-header",
792
+ "verify: Stripe-Signature missing or malformed t=<unix>");
793
+ }
794
+ if (v1.length === 0) {
795
+ throw new WebhookError("webhook/bad-stripe-header",
796
+ "verify: Stripe-Signature missing v1=<hex> entry");
797
+ }
798
+ return { timestamp: parseInt(ts, 10), v1: v1 };
799
+ }
800
+
801
+ function _timingSafeHexEqual(aHex, bHex) {
802
+ if (typeof aHex !== "string" || typeof bHex !== "string") return false;
803
+ if (aHex.length !== bHex.length) return false;
804
+ // Route through the framework's length-tolerant timing-safe compare;
805
+ // bCrypto handles the hex-string shape directly without manual
806
+ // Buffer.from coercion.
807
+ return bCrypto.timingSafeEqual(aHex, bHex);
808
+ }
809
+
810
+ function _coerceSecretBytes(secret) {
811
+ if (typeof secret === "string") return Buffer.from(secret, "utf8");
812
+ if (Buffer.isBuffer(secret)) return Buffer.from(secret);
813
+ throw new WebhookError("webhook/bad-secret",
814
+ "verify: secret must be a non-empty string or Buffer");
815
+ }
816
+
817
+ function _coerceBodyString(body) {
818
+ if (typeof body === "string") return body;
819
+ if (Buffer.isBuffer(body)) return body.toString("utf8");
820
+ throw new WebhookError("webhook/bad-body",
821
+ "verify: body must be a Buffer or string");
822
+ }
823
+
824
+ /**
825
+ * @primitive b.webhook.verify
826
+ * @signature b.webhook.verify(input)
827
+ * @since 0.11.25
828
+ * @status stable
829
+ *
830
+ * Stripe-spec inbound webhook signature verifier. Validates the
831
+ * `Stripe-Signature: t=<unix>,v1=<hex>[,v1=<hex>...]` header against an
832
+ * HMAC-SHA-256 over the literal `<t>.<rawBody>` string using the
833
+ * operator's `whsec_...` secret bytes verbatim (the prefix IS the key).
834
+ * Refuses signatures older than the tolerance window (default 5 min,
835
+ * minimum 30 s). When `nonceStore` is supplied the verifier records
836
+ * the accepted v1 signature so a replay within the tolerance window
837
+ * is refused. Constant-time compare via `b.crypto.timingSafeEqual`.
838
+ *
839
+ * @example
840
+ * b.webhook.verify({
841
+ * alg: "hmac-sha256-stripe",
842
+ * secret: "whsec_abc...",
843
+ * header: req.headers["stripe-signature"],
844
+ * body: rawBodyBuffer,
845
+ * });
846
+ * // → { ok: true, timestamp: 1700000000, scheme: "v1" }
847
+ */
848
+ async function verify(input) {
849
+ if (!input || typeof input !== "object") {
850
+ throw new WebhookError("webhook/bad-opts",
851
+ "verify: input object required");
852
+ }
853
+ if (input.alg !== STRIPE_HMAC_ALG) {
854
+ throw new WebhookError("webhook/bad-alg",
855
+ "verify: alg must be '" + STRIPE_HMAC_ALG + "'");
856
+ }
857
+ var secretBytes = _coerceSecretBytes(input.secret);
858
+ if (secretBytes.length === 0) {
859
+ throw new WebhookError("webhook/bad-secret",
860
+ "verify: secret must be non-empty");
861
+ }
862
+ var bodyStr = _coerceBodyString(input.body);
863
+ var parsed = _parseStripeSignatureHeader(input.header);
864
+ var tolerance = input.toleranceMs;
865
+ if (tolerance === undefined) tolerance = STRIPE_DEFAULT_TOLERANCE_MS;
866
+ if (typeof tolerance !== "number" || !isFinite(tolerance) || tolerance < STRIPE_MIN_TOLERANCE_MS) {
867
+ throw new WebhookError("webhook/bad-tolerance",
868
+ "verify: toleranceMs must be a finite number >= " + STRIPE_MIN_TOLERANCE_MS);
869
+ }
870
+ var nowMs = (input._nowMs !== undefined && typeof input._nowMs === "number")
871
+ ? input._nowMs : Date.now();
872
+ var ageMs = nowMs - C.TIME.seconds(parsed.timestamp);
873
+ if (ageMs < 0) ageMs = -ageMs;
874
+ if (ageMs > tolerance) {
875
+ throw new WebhookError("webhook/stale-timestamp",
876
+ "verify: signature timestamp outside tolerance (" + ageMs + "ms > " + tolerance + "ms)");
877
+ }
878
+
879
+ var expectedHex = _hmacSha256Hex(secretBytes, parsed.timestamp + "." + bodyStr);
880
+ var matched = false;
881
+ for (var i = 0; i < parsed.v1.length; i += 1) {
882
+ if (_timingSafeHexEqual(parsed.v1[i], expectedHex)) {
883
+ matched = true;
884
+ break;
885
+ }
886
+ }
887
+ if (!matched) {
888
+ throw new WebhookError("webhook/bad-signature",
889
+ "verify: no v1 signature matched HMAC-SHA-256 of <t>.<body>");
890
+ }
891
+
892
+ // Optional replay defense — the nonceStore is operator-supplied
893
+ // (e.g. `b.kv` or a Redis-shaped { has, set, expire }) so the
894
+ // primitive doesn't own retention. `has` / `set` MAY return a
895
+ // Promise — we always `await` them so async backends (Redis, KV,
896
+ // DynamoDB) work without falsely flagging a Promise as truthy.
897
+ if (input.nonceStore) {
898
+ var ns = input.nonceStore;
899
+ if (typeof ns.has !== "function" || typeof ns.set !== "function") {
900
+ throw new WebhookError("webhook/bad-nonce-store",
901
+ "verify: nonceStore must expose { has(key), set(key, ttlMs) }");
902
+ }
903
+ var nonceKey = "stripe:" + parsed.timestamp + ":" + expectedHex;
904
+ var seen = await ns.has(nonceKey);
905
+ if (seen) {
906
+ throw new WebhookError("webhook/replay",
907
+ "verify: signature already seen within tolerance window (replay)");
908
+ }
909
+ await ns.set(nonceKey, tolerance);
910
+ }
911
+
912
+ return { ok: true, timestamp: parsed.timestamp, scheme: "v1" };
913
+ }
914
+
915
+ /**
916
+ * @primitive b.webhook.sign
917
+ * @signature b.webhook.sign(input)
918
+ * @since 0.11.25
919
+ * @status stable
920
+ *
921
+ * Round-trip companion to `b.webhook.verify` for the
922
+ * `hmac-sha256-stripe` algorithm. Returns the `Stripe-Signature`
923
+ * header value `t=<unix>,v1=<hex>` for a given body + secret +
924
+ * (optional) timestamp. Operators emitting Stripe-shaped webhooks
925
+ * downstream — and the test surface — use this to produce the
926
+ * matching header.
927
+ *
928
+ * @example
929
+ * var header = b.webhook.sign({
930
+ * alg: "hmac-sha256-stripe",
931
+ * secret: "whsec_abc...",
932
+ * body: '{"id":"evt_1"}',
933
+ * });
934
+ */
935
+ function sign(input) {
936
+ if (!input || typeof input !== "object") {
937
+ throw new WebhookError("webhook/bad-opts",
938
+ "sign: input object required");
939
+ }
940
+ if (input.alg !== STRIPE_HMAC_ALG) {
941
+ throw new WebhookError("webhook/bad-alg",
942
+ "sign: alg must be '" + STRIPE_HMAC_ALG + "'");
943
+ }
944
+ var secretBytes = _coerceSecretBytes(input.secret);
945
+ if (secretBytes.length === 0) {
946
+ throw new WebhookError("webhook/bad-secret",
947
+ "sign: secret must be non-empty");
948
+ }
949
+ var bodyStr = _coerceBodyString(input.body);
950
+ var ts;
951
+ if (input.timestamp !== undefined) {
952
+ if (typeof input.timestamp !== "number" || !isFinite(input.timestamp) || input.timestamp < 0) {
953
+ throw new WebhookError("webhook/bad-timestamp",
954
+ "sign: timestamp must be a non-negative finite number (unix seconds)");
955
+ }
956
+ ts = Math.floor(input.timestamp);
957
+ } else {
958
+ ts = Math.floor(Date.now() / 1000); // allow:raw-time-literal — unix-seconds conversion, Stripe spec uses seconds-not-ms
959
+ }
960
+ var hex = _hmacSha256Hex(secretBytes, ts + "." + bodyStr);
961
+ return "t=" + ts + ",v1=" + hex;
962
+ }
963
+
738
964
  // ---- Public surface ----
739
965
 
740
966
  module.exports = {
@@ -745,4 +971,7 @@ module.exports = {
745
971
  HEADER: HEADER,
746
972
  DEFAULTS: DEFAULTS,
747
973
  WebhookError: WebhookError,
974
+ // v0.11.25 — Stripe-shaped inbound HMAC-SHA-256 verifier + signer.
975
+ verify: verify,
976
+ sign: sign,
748
977
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.11.24",
3
+ "version": "0.11.25",
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:f66cc3af-cd05-47b5-b147-bc1e05a046f1",
5
+ "serialNumber": "urn:uuid:6b814cb5-e99f-4bcb-a349-6b597ea780a6",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-21T03:43:16.022Z",
8
+ "timestamp": "2026-05-21T05:38:05.971Z",
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.11.24",
22
+ "bom-ref": "@blamejs/core@0.11.25",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.11.24",
25
+ "version": "0.11.25",
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.11.24",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.25",
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.11.24",
57
+ "ref": "@blamejs/core@0.11.25",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]