@blamejs/core 0.8.39 → 0.8.41

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/crypto.js CHANGED
@@ -91,6 +91,19 @@ function hmacSha3(key, data) { return hmac(key, data, "sha3-512"); }
91
91
  // ---- KDF ----
92
92
  function kdf(input, outputLength) { return hash(input, "shake256", outputLength); }
93
93
 
94
+ // _suiteFixedInfo — NIST SP 800-56C r2 §4.1 OtherInfo / RFC 9180
95
+ // (HPKE) §5.1 suite_id binding. Returns the byte string that the KDF
96
+ // MUST absorb alongside the shared-secret(s) so a key derived under
97
+ // one suite is not silently usable under a different suite. Same
98
+ // label is recovered on decrypt by re-reading the envelope-prefix
99
+ // bytes (kemId / cipherId / kdfId).
100
+ function _suiteFixedInfo(kemId, cipherId, kdfId) {
101
+ return Buffer.concat([
102
+ Buffer.from(C.ENVELOPE_FIXED_INFO_LABEL, "utf8"),
103
+ Buffer.from([0x00, kemId, cipherId, kdfId, 0x00]),
104
+ ]);
105
+ }
106
+
94
107
  // ---- Random ----
95
108
  function generateBytes(byteLength) { return Buffer.from(random(byteLength)); }
96
109
  function generateToken(byteLength) { return random(byteLength || 32).toString("hex"); }
@@ -206,28 +219,38 @@ function encrypt(plaintext, publicKeys) {
206
219
  privateKey: nodeCrypto.createPrivateKey(ephEc.privateKey),
207
220
  publicKey: nodeCrypto.createPublicKey(ecPubPem),
208
221
  });
209
- var key = kdf(Buffer.concat([kem.sharedKey, ecSs]), C.BYTES.bytes(32));
222
+ var key = kdf(Buffer.concat([kem.sharedKey, ecSs,
223
+ _suiteFixedInfo(C.ACTIVE.KEM, C.ACTIVE.CIPHER, C.ACTIVE.KDF)]),
224
+ C.BYTES.bytes(32));
210
225
  var nonce = generateBytes(C.BYTES.bytes(24));
211
- var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
226
+ // Bind the 4-byte envelope header (MAGIC + kemId + cipherId + kdfId)
227
+ // as AAD so a tampered header (algorithm-substitution attack) fails
228
+ // the Poly1305 tag.
229
+ var headerAad = Buffer.from([C.ENVELOPE_MAGIC, C.ACTIVE.KEM, C.ACTIVE.CIPHER, C.ACTIVE.KDF]);
230
+ var ct = xchacha20poly1305(key, nonce, headerAad).encrypt(Buffer.from(plaintext, "utf8"));
212
231
 
213
232
  var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
214
233
  var ecEphDer = ephEc.publicKey;
215
234
  var ecEphLen = Buffer.alloc(2); ecEphLen.writeUInt16BE(ecEphDer.length);
216
235
 
217
236
  return Buffer.concat([
218
- Buffer.from([C.ENVELOPE_MAGIC, C.ACTIVE.KEM, C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
237
+ headerAad,
219
238
  kemCtLen, kem.ciphertext, ecEphLen, ecEphDer, nonce, Buffer.from(ct),
220
239
  ]).toString("base64");
221
240
  }
222
241
 
223
242
  function encryptMlkemOnly(plaintext, publicKeyPem) {
224
243
  var kem = nodeCrypto.encapsulate(nodeCrypto.createPublicKey(publicKeyPem));
225
- var key = kdf(kem.sharedKey, C.BYTES.bytes(32));
244
+ var key = kdf(Buffer.concat([kem.sharedKey,
245
+ _suiteFixedInfo(C.KEM_IDS.ML_KEM_1024, C.ACTIVE.CIPHER, C.ACTIVE.KDF)]),
246
+ C.BYTES.bytes(32));
226
247
  var nonce = generateBytes(C.BYTES.bytes(24));
227
- var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
248
+ var headerAad = Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_1024,
249
+ C.ACTIVE.CIPHER, C.ACTIVE.KDF]);
250
+ var ct = xchacha20poly1305(key, nonce, headerAad).encrypt(Buffer.from(plaintext, "utf8"));
228
251
  var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
