@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/CHANGELOG.md +4 -0
- package/README.md +1 -0
- package/index.js +18 -0
- package/lib/auth/oauth.js +187 -0
- package/lib/auth/saml.js +1366 -13
- package/lib/cms-codec.js +141 -0
- package/lib/compliance.js +73 -0
- package/lib/csp.js +271 -0
- package/lib/dbsc.js +299 -0
- package/lib/fedcm.js +264 -0
- package/lib/hal.js +125 -0
- package/lib/importmap-integrity.js +90 -0
- package/lib/jsonapi.js +230 -0
- package/lib/lro.js +200 -0
- package/lib/mail-crypto-pgp.js +312 -2
- package/lib/mail-crypto-smime.js +530 -69
- package/lib/mail-deploy.js +632 -5
- package/lib/metrics.js +62 -12
- package/lib/middleware/security-headers.js +2 -1
- package/lib/standard-webhooks.js +183 -0
- package/lib/web-push-vapid.js +322 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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:
|
|
535
|
-
counts:
|
|
536
|
-
sum:
|
|
537
|
-
count:
|
|
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])
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|