@blamejs/core 0.10.15 → 0.11.1

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,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.15",
3
+ "version": "0.11.1",
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.6",
5
- "serialNumber": "urn:uuid:3a0ed9ad-ab9e-433f-80ae-8bd6213c52d8",
5
+ "serialNumber": "urn:uuid:7fbc6c04-e867-4e6a-b8a0-d1686fdb9dc7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-19T02:11:46.591Z",
8
+ "timestamp": "2026-05-19T15:04:27.770Z",
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.10.15",
22
+ "bom-ref": "@blamejs/core@0.11.1",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.15",
25
+ "version": "0.11.1",
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.10.15",
29
+ "purl": "pkg:npm/%40blamejs/core@0.11.1",
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.10.15",
57
+ "ref": "@blamejs/core@0.11.1",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]