229
252
  return Buffer.concat([
230
- Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_1024, C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
253
+ headerAad,
231
254
  kemCtLen, kem.ciphertext, nonce, Buffer.from(ct),
232
255
  ]).toString("base64");
233
256
  }
@@ -235,6 +258,10 @@ function encryptMlkemOnly(plaintext, publicKeyPem) {
235
258
  // ---- Envelope decrypt (dispatches on envelope IDs, supports both KEM IDs) ----
236
259
  function decrypt(ciphertext, privateKeys) {
237
260
  var packed = Buffer.from(ciphertext, "base64");
261
+ if (packed[0] === 0xE1) { // allow:raw-byte-literal — legacy envelope magic
262
+ throw new Error("Invalid envelope: legacy 0xE1 format predates the FixedInfo " +
263
+ "KDF binding (NIST SP 800-56C r2 §4.1) — re-seal data under the current envelope");
264
+ }
238
265
  if (packed[0] !== C.ENVELOPE_MAGIC) {
239
266
  throw new Error("Invalid envelope: unsupported format");
240
267
  }
@@ -269,9 +296,11 @@ function decryptEnvelope(packed, privateKeys) {
269
296
  privateKey: nodeCrypto.createPrivateKey(ecPrivPem),
270
297
  publicKey: nodeCrypto.createPublicKey({ key: ecEphDer, type: "spki", format: "der" }),
271
298
  });
272
- symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs]), C.BYTES.bytes(32));
299
+ symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs,
300
+ _suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
273
301
  } else if (kemId === C.KEM_IDS.ML_KEM_1024) {
274
- symmetricKey = kdf(mlkemSs, C.BYTES.bytes(32));
302
+ symmetricKey = kdf(Buffer.concat([mlkemSs,
303
+ _suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
275
304
  } else if (kemId === C.KEM_IDS.ML_KEM_768_X25519) {
276
305
  // ML-KEM-768 + X25519 hybrid envelope. The mlkemPriv must be an
277
306
  // ML-KEM-768 key (not 1024); operators are responsible for passing
@@ -286,14 +315,19 @@ function decryptEnvelope(packed, privateKeys) {
286
315
  privateKey: nodeCrypto.createPrivateKey(x25519PrivPem),
287
316
  publicKey: nodeCrypto.createPublicKey({ key: x25519EphDer, type: "spki", format: "der" }),
288
317
  });
289
- symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss]), C.BYTES.bytes(32));
318
+ symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss,
319
+ _suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
290
320
  } else {
291
321
  throw new Error("Invalid envelope: unsupported KEM ID " + kemId);
292
322
  }
293
323
 
294
324
  var nonce = packed.subarray(pos, pos + C.BYTES.bytes(24)); pos += C.BYTES.bytes(24);
325
+ // Re-derive the 4-byte envelope-header AAD from the bytes we just
326
+ // dispatched on. A tampered header (algorithm-substitution attack)
327
+ // surfaces here as a Poly1305 tag verification failure.
328
+ var headerAad = packed.subarray(0, 4); // allow:raw-byte-literal — envelope-header byte slice
295
329
  return Buffer.from(
296
- xchacha20poly1305(symmetricKey, nonce).decrypt(packed.subarray(pos))
330
+ xchacha20poly1305(symmetricKey, nonce, headerAad).decrypt(packed.subarray(pos))
297
331
  ).toString("utf8");
298
332
  }
299
333
 
@@ -375,17 +409,20 @@ function encryptMlkem768X25519(plaintext, recipient) {
375
409
  privateKey: nodeCrypto.createPrivateKey(ephX25519.privateKey),
376
410
  publicKey: nodeCrypto.createPublicKey(recipient.x25519PublicKey),
377
411
  });
378
- var key = kdf(Buffer.concat([kem.sharedKey, x25519Ss]), C.BYTES.bytes(32));
412
+ var key = kdf(Buffer.concat([kem.sharedKey, x25519Ss,
413
+ _suiteFixedInfo(C.KEM_IDS.ML_KEM_768_X25519, C.ACTIVE.CIPHER, C.ACTIVE.KDF)]),
414
+ C.BYTES.bytes(32));
379
415
  var nonce = generateBytes(C.BYTES.bytes(24));
