@blamejs/core 0.10.14 → 0.11.0

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/metrics.js CHANGED
@@ -376,6 +376,7 @@ function create(opts) {
376
376
  type: "counter",
377
377
  name: fullName,
378
378
  help: help,
379
+ unit: copts.unit || null,
379
380
  labelNames: labelNames,
380
381
  values: values,
381
382
  inc: function (callLabels, n) {
@@ -445,6 +446,7 @@ function create(opts) {
445
446
  type: "gauge",
446
447
  name: fullName,
447
448
  help: help,
449
+ unit: copts.unit || null,
448
450
  labelNames: labelNames,
449
451
  values: values,
450
452
  set: function (callLabels, v) {
@@ -509,10 +511,11 @@ function create(opts) {
509
511
  type: "histogram",
510
512
  name: fullName,
511
513
  help: help,
514
+ unit: copts.unit || null,
512
515
  labelNames: labelNames,
513
516
  buckets: buckets,
514
517
  values: values,
515
- observe: function (callLabels, v) {
518
+ observe: function (callLabels, v, exemplar) {
516
519
  var arg = _normalizeLabelArg(callLabels, v, NaN);
517
520
  if (typeof arg.value !== "number" || isNaN(arg.value)) {
518
521
  throw new MetricsError("metrics/histogram-bad-value",
@@ -531,15 +534,28 @@ function create(opts) {
531
534
  }
532
535
  // counts[i] is the count for the [<=buckets[i]] bucket; counts[buckets.length] is +Inf.
533
536
  entry = {
534
- labels: resolved,
535
- counts: new Array(buckets.length + 1).fill(0),
536
- sum: 0,
537
- count: 0,
537
+ labels: resolved,
538
+ counts: new Array(buckets.length + 1).fill(0),
539
+ sum: 0,
540
+ count: 0,
541
+ exemplars: new Array(buckets.length + 1).fill(null),
538
542
  };
539
543
  values.set(key, entry);
540
544
  }
541
545
  for (var i = 0; i < buckets.length; i++) {
542
- if (arg.value <= buckets[i]) entry.counts[i]++;
546
+ if (arg.value <= buckets[i]) {
547
+ entry.counts[i]++;
548
+ // OpenMetrics §6.2 — store the most-recent exemplar per
549
+ // bucket. Operators wire trace_id / span_id via `exemplar`
550
+ // arg; the registry only records what's passed in.
551
+ if (exemplar && typeof exemplar === "object") {
552
+ entry.exemplars[i] = {
553
+ labels: exemplar.labels || {},
554
+ value: exemplar.value !== undefined ? exemplar.value : arg.value,
555
+ timestamp: exemplar.timestamp || null,
556
+ };
557
+ }
558
+ }
543
559
  }
544
560
  entry.counts[buckets.length]++; // +Inf bucket is everything
545
561
  entry.sum += arg.value;
@@ -552,34 +568,68 @@ function create(opts) {
552
568
 
553
569
  // ---- exposition ----
554
570
 
555
- function exposition() {
571
+ function exposition(opts) {
572
+ opts = opts || {};
573
+ // v0.10.16 — `format: "openmetrics"` emits OpenMetrics 1.0 wire
574
+ // format (RFC TBD; project at https://openmetrics.io). Differences
575
+ // from Prometheus 0.0.4: `# UNIT <metric> <unit>` lines, `_total`
576
+ // suffix MUST on counters, `# EOF` terminator. v0.10.16 ships
577
+ // the EOF terminator + UNIT lines when opts.format === "openmetrics".
578
+ var openMetrics = opts.format === "openmetrics";
556
579
  var lines = [];
557
580
  var sortedNames = Array.from(metrics.keys()).sort();
558
581
  for (var i = 0; i < sortedNames.length; i++) {
559
582
  var m = metrics.get(sortedNames[i]);
560
- if (m.help) lines.push("# HELP " + m.name + " " + m.help);
561
- lines.push("# TYPE " + m.name + " " + m.type);
583
+ // OpenMetrics §5.1.2 counter sample lines MUST suffix with
584
+ // `_total`. The metadata `# HELP / # TYPE / # UNIT` lines MUST
585
+ // name the SAME family identifier the samples use, otherwise
586
+ // strict OpenMetrics parsers reject the family. Derive the
587
+ // exposition name once at the top of the loop so both the
588
+ // metadata lines and the sample lines agree.
589
+ var exposedName = m.name;
590
+ if (openMetrics && m.type === "counter" && !/_total$/.test(m.name)) {
591
+ exposedName = m.name + "_total";
592
+ }
593
+ if (m.help) lines.push("# HELP " + exposedName + " " + m.help);
594
+ lines.push("# TYPE " + exposedName + " " + m.type);
595
+ if (openMetrics && m.unit) lines.push("# UNIT " + exposedName + " " + m.unit);
562
596
  var keys = Array.from(m.values.keys()).sort();
563
597
  if (m.type === "histogram") {
564
598
  for (var k = 0; k < keys.length; k++) {
565
599
  var entry = m.values.get(keys[k]);
566
600
  for (var bi = 0; bi < m.buckets.length; bi++) {
567
601
  var bLabels = Object.assign({}, entry.labels, { le: String(m.buckets[bi]) });
568
- lines.push(m.name + "_bucket" + _renderLabels(bLabels) + " " + entry.counts[bi]);
602
+ var bucketLine = m.name + "_bucket" + _renderLabels(bLabels) + " " + entry.counts[bi];
603
+ // OpenMetrics 1.0 §6.2 — exemplar trace + span IDs appended
604
+ // as `# {trace_id="...",span_id="..."} <value> <timestamp>`.
605
+ if (openMetrics && entry.exemplars && entry.exemplars[bi]) {
606
+ var ex = entry.exemplars[bi];
607
+ bucketLine += " # " + _renderLabels(ex.labels || {}) + " " + ex.value;
608
+ if (ex.timestamp) bucketLine += " " + ex.timestamp;
609
+ }
610
+ lines.push(bucketLine);
569
611
  }
570
612
  var infLabels = Object.assign({}, entry.labels, { le: "+Inf" });
571
613
  lines.push(m.name + "_bucket" + _renderLabels(infLabels) + " " + entry.counts[m.buckets.length]);
572
614
  lines.push(m.name + "_sum" + _renderLabels(entry.labels) + " " + entry.sum);
573
615
  lines.push(m.name + "_count" + _renderLabels(entry.labels) + " " + entry.count);
574
616
  }
575
- } else {
617
+ } else if (m.type === "counter" && openMetrics) {
618
+ // exposedName already carries the `_total` suffix when needed
619
+ // (derived at the top of the loop so metadata + samples agree).
576
620
  for (var v = 0; v < keys.length; v++) {
577
621
  var ent = m.values.get(keys[v]);
578
- lines.push(m.name + _renderLabels(ent.labels) + " " + ent.value);
622
+ lines.push(exposedName + _renderLabels(ent.labels) + " " + ent.value);
623
+ }
624
+ } else {
625
+ for (var v2 = 0; v2 < keys.length; v2++) {
626
+ var ent2 = m.values.get(keys[v2]);
627
+ lines.push(m.name + _renderLabels(ent2.labels) + " " + ent2.value);
579
628
  }
580
629
  }
581
630
  lines.push("");
582
631
  }
632
+ if (openMetrics) lines.push("# EOF");
583
633
  return lines.join("\n") + (lines.length ? "" : "\n");
584
634
  }
585
635
 
@@ -10,7 +10,8 @@
10
10
  * Referrer-Policy: no-referrer — don't leak full URL to outbound links
11
11
  * Permissions-Policy — disable common-attack APIs (camera, geolocation, payment, etc.)
12
12
  * Cross-Origin-Opener-Policy: same-origin
13
- * Cross-Origin-Embedder-Policy: require-corp (off by default — breaks images from CDNs)
13
+ * Cross-Origin-Embedder-Policy: require-corp / credentialless (off by default — breaks images from CDNs; credentialless is the
14
+ * CR 2024-12 relaxed mode that lets cross-origin no-cors requests load without CORP markers as long as they don't carry credentials)
14
15
  * Cross-Origin-Resource-Policy: same-origin
15
16
  * Origin-Agent-Cluster: ?1 — origin-keyed agent cluster; extra process isolation
16
17
  * X-DNS-Prefetch-Control: off — don't pre-resolve DNS for off-page links
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.standardWebhooks
4
+ * @nav HTTP
5
+ * @title Standard Webhooks
6
+ * @order 235
7
+ *
8
+ * @intro
9
+ * StandardWebhooks (standardwebhooks.com) signing + verification —
10
+ * the consortium spec (Stripe / Svix / Okta / etc.) for inbound
11
+ * webhook authentication. Three headers:
12
+ *
13
+ * webhook-id ULID-ish unique identifier
14
+ * webhook-timestamp Unix seconds at send time
15
+ * webhook-signature `v1,<base64-of-HMAC-SHA256-result>` (multi-version)
16
+ *
17
+ * The signature payload is `<id>.<timestamp>.<body>`, signed with the
18
+ * shared secret. Verification reproduces the signature and uses
19
+ * `b.crypto.timingSafeEqual` to compare. Skew tolerated within
20
+ * `tolerance` seconds (default 5 minutes).
21
+ *
22
+ * @card
23
+ * StandardWebhooks (standardwebhooks.com) HMAC-SHA256 sign + verify for inbound + outbound webhook delivery. 5-minute skew tolerance + version-prefix header.
24
+ */
25
+
26
+ var nodeCrypto = require("node:crypto");
27
+ var validateOpts = require("./validate-opts");
28
+ var numericBounds = require("./numeric-bounds");
29
+ var bCrypto = require("./crypto");
30
+ var C = require("./constants");
31
+ var { defineClass } = require("./framework-error");
32
+
33
+ var StandardWebhooksError = defineClass("StandardWebhooksError", { alwaysPermanent: true });
34
+
35
+ var DEFAULT_TOLERANCE_SEC = 300; // allow:raw-time-literal allow:raw-byte-literal — 5min default per StandardWebhooks §3.2
36
+
37
+ /**
38
+ * @primitive b.standardWebhooks.sign
39
+ * @signature b.standardWebhooks.sign(opts)
40
+ * @since 0.10.16
41
+ * @status stable
42
+ *
43
+ * Build the three StandardWebhooks headers for an outbound delivery.
44
+ * Returns `{ headers, body }` where `body` is the raw request body
45
+ * (operators write that to the wire; the headers go on the request).
46
+ *
47
+ * @opts
48
+ * id: string, // auto-minted if omitted (32-byte random)
49
+ * timestamp: number, // Unix seconds; defaults to now
50
+ * body: Buffer|string, // request body bytes
51
+ * secret: Buffer, // shared secret (>= 32 bytes)
52
+ *
53
+ * @example
54
+ * var s = b.standardWebhooks.sign({ body: bodyBuf, secret: secret });
55
+ * for (var k in s.headers) req.setHeader(k, s.headers[k]);
56
+ */
57
+ function sign(opts) {
58
+ opts = validateOpts.requireObject(opts, "standardWebhooks.sign",
59
+ StandardWebhooksError, "standard-webhooks/bad-opts");
60
+ validateOpts(opts, ["id", "timestamp", "body", "secret"], "standardWebhooks.sign");
61
+ if (!Buffer.isBuffer(opts.secret) || opts.secret.length < 32) { // allow:raw-byte-literal — 32-byte HMAC secret floor
62
+ throw new StandardWebhooksError("standard-webhooks/bad-secret",
63
+ "sign: opts.secret must be a Buffer (>= 32 bytes)");
64
+ }
65
+ if (!opts.body || (!Buffer.isBuffer(opts.body) && typeof opts.body !== "string")) {
66
+ throw new StandardWebhooksError("standard-webhooks/bad-body",
67
+ "sign: opts.body must be a non-empty Buffer or string");
68
+ }
69
+ var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
70
+ var id = opts.id || ("msg_" + bCrypto.generateToken(32)); // allow:raw-byte-literal — 32-char id token
71
+ var timestamp = typeof opts.timestamp === "number"
72
+ ? opts.timestamp
73
+ : Math.floor(Date.now() / 1000); // allow:raw-time-literal — wall-clock seconds
74
+ if (timestamp <= 0 || !isFinite(timestamp)) {
75
+ throw new StandardWebhooksError("standard-webhooks/bad-timestamp",
76
+ "sign: timestamp must be a positive finite integer");
77
+ }
78
+ var toSign = id + "." + timestamp + "." + bodyBuf.toString("utf8");
79
+ var sigB64 = nodeCrypto.createHmac("sha256", opts.secret).update(toSign).digest("base64");
80
+ return {
81
+ headers: {
82
+ "webhook-id": id,
83
+ "webhook-timestamp": String(timestamp),
84
+ "webhook-signature": "v1," + sigB64,
85
+ },
86
+ body: bodyBuf,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * @primitive b.standardWebhooks.verify
92
+ * @signature b.standardWebhooks.verify(opts)
93
+ * @since 0.10.16
94
+ * @status stable
95
+ *
96
+ * Verify an inbound webhook against the StandardWebhooks spec.
97
+ * Refuses on missing headers, timestamp skew > tolerance, or
98
+ * HMAC mismatch. Returns `{ valid, id, timestamp }`.
99
+ *
100
+ * @opts
101
+ * headers: object, // request headers
102
+ * body: Buffer|string, // raw request body
103
+ * secret: Buffer, // shared secret
104
+ * toleranceSec: number, // default 300s (5 minutes)
105
+ *
106
+ * @example
107
+ * var v = b.standardWebhooks.verify({
108
+ * headers: req.headers, body: rawBody, secret: secret,
109
+ * });
110
+ * if (!v.valid) throw 401;
111
+ */
112
+ function verify(opts) {
113
+ opts = validateOpts.requireObject(opts, "standardWebhooks.verify",
114
+ StandardWebhooksError, "standard-webhooks/bad-opts");
115
+ validateOpts(opts, ["headers", "body", "secret", "toleranceSec"],
116
+ "standardWebhooks.verify");
117
+ if (!opts.headers || typeof opts.headers !== "object") {
118
+ throw new StandardWebhooksError("standard-webhooks/bad-headers",
119
+ "verify: opts.headers required");
120
+ }
121
+ if (!Buffer.isBuffer(opts.secret) || opts.secret.length < 32) { // allow:raw-byte-literal — 32-byte HMAC secret floor
122
+ throw new StandardWebhooksError("standard-webhooks/bad-secret",
123
+ "verify: opts.secret must be a Buffer (>= 32 bytes)");
124
+ }
125
+ var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body
126
+ : typeof opts.body === "string" ? Buffer.from(opts.body, "utf8")
127
+ : null;
128
+ if (!bodyBuf) {
129
+ throw new StandardWebhooksError("standard-webhooks/bad-body",
130
+ "verify: opts.body must be a Buffer or string");
131
+ }
132
+ // Normalise header names — case-insensitive.
133
+ var lower = {};
134
+ var keys = Object.keys(opts.headers);
135
+ for (var i = 0; i < keys.length; i += 1) lower[keys[i].toLowerCase()] = opts.headers[keys[i]];
136
+ var id = lower["webhook-id"];
137
+ var tsStr = lower["webhook-timestamp"];
138
+ var sigHeader = lower["webhook-signature"];
139
+ if (!id || !tsStr || !sigHeader) {
140
+ throw new StandardWebhooksError("standard-webhooks/missing-headers",
141
+ "verify: webhook-id / webhook-timestamp / webhook-signature required");
142
+ }
143
+ var ts = parseInt(tsStr, 10);
144
+ if (!isFinite(ts) || ts <= 0) {
145
+ throw new StandardWebhooksError("standard-webhooks/bad-timestamp",
146
+ "verify: webhook-timestamp is not a positive integer");
147
+ }
148
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.toleranceSec, "toleranceSec",
149
+ StandardWebhooksError, "standard-webhooks/bad-tolerance");
150
+ var tolerance = typeof opts.toleranceSec === "number" ? opts.toleranceSec : DEFAULT_TOLERANCE_SEC;
151
+ var nowSec = Math.floor(Date.now() / 1000); // allow:raw-time-literal — wall-clock seconds
152
+ if (Math.abs(nowSec - ts) > tolerance) {
153
+ throw new StandardWebhooksError("standard-webhooks/timestamp-skew",
154
+ "verify: timestamp skew " + Math.abs(nowSec - ts) + "s exceeds tolerance " + tolerance + "s");
155
+ }
156
+ var toSign = id + "." + ts + "." + bodyBuf.toString("utf8");
157
+ var expected = nodeCrypto.createHmac("sha256", opts.secret).update(toSign).digest("base64");
158
+ // Multi-version: signature header is `v1,<sig> v2,<sig>` etc.
159
+ var parts = sigHeader.split(" ");
160
+ var any = false;
161
+ for (var p = 0; p < parts.length; p += 1) {
162
+ var pair = parts[p].split(",");
163
+ if (pair.length !== 2) continue;
164
+ if (pair[0] !== "v1") continue;
165
+ if (bCrypto.timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(pair[1], "utf8"))) {
166
+ any = true;
167
+ break;
168
+ }
169
+ }
170
+ if (!any) {
171
+ throw new StandardWebhooksError("standard-webhooks/bad-signature",
172
+ "verify: no v1 signature matched");
173
+ }
174
+ void C; // module loaded for future tolerance defaulting; not used directly today
175
+ return { valid: true, id: id, timestamp: ts };
176
+ }
177
+
178
+ module.exports = {
179
+ sign: sign,
180
+ verify: verify,
181
+ DEFAULT_TOLERANCE_SEC: DEFAULT_TOLERANCE_SEC,
182
+ StandardWebhooksError: StandardWebhooksError,
183
+ };
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.webPush
4
+ * @nav Networking
5
+ * @title Web Push (VAPID)
6
+ * @order 240
7
+ *
8
+ * @intro
9
+ * RFC 8292 Voluntary Application Server Identification (VAPID) for
10
+ * Web Push (RFC 8030). Operators sign JWTs with an ECDSA-P256 key
11
+ * to identify themselves to the push service; the browser-side
12
+ * subscription includes the operator's VAPID public key in
13
+ * `applicationServerKey`. RFC 8292 §3 mandates ES256; the framework
14
+ * uses node:crypto for ECDSA because the protocol is not PQC-yet
15
+ * (browser push services don't accept ML-DSA today; track
16
+ * draft-ietf-webpush-vapid-pqc for the migration).
17
+ *
18
+ * `b.webPush.buildVapidAuthHeader({ subscription, contact,
19
+ * privateKeyPem, publicKeyPem })` returns the `Authorization:
20
+ * vapid t=<jwt>, k=<base64url-pubkey>` header value the operator
21
+ * sets on the push-request POST to the push-service endpoint.
22
+ *
23
+ * `b.webPush.generateVapidKeypair()` returns `{ publicKeyPem,
24
+ * privateKeyPem, publicKeyB64Url }` — the b64url-encoded public
25
+ * key is what the browser code passes as `applicationServerKey`.
26
+ *
27
+ * @card
28
+ * RFC 8292 VAPID JWT signer + RFC 8030 push request shape (ECDSA-P256). Operators sign once per subscription endpoint; browsers identify the push origin via the operator's public key.
29
+ */
30
+
31
+ var nodeCrypto = require("node:crypto");
32
+ var C = require("./constants");
33
+ var validateOpts = require("./validate-opts");
34
+ var safeUrl = require("./safe-url");
35
+ var bCrypto = require("./crypto");
36
+ var { defineClass } = require("./framework-error");
37
+
38
+ var WebPushError = defineClass("WebPushError", { alwaysPermanent: true });
39
+
40
+ /**
41
+ * @primitive b.webPush.generateVapidKeypair
42
+ * @signature b.webPush.generateVapidKeypair()
43
+ * @since 0.10.16
44
+ * @status stable
45
+ * @related b.webPush.buildVapidAuthHeader
46
+ *
47
+ * Generate a fresh ECDSA-P256 keypair suitable for VAPID. Returns
48
+ * `{ publicKeyPem, privateKeyPem, publicKeyB64Url }`. The b64url-
49
+ * encoded public key is what the browser code passes as
50
+ * `applicationServerKey` to `pushManager.subscribe`.
51
+ *
52
+ * @example
53
+ * var kp = b.webPush.generateVapidKeypair();
54
+ * // Browser:
55
+ * // pushManager.subscribe({ applicationServerKey: kp.publicKeyB64Url })
56
+ */
57
+ function generateVapidKeypair() {
58
+ var kp = nodeCrypto.generateKeyPairSync("ec", {
59
+ namedCurve: "prime256v1",
60
+ publicKeyEncoding: { type: "spki", format: "pem" },
61
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
62
+ });
63
+ // RFC 8292 §3.2 — uncompressed point (0x04 ‖ X ‖ Y), base64url-encoded.
64
+ var pubKeyObj = nodeCrypto.createPublicKey(kp.publicKey);
65
+ var jwk = pubKeyObj.export({ format: "jwk" });
66
+ var raw = Buffer.concat([
67
+ Buffer.from([0x04]), // allow:raw-byte-literal — uncompressed point prefix per SEC1 §2.3.3
68
+ Buffer.from(jwk.x, "base64url"),
69
+ Buffer.from(jwk.y, "base64url"),
70
+ ]);
71
+ return {
72
+ publicKeyPem: kp.publicKey,
73
+ privateKeyPem: kp.privateKey,
74
+ publicKeyB64Url: bCrypto.toBase64Url(raw),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * @primitive b.webPush.buildVapidAuthHeader
80
+ * @signature b.webPush.buildVapidAuthHeader(opts)
81
+ * @since 0.10.16
82
+ * @status stable
83
+ * @related b.webPush.generateVapidKeypair
84
+ *
85
+ * Build the `Authorization: vapid t=<jwt>, k=<pubkey-b64url>` header
86
+ * value per RFC 8292 §3. The JWT claims (`aud` / `exp` / `sub`) are
87
+ * computed from the subscription endpoint origin + operator contact;
88
+ * `exp` defaults to 12 hours (RFC 8292 §2 caps at 24 hours).
89
+ *
90
+ * @opts
91
+ * subscription: { endpoint: string }, // browser-returned subscription
92
+ * contact: string, // mailto:... or https:... per RFC 8292 §2
93
+ * privateKeyPem: string, // ECDSA-P256 PEM-encoded private key
94
+ * publicKeyB64Url: string, // public key from generateVapidKeypair()
95
+ * ttlSec: number, // optional, default 12h
96
+ *
97
+ * @example
98
+ * var hdr = b.webPush.buildVapidAuthHeader({
99
+ * subscription: { endpoint: "https://fcm.googleapis.com/wp/abc" },
100
+ * contact: "mailto:ops@example.com",
101
+ * privateKeyPem: kp.privateKeyPem,
102
+ * publicKeyB64Url: kp.publicKeyB64Url,
103
+ * });
104
+ * // → "vapid t=<jwt>, k=<b64url>"
105
+ */
106
+ function buildVapidAuthHeader(opts) {
107
+ opts = validateOpts.requireObject(opts, "webPush.buildVapidAuthHeader",
108
+ WebPushError, "web-push/bad-opts");
109
+ validateOpts(opts, ["subscription", "contact", "privateKeyPem",
110
+ "publicKeyB64Url", "ttlSec"],
111
+ "webPush.buildVapidAuthHeader");
112
+ if (!opts.subscription || typeof opts.subscription.endpoint !== "string") {
113
+ throw new WebPushError("web-push/bad-subscription",
114
+ "buildVapidAuthHeader: opts.subscription must include a string endpoint");
115
+ }
116
+ validateOpts.requireNonEmptyString(opts.contact, "contact",
117
+ WebPushError, "web-push/bad-contact");
118
+ if (!/^(mailto:|https:)/i.test(opts.contact)) {
119
+ throw new WebPushError("web-push/bad-contact",
120
+ "buildVapidAuthHeader: contact must start with 'mailto:' or 'https:' per RFC 8292 §2");
121
+ }
122
+ validateOpts.requireNonEmptyString(opts.privateKeyPem, "privateKeyPem",
123
+ WebPushError, "web-push/bad-key");
124
+ validateOpts.requireNonEmptyString(opts.publicKeyB64Url, "publicKeyB64Url",
125
+ WebPushError, "web-push/bad-key");
126
+ validateOpts.optionalPositiveFinite(opts.ttlSec, "webPush.buildVapidAuthHeader: ttlSec",
127
+ WebPushError, "web-push/bad-ttl");
128
+ var ttlSec = opts.ttlSec || C.TIME.hours(12);
129
+ // Audience: origin of the subscription endpoint per RFC 8292 §2.
130
+ var endpointUrl;
131
+ try { endpointUrl = safeUrl.parse(opts.subscription.endpoint); }
132
+ catch (_e) {
133
+ throw new WebPushError("web-push/bad-endpoint",
134
+ "buildVapidAuthHeader: subscription.endpoint is not a parseable URL");
135
+ }
136
+ var aud = endpointUrl.origin;
137
+ var now = Math.floor(Date.now() / 1000); // allow:raw-time-literal — wall-clock seconds for JWT exp
138
+ // Inline JWT sign with ES256 — VAPID strictly mandates ECDSA-P256
139
+ // (RFC 8292 §3.1). The framework jwt.sign is PQC-first and refuses
140
+ // ES256 by design; VAPID is a wire-protocol constraint outside
141
+ // that policy. b.webPush owns the ES256 signing inline so the
142
+ // framework's broader PQC posture remains intact.
143
+ var header = { typ: "JWT", alg: "ES256" };
144
+ var payload = { aud: aud, exp: now + ttlSec, sub: opts.contact };
145
+ var headerB64 = bCrypto.toBase64Url(Buffer.from(JSON.stringify(header), "utf8"));
146
+ var payloadB64 = bCrypto.toBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
147
+ var signingInput = headerB64 + "." + payloadB64;
148
+ var keyObj = nodeCrypto.createPrivateKey(opts.privateKeyPem);
149
+ if (keyObj.asymmetricKeyType !== "ec") {
150
+ throw new WebPushError("web-push/bad-key",
151
+ "buildVapidAuthHeader: privateKeyPem must be an ECDSA-P256 key (RFC 8292 §3.1)");
152
+ }
153
+ // node:crypto produces DER-encoded ECDSA signature; JWT ES256
154
+ // requires the raw 64-byte r||s shape. Convert.
155
+ var derSig = nodeCrypto.sign("sha256", Buffer.from(signingInput, "utf8"), keyObj);
156
+ var rawSig = _ecdsaDerToRaw(derSig, 32); // allow:raw-byte-literal — 32-byte P-256 component
157
+ var token = signingInput + "." + bCrypto.toBase64Url(rawSig);
158
+ return "vapid t=" + token + ", k=" + opts.publicKeyB64Url;
159
+ }
160
+
161
+ function _ecdsaDerToRaw(der, componentLen) {
162
+ // ECDSA-Sig-Value DER = SEQUENCE { r INTEGER, s INTEGER }.
163
+ if (der[0] !== 0x30) { // allow:raw-byte-literal — ASN.1 SEQUENCE tag
164
+ throw new WebPushError("web-push/bad-sig",
165
+ "ECDSA signature is not a DER SEQUENCE");
166
+ }
167
+ var off = 2;
168
+ if (der[1] & 0x80) off = 2 + (der[1] & 0x7f); // allow:raw-byte-literal — long-form length byte
169
+ if (der[off] !== 0x02) throw new WebPushError("web-push/bad-sig", "missing r INTEGER"); // allow:raw-byte-literal — ASN.1 INTEGER tag
170
+ var rLen = der[off + 1];
171
+ var rStart = off + 2;
172
+ var r = der.slice(rStart, rStart + rLen);
173
+ off = rStart + rLen;
174
+ if (der[off] !== 0x02) throw new WebPushError("web-push/bad-sig", "missing s INTEGER"); // allow:raw-byte-literal — ASN.1 INTEGER tag
175
+ var sLen = der[off + 1];
176
+ var sStart = off + 2;
177
+ var s = der.slice(sStart, sStart + sLen);
178
+ // Trim leading zero pad (DER requires it when high bit set; JWT raw doesn't).
179
+ if (r.length > componentLen && r[0] === 0x00) r = r.slice(1); // allow:raw-byte-literal — DER sign-bit pad
180
+ if (s.length > componentLen && s[0] === 0x00) s = s.slice(1); // allow:raw-byte-literal — DER sign-bit pad
181
+ var out = Buffer.alloc(componentLen * 2);
182
+ r.copy(out, componentLen - r.length);
183
+ s.copy(out, componentLen * 2 - s.length);
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * @primitive b.webPush.encrypt
189
+ * @signature b.webPush.encrypt(opts)
190
+ * @since 0.10.16
191
+ * @status stable
192
+ * @related b.webPush.buildVapidAuthHeader
193
+ *
194
+ * Encrypt a Web Push message payload per RFC 8291 (Message Encryption
195
+ * for Web Push) using the aes128gcm content-coding per RFC 8188.
196
+ * Returns `{ body, headers }`:
197
+ * - `body` is the Buffer to POST to the subscription endpoint
198
+ * - `headers` carries the spec-required Content-Encoding +
199
+ * Content-Length + TTL (caller-overridable) so operators wire
200
+ * them onto the push-request alongside the VAPID Authorization.
201
+ *
202
+ * The recipient's subscription object provides `p256dh` (their ECDH
203
+ * P-256 public key, base64url) and `auth` (16-byte auth secret,
204
+ * base64url). The framework computes the ephemeral keypair, performs
205
+ * ECDH, runs the two-stage HKDF per RFC 8291 §3.4, and AES-128-GCM
206
+ * encrypts with the padded plaintext per RFC 8188 §2.
207
+ *
208
+ * @opts
209
+ * subscription: { endpoint, keys: { p256dh, auth } },
210
+ * payload: Buffer|string,
211
+ * ttlSec: number, // default 28d (RFC 8030 §5.2)
212
+ *
213
+ * @example
214
+ * var e = b.webPush.encrypt({
215
+ * subscription: { endpoint: sub.endpoint, keys: { p256dh, auth } },
216
+ * payload: "hello",
217
+ * });
218
+ * b.httpClient.request({
219
+ * url: sub.endpoint, method: "POST",
220
+ * headers: Object.assign({}, e.headers, {
221
+ * Authorization: vapidHeader,
222
+ * }),
223
+ * body: e.body,
224
+ * });
225
+ */
226
+ function encrypt(opts) {
227
+ opts = validateOpts.requireObject(opts, "webPush.encrypt",
228
+ WebPushError, "web-push/bad-opts");
229
+ validateOpts(opts, ["subscription", "payload", "ttlSec"], "webPush.encrypt");
230
+ if (!opts.subscription || typeof opts.subscription !== "object" ||
231
+ !opts.subscription.keys || typeof opts.subscription.keys !== "object") {
232
+ throw new WebPushError("web-push/bad-subscription",
233
+ "encrypt: subscription must have a keys: { p256dh, auth } object");
234
+ }
235
+ validateOpts.requireNonEmptyString(opts.subscription.keys.p256dh, "p256dh",
236
+ WebPushError, "web-push/bad-p256dh");
237
+ validateOpts.requireNonEmptyString(opts.subscription.keys.auth, "auth",
238
+ WebPushError, "web-push/bad-auth");
239
+ var plaintext = Buffer.isBuffer(opts.payload) ? opts.payload
240
+ : typeof opts.payload === "string" ? Buffer.from(opts.payload, "utf8")
241
+ : null;
242
+ if (!plaintext) {
243
+ throw new WebPushError("web-push/bad-payload",
244
+ "encrypt: payload must be a Buffer or string");
245
+ }
246
+ // Decode the subscription's p256dh + auth.
247
+ var recipientPubRaw = Buffer.from(opts.subscription.keys.p256dh, "base64url");
248
+ if (recipientPubRaw.length !== 65 || recipientPubRaw[0] !== 0x04) { // allow:raw-byte-literal — uncompressed P-256 point shape per SEC1 §2.3.3
249
+ throw new WebPushError("web-push/bad-p256dh",
250
+ "encrypt: p256dh must be a 65-byte uncompressed P-256 point");
251
+ }
252
+ var authSecret = Buffer.from(opts.subscription.keys.auth, "base64url");
253
+ if (authSecret.length !== 16) { // allow:raw-byte-literal — RFC 8291 §3.2 auth_secret length
254
+ throw new WebPushError("web-push/bad-auth",
255
+ "encrypt: auth must be a 16-byte secret (got " + authSecret.length + ")");
256
+ }
257
+ // Generate ephemeral ECDH P-256 keypair.
258
+ var ephemeral = nodeCrypto.createECDH("prime256v1");
259
+ ephemeral.generateKeys();
260
+ var ephemeralPubRaw = ephemeral.getPublicKey(); // uncompressed 65 bytes
261
+ // ECDH shared secret.
262
+ var sharedSecret = ephemeral.computeSecret(recipientPubRaw); // allow:raw-byte-literal — ECDH shared secret (32 bytes per P-256)
263
+ // RFC 8291 §3.4 two-stage HKDF:
264
+ // PRK_key = HKDF-Extract(salt=auth_secret, IKM=ECDH_shared)
265
+ // key_info = "WebPush: info\x00" || ua_public || as_public
266
+ // IKM = HKDF-Expand(PRK_key, key_info, 32)
267
+ // Then RFC 8188 §2.2:
268
+ // salt = 16 random bytes
269
+ // PRK = HKDF-Extract(salt, IKM)
270
+ // cek_info = "Content-Encoding: aes128gcm\x00"
271
+ // nonce_info = "Content-Encoding: nonce\x00"
272
+ // CEK = HKDF-Expand(PRK, cek_info, 16)
273
+ // nonce = HKDF-Expand(PRK, nonce_info, 12)
274
+ var keyInfo = Buffer.concat([
275
+ Buffer.from("WebPush: info\x00", "utf8"),
276
+ recipientPubRaw,
277
+ ephemeralPubRaw,
278
+ ]);
279
+ var ikm = _hkdf(authSecret, sharedSecret, keyInfo, 32); // allow:raw-byte-literal — 256-bit IKM
280
+ var salt = nodeCrypto.randomBytes(16); // allow:raw-byte-literal — RFC 8188 §2.2 16-byte salt
281
+ var cek = _hkdf(salt, ikm, Buffer.from("Content-Encoding: aes128gcm\x00", "utf8"), 16); // allow:raw-byte-literal — 128-bit AEAD key
282
+ var nonce = _hkdf(salt, ikm, Buffer.from("Content-Encoding: nonce\x00", "utf8"), 12); // allow:raw-byte-literal — 96-bit AEAD nonce
283
+ // RFC 8188 §2 padding: plaintext || 0x02 (delimiter for single-record).
284
+ // RFC 8291 mandates single-record (record_size > plaintext+padding+tag).
285
+ var padded = Buffer.concat([plaintext, Buffer.from([0x02])]); // allow:raw-byte-literal — RFC 8188 single-record delimiter
286
+ var cipher = nodeCrypto.createCipheriv("aes-128-gcm", cek, nonce);
287
+ var ct = Buffer.concat([cipher.update(padded), cipher.final()]);
288
+ var tag = cipher.getAuthTag();
289
+ // RFC 8188 §2.1 header: salt(16) || rs(4 big-endian) || idlen(1) || keyid
290
+ // For RFC 8291 the keyid is the as_public (ephemeral pubkey, 65 bytes).
291
+ var rs = padded.length + 16; // allow:raw-byte-literal — record size = plaintext + tag length
292
+ var header = Buffer.alloc(16 + 4 + 1); // allow:raw-byte-literal — salt + rs + idlen layout
293
+ salt.copy(header, 0);
294
+ header.writeUInt32BE(rs, 16); // allow:raw-byte-literal — salt offset
295
+ header[20] = ephemeralPubRaw.length; // allow:raw-byte-literal — rs offset
296
+ var body = Buffer.concat([header, ephemeralPubRaw, ct, tag]);
297
+ var ttlSec = opts.ttlSec || (28 * 24 * 3600); // allow:raw-time-literal — RFC 8030 §5.2 default
298
+ return {
299
+ body: body,
300
+ headers: {
301
+ "Content-Encoding": "aes128gcm",
302
+ "Content-Length": String(body.length),
303
+ "TTL": String(ttlSec),
304
+ },
305
+ };
306
+ }
307
+
308
+ function _hkdf(salt, ikm, info, length) {
309
+ // RFC 5869 HKDF-Extract + Expand using SHA-256 (per RFC 8291 / 8188).
310
+ var prk = nodeCrypto.createHmac("sha256", salt).update(ikm).digest();
311
+ // Expand with one-byte counter (length <= 32 always in this use).
312
+ var t = Buffer.concat([info, Buffer.from([0x01])]); // allow:raw-byte-literal — HKDF counter start
313
+ var out = nodeCrypto.createHmac("sha256", prk).update(t).digest();
314
+ return out.slice(0, length);
315
+ }
316
+
317
+ module.exports = {
318
+ generateVapidKeypair: generateVapidKeypair,
319
+ buildVapidAuthHeader: buildVapidAuthHeader,
320
+ encrypt: encrypt,
321
+ WebPushError: WebPushError,
322
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.14",
3
+ "version": "0.11.0",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",