@blamejs/core 0.16.32 → 0.16.33

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.33 (2026-07-16) — **Agent-snapshot restore now authenticates a sealed snapshot's tenant and capture-time against its signature, closing a cross-tenant restore path, alongside fail-closed and never-throw hardening across the DKIM/ARC, crypto-envelope, and image/PDF verifiers.** A sealed agent snapshot's authenticated envelope binds its table, snapshot id, and schema version, but the decorative wrapper fields a hostile or compromised storage backend can rewrite -- the tenant id that loadLatest filters on and the capture time it sorts on -- were trusted without being checked against the signed body. b.agent.snapshot restore now cross-checks both wrapper fields against the signature-covered values and refuses a mismatch, so a relabelled row can no longer surface one tenant's authentic snapshot to another (cross-tenant restore) or misrepresent when the restored state was captured. The release also hardens several verifiers that a valid-but-unusual or hostile input could push off their documented contract: the DKIM, inbound-authentication, and ARC verifiers now accept a bare-LF (Unix line-ending) message and return an authentication verdict instead of throwing; the crypto envelope and packed-secret decoders reject a truncated ciphertext with their typed error instead of leaking a raw cipher exception; the image guard routes a byte-order-mark-prefixed SVG to refusal at every profile instead of serving it as unknown content; and the image and PDF guards no longer throw on a hostile metadata bag whose bytes field is an array-like object, honoring their never-throw inspection contract. **Fixed:** *DKIM, inbound-authentication, and ARC verifiers accept bare-LF messages instead of throwing* — b.mail.dkim.verify, b.mail.inbound.verify, and b.mail.arc.verify canonicalize over CRLF and split the header block on a CRLF-CRLF separator. A message read from a Unix file or mbox, or passed through operator tooling that stripped carriage returns, arrives with bare-LF line endings and previously raised an uncaught error out of the verifier rather than returning an authentication verdict. The header/body split now normalizes bare-LF to canonical CRLF before locating the separator (a no-op on a proper CRLF message), so a bare-LF message produces a verdict -- and a message signed on the CRLF wire but transported bare-LF now verifies correctly rather than failing. inbound.verify and arc.verify additionally treat a message with no separator at all as headers-only, returning a verdict in keeping with their always-return-a-verdict contract. · *Crypto envelope and packed-secret decoders reject truncated ciphertext with a typed error* — b.crypto.decryptEnvelope and b.crypto.decryptPacked verify that each declared component of an untrusted ciphertext -- the length-prefixed KEM ciphertext and hybrid ephemeral public key, and the trailing nonce and authentication tag -- fits within the envelope before handing it to the cipher or the key-agreement step. A ciphertext truncated inside any of those components previously reached Node crypto as an under-length value and surfaced as a raw exception (a cipher RangeError on the nonce, or a Failed to perform decapsulation / key-parse error on the KEM ciphertext or ephemeral key), escaping the documented Invalid envelope error contract and leaking implementation detail. Both decoders now reject a truncated input with their typed Invalid envelope / Invalid packed format error, while a truncation inside the ciphertext body still surfaces as the genuine authentication-tag failure; a well-formed input is never affected. · *Image guard routes a BOM-prefixed SVG to refusal at every profile* — b.guardImage detects SVG by its leading markup so it can route it to the SVG guard or refuse it. A UTF-8 byte-order-mark before the markup previously defeated the offset-anchored signature scan, so a BOM-prefixed SVG fell through as unknown content -- served rather than refused under the balanced and permissive profiles. The magic-byte scanner now skips a leading BOM when matching the SVG and XML signatures, so a BOM-prefixed SVG is detected and refused at every profile. The BOM skip applies only to those text-family signatures: a binary raster's magic must sit at its real offset, so a BOM-prefixed PNG or JPEG is still refused as unknown content rather than accepted as a valid raster. · *Image and PDF guards no longer throw on a hostile array-like metadata bag* — b.guardImage.validate and b.guardPdf.validate document pure inspection that never throws on hostile metadata. Their byte-size cap measured any value carrying a numeric length, but the measurement primitive accepts only strings, Buffers, and Uint8Arrays and threw on a plain Array or array-like object -- crashing a direct validate or sanitize caller (the gate path already fails closed). The cap now measures only those byte-carrying types and passes an unmeasurable array-like through to magic detection, which reads only the leading bytes and refuses unrecognized content, so validate returns a refusal instead of throwing. **Security:** *Agent-snapshot restore binds the requested tenant and capture-time to the sealed snapshot's signature* — b.agent.snapshot seals each snapshot under an authenticated envelope whose AAD binds the table, snapshot id, and schema version. The metadata a backend stores alongside the sealed blob -- the tenant id that loadLatest({ tenantId }) filters on and the takenAt it sorts on to pick the latest -- is not covered by that AAD, and a hostile or compromised backend can return independently tampered list() and get() results. A backend could therefore relabel tenant A's list() entry as tenant B (leaving A's get() row honest) so that loadLatest({ tenantId: 'tenant-b' }) selected and returned tenant A's authentic snapshot -- a cross-tenant restore of in-flight sagas, streams, and idempotency state -- or inflate a row's list() age to serve an older snapshot as the latest. loadLatest now binds the requested selection criteria to the loaded snapshot's signature-covered values: the authenticated tenant id must equal the requested tenant id, and the list() sort key that selected a row must equal that row's authenticated capture time; a divergence is refused (agent-snapshot/tenant-id-mismatch, agent-snapshot/taken-at-mismatch), and the load path additionally cross-checks the get() wrapper against the signed body. The fix is at load time and does not change the seal format, so previously persisted snapshots remain restorable. A hostile backend can still withhold snapshots it never reveals, but every snapshot returned is authentic and bound to the requested tenant. **Detectors:** *A byte-size cap must vet its input type before measuring it* — A codebase-patterns gate refuses a guard that measures a metadata bag's byte length gated only on a numeric length property -- the shape that let an array-like bytes field crash the image and PDF guards. A byte-size cap over untrusted metadata must confirm the value is a string, Buffer, or Uint8Array before measuring it, so a future guard cannot reintroduce the never-throw-contract violation.
12
+
11
13
  - v0.16.32 (2026-07-15) — **The vendored Public Suffix List is refreshed to the current upstream snapshot, and vendor-update.sh gains the --refresh-data mode its file headers and verifier messages have always pointed at.** The vendored Mozilla Public Suffix List is updated to the latest upstream build, so organizational-domain derivation for DMARC alignment, BIMI issuer scoping, cookie-scope confinement, and same-site policy reflects the current registry delegations; the data module is regenerated and re-signed (SHA-256 + SHA3-512 + SLH-DSA), the manifest hashes refreshed, and the NOTICE attribution date updated. The refresh itself now runs through scripts/vendor-update.sh --refresh-data — the maintenance command that every vendored data file's header and lib/vendor-data.js's tamper-error messages reference — which fetches the upstream where one exists, sanity-checks the body before anything reaches the signer, re-appends the in-payload integrity canary, regenerates and re-signs the .data.js carrier, updates the manifest and NOTICE dates, and verifies all four integrity layers. A codebase-patterns gate now refuses any script flag referenced from error messages, file headers, or operator docs that the target script does not implement. **Added:** *scripts/vendor-update.sh --refresh-data — fetch, canary, re-sign, and verify the vendored data files in one command* — The mode that vendored data-file headers and lib/vendor-data.js tamper-error messages direct operators to now exists. `vendor-update.sh --refresh-data [entry]` refreshes the Public Suffix List and the SecLists common-password corpus from their upstreams (refusing a truncated or error body before it can reach the signer), re-appends each file's in-payload integrity canary, regenerates and re-signs the .data.js carrier with the operator-local SLH-DSA key whenever the raw file changed OR the carrier fails four-layer verification (so a corrupted signature block or a stripped provenance header is repairable through the documented path), updates the MANIFEST bundledAt and NOTICE attribution dates, refreshes the manifest hashes, and finishes by running the four-layer verifier (SHA-256 + SHA3-512 + SLH-DSA signature + canary) over every vendored data file. The operator-managed BIMI trust-anchor bundle is never fetched — it is re-signed only when the local .pem was edited per its file-header procedure. A Public Suffix List fetch whose VERSION timestamp is older than the vendored one is refused rather than signed (the list is CDN-served, and a lagging edge can return an older snapshot than the one already vendored). Entries whose upstream is unchanged are left byte-identical, so a no-op refresh produces a clean working tree; in-flight downloads live in gitignored *.refresh-tmp files that are removed on exit. **Changed:** *Vendored Public Suffix List refreshed to the current upstream snapshot* — The vendored Mozilla Public Suffix List is updated to the latest upstream build (2026-07-15), so organizational-domain derivation for DMARC alignment, BIMI issuer scoping, cookie-scope confinement, and same-site policy reflects the current registry delegations. The data module is regenerated and re-signed (SHA-256 + SHA3-512 + SLH-DSA), the manifest hashes refreshed, and the NOTICE attribution date updated. **Detectors:** *Script flags referenced from error messages, file headers, and operator docs must exist in the target script* — A codebase-patterns gate resolves every `<script>.sh --flag` / `<script>.js --flag` reference in lib/, scripts/, and the operator docs against the scripts/ directory and refuses any flag with no whole-token occurrence outside comment lines in the target script — a usage-header comment alone does not count as an implementation. A maintenance command recommended by a verification-failure message or a stale-data gate must work when the operator reaches for it.
12
14
 
13
15
  - v0.16.31 (2026-07-13) — **Vendored library bundles ship as unminified, reviewable source at the same pinned versions, removing the last dynamic-code-execution shims from the package.** The five vendored library bundles -- @noble/ciphers 2.2.0, @noble/curves 2.2.0, @noble/post-quantum 0.6.1, @simplewebauthn/server 13.3.2, and the @peculiar/x509 2.0.0 + pkijs 3.4.0 PKI meta-bundle -- now ship as unminified esbuild output of the same pinned upstream versions, so an operator can open any lib/vendor/*.cjs and read it, or diff it against upstream at the MANIFEST-pinned version, instead of auditing multi-hundred-KB minified lines. Their reflect-metadata dependency now resolves to upstream's lite build, which drops the legacy global-object probes (Function("return this") and indirect eval) that could never execute on supported Node versions -- the published package now contains no eval or Function-constructor construct outside the documented worker-thread sandbox compiler. The vendor pipeline records the exact build invocation in MANIFEST.json, can rebuild the PKI meta-bundle at pinned component versions, and a codebase-patterns gate refuses any future refresh that reintroduces minified or dynamic-execution output under lib/vendor/. The repository supply-chain policy adds audited dispositions for shell, filesystem, and debug access, re-enables the eval alert class now that the package contains none, and narrows the minified-code disposition to the signed data payload carriers -- the code bundles themselves are enforced unminified by an in-repo gate. **Changed:** *Vendored bundles are unminified, reviewable esbuild output at the same pinned upstream versions* — lib/vendor/noble-ciphers.cjs, noble-curves.cjs, noble-post-quantum.cjs, simplewebauthn-server.cjs, and pki.cjs are rebuilt without minification from the same pinned upstream versions recorded in lib/vendor/MANIFEST.json (@noble/ciphers 2.2.0, @noble/curves 2.2.0, @noble/post-quantum 0.6.1, @simplewebauthn/server 13.3.2, @peculiar/x509 2.0.0 + pkijs 3.4.0). Exports, versions, and runtime behavior are unchanged; upstream license headers and esbuild's bundled-license footers are preserved. The five bundles grow from roughly 1.2 MB to 2.7 MB on disk (the npm tarball transfers compressed), in exchange for source an operator or scanner can actually review and diff against upstream. · *reflect-metadata inside the WebAuthn and PKI bundles resolves to upstream's lite build, removing Function("return this") and indirect-eval global probes* — The @simplewebauthn/server and PKI meta-bundles pull in reflect-metadata (decorator metadata for their ASN.1 schema serializers). The bundles now resolve it to the package's own ./lite entry at the same pinned version: an identical metadata API with the same cross-copy registry protocol, built for runtimes with native globalThis -- it contains none of the legacy global-object probes (Function("return this") / indirect eval) that were unreachable dead code on the framework's Node floor anyway. With this change the published package contains no eval, indirect eval, or Function-constructor construct outside b.sandbox's worker-thread compiler, which is the documented isolation boundary for operator-submitted code. · *Vendor pipeline: pinned PKI meta-bundle rebuilds, and the manifest bundler field derives from the actual build invocation* — scripts/vendor-update.sh now accepts the PKI meta-bundle's manifest version form directly (for example: vendor-update.sh peculiar-pki "2.0.0+pkijs-3.4.0") and installs exactly those @peculiar/x509 and pkijs component versions, where previously it could only bundle latest. The bundler field in lib/vendor/MANIFEST.json is now written by the script from the esbuild invocation that actually produced the artifact, so the recorded build command can no longer drift from the real one. · *Package-scanner policy adds shell, filesystem, and debug access dispositions and re-enables the eval alert class* — socket.yml documents why shell access (all process execution goes through b.processSpawn -- fixed binary, argument array, never shell:true), filesystem access, and debug access (no debugger statements or node:inspector in first-party code; Reflect metadata in the vendored ASN.1 serializers is reflection, not code execution) are inherent to a server framework. The eval alert class is deliberately no longer suppressed: the package contains no eval or Function-constructor construct outside the documented sandbox worker, so a future occurrence is a genuine regression signal rather than recurring noise. The minified-code class stays dispositioned, narrowed to the signed data payload carriers (lib/vendor/*.data.js): minified-code detectors also fire on high-density embedded-asset files, which those carriers are by design -- a 76-char-wrapped base64 payload plus a multi-KB signature line, verified at every load. The unminified property of the code bundles is enforced by the repository's own gate, not by the scanner class. **Detectors:** *Vendored bundles must stay unminified and free of dynamic-code-execution constructs* — A codebase-patterns gate walks every JS artifact under lib/vendor/ and refuses minified bundle output (whole-file average line length, with wide margins around the measured unminified and minified populations) and any eval, indirect-eval, Function-constructor, createRequire, or process.binding token. A vendor refresh that reintroduces --minify or an upstream global-object eval probe fails the gate before it can ship. · *Scanner policy keeps the minified-code class dispositioned while signed data carriers ship* — A companion gate refuses removing socket.yml's minifiedFile disposition while lib/vendor/*.data.js payload carriers ship: minified-code detectors match the carriers' high-density base64-plus-signature shape, and an omitted issue rule falls back to dashboard defaults, so dropping the entry would re-report the known-benign artifacts on every routine scan.
@@ -457,9 +457,12 @@ async function _persist(ctx, snap) {
457
457
  snap.sigPubKey = (typeof signer.getPublicKey === "function" && signer.getPublicKey()) || null;
458
458
 
459
459
  // Seal the entire envelope under AAD that pins
460
- // snapshotId + schemaVersion + tenantId. AAD mismatch on unseal (a
460
+ // table + snapshotId + schemaVersion. AAD mismatch on unseal (a
461
461
  // copy-paste attack from one snapshotId's row into another) fails
462
- // the Poly1305 tag check; tampered bytes also fail. The sealed
462
+ // the Poly1305 tag check; tampered bytes also fail. The tenantId is
463
+ // authenticated by the signature (it is a signed field) and the
464
+ // wrapper's decorative tenantId is cross-checked against the signed
465
+ // body at load, so it is not additionally bound in the AAD. The sealed
463
466
  // string is what reaches durable storage.
464
467
  var sealer = _resolveSealer(ctx);
465
468
  var serialized = safeJson.stringify(snap);
@@ -475,7 +478,9 @@ async function _persist(ctx, snap) {
475
478
  // implementation needs to filter by tenantId / takenAt without
476
479
  // having to unseal every row. Sealed-blob carries the full
477
480
  // envelope; the metadata is decorative + may be tamper-fuzzed by
478
- // a hostile backend (the AEAD tag still binds via AAD on unseal).
481
+ // a hostile backend. snapshotId is bound by the AAD (unseal fails on
482
+ // a swap) and tenantId is cross-checked against the signed body at
483
+ // load, so a relabelled wrapper is refused rather than trusted.
479
484
  stored = {
480
485
  snapshotId: snap.snapshotId,
481
486
  takenAt: snap.takenAt,
@@ -547,6 +552,31 @@ async function _unwrapAndVerify(ctx, raw, expectedId) {
547
552
  throw new AgentSnapshotError("agent-snapshot/snapshot-id-mismatch",
548
553
  "load: wrapper snapshotId='" + expectedId + "' does not match envelope='" + snap.snapshotId + "'");
549
554
  }
555
+ // The wrapper's decorative tenantId is what the backend's list() /
556
+ // loadLatest tenant filter selects on, but it is NOT bound by the seal's
557
+ // AAD (which pins table + snapshotId + schemaVersion). A hostile backend
558
+ // can therefore relabel a sealed row's wrapper tenantId while the
559
+ // Poly1305 tag still verifies — so loadLatest({ tenantId }) would return
560
+ // a DIFFERENT tenant's authentic snapshot (cross-tenant restore).
561
+ // Cross-check the untrusted wrapper tenantId against the signed body's
562
+ // tenantId and refuse the mismatch, mirroring the snapshotId gate above;
563
+ // the inner value is the authentic one (covered by the signature).
564
+ if ((raw.tenantId || null) !== (snap.tenantId || null)) {
565
+ throw new AgentSnapshotError("agent-snapshot/tenant-id-mismatch",
566
+ "load: wrapper tenantId='" + (raw.tenantId || null) +
567
+ "' does not match envelope tenantId='" + (snap.tenantId || null) + "'");
568
+ }
569
+ // Same class as the tenantId/snapshotId gates: the wrapper takenAt is the
570
+ // field loadLatest sorts on to pick "latest", but it is not AAD-bound, so a
571
+ // hostile backend could relabel a returned row's age (misrepresenting when
572
+ // the restored state was captured — an audit-integrity / rollback signal).
573
+ // takenAt is a signed body field, so cross-check the untrusted wrapper value
574
+ // against it and refuse a divergence; the inner value is the authentic one.
575
+ if ((raw.takenAt || null) !== (snap.takenAt || null)) {
576
+ throw new AgentSnapshotError("agent-snapshot/taken-at-mismatch",
577
+ "load: wrapper takenAt='" + (raw.takenAt || null) +
578
+ "' does not match envelope takenAt='" + (snap.takenAt || null) + "'");
579
+ }
550
580
  // Verify the signature before returning the envelope
551
581
  // to the caller. Restore-side trust derives from this gate. The
552
582
  // allowPlaintext escape hatch (operator-acknowledged dev mode)
@@ -595,9 +625,36 @@ async function _loadLatest(ctx, loadOpts) {
595
625
  });
596
626
  if (filtered.length === 0) return null;
597
627
  filtered.sort(function (a, b) { return (b.takenAt || 0) - (a.takenAt || 0); });
598
- var latestId = filtered[0].snapshotId;
599
- var raw = await ctx.backend.get(latestId);
600
- return await _unwrapAndVerify(ctx, raw, latestId);
628
+ var selected = filtered[0];
629
+ var raw = await ctx.backend.get(selected.snapshotId);
630
+ var snap = await _unwrapAndVerify(ctx, raw, selected.snapshotId);
631
+ // list() and get() are independently-tamperable untrusted backend surfaces.
632
+ // _unwrapAndVerify only proves the get() wrapper agrees with the signed body;
633
+ // it does NOT prove the list() metadata used to FILTER and SELECT this row is
634
+ // honest. A backend that relabels tenant A's list() entry as tenant B while
635
+ // leaving A's get() row untouched would pass that wrapper/body check yet
636
+ // still surface A's authentic snapshot for a tenant-B query. So bind the
637
+ // REQUESTED selection criteria to the authenticated body: the loaded signed
638
+ // tenantId must equal the requested tenantId.
639
+ if (loadOpts.tenantId && (snap.tenantId || null) !== loadOpts.tenantId) {
640
+ throw new AgentSnapshotError("agent-snapshot/tenant-id-mismatch",
641
+ "loadLatest: requested tenantId='" + loadOpts.tenantId +
642
+ "' does not match the loaded snapshot's authenticated tenantId='" +
643
+ (snap.tenantId || null) + "'");
644
+ }
645
+ // The list() sort key that selected this row must match its authenticated
646
+ // takenAt, so a backend cannot inflate a row's list() age to win the "latest"
647
+ // sort while get() returns the honest (older) body. A backend can still
648
+ // withhold or reorder rows it never reveals -- an inherent freshness limit of
649
+ // an untrusted store -- but every RETURNED snapshot is authentic, tenant-
650
+ // bound, and carries its own authenticated capture time.
651
+ if ((selected.takenAt || null) !== (snap.takenAt || null)) {
652
+ throw new AgentSnapshotError("agent-snapshot/taken-at-mismatch",
653
+ "loadLatest: list() takenAt='" + (selected.takenAt || null) +
654
+ "' for the selected snapshot does not match its authenticated takenAt='" +
655
+ (snap.takenAt || null) + "'");
656
+ }
657
+ return snap;
601
658
  }
602
659
 
603
660
  async function _loadById(ctx, snapshotId) {
package/lib/crypto.js CHANGED
@@ -1369,6 +1369,24 @@ function _envU16(buf, at) {
1369
1369
  return buf.readUInt16BE(at);
1370
1370
  }
1371
1371
 
1372
+ // Read a 2-byte-length-prefixed component and bounds-check that the declared
1373
+ // body actually fits in the envelope BEFORE slicing it. Without this a
1374
+ // truncated envelope hands an under-length component (a short KEM ciphertext /
1375
+ // ephemeral public key) to Node crypto, which throws a raw
1376
+ // "Failed to perform decapsulation" / key-parse error that escapes the
1377
+ // documented "Invalid envelope: ..." contract. Returns { bytes, pos } where
1378
+ // pos is the offset past the component. A well-formed envelope always carries
1379
+ // each declared component in full, so this never rejects a valid input.
1380
+ function _envSlice(buf, at, label) {
1381
+ var len = _envU16(buf, at);
1382
+ at += 2;
1383
+ if (at + len > buf.length) {
1384
+ throw new Error("Invalid envelope: truncated (declared " + len + "-byte " +
1385
+ label + " at offset " + at + " exceeds the " + buf.length + "-byte envelope)");
1386
+ }
1387
+ return { bytes: buf.subarray(at, at + len), pos: at + len };
1388
+ }
1389
+
1372
1390
  function decryptEnvelope(packed, privateKeys, internalOpts) {
1373
1391
  if (!Buffer.isBuffer(packed) || packed.length < 4) {
1374
1392
  throw new Error("Invalid envelope: too short (need at least the 4-byte suite header)");
@@ -1388,8 +1406,8 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1388
1406
  throw new Error("Invalid envelope: unsupported KDF (only SHAKE256 supported)");
1389
1407
  }
1390
1408
 
1391
- var kemCtLen = _envU16(packed, pos); pos += 2;
1392
- var kemCt = packed.subarray(pos, pos + kemCtLen); pos += kemCtLen;
1409
+ var kemSlice = _envSlice(packed, pos, "KEM ciphertext");
1410
+ var kemCt = kemSlice.bytes; pos = kemSlice.pos;
1393
1411
 
1394
1412
  var mlkemPriv = nodeCrypto.createPrivateKey(
1395
1413
  typeof privateKeys === "string" ? privateKeys : privateKeys.privateKey
@@ -1399,8 +1417,8 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1399
1417
  var fixedInfo = omitFixedInfo ? Buffer.alloc(0) : _suiteFixedInfo(kemId, cipherId, kdfId);
1400
1418
 
1401
1419
  if (kemId === C.KEM_IDS.ML_KEM_1024_P384) {
1402
- var ecEphLen = _envU16(packed, pos); pos += 2;
1403
- var ecEphDer = packed.subarray(pos, pos + ecEphLen); pos += ecEphLen;
1420
+ var ecEphSlice = _envSlice(packed, pos, "EC ephemeral public key");
1421
+ var ecEphDer = ecEphSlice.bytes; pos = ecEphSlice.pos;
1404
1422
  var ecPrivPem = typeof privateKeys === "string" ? null : privateKeys.ecPrivateKey;
1405
1423
  if (!ecPrivPem) throw new Error("Hybrid KEM requires EC private key");
1406
1424
  var ecSs = nodeCrypto.diffieHellman({
@@ -1416,8 +1434,8 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1416
1434
  // the correct keypair via privateKeys when the envelope was sealed
1417
1435
  // with this algorithm. Same length-prefixed shape as the P-384
1418
1436
  // hybrid: 2-byte ec-eph-len + DER X25519 pubkey + nonce + ct.
1419
- var x25519EphLen = _envU16(packed, pos); pos += 2;
1420
- var x25519EphDer = packed.subarray(pos, pos + x25519EphLen); pos += x25519EphLen;
1437
+ var x25519EphSlice = _envSlice(packed, pos, "X25519 ephemeral public key");
1438
+ var x25519EphDer = x25519EphSlice.bytes; pos = x25519EphSlice.pos;
1421
1439
  var x25519PrivPem = typeof privateKeys === "string" ? null : privateKeys.x25519PrivateKey;
1422
1440
  if (!x25519PrivPem) throw new Error("ML-KEM-768 + X25519 hybrid envelope requires x25519PrivateKey");
1423
1441
  var x25519Ss = nodeCrypto.diffieHellman({
@@ -1429,7 +1447,22 @@ function decryptEnvelope(packed, privateKeys, internalOpts) {
1429
1447
  throw new Error("Invalid envelope: unsupported KEM ID " + kemId);
1430
1448
  }
1431
1449
 
1432
- var nonce = packed.subarray(pos, pos + C.BYTES.bytes(24)); pos += C.BYTES.bytes(24);
1450
+ // Bounds-check the trailing nonce + AEAD tag before slicing them out. A
1451
+ // truncated envelope (untrusted ciphertext) that ends inside the 24-byte
1452
+ // nonce or before the 16-byte Poly1305 tag otherwise reaches the cipher as
1453
+ // an under-length nonce / ciphertext and surfaces as a raw noble
1454
+ // RangeError ("nonce"/"ciphertext" length) — escaping the documented
1455
+ // "Invalid envelope: ..." error contract exactly the way an unchecked
1456
+ // _envU16 read would (see _envU16 above). Fail with the typed envelope
1457
+ // error instead. A valid envelope always carries a full nonce and at
1458
+ // least the tag, so this never rejects a well-formed input.
1459
+ var nonceLen = C.BYTES.bytes(24);
1460
+ var tagLen = C.BYTES.bytes(16);
1461
+ if (pos + nonceLen + tagLen > packed.length) {
1462
+ throw new Error("Invalid envelope: truncated (expected a " + nonceLen +
1463
+ "-byte nonce and a " + tagLen + "-byte authentication tag at offset " + pos + ")");
1464
+ }
1465
+ var nonce = packed.subarray(pos, pos + nonceLen); pos += nonceLen;
1433
1466
  // Re-derive the 4-byte envelope-header AAD from the bytes we just
1434
1467
  // dispatched on. A tampered header (algorithm-substitution attack)
1435
1468
  // surfaces here as a Poly1305 tag verification failure.
@@ -1509,6 +1542,17 @@ function decryptPacked(packed, key, aad) {
1509
1542
  if (packed[0] !== C.FORMAT.XCHACHA20_POLY1305) {
1510
1543
  throw new Error("Invalid packed format: unsupported version");
1511
1544
  }
1545
+ // A packed blob shorter than the 1-byte format id + 24-byte nonce +
1546
+ // 16-byte Poly1305 tag is truncated / corrupt (untrusted or damaged
1547
+ // storage cell); slicing the nonce / ciphertext out of it otherwise
1548
+ // reaches the cipher as an under-length nonce and throws a raw noble
1549
+ // RangeError that escapes this function's typed error contract (mirrors
1550
+ // the envelope decrypt bounds check). A valid packet is always at least
1551
+ // this long, so this never rejects a well-formed input.
1552
+ if (packed.length < 1 + C.BYTES.bytes(24) + C.BYTES.bytes(16)) {
1553
+ throw new Error("Invalid packed format: truncated (need at least a 1-byte " +
1554
+ "version, a 24-byte nonce, and a 16-byte authentication tag)");
1555
+ }
1512
1556
  return Buffer.from(
1513
1557
  xchacha20poly1305(key, packed.subarray(1, 25), aad ? Buffer.from(aad) : undefined)
1514
1558
  .decrypt(packed.subarray(25))
@@ -101,8 +101,11 @@ var MAGIC_BYTES = Object.freeze([
101
101
  { mime: "image/heic", bytes: [0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x78], offset: 4 },
102
102
  { mime: "image/avif", bytes: [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], offset: 4 },
103
103
  // SVG (XML) — `<?xml` or `<svg` starting bytes (after any UTF-8 BOM).
104
- { mime: "image/svg+xml", bytes: [0x3C, 0x3F, 0x78, 0x6D, 0x6C] }, // `<?xml`
105
- { mime: "image/svg+xml", bytes: [0x3C, 0x73, 0x76, 0x67] }, // `<svg`
104
+ // textFamily: a leading UTF-8 BOM is legal before XML/SVG text and is skipped
105
+ // when matching these; binary rasters (above) are matched only at their real
106
+ // offset, so a BOM-prefixed raster is NOT accepted.
107
+ { mime: "image/svg+xml", bytes: [0x3C, 0x3F, 0x78, 0x6D, 0x6C], textFamily: true }, // `<?xml`
108
+ { mime: "image/svg+xml", bytes: [0x3C, 0x73, 0x76, 0x67], textFamily: true }, // `<svg`
106
109
  ]);
107
110
 
108
111
  // ---- Profile presets ----
@@ -163,12 +166,21 @@ function _bytesAt(buf, offset, sig) {
163
166
 
164
167
  function _detectMagicMimes(buf) {
165
168
  if (!buf || typeof buf.length !== "number") return [];
169
+ // A leading UTF-8 BOM (EF BB BF) is legal only before the text-family images
170
+ // (SVG / XML); skip it ONLY for those entries so a BOM-prefixed SVG still
171
+ // matches its `<?xml` / `<svg` signature and routes to guardSvg at every
172
+ // profile. Binary rasters (PNG/JPEG/GIF/...) must carry their signature at
173
+ // its real offset — applying the BOM shift to them would let a BOM-prefixed
174
+ // raster masquerade as valid, bypassing strict magic validation. So the
175
+ // shift is gated on entry.textFamily.
176
+ var bom = (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) ? 3 : 0;
166
177
  var hits = [];
167
178
  for (var i = 0; i < MAGIC_BYTES.length; i += 1) {
168
179
  var entry = MAGIC_BYTES[i];
169
- var offset = entry.offset || 0;
180
+ var shift = entry.textFamily ? bom : 0;
181
+ var offset = (entry.offset || 0) + shift;
170
182
  if (!_bytesAt(buf, offset, entry.bytes)) continue;
171
- if (entry.tail && !_bytesAt(buf, entry.tailOffset, entry.tail)) continue;
183
+ if (entry.tail && !_bytesAt(buf, entry.tailOffset + shift, entry.tail)) continue;
172
184
  hits.push(entry.mime);
173
185
  }
174
186
  return hits;
@@ -182,8 +194,15 @@ function _detectIssues(metadata, opts) {
182
194
  snippet: "image metadata is not an object" }];
183
195
  }
184
196
 
197
+ // Measure the byte cap only for the types safeBuffer.byteLengthOf accepts
198
+ // (string / Buffer / Uint8Array). A hostile bag whose `bytes` is a plain
199
+ // Array or an array-like object still carries a numeric `.length` but is NOT
200
+ // measurable — measuring it threw TypeError, breaking validate's documented
201
+ // never-throw contract. Skip the cap for those (magic detection below is
202
+ // O(1)-bounded regardless of size) instead of crashing the caller.
185
203
  var bytes = metadata.bytes;
186
- if (bytes && typeof bytes.length === "number" && safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
204
+ if (bytes && (Buffer.isBuffer(bytes) || typeof bytes === "string" || bytes instanceof Uint8Array) &&
205
+ safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
187
206
  return [{ kind: "image-cap", severity: "high",
188
207
  ruleId: "image.image-cap",
189
208
  snippet: "image bytes exceed maxBytes " + opts.maxBytes }];
package/lib/guard-pdf.js CHANGED
@@ -156,8 +156,16 @@ function _detectIssues(metadata, opts) {
156
156
  snippet: "pdf metadata is not an object" }];
157
157
  }
158
158
 
159
+ // Measure the byte cap only for the types safeBuffer.byteLengthOf accepts
160
+ // (string / Buffer / Uint8Array). A hostile bag whose `bytes` is a plain
161
+ // Array or an array-like object still carries a numeric `.length` but is NOT
162
+ // measurable — measuring it threw TypeError, breaking validate's documented
163
+ // never-throw-on-hostile-metadata contract. Skip the cap for those (magic
164
+ // detection reads only the leading bytes, O(1)-bounded regardless of the
165
+ // reported size) instead of crashing the caller.
159
166
  var bytes = metadata.bytes;
160
- if (bytes && typeof bytes.length === "number" && safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
167
+ if (bytes && (Buffer.isBuffer(bytes) || typeof bytes === "string" || bytes instanceof Uint8Array) &&
168
+ safeBuffer.byteLengthOf(bytes) > opts.maxBytes) {
161
169
  return [{ kind: "pdf-cap", severity: "high",
162
170
  ruleId: "pdf.pdf-cap",
163
171
  snippet: "pdf bytes exceed maxBytes " + opts.maxBytes }];
package/lib/mail-auth.js CHANGED
@@ -1507,6 +1507,17 @@ async function arcVerify(rfc822, opts) {
1507
1507
  "arc.verify: rfc822 must be a non-empty string");
1508
1508
  }
1509
1509
  opts = opts || {};
1510
+ // RFC 8617 §5.1.1 / RFC 6376 §3.4 — ARC-Message-Signature verification
1511
+ // reuses the DKIM verifier, whose header/body split REQUIRES CRLF CRLF and
1512
+ // whose canonicalization is CRLF-based. _splitHeaders / _parseHeaderLines
1513
+ // here accept bare-LF, so a bare-LF ARC message (proper for its own header
1514
+ // scan) reached the DKIM step and threw an uncaught DkimError out of
1515
+ // arc.verify — a foreign error type breaking the "returns a chain result or
1516
+ // throws only a typed MailAuthError" contract. Normalize bare LF the
1517
+ // operator's tooling introduced to canonical CRLF (a no-op on a proper CRLF
1518
+ // message; identical to the LF→CRLF fold the DKIM body canon already does)
1519
+ // so the AMS DKIM verify sees the same canonical bytes the sealer signed.
1520
+ rfc822 = rfc822.replace(/\r?\n/g, "\r\n");
1510
1521
  var headers = _parseHeaderLines(_splitHeaders(rfc822));
1511
1522
  var hops = [];
1512
1523
  var seenSlot = {}; // {`<instance>:<name>`: true} — duplicate detection
@@ -2389,6 +2400,27 @@ async function inboundVerify(opts) {
2389
2400
  throw new MailAuthError("mail-auth/inbound-bad-message",
2390
2401
  "inbound.verify: message must be a non-empty string or Buffer (the full RFC 5322 message)");
2391
2402
  }
2403
+ // RFC 5322 §2.1 — the SMTP wire format is CRLF, but bare-LF input is
2404
+ // accepted defensively (see the pipeline note above; _splitHeaderBlock
2405
+ // already honors it for the From extraction). The DKIM verifier
2406
+ // (RFC 6376 §3.4) canonicalizes over CRLF and its header/body split
2407
+ // REQUIRES CRLF CRLF — a bare-LF message otherwise threw an uncaught
2408
+ // DkimError out of the pipeline, breaking the documented "bare-LF
2409
+ // accepted" contract (and the module-wide invariant that message-derived
2410
+ // faults surface as a verdict, never a throw). Normalize any bare LF the
2411
+ // operator's tooling introduced to canonical CRLF — a no-op on a proper
2412
+ // CRLF message, and identical to the LF→CRLF fold the DKIM body
2413
+ // canonicalizer already performs — so every sub-verifier receives the
2414
+ // same canonical bytes and produces a verdict.
2415
+ message = message.replace(/\r?\n/g, "\r\n");
2416
+ // RFC 5322 §2.1 — a well-formed message terminates its header block with
2417
+ // an empty line. _splitHeaderBlock already treats a message with no such
2418
+ // separator as headers-only (empty body); mirror that here so the DKIM
2419
+ // verifier — whose split REQUIRES CRLF CRLF — sees a separator and
2420
+ // returns a "none" verdict on the same headers-only input instead of
2421
+ // throwing. Same root as the bare-LF case: every message _splitHeaderBlock
2422
+ // accepts must reach a verdict, never an uncaught throw.
2423
+ if (message.indexOf("\r\n\r\n") === -1) message += "\r\n\r\n";
2392
2424
  var mailFrom = (typeof opts.mailFrom === "string" && opts.mailFrom.length > 0) ? opts.mailFrom : null;
2393
2425
  var helo = (typeof opts.helo === "string" && opts.helo.length > 0) ? opts.helo : null;
2394
2426
 
package/lib/mail-dkim.js CHANGED
@@ -134,6 +134,17 @@ function _splitHeadersBody(rfc822) {
134
134
  // Headers terminated by the first empty line. Headers may use folded
135
135
  // continuation lines (CRLF + WSP); we keep them folded and let the
136
136
  // canonicalizer unfold relaxed-mode.
137
+ //
138
+ // RFC 5322 §2.1 — the wire format is CRLF, but a message read from a
139
+ // Unix file/mbox or passed through operator tooling that stripped CRs
140
+ // arrives bare-LF. Normalize to canonical CRLF before locating the
141
+ // separator (a no-op on a proper CRLF message, and identical to the
142
+ // LF->CRLF fold _canonBodySimple already performs) so a valid bare-LF
143
+ // message verifies instead of failing the separator search. A message
144
+ // with no empty line at all (no \n either) is genuinely separator-less
145
+ // and still throws below — normalization converts existing line endings,
146
+ // it never invents a separator.
147
+ rfc822 = rfc822.replace(/\r?\n/g, "\r\n");
137
148
  var sep = rfc822.indexOf("\r\n\r\n");
138
149
  if (sep === -1) {
139
150
  throw new DkimError("dkim/missing-body-separator",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.32",
3
+ "version": "0.16.33",
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.5",
5
- "serialNumber": "urn:uuid:1086bb90-402f-4519-989d-e04d1bf3e287",
5
+ "serialNumber": "urn:uuid:28a4f186-23d8-487b-bead-ee0129c21413",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T02:48:03.873Z",
8
+ "timestamp": "2026-07-16T10:43:55.252Z",
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.16.32",
22
+ "bom-ref": "@blamejs/core@0.16.33",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.32",
25
+ "version": "0.16.33",
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.16.32",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.33",
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.16.32",
57
+ "ref": "@blamejs/core@0.16.33",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]