@blamejs/core 0.9.49 → 0.10.2
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 +952 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +78 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/cli.js +13 -0
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-graphql.js +37 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-regex.js +138 -1
- package/lib/guard-smtp-command.js +58 -3
- package/lib/guard-xml.js +39 -1
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/otel-export.js +13 -4
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/crypto.js
CHANGED
|
@@ -49,6 +49,12 @@ var nodeFs = require("node:fs");
|
|
|
49
49
|
var { pipeline } = require("node:stream/promises");
|
|
50
50
|
var { xchacha20poly1305 } = require("./vendor/noble-ciphers.cjs");
|
|
51
51
|
var C = require("./constants");
|
|
52
|
+
// Circular: audit imports b.crypto for sha3Hash + envelope sign. Lazy-
|
|
53
|
+
// load the audit module so the legacy-envelope decrypt path can emit
|
|
54
|
+
// `system.crypto.decrypt.allow_legacy` events without an inline
|
|
55
|
+
// require() inside setImmediate (top-of-file requires per rule §3).
|
|
56
|
+
var lazyRequire = require("./lazy-require");
|
|
57
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
52
58
|
|
|
53
59
|
// Streaming-hash algorithm allowlist. Mirrors the framework's PQC-
|
|
54
60
|
// first crypto policy: SHA3 / SHAKE family is the default surface;
|
|
@@ -151,8 +157,50 @@ function hashFile(filePath, algorithm) {
|
|
|
151
157
|
// parallel. Returns { path, byteLength, <alg>: hex } for every
|
|
152
158
|
// `algorithms` entry. Used by hashFilesParallel below; not exported
|
|
153
159
|
// directly because the common case is the parallel-many shape.
|
|
154
|
-
|
|
160
|
+
//
|
|
161
|
+
// CRYPTO-5 hardening (v0.9.58):
|
|
162
|
+
// - lstat-then-stat so symlinks are detected before open; refused
|
|
163
|
+
// unless opts.followSymlinks === true (default false — a symlink-
|
|
164
|
+
// in-input-list attack lets a write-restricted caller hash files
|
|
165
|
+
// they can't otherwise reach).
|
|
166
|
+
// - Refuses non-regular files (FIFOs / sockets / block / char
|
|
167
|
+
// devices) which read indefinitely or return platform-undefined
|
|
168
|
+
// bytes. The same path also defeats /dev/zero-as-input DoS.
|
|
169
|
+
// - opts.maxBytesPerFile (default C.BYTES.gib(1)) caps the bytes
|
|
170
|
+
// read per file; oversized inputs reject before exhausting heap.
|
|
171
|
+
function _hashFileMulti(filePath, algorithms, opts) {
|
|
172
|
+
var maxBytes = opts && opts.maxBytesPerFile;
|
|
173
|
+
var followSymlink = opts && opts.followSymlinks === true;
|
|
155
174
|
return new Promise(function (resolve, reject) {
|
|
175
|
+
// Pre-open lstat: detect symlinks + special files before opening the
|
|
176
|
+
// stream. Open-then-stat is racy under symlink-swap; lstat the path
|
|
177
|
+
// itself ahead of createReadStream.
|
|
178
|
+
var st;
|
|
179
|
+
try { st = nodeFs.lstatSync(filePath); }
|
|
180
|
+
catch (statErr) {
|
|
181
|
+
reject(new Error("crypto.hashFilesParallel: stat failed for '" +
|
|
182
|
+
filePath + "': " + (statErr && statErr.message ? statErr.message : String(statErr))));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (st.isSymbolicLink() && !followSymlink) {
|
|
186
|
+
reject(new Error("crypto.hashFilesParallel: refusing symlink '" +
|
|
187
|
+
filePath + "' — pass {followSymlinks: true} to opt in (an attacker " +
|
|
188
|
+
"with write access to the input list can otherwise direct the hasher " +
|
|
189
|
+
"to files the caller cannot read directly)"));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!st.isFile() && !st.isSymbolicLink()) {
|
|
193
|
+
reject(new Error("crypto.hashFilesParallel: refusing non-regular file '" +
|
|
194
|
+
filePath + "' (FIFOs / sockets / character / block devices read indefinitely " +
|
|
195
|
+
"or return platform-undefined bytes; hashing them is meaningless and " +
|
|
196
|
+
"DoS-prone). Type: " +
|
|
197
|
+
(st.isFIFO() ? "FIFO" :
|
|
198
|
+
st.isSocket() ? "socket" :
|
|
199
|
+
st.isBlockDevice() ? "block-device" :
|
|
200
|
+
st.isCharacterDevice() ? "char-device" :
|
|
201
|
+
st.isDirectory() ? "directory" : "unknown")));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
156
204
|
var hashers = new Array(algorithms.length);
|
|
157
205
|
for (var i = 0; i < algorithms.length; i += 1) {
|
|
158
206
|
try { hashers[i] = nodeCrypto.createHash(algorithms[i]); }
|
|
@@ -163,13 +211,24 @@ function _hashFileMulti(filePath, algorithms) {
|
|
|
163
211
|
}
|
|
164
212
|
}
|
|
165
213
|
var byteLength = 0;
|
|
214
|
+
var aborted = false;
|
|
166
215
|
var stream = nodeFs.createReadStream(filePath);
|
|
167
216
|
stream.on("error", reject);
|
|
168
217
|
stream.on("data", function (chunk) {
|
|
218
|
+
if (aborted) return;
|
|
169
219
|
byteLength += chunk.length;
|
|
220
|
+
if (maxBytes && byteLength > maxBytes) {
|
|
221
|
+
aborted = true;
|
|
222
|
+
try { stream.destroy(); } catch (_e) { /* best-effort */ }
|
|
223
|
+
reject(new Error("crypto.hashFilesParallel: file '" + filePath +
|
|
224
|
+
"' exceeded opts.maxBytesPerFile (" + maxBytes +
|
|
225
|
+
" bytes); refusing to continue hashing"));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
170
228
|
for (var j = 0; j < hashers.length; j += 1) hashers[j].update(chunk);
|
|
171
229
|
});
|
|
172
230
|
stream.on("end", function () {
|
|
231
|
+
if (aborted) return;
|
|
173
232
|
var out = { path: filePath, byteLength: byteLength };
|
|
174
233
|
for (var k = 0; k < hashers.length; k += 1) {
|
|
175
234
|
// Field name = algorithm with `-` → `_` so "sha3-512" surfaces
|
|
@@ -205,9 +264,11 @@ function _hashFileMulti(filePath, algorithms) {
|
|
|
205
264
|
* every release.
|
|
206
265
|
*
|
|
207
266
|
* @opts
|
|
208
|
-
* algorithms?:
|
|
209
|
-
* concurrency?:
|
|
210
|
-
* onProgress?:
|
|
267
|
+
* algorithms?: string[], // default ["sha256", "sha3-512"]; any node:crypto-known digest
|
|
268
|
+
* concurrency?: number, // default min(8, filePaths.length); 1..256
|
|
269
|
+
* onProgress?: function (completed, total) // best-effort; thrown errors swallowed
|
|
270
|
+
* maxBytesPerFile?: number, // default C.BYTES.gib(1) — DoS cap; oversized inputs reject
|
|
271
|
+
* followSymlinks?: boolean, // default false — refuse symlinks unless explicitly opted in
|
|
211
272
|
*
|
|
212
273
|
* @example
|
|
213
274
|
* var rows = await b.crypto.hashFilesParallel(
|
|
@@ -262,8 +323,24 @@ function hashFilesParallel(filePaths, opts) {
|
|
|
262
323
|
"crypto.hashFilesParallel: opts.onProgress must be a function when supplied"
|
|
263
324
|
));
|
|
264
325
|
}
|
|
326
|
+
// CRYPTO-5 — DoS cap. Default 1 GiB per file; operators with larger
|
|
327
|
+
// legitimate hashing workloads (firmware images, vendor packs)
|
|
328
|
+
// override per-call.
|
|
329
|
+
var maxBytesPerFile = opts.maxBytesPerFile !== undefined
|
|
330
|
+
? opts.maxBytesPerFile : C.BYTES.gib(1);
|
|
331
|
+
if (typeof maxBytesPerFile !== "number" || !isFinite(maxBytesPerFile) ||
|
|
332
|
+
maxBytesPerFile <= 0 || Math.floor(maxBytesPerFile) !== maxBytesPerFile) {
|
|
333
|
+
return Promise.reject(new TypeError(
|
|
334
|
+
"crypto.hashFilesParallel: opts.maxBytesPerFile must be a positive integer, got " + maxBytesPerFile
|
|
335
|
+
));
|
|
336
|
+
}
|
|
337
|
+
var followSymlinks = opts.followSymlinks === true;
|
|
265
338
|
if (filePaths.length === 0) return Promise.resolve([]);
|
|
266
339
|
|
|
340
|
+
var hashOpts = {
|
|
341
|
+
maxBytesPerFile: maxBytesPerFile,
|
|
342
|
+
followSymlinks: followSymlinks,
|
|
343
|
+
};
|
|
267
344
|
var results = new Array(filePaths.length);
|
|
268
345
|
var nextIdx = 0;
|
|
269
346
|
var completed = 0;
|
|
@@ -273,7 +350,7 @@ function hashFilesParallel(filePaths, opts) {
|
|
|
273
350
|
var idx = nextIdx;
|
|
274
351
|
nextIdx += 1;
|
|
275
352
|
if (idx >= total) return Promise.resolve();
|
|
276
|
-
return _hashFileMulti(filePaths[idx], algorithms).then(function (rec) {
|
|
353
|
+
return _hashFileMulti(filePaths[idx], algorithms, hashOpts).then(function (rec) {
|
|
277
354
|
results[idx] = rec;
|
|
278
355
|
completed += 1;
|
|
279
356
|
if (onProgress) {
|
|
@@ -612,7 +689,7 @@ function toBase64Url(buf) {
|
|
|
612
689
|
|
|
613
690
|
/**
|
|
614
691
|
* @primitive b.crypto.fromBase64Url
|
|
615
|
-
* @signature b.crypto.fromBase64Url(s)
|
|
692
|
+
* @signature b.crypto.fromBase64Url(s, opts?)
|
|
616
693
|
* @since 0.9.45
|
|
617
694
|
* @status stable
|
|
618
695
|
* @related b.crypto.toBase64Url
|
|
@@ -623,15 +700,61 @@ function toBase64Url(buf) {
|
|
|
623
700
|
* string + provides a single grep-able call site for the round-trip
|
|
624
701
|
* pair.
|
|
625
702
|
*
|
|
703
|
+
* Strict mode (default) refuses non-canonical input — chars outside
|
|
704
|
+
* the RFC 4648 §5 alphabet, length-mod-4-of-1, mixed `+/` from
|
|
705
|
+
* standard base64, trailing garbage. Defends a CVE-2022-0235-class
|
|
706
|
+
* footgun where Node's permissive decoder silently tolerated
|
|
707
|
+
* tampered JWT signatures. Operators with a documented lossy legacy
|
|
708
|
+
* payload opt out per call via `{ strict: false }`.
|
|
709
|
+
*
|
|
710
|
+
* @opts
|
|
711
|
+
* strict: boolean // default: true — refuse non-canonical input
|
|
712
|
+
*
|
|
626
713
|
* @example
|
|
627
714
|
* var buf = b.crypto.fromBase64Url("aGVsbG8");
|
|
628
715
|
* buf.toString("utf8");
|
|
629
716
|
* // → "hello"
|
|
630
717
|
*/
|
|
631
|
-
|
|
718
|
+
// RFC 4648 §5 alphabet for base64url, with optional padding. The
|
|
719
|
+
// canonical form has no padding, the URL-safe alphabet (`-_`), and a
|
|
720
|
+
// length consistent with the byte count (length % 4 ∈ {0, 2, 3}; the
|
|
721
|
+
// `length % 4 === 1` shape is impossible to produce by any conforming
|
|
722
|
+
// encoder and signals truncated / forged input).
|
|
723
|
+
var _BASE64URL_STRICT_RE = /^[A-Za-z0-9_-]*={0,2}$/;
|
|
724
|
+
|
|
725
|
+
function fromBase64Url(s, opts) {
|
|
632
726
|
if (typeof s !== "string") {
|
|
633
727
|
throw new TypeError("crypto.fromBase64Url: input must be a string");
|
|
634
728
|
}
|
|
729
|
+
// Crypto callers (JWT signature payloads, JWS / COSE encoded values,
|
|
730
|
+
// OAuth `state` round-tripping) MUST reject non-canonical / malformed
|
|
731
|
+
// input. The Node base64url decoder silently tolerates trailing
|
|
732
|
+
// garbage, mixed `+/` from standard base64, missing padding errors,
|
|
733
|
+
// and length-mod-4 shapes — CVE-2022-0235-class footgun. Strict mode
|
|
734
|
+
// (the default) refuses anything outside the RFC 4648 §5 alphabet +
|
|
735
|
+
// length rules. Operators with a known-lossy legacy payload pass
|
|
736
|
+
// `{ strict: false }` to opt out per call.
|
|
737
|
+
var strict = !opts || opts.strict !== false;
|
|
738
|
+
if (strict) {
|
|
739
|
+
// Manual trailing-`=` strip — avoids the polynomial-regex shape
|
|
740
|
+
// `/=+$/` CodeQL flags, where `=+` can backtrack on long input
|
|
741
|
+
// ending in many `=`. Walking from end is O(n) worst-case.
|
|
742
|
+
var trimEnd = s.length;
|
|
743
|
+
while (trimEnd > 0 && s.charCodeAt(trimEnd - 1) === 0x3D) trimEnd -= 1; // allow:raw-byte-literal — '=' codepoint
|
|
744
|
+
var unpadded = s.slice(0, trimEnd);
|
|
745
|
+
if (!_BASE64URL_STRICT_RE.test(s)) {
|
|
746
|
+
throw new TypeError(
|
|
747
|
+
"crypto.fromBase64Url: input contains characters outside RFC 4648 §5 " +
|
|
748
|
+
"base64url alphabet (A-Z a-z 0-9 - _ =) — pass {strict:false} to allow non-canonical input"
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (unpadded.length % 4 === 1) { // allow:raw-byte-literal — base64 group length, not bytes
|
|
752
|
+
throw new TypeError(
|
|
753
|
+
"crypto.fromBase64Url: input length %% 4 === 1 is not a valid base64url encoding " +
|
|
754
|
+
"(every conforming encoder produces 0 / 2 / 3 remainder; got " + unpadded.length + " chars)"
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
635
758
|
return Buffer.from(s, "base64url");
|
|
636
759
|
}
|
|
637
760
|
|
|
@@ -867,8 +990,7 @@ function encrypt(plaintext, publicKeys) {
|
|
|
867
990
|
_hybridDisabledAuditEmitted = true;
|
|
868
991
|
setImmediate(function () {
|
|
869
992
|
try {
|
|
870
|
-
|
|
871
|
-
auditMod.safeEmit({
|
|
993
|
+
audit().safeEmit({
|
|
872
994
|
action: "system.crypto.hybrid_disabled",
|
|
873
995
|
outcome: "success",
|
|
874
996
|
metadata: { reason: "no-ec-public-key", note: "encrypt() received only mlkem; ecPublicKey absent — call encryptMlkemOnly explicitly to silence (audited once per process)" },
|
|
@@ -929,7 +1051,7 @@ function encryptMlkemOnly(plaintext, publicKeyPem) {
|
|
|
929
1051
|
// ---- Envelope decrypt (dispatches on envelope IDs, supports both KEM IDs) ----
|
|
930
1052
|
/**
|
|
931
1053
|
* @primitive b.crypto.decrypt
|
|
932
|
-
* @signature b.crypto.decrypt(ciphertext, privateKeys)
|
|
1054
|
+
* @signature b.crypto.decrypt(ciphertext, privateKeys, opts?)
|
|
933
1055
|
* @since 0.1.0
|
|
934
1056
|
* @related b.crypto.encrypt, b.crypto.generateEncryptionKeyPair, b.crypto.decryptMlkem768X25519
|
|
935
1057
|
*
|
|
@@ -942,6 +1064,28 @@ function encryptMlkemOnly(plaintext, publicKeyPem) {
|
|
|
942
1064
|
* Pass `{ privateKey, ecPrivateKey }` for the default hybrid; the
|
|
943
1065
|
* ML-KEM-768 + X25519 KEM ID also requires `x25519PrivateKey`.
|
|
944
1066
|
*
|
|
1067
|
+
* ## Legacy 0xE1 envelopes (`opts.allowLegacy: true`)
|
|
1068
|
+
*
|
|
1069
|
+
* The framework's envelope magic byte was bumped from 0xE1 to 0xE2
|
|
1070
|
+
* pre-v1 to enforce a NIST SP 800-56C r2 §4.1 FixedInfo / RFC 9180
|
|
1071
|
+
* §5.1 suite-binding KDF input — SHAKE256 absorbs the suite-id triple
|
|
1072
|
+
* (kemId / cipherId / kdfId) plus the literal "blamejs/v1" label
|
|
1073
|
+
* alongside the shared secret(s), so the same key cannot be reused
|
|
1074
|
+
* across suites without distinct derived material. 0xE1 envelopes
|
|
1075
|
+
* lack this binding.
|
|
1076
|
+
*
|
|
1077
|
+
* By default 0xE1 envelopes are refused with a hard error directing
|
|
1078
|
+
* the operator to re-seal under 0xE2. Operators with at-rest data
|
|
1079
|
+
* sealed pre-bump (rare; the bump landed before any operator started
|
|
1080
|
+
* depending on the framework) pass `opts.allowLegacy: true` to read
|
|
1081
|
+
* the old envelope, then immediately re-seal via `b.crypto.encrypt`
|
|
1082
|
+
* to migrate. Each legacy decrypt emits a `crypto.decrypt.allow_legacy`
|
|
1083
|
+
* audit event so the migration window is visible in the audit log.
|
|
1084
|
+
*
|
|
1085
|
+
* @opts
|
|
1086
|
+
* allowLegacy: boolean // default false — when true, 0xE1 envelopes
|
|
1087
|
+
* // decrypt via the pre-FixedInfo KDF path
|
|
1088
|
+
*
|
|
945
1089
|
* @example
|
|
946
1090
|
* var pair = b.crypto.generateEncryptionKeyPair();
|
|
947
1091
|
* var sealed = b.crypto.encrypt("session-token=abc123", {
|
|
@@ -953,12 +1097,48 @@ function encryptMlkemOnly(plaintext, publicKeyPem) {
|
|
|
953
1097
|
* ecPrivateKey: pair.ecPrivateKey,
|
|
954
1098
|
* });
|
|
955
1099
|
* // → "session-token=abc123"
|
|
1100
|
+
*
|
|
1101
|
+
* // Legacy 0xE1 migration:
|
|
1102
|
+
* var plaintext = b.crypto.decrypt(legacyBlob, oldKeys, { allowLegacy: true });
|
|
1103
|
+
* var resealed = b.crypto.encrypt(plaintext, newKeys); // now 0xE2
|
|
956
1104
|
*/
|
|
957
|
-
function decrypt(ciphertext, privateKeys) {
|
|
1105
|
+
function decrypt(ciphertext, privateKeys, opts) {
|
|
958
1106
|
var packed = Buffer.from(ciphertext, "base64");
|
|
959
1107
|
if (packed[0] === 0xE1) { // allow:raw-byte-literal — legacy envelope magic
|
|
960
|
-
|
|
961
|
-
|
|
1108
|
+
if (!opts || !opts.allowLegacy) {
|
|
1109
|
+
throw new Error("Invalid envelope: legacy 0xE1 format predates the FixedInfo " +
|
|
1110
|
+
"KDF binding (NIST SP 800-56C r2 §4.1) — re-seal data under the current envelope, " +
|
|
1111
|
+
"or pass { allowLegacy: true } to opt in to one-shot read for migration");
|
|
1112
|
+
}
|
|
1113
|
+
// Audit-emit every legacy decrypt so the migration window is
|
|
1114
|
+
// visible. Emit success ONLY on actual decrypt success; emit
|
|
1115
|
+
// failure on throw. Codex P2 PR #74 — pre-fix the audit fired
|
|
1116
|
+
// before decryptEnvelope() ran, so corrupted 0xE1 blobs / wrong
|
|
1117
|
+
// private keys / unsupported KEMs got logged as successful legacy
|
|
1118
|
+
// decrypts when the call actually threw, inflating real success
|
|
1119
|
+
// rates during migration windows. Audit module is top-of-file
|
|
1120
|
+
// lazy-loaded (var audit above) so this hot-path emit doesn't
|
|
1121
|
+
// re-resolve the require() cache on every legacy decrypt.
|
|
1122
|
+
function _emitLegacyAudit(outcome, extra) {
|
|
1123
|
+
setImmediate(function () {
|
|
1124
|
+
try {
|
|
1125
|
+
audit().safeEmit({
|
|
1126
|
+
action: "system.crypto.decrypt.allow_legacy",
|
|
1127
|
+
outcome: outcome,
|
|
1128
|
+
metadata: Object.assign({ magic: "0xE1", kemId: packed[1] }, extra || {}),
|
|
1129
|
+
});
|
|
1130
|
+
} catch (_e) { /* drop-silent — audit best-effort */ }
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
var plaintext;
|
|
1134
|
+
try {
|
|
1135
|
+
plaintext = decryptEnvelope(packed, privateKeys, { omitFixedInfo: true });
|
|
1136
|
+
} catch (e) {
|
|
1137
|
+
_emitLegacyAudit("failure", { reason: (e && e.message) || "decrypt threw" });
|
|
1138
|
+
throw e;
|
|
1139
|
+
}
|
|
1140
|
+
_emitLegacyAudit("success", {});
|
|
1141
|
+
return plaintext;
|
|
962
1142
|
}
|
|
963
1143
|
if (packed[0] !== C.ENVELOPE_MAGIC) {
|
|
964
1144
|
throw new Error("Invalid envelope: unsupported format");
|
|
@@ -966,9 +1146,15 @@ function decrypt(ciphertext, privateKeys) {
|
|
|
966
1146
|
return decryptEnvelope(packed, privateKeys);
|
|
967
1147
|
}
|
|
968
1148
|
|
|
969
|
-
function decryptEnvelope(packed, privateKeys) {
|
|
1149
|
+
function decryptEnvelope(packed, privateKeys, internalOpts) {
|
|
970
1150
|
var kemId = packed[1], cipherId = packed[2], kdfId = packed[3], pos = 4;
|
|
971
1151
|
|
|
1152
|
+
// The legacy 0xE1 envelope predates the FixedInfo / suite-binding
|
|
1153
|
+
// KDF input; the same wire format otherwise. The dispatcher passes
|
|
1154
|
+
// omitFixedInfo: true for the 0xE1 path and the KDF input below
|
|
1155
|
+
// skips the _suiteFixedInfo concat.
|
|
1156
|
+
var omitFixedInfo = !!(internalOpts && internalOpts.omitFixedInfo);
|
|
1157
|
+
|
|
972
1158
|
if (cipherId !== C.CIPHER_IDS.XCHACHA20_POLY1305) {
|
|
973
1159
|
throw new Error("Invalid envelope: unsupported cipher (only XChaCha20-Poly1305 supported)");
|
|
974
1160
|
}
|
|
@@ -984,6 +1170,7 @@ function decryptEnvelope(packed, privateKeys) {
|
|
|
984
1170
|
);
|
|
985
1171
|
var mlkemSs = nodeCrypto.decapsulate(mlkemPriv, kemCt);
|
|
986
1172
|
var symmetricKey;
|
|
1173
|
+
var fixedInfo = omitFixedInfo ? Buffer.alloc(0) : _suiteFixedInfo(kemId, cipherId, kdfId);
|
|
987
1174
|
|
|
988
1175
|
if (kemId === C.KEM_IDS.ML_KEM_1024_P384) {
|
|
989
1176
|
var ecEphLen = packed.readUInt16BE(pos); pos += 2;
|
|
@@ -994,11 +1181,9 @@ function decryptEnvelope(packed, privateKeys) {
|
|
|
994
1181
|
privateKey: nodeCrypto.createPrivateKey(ecPrivPem),
|
|
995
1182
|
publicKey: nodeCrypto.createPublicKey({ key: ecEphDer, type: "spki", format: "der" }),
|
|
996
1183
|
});
|
|
997
|
-
symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs,
|
|
998
|
-
_suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
|
|
1184
|
+
symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs, fixedInfo]), C.BYTES.bytes(32));
|
|
999
1185
|
} else if (kemId === C.KEM_IDS.ML_KEM_1024) {
|
|
1000
|
-
symmetricKey = kdf(Buffer.concat([mlkemSs,
|
|
1001
|
-
_suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
|
|
1186
|
+
symmetricKey = kdf(Buffer.concat([mlkemSs, fixedInfo]), C.BYTES.bytes(32));
|
|
1002
1187
|
} else if (kemId === C.KEM_IDS.ML_KEM_768_X25519) {
|
|
1003
1188
|
// ML-KEM-768 + X25519 hybrid envelope. The mlkemPriv must be an
|
|
1004
1189
|
// ML-KEM-768 key (not 1024); operators are responsible for passing
|
|
@@ -1013,8 +1198,7 @@ function decryptEnvelope(packed, privateKeys) {
|
|
|
1013
1198
|
privateKey: nodeCrypto.createPrivateKey(x25519PrivPem),
|
|
1014
1199
|
publicKey: nodeCrypto.createPublicKey({ key: x25519EphDer, type: "spki", format: "der" }),
|
|
1015
1200
|
});
|
|
1016
|
-
symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss,
|
|
1017
|
-
_suiteFixedInfo(kemId, cipherId, kdfId)]), C.BYTES.bytes(32));
|
|
1201
|
+
symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss, fixedInfo]), C.BYTES.bytes(32));
|
|
1018
1202
|
} else {
|
|
1019
1203
|
throw new Error("Invalid envelope: unsupported KEM ID " + kemId);
|
|
1020
1204
|
}
|
|
@@ -1571,6 +1755,18 @@ var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
|
|
|
1571
1755
|
{ id: "ml-kem-768-x25519", envelopeId: C.KEM_IDS.ML_KEM_768_X25519, description: "ML-KEM-768 + X25519 hybrid (IETF / Cloudflare / Chrome TLS 1.3 codepoint 0x11EC)" },
|
|
1572
1756
|
]);
|
|
1573
1757
|
|
|
1758
|
+
// Note: legacy 0xE1 envelope minting (used only by test round-trip
|
|
1759
|
+
// coverage) lives at `lib/_test/crypto-fixtures.js` —
|
|
1760
|
+
// `mintLegacyEnvelope0xE1`. The function is NOT auto-exported on
|
|
1761
|
+
// `b.crypto.*` because (a) operator code has no production use for
|
|
1762
|
+
// it (the framework's only contract is READING pre-bump 0xE1 data
|
|
1763
|
+
// via `decrypt(..., { allowLegacy: true })`), and (b) exposing a
|
|
1764
|
+
// mint helper widens the attack surface of the `allowLegacy: true`
|
|
1765
|
+
// decrypt path. Tests require it directly:
|
|
1766
|
+
//
|
|
1767
|
+
// var fixtures = require("blamejs/lib/_test/crypto-fixtures");
|
|
1768
|
+
// var blob = fixtures.mintLegacyEnvelope0xE1(plaintext, recipient);
|
|
1769
|
+
|
|
1574
1770
|
module.exports = {
|
|
1575
1771
|
sri: sri,
|
|
1576
1772
|
// Hashing
|
package/lib/db.js
CHANGED
|
@@ -662,6 +662,7 @@ function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
|
|
|
662
662
|
}
|
|
663
663
|
// First run — generate, seal, persist (atomic)
|
|
664
664
|
var raw = generateBytes(C.BYTES.bytes(32));
|
|
665
|
+
// allow:seal-without-aad — whole-file DB encryption key, not a row column
|
|
665
666
|
var sealedKey = vault.seal(raw.toString("base64"));
|
|
666
667
|
atomicFile.writeSync(keyPath, sealedKey, { fileMode: 0o600 });
|
|
667
668
|
log("generated DB encryption key at " + keyPath);
|
package/lib/guard-graphql.js
CHANGED
|
@@ -89,6 +89,15 @@ void observability;
|
|
|
89
89
|
|
|
90
90
|
var _err = GuardGraphqlError.factory;
|
|
91
91
|
|
|
92
|
+
// Query-body proto-poison literal (CVE-2026-32621). Matches the bare
|
|
93
|
+
// identifier in field / alias / variable-declaration positions —
|
|
94
|
+
// `$__proto__: String`, `__proto__: realField`, `__proto__ { ... }`,
|
|
95
|
+
// and the no-whitespace alias form `query { a:__proto__ }` /
|
|
96
|
+
// `query { a:constructor }` (GraphQL parsers accept the colon with
|
|
97
|
+
// or without trailing whitespace, so `:` is a valid identifier-
|
|
98
|
+
// position prefix that must also trigger refusal).
|
|
99
|
+
var PROTO_POISON_QUERY_RE = /[\s,({:]\$?(?:__proto__|constructor|prototype)\b/;
|
|
100
|
+
|
|
92
101
|
// ---- Profile presets ----
|
|
93
102
|
|
|
94
103
|
var PROFILES = Object.freeze({
|
|
@@ -319,6 +328,34 @@ function _detectIssues(req, opts) {
|
|
|
319
328
|
} catch (_e) { /* unstringifiable variables */ }
|
|
320
329
|
}
|
|
321
330
|
|
|
331
|
+
// Prototype-pollution defense (CVE-2026-32621). A `__proto__` /
|
|
332
|
+
// `constructor` / `prototype` variable key OR query-body identifier
|
|
333
|
+
// pivots a downstream deep-merge / deep-set into a poisoned shape.
|
|
334
|
+
// Refused at every profile.
|
|
335
|
+
var pVar = req.variables;
|
|
336
|
+
var pHas = Object.prototype.hasOwnProperty;
|
|
337
|
+
var pName = (pVar && typeof pVar === "object" && !Array.isArray(pVar) &&
|
|
338
|
+
(pHas.call(pVar, "__proto__") ? "__proto__" :
|
|
339
|
+
pHas.call(pVar, "constructor") ? "constructor" :
|
|
340
|
+
pHas.call(pVar, "prototype") ? "prototype" : null));
|
|
341
|
+
if (pName) {
|
|
342
|
+
issues.push({
|
|
343
|
+
kind: "variable-prototype-poison", severity: "critical",
|
|
344
|
+
ruleId: "graphql.variable-prototype-poison",
|
|
345
|
+
snippet: "variable name `" + pName + "` — prototype-pollution " +
|
|
346
|
+
"gadget (CVE-2026-32621)",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (PROTO_POISON_QUERY_RE.test(req.query)) { // allow:regex-no-length-cap — input bounded by maxQueryBytes above
|
|
350
|
+
issues.push({
|
|
351
|
+
kind: "query-prototype-poison", severity: "critical",
|
|
352
|
+
ruleId: "graphql.query-prototype-poison",
|
|
353
|
+
snippet: "query references `__proto__` / `constructor` / " +
|
|
354
|
+
"`prototype` as a field / alias / variable — prototype-" +
|
|
355
|
+
"pollution gadget (CVE-2026-32621)",
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
322
359
|
// Introspection.
|
|
323
360
|
if (opts.introspectionPolicy !== "allow") {
|
|
324
361
|
if (req.query.indexOf("__schema") !== -1 ||
|