@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/CHANGELOG.md +2 -0
- package/index.js +4 -0
- package/lib/audit-tools.js +69 -0
- package/lib/audit.js +3 -0
- package/lib/auth/password.js +51 -7
- package/lib/backup/index.js +63 -0
- package/lib/canonical-json.js +35 -7
- package/lib/config.js +118 -7
- package/lib/constants.js +10 -1
- package/lib/crypto.js +51 -14
- package/lib/honeytoken.js +132 -0
- package/lib/mail-auth.js +16 -4
- package/lib/middleware/csp-report.js +133 -0
- package/lib/middleware/index.js +2 -0
- package/lib/network-smtp-policy.js +11 -4
- package/lib/network-tls.js +77 -0
- package/lib/pqc-software.js +42 -0
- package/lib/resource-access-lock.js +116 -0
- package/lib/vault/index.js +23 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
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 };
|
package/lib/middleware/index.js
CHANGED
|
@@ -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:
|
|
171
|
-
url:
|
|
172
|
-
maxBytes:
|
|
173
|
-
timeoutMs:
|
|
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;
|
package/lib/network-tls.js
CHANGED
|
@@ -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,
|
package/lib/pqc-software.js
CHANGED
|
@@ -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;
|