380
- var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
416
+ var headerAad = Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_768_X25519,
417
+ C.ACTIVE.CIPHER, C.ACTIVE.KDF]);
418
+ var ct = xchacha20poly1305(key, nonce, headerAad).encrypt(Buffer.from(plaintext, "utf8"));
381
419
 
382
420
  var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
383
421
  var x25519EphDer = ephX25519.publicKey;
384
422
  var x25519EphLen = Buffer.alloc(2); x25519EphLen.writeUInt16BE(x25519EphDer.length);
385
423
 
386
424
  return Buffer.concat([
387
- Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_768_X25519,
388
- C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
425
+ headerAad,
389
426
  kemCtLen, kem.ciphertext, x25519EphLen, x25519EphDer, nonce, Buffer.from(ct),
390
427
  ]).toString("base64");
391
428
  }
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /**
3
+ * b.honeytoken — canary credential framework. Generates decoy values
4
+ * (fake api-key shapes, fake admin URLs, fake DB row references) that
5
+ * are NEVER handed to a real client; their presence in a request,
6
+ * log, or DB lookup means an attacker found something they shouldn't
7
+ * have. The framework registers each token at issuance and refuses
8
+ * silently in production but always emits a `honeytoken.tripped`
9
+ * audit row on any positive lookup.
10
+ *
11
+ * var honey = b.honeytoken.create({ audit: b.audit });
12
+ *
13
+ * var token = honey.issue({
14
+ * kind: "apiKey",
15
+ * metadata: { plantedAt: "GET /admin/keys/404", linkedTo: "u_42" },
16
+ * });
17
+ * // → { value: "bk_canary_8f3a7b2e0c…", id: "ht_<hex>" }
18
+ *
19
+ * if (honey.lookup(req.headers["x-api-key"])) {
20
+ * // attacker is using the canary; tripped event already audited
21
+ * return res.status(403).end();
22
+ * }
23
+ *
24
+ * Canary value shapes (`kind`):
25
+ * - "apiKey" → `bk_canary_<32 hex>` (matches b.apiKey shape)
26
+ * - "session" → `bks_canary_<48 hex>` (matches b.session shape)
27
+ * - "url" → `/admin/canary-<32 hex>` (planted as a clickable link)
28
+ * - "rowId" → `ht_canary_<32 hex>` (planted as a fake foreign key)
29
+ *
30
+ * Audit shape:
31
+ * - `honeytoken.issued` — outcome=success; metadata: { id, kind }
32
+ * - `honeytoken.tripped` — outcome=failure; metadata: { id, kind,
33
+ * metadata, observedAt, observedActor }
34
+ */
35
+
36
+ var crypto = require("./crypto");
37
+ var lazyRequire = require("./lazy-require");
38
+ var validateOpts = require("./validate-opts");
39
+ var { defineClass } = require("./framework-error");
40
+
41
+ var audit = lazyRequire(function () { return require("./audit"); });
42
+
43
+ var HoneytokenError = defineClass("HoneytokenError", { alwaysPermanent: true });
44
+
45
+ var KINDS = Object.freeze({
46
+ apiKey: function () { return "bk_canary_" + crypto.generateToken(16); }, // allow:raw-byte-literal — 16-byte (128-bit) canary entropy
47
+ session: function () { return "bks_canary_" + crypto.generateToken(24); }, // allow:raw-byte-literal — 24-byte (192-bit) canary entropy
48
+ url: function () { return "/admin/canary-" + crypto.generateToken(16); }, // allow:raw-byte-literal — 16-byte canary entropy
49
+ rowId: function () { return "ht_canary_" + crypto.generateToken(16); }, // allow:raw-byte-literal — 16-byte canary entropy
50
+ });
51
+
52
+ function create(opts) {
53
+ opts = opts || {};
54
+ validateOpts(opts, ["audit"], "honeytoken.create");
55
+
56
+ var registry = new Map(); // value → { id, kind, metadata, issuedAt }
57
+
58
+ function issue(spec) {
59
+ spec = spec || {};
60
+ validateOpts(spec, ["kind", "metadata"], "honeytoken.issue");
61
+ var kind = spec.kind;
62
+ if (typeof KINDS[kind] !== "function") {
63
+ throw new HoneytokenError(
64
+ "honeytoken/unknown-kind",
65
+ "honeytoken.issue: unknown kind '" + kind + "' " +
66
+ "(supported: " + Object.keys(KINDS).join(", ") + ")");
67
+ }
68
+ var value = KINDS[kind]();
69
+ var id = "ht_" + crypto.generateToken(8); // allow:raw-byte-literal — 8-byte registry id
70
+ var record = Object.freeze({
71
+ id: id,
72
+ kind: kind,
73
+ metadata: spec.metadata || null,
74
+ issuedAt: Date.now(),
75
+ });
76
+ registry.set(value, record);
77
+ try {
78
+ audit().safeEmit({
79
+ action: "honeytoken.issued",
80
+ outcome: "success",
81
+ metadata: { id: id, kind: kind },
82
+ });
83
+ } catch (_e) { /* audit best-effort */ }
84
+ return { id: id, value: value };
85
+ }
86
+
87
+ function lookup(value, observedActor) {
88
+ if (typeof value !== "string" || value.length === 0) return null;
89
+ var record = registry.get(value);
90
+ if (!record) return null;
91
+ try {
92
+ audit().safeEmit({
93
+ action: "honeytoken.tripped",
94
+ outcome: "failure",
95
+ metadata: {
96
+ id: record.id,
97
+ kind: record.kind,
98
+ metadata: record.metadata,
99
+ observedAt: Date.now(),
100
+ observedActor: observedActor || null,
101
+ },
102
+ });
103
+ } catch (_e) { /* audit best-effort */ }
104
+ return record;
105
+ }
106
+
107
+ function revoke(id) {
108
+ var found = false;
109
+ registry.forEach(function (record, value) {
110
+ if (record.id === id) {
111
+ registry.delete(value);
112
+ found = true;
113
+ }
114
+ });
115
+ return found;
116
+ }
117
+
118
+ function size() { return registry.size; }
119
+
120
+ return {
121
+ issue: issue,
122
+ lookup: lookup,
123
+ revoke: revoke,
124
+ size: size,
125
+ };
126
+ }
127
+
128
+ module.exports = {
129
+ create: create,
130
+ KINDS: Object.freeze(Object.keys(KINDS)),
131
+ HoneytokenError: HoneytokenError,
132
+ };
package/lib/mail-auth.js CHANGED
@@ -565,7 +565,11 @@ async function arcVerify(rfc822, opts) {
565
565
  var value = line.slice(colonAt + 1).trim();
566
566
  if (name !== "arc-seal" && name !== "arc-message-signature" &&
567
567
  name !== "arc-authentication-results") continue;
568
- var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap header bounded by RFC 5322 998
568
+ // ARC hop instance per RFC 8617 §4.2.1 — bounded to 3 digits; the
569
+ // spec doesn't define a hard ceiling but operational use never
570
+ // exceeds 50 hops, and a 999-hop limit prevents pathological
571
+ // header values from chewing the verifier.
572
+ var iMatch = value.match(/(?:^|[;,\s])i=(\d{1,3})\b/);
569
573
  var inst = iMatch ? parseInt(iMatch[1], 10) : null;
570
574
  if (inst === null || !isFinite(inst) || inst < 1) continue;
571
575
  if (inst > maxInstanceSeen) maxInstanceSeen = inst;
@@ -1126,9 +1130,17 @@ function authResultsEmit(opts) {
1126
1130
  var propKeys = Object.keys(props);
1127
1131
  for (var pk = 0; pk < propKeys.length; pk += 1) {
1128
1132
  var k = propKeys[pk];
1129
- if (typeof r[k] === "string" && r[k].length > 0 && !/[\r\n\0;]/.test(r[k])) {
1130
- clause += " " + props[k] + "=" + r[k];
1131
- }
1133
+ var rv = r[k];
1134
+ if (typeof rv !== "string" || rv.length === 0) continue;
1135
+ // pvalue ABNF per RFC 8601 §2.3:
1136
+ // pvalue = [CFWS] ((value / dot-atom-text) [CFWS]) /
1137
+ // (local-part "@" domain) [CFWS]
1138
+ // For framework emit we require the printable-ASCII subset of
1139
+ // dot-atom-text + local-part-at-domain shapes; CRLF / NUL /
1140
+ // semicolon / SP / HTAB / quoting metacharacters are refused
1141
+ // (operator-supplied value is structured, not free-form).
1142
+ if (!/^[A-Za-z0-9._@\-:[\]]+$/.test(rv)) continue; // allow:regex-no-length-cap — bounded by header line cap
1143
+ clause += " " + props[k] + "=" + rv;
1132
1144
  }
1133
1145
  clauses.push(clause);
1134
1146
  }
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ /**
3
+ * b.middleware.cspReport — Reporting-API endpoint for CSP / COEP /
4
+ * COOP / Permissions-Policy violations.
5
+ *
6
+ * The framework's default CSP appends `report-to default;` (see
7
+ * lib/middleware/security-headers.js); operators wire the matching
8
+ * `Reporting-Endpoints: default="https://app.example.com/csp-report"`
9
+ * header — and mount this middleware at the configured path. Browsers
10
+ * POST batches of violations as `application/reports+json`.
11
+ *
12
+ * var cspReport = b.middleware.cspReport.create({
13
+ * audit: b.audit,
14
+ * onReport: function (report) { metrics.count("csp.violation", 1, { directive: report.body.effectiveDirective }); },
15
+ * maxBytes: C.BYTES.kib(64),
16
+ * });
17
+ * router.post("/csp-report", cspReport);
18
+ *
19
+ * Audit shape: `csp.violation` (failure) per report; metadata carries
20
+ * the report.body fields (blockedURL, documentURL, effectiveDirective,
21
+ * sample, statusCode). Sample is truncated to 200 chars.
22
+ *
23
+ * Validation:
24
+ * - Refuses non-POST methods with 405
25
+ * - Refuses bodies > maxBytes (default 64 KiB) with 413
26
+ * - Refuses non-JSON bodies with 400
27
+ * - Accepts `application/reports+json` AND legacy `application/csp-report`
28
+ */
29
+
30
+ var C = require("../constants");
31
+ var lazyRequire = require("../lazy-require");
32
+ var safeBuffer = require("../safe-buffer");
33
+ var safeJson = require("../safe-json");
34
+ var validateOpts = require("../validate-opts");
35
+
36
+ var audit = lazyRequire(function () { return require("../audit"); });
37
+
38
+ var DEFAULT_MAX_BYTES = C.BYTES.kib(64);
39
+ var SAMPLE_TRUNCATE = 200;
40
+
41
+ function _truncate(value) {
42
+ if (typeof value !== "string") return value;
43
+ if (value.length <= SAMPLE_TRUNCATE) return value;
44
+ return value.slice(0, SAMPLE_TRUNCATE) + "…";
45
+ }
46
+
47
+ function _normalizeOne(reportLike) {
48
+ // Reporting API shape: { type, age, url, user_agent, body: {...} }
49
+ // Legacy CSP shape: { "csp-report": { ... } }
50
+ if (!reportLike || typeof reportLike !== "object") return null;
51
+ if (reportLike["csp-report"] && typeof reportLike["csp-report"] === "object") {
52
+ var legacy = reportLike["csp-report"];
53
+ return {
54
+ type: "csp-violation",
55
+ url: legacy["document-uri"] || null,
56
+ body: {
57
+ documentURL: legacy["document-uri"] || null,
58
+ blockedURL: legacy["blocked-uri"] || null,
59
+ effectiveDirective: legacy["effective-directive"] || legacy["violated-directive"] || null,
60
+ statusCode: legacy["status-code"] || null,
61
+ sample: _truncate(legacy["script-sample"] || ""),
62
+ sourceFile: legacy["source-file"] || null,
63
+ lineNumber: legacy["line-number"] || null,
64
+ },
65
+ };
66
+ }
67
+ if (reportLike.type && reportLike.body && typeof reportLike.body === "object") {
68
+ return {
69
+ type: reportLike.type,
70
+ url: reportLike.url || null,
71
+ body: {
72
+ documentURL: reportLike.body.documentURL || null,
73
+ blockedURL: reportLike.body.blockedURL || null,
74
+ effectiveDirective: reportLike.body.effectiveDirective || null,
75
+ statusCode: reportLike.body.statusCode || null,
76
+ sample: _truncate(reportLike.body.sample || ""),
77
+ sourceFile: reportLike.body.sourceFile || null,
78
+ lineNumber: reportLike.body.lineNumber || null,
79
+ },
80
+ };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function create(opts) {
86
+ opts = opts || {};
87
+ validateOpts(opts, ["audit", "onReport", "maxBytes"], "middleware.cspReport");
88
+ var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
89
+ ? opts.maxBytes : DEFAULT_MAX_BYTES;
90
+ var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
91
+
92
+ return async function cspReport(req, res, _next) {
93
+ if (req.method !== "POST") {
94
+ res.writeHead(405, { "Allow": "POST" }); // allow:raw-byte-literal — HTTP 405 status
95
+ res.end();
96
+ return;
97
+ }
98
+ var body;
99
+ try {
100
+ body = await safeBuffer.boundedChunkCollector(req, { maxBytes: maxBytes });
101
+ } catch (_e) {
102
+ res.writeHead(413); // allow:raw-byte-literal — HTTP 413 status
103
+ res.end();
104
+ return;
105
+ }
106
+ var parsed;
107
+ try { parsed = safeJson.parse(body.toString("utf8")); }
108
+ catch (_e) {
109
+ res.writeHead(400); // allow:raw-byte-literal — HTTP 400 status
110
+ res.end();
111
+ return;
112
+ }
113
+ var reports = Array.isArray(parsed) ? parsed : [parsed];
114
+ for (var i = 0; i < reports.length; i++) {
115
+ var normalized = _normalizeOne(reports[i]);
116
+ if (!normalized) continue;
117
+ try {
118
+ audit().safeEmit({
119
+ action: "csp.violation",
120
+ outcome: "failure",
121
+ metadata: Object.assign({ type: normalized.type, url: normalized.url }, normalized.body),
122
+ });
123
+ } catch (_e) { /* audit best-effort */ }
124
+ if (onReport) {
125
+ try { onReport(normalized); } catch (_e) { /* hook best-effort */ }
126
+ }
127
+ }
128
+ res.writeHead(204); // allow:raw-byte-literal — HTTP 204 status
129
+ res.end();
130
+ };
131
+ }
132
+
133
+ module.exports = { create: create };
@@ -32,6 +32,7 @@ var cookies = require("./cookies");
32
32
  var cors = require("./cors");
33
33
  var dailyByteQuota = require("./daily-byte-quota");
34
34
  var cspNonce = require("./csp-nonce");
35
+ var cspReport = require("./csp-report");
35
36
  var csrfProtect = require("./csrf-protect");
36
37
  var dbRoleFor = require("./db-role-for");
37
38
  var dpop = require("./dpop");
@@ -88,6 +89,7 @@ module.exports = {
88
89
  compression: compression.create,
89
90
  cookies: cookies.create,
90
91
  cspNonce: cspNonce.create,
92
+ cspReport: cspReport.create,
91
93
  securityTxt: securityTxt.create,
92
94
  sse: sse.create,
93
95
  requestLog: requestLog.create,
@@ -164,13 +164,20 @@ async function mtaStsFetch(domain, opts) {
164
164
  return await _getStsCache().wrap(cacheKey, async function () {
165
165
  var url = "https://mta-sts." + lcDomain + "/.well-known/mta-sts.txt";
166
166
  safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
167
+ // RFC 8461 §3.3 — the HTTPS cert MUST validate against
168
+ // mta-sts.<domain> with the standard public-CA chain. We pass
169
+ // checkServerIdentity:default + rejectUnauthorized:true (the
170
+ // framework default) and pin servername to the expected host
171
+ // so a permissive httpClient default can't be flipped on.
167
172
  var res;
168
173
  try {
169
174
  res = await httpClient().request({
170
- method: "GET",
171
- url: url,
172
- maxBytes: MAX_POLICY_BYTES,
173
- timeoutMs: C.TIME.seconds(10),
175
+ method: "GET",
176
+ url: url,
177
+ maxBytes: MAX_POLICY_BYTES,
178
+ timeoutMs: C.TIME.seconds(10),
179
+ servername: "mta-sts." + lcDomain,
180
+ rejectUnauthorized: true,
174
181
  });
175
182
  } catch (_e) {
176
183
  return null;
@@ -329,6 +329,67 @@ function captureBaselineFingerprints() {
329
329
  STATE.baselineFingerprints = STATE.cas.map(function (e) { return e.meta.fingerprint256; });
330
330
  }
331
331
 
332
+ // pinsetDriftMonitor — periodic check that emits audit + observability
333
+ // events when the trust-store fingerprint set drifts from the captured
334
+ // baseline. Different intent from expiryMonitor: this fires when a
335
+ // CA is added or removed (by operator config-flip OR by a tampered
336
+ // MANIFEST / vendor refresh), not when an existing one approaches
337
+ // validity expiry.
338
+ //
339
+ // b.network.tls.captureBaselineFingerprints(); // at boot
340
+ // var mon = b.network.tls.pinsetDriftMonitor({
341
+ // intervalMs: C.TIME.minutes(15),
342
+ // onDrift: function (drift) { /* operator hook */ },
343
+ // });
344
+ //
345
+ // Audit emissions:
346
+ // network.tls.pinset.drift_check — every check, ok / warn
347
+ // network.tls.pinset.drifted — when added.length || removed.length
348
+ function pinsetDriftMonitor(opts) {
349
+ opts = opts || {};
350
+ var intervalMs = opts.intervalMs;
351
+ var auditOn = opts.audit !== false;
352
+ if (typeof intervalMs !== "number" || !isFinite(intervalMs) || intervalMs <= 0) {
353
+ throw new TlsTrustError("tls/bad-interval",
354
+ "tls.pinsetDriftMonitor: intervalMs must be a positive finite number");
355
+ }
356
+ function _tick() {
357
+ var drift;
358
+ try { drift = detectBaselineDrift(); }
359
+ catch (_e) { return; }
360
+ if (drift === null) return; // baseline not captured; nothing to compare
361
+ if (auditOn) {
362
+ try {
363
+ audit().safeEmit({
364
+ action: "network.tls.pinset.drift_check",
365
+ outcome: drift.drifted ? "warn" : "ok",
366
+ metadata: { added: drift.added.length, removed: drift.removed.length },
367
+ });
368
+ } catch (_e) { /* drop-silent */ }
369
+ }
370
+ if (drift.drifted) {
371
+ try { observability().safeEvent("network.tls.pinset.drifted", 1, {}); }
372
+ catch (_e) { /* drop-silent */ }
373
+ if (auditOn) {
374
+ try {
375
+ audit().safeEmit({
376
+ action: "network.tls.pinset.drifted",
377
+ outcome: "failure",
378
+ metadata: { added: drift.added, removed: drift.removed },
379
+ });
380
+ } catch (_e) { /* drop-silent */ }
381
+ }
382
+ if (typeof opts.onDrift === "function") {
383
+ try { opts.onDrift(drift); } catch (_e) { /* operator hook */ }
384
+ }
385
+ }
386
+ }
387
+ var handle = safeAsync.repeating(_tick, intervalMs, { name: "tls-pinset-drift-monitor" });
388
+ return {
389
+ stop: function () { if (handle) { handle.stop(); handle = null; } },
390
+ };
391
+ }
392
+
332
393
  function detectBaselineDrift() {
333
394
  if (!STATE.baselineFingerprints) return null;
334
395
  var current = STATE.cas.map(function (e) { return e.meta.fingerprint256; });
@@ -1589,6 +1650,21 @@ function verifyScts(certDer, opts) {
1589
1650
  error: (e && e.message) || String(e) });
1590
1651
  continue;
1591
1652
  }
1653
+ // RFC 6962 §2.1.4 — log-key SignatureAndHashAlgorithm pair must
1654
+ // match the SCT's signatureAlgorithm. signatureAlgo enum 1=RSA,
1655
+ // 3=ECDSA. Cross-check against the actual log-key type so a
1656
+ // malformed log-keys map can't silently accept SCTs signed
1657
+ // under one algorithm against a key registered under another.
1658
+ var keyType = keyObj.asymmetricKeyType;
1659
+ var sctSigAlgo = sct.signatureAlgo;
1660
+ var algoOk = (sctSigAlgo === 1 && keyType === "rsa") || // allow:raw-byte-literal — TLS 1.2 SignatureAlgorithm rsa
1661
+ (sctSigAlgo === 3 && (keyType === "ec" || keyType === "ecdsa")); // allow:raw-byte-literal — TLS 1.2 SignatureAlgorithm ecdsa
1662
+ if (!algoOk) {
1663
+ perSctResults.push({ logIdHex: sct.logIdHex, verified: false,
1664
+ reason: "log-key-algo-mismatch",
1665
+ sctSignatureAlgo: sctSigAlgo, logKeyType: keyType });
1666
+ continue;
1667
+ }
1592
1668
  var verified;
1593
1669
  try { verified = nodeCrypto.verify(nodeAlgo, signedEntry, keyObj, sct.signature); }
1594
1670
  catch (e) {
@@ -1683,6 +1759,7 @@ module.exports = {
1683
1759
  purgeExpired: purgeExpired,
1684
1760
  expiringSoon: expiringSoon,
1685
1761
  expiryMonitor: expiryMonitor,
1762
+ pinsetDriftMonitor: pinsetDriftMonitor,
1686
1763
  useSystemTrust: useSystemTrust,
1687
1764
  isSystemTrustEnabled: isSystemTrustEnabled,
1688
1765
  getTrustStore: getTrustStore,
@@ -57,6 +57,7 @@
57
57
  */
58
58
 
59
59
  var { defineClass } = require("./framework-error");
60
+ var bCrypto = require("./crypto");
60
61
  var PqcError = defineClass("PqcError", { alwaysPermanent: true });
61
62
 
62
63
  var _vendoredOnce = null;
@@ -192,4 +193,45 @@ Object.defineProperty(pqc, "DEFAULT_HASH_SIG", {
192
193
  get: function () { return _accessor("slh_dsa_shake_256f"); },
193
194
  });
194
195
 
196
+ // runKnownAnswerTest — round-trip the vendored ML-KEM-1024 against
197
+ // itself with a self-generated keypair. This is NOT the FIPS 203
198
+ // Appendix A KAT vector (those are 800 KB of test data the framework
199
+ // chooses not to vendor); it's a self-consistency check that the
200
+ // vendored bundle's keygen / encapsulate / decapsulate survives a
201
+ // full cycle and produces a 32-byte shared secret. The fallback
202
+ // path becomes load-bearing if Node strips the WebCrypto ML-KEM
203
+ // extension; this gate fails fast at boot rather than mid-request.
204
+ //
205
+ // var result = b.pqcSoftware.runKnownAnswerTest();
206
+ // if (!result.ok) throw new Error("PQC KAT failed: " + result.reason);
207
+ function runKnownAnswerTest() {
208
+ if (!isAvailable()) {
209
+ return { ok: false, reason: "vendored @noble/post-quantum bundle not loadable" };
210
+ }
211
+ try {
212
+ var kem = _accessor("ml_kem1024");
213
+ var kp = kem.keygen();
214
+ var enc = kem.encapsulate(kp.publicKey);
215
+ var ssAlice = enc.sharedSecret;
216
+ var ssBob = kem.decapsulate(enc.cipherText, kp.secretKey);
217
+ if (!ssAlice || !ssBob) {
218
+ return { ok: false, reason: "keygen/encapsulate/decapsulate returned falsy" };
219
+ }
220
+ if (ssAlice.length !== 32 || ssBob.length !== 32) { // allow:raw-byte-literal — FIPS 203 §1 K_size = 32 bytes
221
+ return { ok: false, reason: "shared-secret length mismatch (expected 32 bytes)" };
222
+ }
223
+ // Constant-time compare via the framework wrapper. The KAT runs
224
+ // at boot only, but using the timing-safe path keeps the wider
225
+ // pattern-detector signal clean.
226
+ if (!bCrypto.timingSafeEqual(Buffer.from(ssAlice), Buffer.from(ssBob))) {
227
+ return { ok: false, reason: "shared-secret bytes diverge" };
228
+ }
229
+ return { ok: true, sharedSecretLength: ssAlice.length };
230
+ } catch (e) {
231
+ return { ok: false, reason: "exception: " + (e && e.message) };
232
+ }
233
+ }
234
+
235
+ pqc.runKnownAnswerTest = runKnownAnswerTest;
236
+
195
237
  module.exports = pqc;