@blamejs/core 0.15.13 → 0.15.15

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.
Files changed (208) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/index.js +2 -0
  3. package/lib/a2a-tasks.js +38 -6
  4. package/lib/agent-event-bus.js +13 -0
  5. package/lib/agent-idempotency.js +5 -1
  6. package/lib/agent-snapshot.js +32 -2
  7. package/lib/ai-aedt-bias-audit.js +2 -1
  8. package/lib/ai-content-detect.js +1 -3
  9. package/lib/ai-frontier-protocol.js +1 -1
  10. package/lib/ai-model-manifest.js +1 -1
  11. package/lib/ai-output.js +16 -7
  12. package/lib/api-snapshot.js +4 -1
  13. package/lib/app-shutdown.js +7 -1
  14. package/lib/archive-gz.js +9 -0
  15. package/lib/archive-read.js +9 -7
  16. package/lib/archive-tar-read.js +51 -8
  17. package/lib/archive.js +4 -2
  18. package/lib/asn1-der.js +70 -22
  19. package/lib/atomic-file.js +204 -2
  20. package/lib/audit-chain.js +54 -5
  21. package/lib/audit-daily-review.js +12 -2
  22. package/lib/audit-sign.js +2 -1
  23. package/lib/audit-tools.js +108 -23
  24. package/lib/audit.js +7 -2
  25. package/lib/auth/access-lock.js +2 -2
  26. package/lib/auth/bot-challenge.js +1 -1
  27. package/lib/auth/ciba.js +71 -8
  28. package/lib/auth/dpop.js +15 -1
  29. package/lib/auth/fido-mds3.js +30 -13
  30. package/lib/auth/jwt.js +21 -5
  31. package/lib/auth/lockout.js +18 -2
  32. package/lib/auth/oauth.js +8 -2
  33. package/lib/auth/passkey.js +1 -1
  34. package/lib/auth/password.js +1 -1
  35. package/lib/auth/saml.js +42 -12
  36. package/lib/auth/sd-jwt-vc.js +24 -4
  37. package/lib/auth/status-list.js +14 -2
  38. package/lib/auth/step-up.js +9 -1
  39. package/lib/auth-bot-challenge.js +21 -2
  40. package/lib/backup/bundle.js +7 -2
  41. package/lib/backup/crypto.js +23 -8
  42. package/lib/backup/index.js +41 -20
  43. package/lib/backup/manifest.js +7 -1
  44. package/lib/break-glass.js +41 -22
  45. package/lib/cbor.js +34 -11
  46. package/lib/cdn-cache-control.js +7 -3
  47. package/lib/cert.js +5 -3
  48. package/lib/cli.js +5 -1
  49. package/lib/cloud-events.js +3 -2
  50. package/lib/cluster-storage.js +7 -3
  51. package/lib/codepoint-class.js +17 -0
  52. package/lib/compliance-eaa.js +1 -1
  53. package/lib/compliance-sanctions.js +9 -7
  54. package/lib/compliance.js +1 -1
  55. package/lib/config-drift.js +22 -8
  56. package/lib/content-credentials.js +11 -8
  57. package/lib/content-digest.js +11 -4
  58. package/lib/cookies.js +10 -2
  59. package/lib/cose.js +20 -0
  60. package/lib/crdt.js +2 -1
  61. package/lib/crypto-field.js +48 -24
  62. package/lib/crypto.js +18 -4
  63. package/lib/csp.js +13 -0
  64. package/lib/daemon.js +4 -1
  65. package/lib/data-act.js +27 -4
  66. package/lib/db-file-lifecycle.js +14 -6
  67. package/lib/db-query.js +102 -11
  68. package/lib/db.js +32 -15
  69. package/lib/dora.js +43 -13
  70. package/lib/dr-runbook.js +1 -1
  71. package/lib/dsa.js +2 -1
  72. package/lib/dsr.js +22 -8
  73. package/lib/early-hints.js +19 -0
  74. package/lib/eat.js +5 -1
  75. package/lib/external-db.js +60 -4
  76. package/lib/fda-21cfr11.js +30 -5
  77. package/lib/flag-providers.js +6 -2
  78. package/lib/forms.js +1 -1
  79. package/lib/gate-contract.js +46 -5
  80. package/lib/gdpr-ropa.js +18 -9
  81. package/lib/graphql-federation.js +17 -4
  82. package/lib/guard-all.js +2 -2
  83. package/lib/guard-dsn.js +1 -1
  84. package/lib/guard-envelope.js +1 -1
  85. package/lib/guard-html.js +9 -11
  86. package/lib/guard-imap-command.js +1 -1
  87. package/lib/guard-jmap.js +1 -1
  88. package/lib/guard-json.js +14 -6
  89. package/lib/guard-mail-move.js +1 -1
  90. package/lib/guard-managesieve-command.js +1 -1
  91. package/lib/guard-pop3-command.js +1 -1
  92. package/lib/guard-smtp-command.js +1 -1
  93. package/lib/guard-svg.js +8 -9
  94. package/lib/html-balance.js +7 -3
  95. package/lib/http-client-cookie-jar.js +33 -12
  96. package/lib/http-client.js +225 -53
  97. package/lib/iab-tcf.js +3 -2
  98. package/lib/importmap-integrity.js +41 -1
  99. package/lib/incident-report.js +9 -6
  100. package/lib/json-patch.js +1 -1
  101. package/lib/json-path.js +24 -3
  102. package/lib/jtd.js +2 -2
  103. package/lib/legal-hold.js +24 -8
  104. package/lib/log.js +2 -2
  105. package/lib/mail-agent.js +2 -2
  106. package/lib/mail-arf.js +1 -1
  107. package/lib/mail-auth.js +27 -4
  108. package/lib/mail-bimi.js +16 -16
  109. package/lib/mail-bounce.js +3 -3
  110. package/lib/mail-crypto-smime.js +71 -6
  111. package/lib/mail-deploy.js +9 -5
  112. package/lib/mail-dkim.js +20 -7
  113. package/lib/mail-greylist.js +2 -4
  114. package/lib/mail-helo.js +2 -4
  115. package/lib/mail-journal.js +11 -8
  116. package/lib/mail-mdn.js +8 -4
  117. package/lib/mail-rbl.js +2 -4
  118. package/lib/mail-scan.js +3 -5
  119. package/lib/mail-server-jmap.js +4 -1
  120. package/lib/mail-server-registry.js +1 -1
  121. package/lib/mail-server-tls.js +9 -2
  122. package/lib/mail-spam-score.js +2 -4
  123. package/lib/mail-store-fts.js +1 -1
  124. package/lib/mail.js +22 -2
  125. package/lib/markup-tokenizer.js +24 -0
  126. package/lib/mcp.js +6 -4
  127. package/lib/mdoc.js +26 -3
  128. package/lib/metrics.js +14 -3
  129. package/lib/middleware/api-encrypt.js +2 -2
  130. package/lib/middleware/body-parser.js +10 -4
  131. package/lib/middleware/bot-guard.js +26 -18
  132. package/lib/middleware/clear-site-data.js +5 -1
  133. package/lib/middleware/compose-pipeline.js +39 -5
  134. package/lib/middleware/compression.js +9 -0
  135. package/lib/middleware/cors.js +32 -23
  136. package/lib/middleware/csrf-protect.js +60 -21
  137. package/lib/middleware/daily-byte-quota.js +6 -4
  138. package/lib/middleware/fetch-metadata.js +28 -4
  139. package/lib/middleware/network-allowlist.js +61 -30
  140. package/lib/middleware/rate-limit.js +25 -16
  141. package/lib/middleware/scim-server.js +2 -1
  142. package/lib/middleware/security-headers.js +24 -6
  143. package/lib/middleware/speculation-rules.js +6 -3
  144. package/lib/middleware/tus-upload.js +2 -2
  145. package/lib/money.js +1 -1
  146. package/lib/mtls-ca.js +10 -6
  147. package/lib/network-dns-resolver.js +1 -1
  148. package/lib/network-dns.js +9 -2
  149. package/lib/network-dnssec.js +2 -1
  150. package/lib/network-smtp-policy.js +23 -5
  151. package/lib/network-tls.js +27 -5
  152. package/lib/network-tsig.js +2 -2
  153. package/lib/nis2-report.js +1 -1
  154. package/lib/nist-crosswalk.js +1 -1
  155. package/lib/ntp-check.js +28 -0
  156. package/lib/numeric-bounds.js +9 -0
  157. package/lib/object-store/azure-blob.js +1 -2
  158. package/lib/object-store/gcs-bucket-ops.js +4 -2
  159. package/lib/object-store/gcs.js +6 -4
  160. package/lib/object-store/http-put.js +1 -2
  161. package/lib/object-store/http-request.js +30 -1
  162. package/lib/object-store/local.js +37 -17
  163. package/lib/object-store/sigv4.js +1 -2
  164. package/lib/observability-otlp-exporter.js +20 -4
  165. package/lib/outbox.js +11 -4
  166. package/lib/parsers/safe-xml.js +1 -1
  167. package/lib/parsers/safe-yaml.js +21 -3
  168. package/lib/pipl-cn.js +11 -8
  169. package/lib/queue-local.js +10 -3
  170. package/lib/redact.js +7 -3
  171. package/lib/request-helpers.js +347 -36
  172. package/lib/resource-access-lock.js +3 -3
  173. package/lib/restore-bundle.js +46 -18
  174. package/lib/restore-rollback.js +10 -4
  175. package/lib/restore.js +19 -0
  176. package/lib/retention.js +20 -4
  177. package/lib/router.js +17 -4
  178. package/lib/safe-ical.js +2 -2
  179. package/lib/safe-icap.js +1 -1
  180. package/lib/safe-json.js +70 -0
  181. package/lib/safe-sieve.js +1 -1
  182. package/lib/safe-vcard.js +1 -1
  183. package/lib/sandbox-worker.js +6 -0
  184. package/lib/sandbox.js +1 -1
  185. package/lib/scheduler.js +17 -1
  186. package/lib/self-update-standalone-verifier.js +16 -0
  187. package/lib/session.js +62 -120
  188. package/lib/sql.js +25 -3
  189. package/lib/static.js +65 -13
  190. package/lib/template.js +7 -5
  191. package/lib/tenant-quota.js +52 -19
  192. package/lib/tsa.js +5 -2
  193. package/lib/vault/index.js +5 -0
  194. package/lib/vault/passphrase-ops.js +22 -26
  195. package/lib/vault/passphrase-source.js +8 -3
  196. package/lib/vault/rotate.js +13 -18
  197. package/lib/vault/seal-pem-file.js +4 -1
  198. package/lib/vc.js +1 -1
  199. package/lib/vendor/MANIFEST.json +10 -10
  200. package/lib/vendor/public-suffix-list.dat +23 -10
  201. package/lib/vendor/public-suffix-list.data.js +5498 -5494
  202. package/lib/webhook.js +16 -1
  203. package/lib/websocket.js +1 -1
  204. package/lib/worm.js +1 -1
  205. package/lib/ws-client.js +83 -46
  206. package/lib/x509-chain.js +91 -0
  207. package/package.json +1 -1
  208. package/sbom.cdx.json +6 -6
@@ -282,7 +282,7 @@ function policy(opts) {
282
282
  // Apply named profile FIRST, then operator opts on top so the
283
283
  // operator can override profile defaults per-field.
284
284
  if (typeof opts.profile === "string" && opts.profile.length > 0) {
285
- if (!POLICY_PROFILES[opts.profile]) {
285
+ if (!Object.prototype.hasOwnProperty.call(POLICY_PROFILES, opts.profile)) {
286
286
  throw new AuthError("auth-password/bad-policy",
287
287
  "policy.profile must be one of " + Object.keys(POLICY_PROFILES).join("/") +
288
288
  ", got " + JSON.stringify(opts.profile));
package/lib/auth/saml.js CHANGED
@@ -170,7 +170,7 @@ function _verifyXmldsig(envelope, signatureNode, certPem) {
170
170
  }
171
171
  var sigMethodNode = _findChild(signedInfo, "SignatureMethod");
172
172
  var sigAlgo = sigMethodNode && _attr(sigMethodNode, "Algorithm");
173
- if (!SUPPORTED_SIG[sigAlgo]) {
173
+ if (!Object.prototype.hasOwnProperty.call(SUPPORTED_SIG, sigAlgo)) {
174
174
  throw new AuthError("auth-saml/unsupported-sig-alg",
175
175
  "Unsupported SignatureMethod: " + sigAlgo);
176
176
  }
@@ -184,7 +184,7 @@ function _verifyXmldsig(envelope, signatureNode, certPem) {
184
184
  var refId = refUri.substring(1);
185
185
  var digestMethodNode = _findChild(refNode, "DigestMethod");
186
186
  var digestAlgo = digestMethodNode && _attr(digestMethodNode, "Algorithm");
187
- if (!SUPPORTED_DIGEST[digestAlgo]) {
187
+ if (!Object.prototype.hasOwnProperty.call(SUPPORTED_DIGEST, digestAlgo)) {
188
188
  throw new AuthError("auth-saml/unsupported-digest",
189
189
  "Unsupported DigestMethod: " + digestAlgo);
190
190
  }
@@ -695,7 +695,7 @@ function create(opts) {
695
695
  if (nbHok && isFinite(Date.parse(nbHok) / 1000) && // ms→s
696
696
  Date.parse(nbHok) / 1000 > nowSec + clockSkewSec) continue; // ms→s
697
697
  var recipHok = _attr(scdHok, "Recipient");
698
- if (recipHok && recipHok !== opts.assertionConsumerServiceUrl) continue;
698
+ if (!recipHok || recipHok !== opts.assertionConsumerServiceUrl) continue; // §3.1→§4.1.4.2 — Recipient is mandatory; absent fails the endpoint binding
699
699
  hokOk = true;
700
700
  break;
701
701
  }
@@ -711,8 +711,11 @@ function create(opts) {
711
711
  var nb = Date.parse(notBefore) / 1000; // ms→s
712
712
  if (isFinite(nb) && nb > nowSec + clockSkewSec) continue;
713
713
  }
714
+ // §4.1.4.2 — a Bearer SubjectConfirmationData delivered to an ACS MUST
715
+ // carry a Recipient equal to this SP's ACS URL. Absent Recipient fails
716
+ // the endpoint binding (treated as a mismatch), not silently skipped.
714
717
  var recipient = _attr(scd, "Recipient");
715
- if (recipient && recipient !== opts.assertionConsumerServiceUrl) {
718
+ if (!recipient || recipient !== opts.assertionConsumerServiceUrl) {
716
719
  continue;
717
720
  }
718
721
  var inResponseTo = _attr(scd, "InResponseTo");
@@ -745,25 +748,52 @@ function create(opts) {
745
748
  var cNotOnOrAfter = _attr(conditions, "NotOnOrAfter");
746
749
  if (cNotBefore) {
747
750
  var cnb = Date.parse(cNotBefore) / 1000; // ms→s
748
- if (isFinite(cnb) && cnb > nowSec + clockSkewSec) {
751
+ // Fail CLOSED on a present-but-unparseable bound (mirrors the Bearer
752
+ // SCD path at line ~708) instead of skipping the window via isFinite().
753
+ if (!isFinite(cnb)) {
754
+ throw new AuthError("auth-saml/conditions-bad-timestamp",
755
+ "Conditions NotBefore is present but unparseable");
756
+ }
757
+ if (cnb > nowSec + clockSkewSec) {
749
758
  throw new AuthError("auth-saml/conditions-not-yet-valid",
750
759
  "Conditions NotBefore is in the future");
751
760
  }
752
761
  }
753
762
  if (cNotOnOrAfter) {
754
763
  var cnoa = Date.parse(cNotOnOrAfter) / 1000; // ms→s
755
- if (isFinite(cnoa) && cnoa < nowSec - clockSkewSec) {
764
+ if (!isFinite(cnoa)) {
765
+ throw new AuthError("auth-saml/conditions-bad-timestamp",
766
+ "Conditions NotOnOrAfter is present but unparseable");
767
+ }
768
+ if (cnoa < nowSec - clockSkewSec) {
756
769
  throw new AuthError("auth-saml/conditions-expired",
757
770
  "Conditions NotOnOrAfter has passed");
758
771
  }
759
772
  }
760
- var ar = _findChild(conditions, "AudienceRestriction", SAML_NS.assertion);
761
- if (ar) {
762
- var audiences = _findAllChildren(ar, "Audience", SAML_NS.assertion).map(_textContent);
773
+ }
774
+ // Audience binding — a signed assertion is bound to THIS SP via
775
+ // AudienceRestriction. A missing Conditions or AudienceRestriction means
776
+ // it is not bound here (audience-confusion: an assertion minted for another
777
+ // SP). Fail closed when an audience is configured; opt out only via
778
+ // vopts.requireAudienceRestriction === false.
779
+ if (audience && vopts.requireAudienceRestriction !== false) {
780
+ var ars = conditions
781
+ ? _findAllChildren(conditions, "AudienceRestriction", SAML_NS.assertion) : [];
782
+ if (ars.length === 0) {
783
+ throw new AuthError("auth-saml/no-audience-restriction",
784
+ "verifyResponse: assertion has no AudienceRestriction binding it to \"" +
785
+ audience + "\" (audience-confusion defense; set requireAudienceRestriction:false to opt out)");
786
+ }
787
+ // SAML core §2.5.1.4: multiple <AudienceRestriction> elements are
788
+ // AND-combined — the SP must be a member of the Audience set of EVERY
789
+ // one. Checking only the first let an IdP that narrowed the assertion to
790
+ // a DIFFERENT audience in a later restriction be accepted here.
791
+ for (var ari = 0; ari < ars.length; ari += 1) {
792
+ var audiences = _findAllChildren(ars[ari], "Audience", SAML_NS.assertion).map(_textContent);
763
793
  if (audiences.indexOf(audience) === -1) {
764
794
  throw new AuthError("auth-saml/wrong-audience",
765
- "Audience \"" + audience + "\" not in assertion's AudienceRestriction (got " +
766
- JSON.stringify(audiences) + ")");
795
+ "Audience \"" + audience + "\" not in AudienceRestriction #" + (ari + 1) +
796
+ " of " + ars.length + " (got " + JSON.stringify(audiences) + ")");
767
797
  }
768
798
  }
769
799
  }
@@ -1897,7 +1927,7 @@ function _verifyEmbeddedXmlDsig(xml, idpVerifyKey, idpVerifyAlg, expectedRootLoc
1897
1927
  // Allow either sha3-512 (framework default) or the SHA-2 family.
1898
1928
  var digestAlgName;
1899
1929
  if (digestUri === "http://www.w3.org/2007/05/xmldsig-more#sha3-512") digestAlgName = "sha3-512";
1900
- else if (SUPPORTED_DIGEST[digestUri]) digestAlgName = SUPPORTED_DIGEST[digestUri];
1930
+ else if (Object.prototype.hasOwnProperty.call(SUPPORTED_DIGEST, digestUri)) digestAlgName = SUPPORTED_DIGEST[digestUri];
1901
1931
  else {
1902
1932
  throw new AuthError("auth-saml/unsupported-digest",
1903
1933
  "_verifyEmbeddedXmlDsig: DigestMethod " + digestUri + " not supported");
@@ -212,7 +212,7 @@ function issue(opts) {
212
212
  "issue: algorithm must be one of " + SUPPORTED_ALGS.join(", "));
213
213
  }
214
214
  var hashAlg = opts.hashAlg || DEFAULT_HASH_ALG;
215
- if (!SUPPORTED_HASH_ALGS[hashAlg]) {
215
+ if (!Object.prototype.hasOwnProperty.call(SUPPORTED_HASH_ALGS, hashAlg)) {
216
216
  throw new AuthError("auth-sd-jwt-vc/bad-hash",
217
217
  "issue: hashAlg must be one of " + Object.keys(SUPPORTED_HASH_ALGS).join(", "));
218
218
  }
@@ -413,8 +413,8 @@ async function verify(presentation, opts) {
413
413
  validateOpts.requireObject(opts, "auth.sdJwtVc.verify", AuthError);
414
414
  validateOpts(opts, [
415
415
  "issuerKeyResolver", "audience", "nonce",
416
- "now", "expectedVct", "maxClockSkewSec",
417
- "requireKeyBinding",
416
+ "now", "expectedVct", "expectedIssuer", "maxClockSkewSec",
417
+ "requireKeyBinding", "requireExp",
418
418
  "keyAttestationVerifier", "requireKeyAttestation",
419
419
  ], "auth.sdJwtVc.verify");
420
420
 
@@ -528,6 +528,15 @@ async function verify(presentation, opts) {
528
528
  throw new AuthError("auth-sd-jwt-vc/iat-future",
529
529
  "verify: iat is in the future (clock skew?)");
530
530
  }
531
+ // SD-JWT-VC makes `exp` OPTIONAL, so the default only checks it when present.
532
+ // Operators with a time-bounded trust expectation (esp. for FOREIGN issuers
533
+ // resolved via issuerKeyResolver) pass requireExp:true to fail closed on a
534
+ // missing / non-numeric exp — mirroring jwt-external's unconditional exp
535
+ // requirement (a credential that never expires is accepted otherwise).
536
+ if (opts.requireExp === true && typeof jwtParsed.payload.exp !== "number") {
537
+ throw new AuthError("auth-sd-jwt-vc/missing-exp",
538
+ "verify: requireExp is set but the credential has no numeric exp claim");
539
+ }
531
540
  if (typeof jwtParsed.payload.exp === "number" && jwtParsed.payload.exp < nowSec - skew) {
532
541
  throw new AuthError("auth-sd-jwt-vc/expired",
533
542
  "verify: token is expired");
@@ -556,7 +565,7 @@ async function verify(presentation, opts) {
556
565
  // to its own DEFAULT_HASH_ALG (`sha3-512`) which broke verification
557
566
  // against spec-conformant issuers when `_sd_alg` was omitted.
558
567
  var hashAlg = jwtParsed.payload._sd_alg || "sha-256";
559
- if (!SUPPORTED_HASH_ALGS[hashAlg]) {
568
+ if (!Object.prototype.hasOwnProperty.call(SUPPORTED_HASH_ALGS, hashAlg)) {
560
569
  throw new AuthError("auth-sd-jwt-vc/bad-hash",
561
570
  "verify: _sd_alg \"" + hashAlg + "\" not supported");
562
571
  }
@@ -638,6 +647,17 @@ async function verify(presentation, opts) {
638
647
  jwtExternal._assertAlgKtyMatch(kbAlg, holderKey);
639
648
  var holderKeyObj = bCrypto.importPublicJwk(holderKey);
640
649
  var kbParsed = _verifyJwt(maybeKbJwt, holderKeyObj, kbAlg);
650
+ // A KB-JWT proves holder possession of THIS presentation's bytes (sd_hash),
651
+ // but binds it to a verifier + a fresh challenge only via aud + nonce. If
652
+ // the caller processes a KB-JWT without supplying both, the aud/nonce
653
+ // compares below would silently skip — making the presentation replayable
654
+ // and acceptable at any verifier. Fail CLOSED: require both (oid4vp always
655
+ // supplies them). RFC: SD-JWT-VC §KB-JWT / OIDC4VP nonce binding.
656
+ if (typeof opts.audience !== "string" || opts.audience.length === 0 ||
657
+ typeof opts.nonce !== "string" || opts.nonce.length === 0) {
658
+ throw new AuthError("auth-sd-jwt-vc/missing-replay-binding",
659
+ "verify: a KB-JWT requires opts.audience + opts.nonce to bind the presentation to this verifier and a fresh challenge (replay / audience-redirection defense)");
660
+ }
641
661
  // Constant-time compares: the nonce is a verifier-issued replay-defense
642
662
  // value, so a short-circuiting !== leaks a matching-prefix timing oracle.
643
663
  // Matches the sd_hash check below (the framework's hash/token discipline).
@@ -78,7 +78,7 @@ var _fromB64url = bCrypto.makeBase64UrlDecoder({
78
78
  });
79
79
 
80
80
  function _validateBits(bits) {
81
- if (!SUPPORTED_BIT_SIZES[bits]) {
81
+ if (!Object.prototype.hasOwnProperty.call(SUPPORTED_BIT_SIZES, bits)) {
82
82
  throw new StatusListError("status-list/bad-bits",
83
83
  "statusList: bits must be 1, 2, 4, or 8 (draft §6.1.1) — got " + bits);
84
84
  }
@@ -249,7 +249,19 @@ async function fromJwt(token, opts) {
249
249
  list: {
250
250
  size: size,
251
251
  bits: bits,
252
- get: function (idx) { return _getAt(inflated, bits, idx); },
252
+ // Bounds-check the relying-party status read, mirroring create().get. An
253
+ // out-of-range index must FAIL CLOSED (throw): _getAt over-reads the buffer
254
+ // and returns 0 for an out-of-bounds index, and status 0 = VALID — so a
255
+ // credential whose status_list index points past the list would otherwise
256
+ // read as "not revoked", a revocation bypass.
257
+ get: function (idx) {
258
+ if (typeof idx !== "number" || idx < 0 || idx >= size || (idx >> 0) !== idx) {
259
+ throw new StatusListError("status-list/bad-index",
260
+ "statusList.fromJwt get: idx out of range — got " + idx + ", size=" + size +
261
+ " (an out-of-range status index fails closed, never reads as status 0/valid)");
262
+ }
263
+ return _getAt(inflated, bits, idx);
264
+ },
253
265
  snapshot: function () { return { size: size, bits: bits, bytes: Buffer.from(inflated) }; },
254
266
  },
255
267
  claims: claims,
@@ -386,7 +386,15 @@ function parseChallenge(headerValue) {
386
386
  if (key === "error") out.error = val;
387
387
  else if (key === "scope") out.scope = val;
388
388
  else if (key === "acr_values") out.acrValues = val.split(/\s+/);
389
- else if (key === "max_age") out.maxAge = parseInt(val, 10);
389
+ else if (key === "max_age") {
390
+ // Defensive: a malformed max_age (non-numeric / negative) from the
391
+ // server's challenge must not land as NaN — a downstream `age > maxAge`
392
+ // comparison against NaN is always false and would silently mis-handle
393
+ // the freshness requirement. Omit it unless it parses to a non-negative
394
+ // integer, so callers fall back to their own default.
395
+ var ma = parseInt(val, 10);
396
+ if (isFinite(ma) && ma >= 0) out.maxAge = ma;
397
+ }
390
398
  });
391
399
  return out;
392
400
  }
@@ -40,6 +40,7 @@ var C = require("./constants");
40
40
  var lazyRequire = require("./lazy-require");
41
41
  var requestHelpers = require("./request-helpers");
42
42
  var validateOpts = require("./validate-opts");
43
+ var numericBounds = require("./numeric-bounds");
43
44
  var { AuthBotChallengeError } = require("./framework-error");
44
45
 
45
46
  var observability = lazyRequire(function () { return require("./observability"); });
@@ -67,7 +68,7 @@ function _requireFunction(name, val) {
67
68
  }
68
69
 
69
70
  function _requirePositiveInt(name, val) {
70
- if (typeof val !== "number" || !isFinite(val) || val < 1 || Math.floor(val) !== val) {
71
+ if (!numericBounds.isPositiveFiniteInt(val)) {
71
72
  throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
72
73
  name + ": expected positive integer, got " + JSON.stringify(val));
73
74
  }
@@ -272,7 +273,25 @@ function create(opts) {
272
273
 
273
274
  // ---- Internal staircase advance ----
274
275
 
275
- async function _advanceFailure(key, req) {
276
+ // Per-key serialization: the failure staircase is a read→increment→write on
277
+ // an async store, so concurrent recordFailure calls for the SAME key would
278
+ // both read N and both write N+1 (lost update), letting an attacker fire
279
+ // parallel failures to stay under the challenge / lockout thresholds. A
280
+ // per-key promise chain applies advances for a key sequentially. (Cross-node
281
+ // atomicity additionally needs an atomic store; this fixes the in-process
282
+ // race the gate actually depends on.)
283
+ var _advanceChains = new Map();
284
+ function _advanceFailure(key, req) {
285
+ var prev = _advanceChains.get(key) || Promise.resolve();
286
+ var run = prev.then(function () { return _doAdvanceFailure(key, req); },
287
+ function () { return _doAdvanceFailure(key, req); });
288
+ var tail = run.then(function () {}, function () {});
289
+ _advanceChains.set(key, tail);
290
+ tail.then(function () { if (_advanceChains.get(key) === tail) _advanceChains.delete(key); });
291
+ return run;
292
+ }
293
+
294
+ async function _doAdvanceFailure(key, req) {
276
295
  var now = clock();
277
296
  var state = await _readState(key) || {
278
297
  stage: STATE_NEW, failures: 0, challengedAt: null, passedAt: null,
@@ -160,14 +160,18 @@ async function create(opts) {
160
160
  },
161
161
  });
162
162
  var checksum = bCrypto.checksum(plain);
163
- var encResult = await bCrypto.encryptWithFreshSalt(plain, passphrase);
163
+ // Bind the ciphertext to this blob's canonical relativePath as AEAD
164
+ // associated data. A blob copied to a different manifest entry (the
165
+ // restore-corruption / blob-remap attack) then fails the Poly1305 tag on
166
+ // restore — tamper-evident even on an unsigned bundle (manifest.aadBound).
167
+ var encResult = await bCrypto.encryptWithFreshSalt(plain, passphrase, entry.relativePath);
164
168
  var encPath = _encryptedPathFor(entry.relativePath);
165
169
  var destFull = nodePath.join(outDir, encPath);
166
170
  atomicFile.ensureDir(nodePath.dirname(destFull));
167
171
  atomicFile.writeSync(destFull, encResult.encrypted, { fileMode: 0o600 });
168
172
 
169
173
  var kind = entry.kind || "raw";
170
- if (!backupManifest.VALID_KINDS[kind]) {
174
+ if (!Object.prototype.hasOwnProperty.call(backupManifest.VALID_KINDS, kind)) {
171
175
  throw new BackupBundleError("backup-bundle/bad-kind",
172
176
  "create: files[" + i + "].kind must be one of raw, vault-sealed, plaintext (got '" + kind + "')");
173
177
  }
@@ -205,6 +209,7 @@ async function create(opts) {
205
209
  vaultKeyEnc: wrappedVk.encrypted.toString("base64"),
206
210
  files: fileEntries,
207
211
  metadata: opts.metadata || undefined,
212
+ aadBound: true, // every blob above was sealed with its relativePath as AEAD AAD
208
213
  });
209
214
  // Sign the manifest with the audit-sign keypair so a tampered
210
215
  // manifest fails verification on restore. The signer is best-
@@ -117,7 +117,21 @@ async function deriveKey(passphrase, saltHex, opts) {
117
117
  return hash;
118
118
  }
119
119
 
120
- async function encryptWithPassphrase(plaintext, passphrase, saltHex) {
120
+ // Normalize optional associated-authenticated-data (AAD) into the
121
+ // Uint8Array the AEAD expects, or undefined when none. AAD is NOT encrypted
122
+ // but IS authenticated: ciphertext sealed under one AAD fails the Poly1305
123
+ // tag when decrypted under a different AAD. Backup file blobs pass their
124
+ // canonical relativePath so a blob remapped to a different manifest entry is
125
+ // cryptographically rejected (the blob-remap / restore-corruption defense).
126
+ function _aadBytes(aad) {
127
+ if (aad === undefined || aad === null) return undefined;
128
+ if (Buffer.isBuffer(aad)) return new Uint8Array(aad);
129
+ if (typeof aad === "string") return new Uint8Array(Buffer.from(aad, "utf8"));
130
+ throw new BackupCryptoError("backup-crypto/bad-aad",
131
+ "associated data must be a Buffer or string");
132
+ }
133
+
134
+ async function encryptWithPassphrase(plaintext, passphrase, saltHex, aad) {
121
135
  if (!Buffer.isBuffer(plaintext) && typeof plaintext !== "string") {
122
136
  throw new BackupCryptoError("backup-crypto/bad-plaintext",
123
137
  "encryptWithPassphrase: plaintext must be a Buffer or string");
@@ -125,11 +139,11 @@ async function encryptWithPassphrase(plaintext, passphrase, saltHex) {
125
139
  var plainBuf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, "utf8");
126
140
  var key = await deriveKey(passphrase, saltHex);
127
141
  var nonce = nodeCrypto.randomBytes(NONCE_BYTES);
128
- var ct = xchacha20poly1305(new Uint8Array(key), nonce).encrypt(new Uint8Array(plainBuf));
142
+ var ct = xchacha20poly1305(new Uint8Array(key), nonce, _aadBytes(aad)).encrypt(new Uint8Array(plainBuf));
129
143
  return Buffer.concat([nonce, Buffer.from(ct)]);
130
144
  }
131
145
 
132
- async function decryptWithPassphrase(encrypted, passphrase, saltHex) {
146
+ async function decryptWithPassphrase(encrypted, passphrase, saltHex, aad) {
133
147
  if (!Buffer.isBuffer(encrypted)) {
134
148
  throw new BackupCryptoError("backup-crypto/bad-input",
135
149
  "decryptWithPassphrase: encrypted must be a Buffer");
@@ -143,11 +157,11 @@ async function decryptWithPassphrase(encrypted, passphrase, saltHex) {
143
157
  var ct = encrypted.subarray(NONCE_BYTES);
144
158
  var plain;
145
159
  try {
146
- plain = xchacha20poly1305(new Uint8Array(key), new Uint8Array(nonce))
160
+ plain = xchacha20poly1305(new Uint8Array(key), new Uint8Array(nonce), _aadBytes(aad))
147
161
  .decrypt(new Uint8Array(ct));
148
162
  } catch (e) {
149
163
  throw new BackupCryptoError("backup-crypto/decrypt-failed",
150
- "XChaCha20-Poly1305 decryption failed (wrong passphrase or tampered ciphertext): " +
164
+ "XChaCha20-Poly1305 decryption failed (wrong passphrase, tampered ciphertext, or blob remapped to a different path): " +
151
165
  ((e && e.message) || String(e)));
152
166
  }
153
167
  return Buffer.from(plain);
@@ -155,11 +169,12 @@ async function decryptWithPassphrase(encrypted, passphrase, saltHex) {
155
169
 
156
170
  // Convenience for the common "encrypt this with a fresh salt" pattern.
157
171
  // Returns the salt as hex so callers can store it alongside the
158
- // ciphertext in the bundle manifest.
159
- async function encryptWithFreshSalt(plaintext, passphrase) {
172
+ // ciphertext in the bundle manifest. `aad` (optional) is bound as AEAD
173
+ // associated data (see _aadBytes).
174
+ async function encryptWithFreshSalt(plaintext, passphrase, aad) {
160
175
  var salt = nodeCrypto.randomBytes(SALT_BYTES);
161
176
  var saltHex = salt.toString("hex");
162
- var encrypted = await encryptWithPassphrase(plaintext, passphrase, saltHex);
177
+ var encrypted = await encryptWithPassphrase(plaintext, passphrase, saltHex, aad);
163
178
  return { encrypted: encrypted, salt: saltHex };
164
179
  }
165
180
 
@@ -53,6 +53,7 @@ var os = require("node:os");
53
53
  var nodePath = require("node:path");
54
54
  var bCrypto = require("../crypto");
55
55
  var atomicFile = require("../atomic-file");
56
+ var C = require("../constants");
56
57
  var backupBundle = require("./bundle");
57
58
  var frameworkFiles = require("../framework-files");
58
59
  var backupManifest = require("./manifest");
@@ -721,11 +722,16 @@ function create(opts) {
721
722
  try {
722
723
  await storage.readBundle(bundleId, stagingDir);
723
724
  manifestPath = nodePath.join(stagingDir, "manifest.json");
724
- if (!nodeFs.existsSync(manifestPath)) {
725
- throw new BackupError("backup/test-no-manifest",
726
- "manifest.json missing under restored bundle " + bundleId);
727
- }
728
- manifest = backupManifest.parse(nodeFs.readFileSync(manifestPath, "utf8"));
725
+ // Capped fd-bound read inside the scheduled restore-drill tick: an
726
+ // oversized manifest must not OOM the scheduler worker.
727
+ manifest = backupManifest.parse(atomicFile.fdSafeReadSync(manifestPath, {
728
+ maxBytes: C.BYTES.mib(4), encoding: "utf8",
729
+ errorFor: function (kind) {
730
+ if (kind === "enoent") return new BackupError("backup/test-no-manifest", "manifest.json missing under restored bundle " + bundleId);
731
+ if (kind === "too-large") return new BackupError("backup/test-bad-manifest", "manifest.json too large under restored bundle " + bundleId);
732
+ return new BackupError("backup/test-no-manifest", "manifest.json unreadable under restored bundle " + bundleId + ": " + kind);
733
+ },
734
+ }));
729
735
  // Verify the manifest signature so a tampered backup test
730
736
  // surfaces here, not as a regulator finding later.
731
737
  sigVerification = backupManifest.verifySignature(manifest, {
@@ -837,11 +843,17 @@ function verifyManifestSignature(target, opts) {
837
843
  var manifest;
838
844
  if (typeof target === "string") {
839
845
  var manifestPath = nodePath.join(target, "manifest.json");
840
- if (!nodeFs.existsSync(manifestPath)) {
841
- throw new BackupError("backup/no-manifest",
842
- "verifyManifestSignature: manifest.json missing at " + manifestPath);
843
- }
844
- try { manifest = backupManifest.parse(nodeFs.readFileSync(manifestPath, "utf8")); }
846
+ // Capped fd-bound read OUTSIDE the parse try (so a missing/oversized manifest
847
+ // surfaces backup/no-manifest|bad-manifest, not a generic parse error).
848
+ var manifestRaw = atomicFile.fdSafeReadSync(manifestPath, {
849
+ maxBytes: C.BYTES.mib(4), encoding: "utf8",
850
+ errorFor: function (kind) {
851
+ if (kind === "enoent") return new BackupError("backup/no-manifest", "verifyManifestSignature: manifest.json missing at " + manifestPath);
852
+ if (kind === "too-large") return new BackupError("backup/bad-manifest", "verifyManifestSignature: manifest.json too large");
853
+ return new BackupError("backup/bad-manifest", "verifyManifestSignature: unreadable: " + kind);
854
+ },
855
+ });
856
+ try { manifest = backupManifest.parse(manifestRaw); }
845
857
  catch (e) {
846
858
  throw new BackupError("backup/bad-manifest",
847
859
  "verifyManifestSignature: parse failed: " + ((e && e.message) || String(e)));
@@ -2187,19 +2199,28 @@ bundleAdapterStorage.fsAdapter = function (fsOpts) {
2187
2199
  // mode 0o600 matches the v0.12.9 directory-format readback
2188
2200
  // discipline — backup payloads carry operator-owned bytes
2189
2201
  // (potentially PHI / PCI / GDPR-scoped); owner-only is the
2190
- // strict posture. wx is not set here because writeFile is
2191
- // the storage primitive (operators legitimately rewrite the
2192
- // same key, e.g. resuming a multipart upload); upper layers
2193
- // (writeBundle's `bundle-exists` check) enforce no-overwrite
2194
- // at the bundle level.
2195
- nodeFs.writeFileSync(path, bytes, { mode: 0o600 });
2202
+ // strict posture. Overwrite of an existing key stays allowed
2203
+ // (operators legitimately rewrite the same key, e.g. resuming a
2204
+ // multipart upload); upper layers (writeBundle's `bundle-exists`
2205
+ // check) enforce no-overwrite at the bundle level. writeSync's
2206
+ // atomic rename preserves that overwrite semantic while refusing a
2207
+ // symlink pre-planted at `path` (CWE-59) and never leaving a torn
2208
+ // payload — a bare writeFileSync did both.
2209
+ atomicFile.writeSync(path, bytes, { fileMode: 0o600 });
2196
2210
  },
2197
2211
  async readFile(key) {
2198
2212
  var path = _keyPath(key);
2199
- if (!nodeFs.existsSync(path)) {
2200
- throw new BackupError("backup/no-key", "fsAdapter: key not found: " + JSON.stringify(key));
2201
- }
2202
- return nodeFs.readFileSync(path);
2213
+ // Capped fd-bound read (no existsSync check-then-read window): fetches a
2214
+ // whole bundle payload, so an oversize/swapped file is an OOM lever. 8 GiB
2215
+ // matches the writeBundle maxBundleBytes ceiling.
2216
+ return atomicFile.fdSafeReadSync(path, {
2217
+ maxBytes: C.BYTES.gib(8),
2218
+ errorFor: function (kind) {
2219
+ if (kind === "enoent") return new BackupError("backup/no-key", "fsAdapter: key not found: " + JSON.stringify(key));
2220
+ if (kind === "too-large") return new BackupError("backup/key-too-large", "fsAdapter: payload for key " + JSON.stringify(key) + " exceeds the read cap");
2221
+ return new BackupError("backup/no-key", "fsAdapter: key " + JSON.stringify(key) + " unreadable: " + kind);
2222
+ },
2223
+ });
2203
2224
  },
2204
2225
  async listKeys(prefix) {
2205
2226
  var out = [];
@@ -140,7 +140,7 @@ function _validateFileEntry(f, idx, errors) {
140
140
  if (!_isHex(f.salt, true)) {
141
141
  errors.push("files[" + idx + "].salt: required hex string");
142
142
  }
143
- if (typeof f.kind !== "string" || !VALID_KINDS[f.kind]) {
143
+ if (typeof f.kind !== "string" || !Object.prototype.hasOwnProperty.call(VALID_KINDS, f.kind)) {
144
144
  errors.push("files[" + idx + "].kind: must be one of raw, vault-sealed, plaintext");
145
145
  }
146
146
  }
@@ -239,6 +239,11 @@ function create(opts) {
239
239
  if (opts.metadata && typeof opts.metadata === "object" && !Array.isArray(opts.metadata)) {
240
240
  manifest.metadata = Object.assign({}, opts.metadata);
241
241
  }
242
+ // Marks that every file blob was sealed with its relativePath as AEAD
243
+ // associated data (the blob-remap defense). Set on all bundles this version
244
+ // writes; absent on legacy bundles, which restore decrypts without AAD.
245
+ // Carried into the signed payload so it cannot be flipped on a signed bundle.
246
+ if (opts.aadBound === true) manifest.aadBound = true;
242
247
  var v = validate(manifest);
243
248
  if (!v.ok) {
244
249
  throw new BackupManifestError("backup-manifest/invalid",
@@ -271,6 +276,7 @@ function _canonical(manifest, includeSignature) {
271
276
  }),
272
277
  };
273
278
  if (manifest.metadata) canonical.metadata = manifest.metadata;
279
+ if (manifest.aadBound === true) canonical.aadBound = true;
274
280
  // Signature block lives alongside the rest of the manifest fields
275
281
  // and is itself stable-ordered. Sign-time canonicalization (the
276
282
  // bytes the audit-sign keypair signs) excludes the signature field
@@ -120,10 +120,13 @@ function _appSqlOpts() { return { dialect: clusterStorage.dialect(), quoteName:
120
120
  // Populated on first access per-table; invalidated on policy.set/delete.
121
121
  var policyCache = new Map(); // table -> policy
122
122
  var initialized = false;
123
- // Framework-wide trustProxy setting (set at init). When true, the
124
- // break-glass primitive consults X-Forwarded-For to populate the
125
- // grant row's `ip` field same trust boundary as middleware.
126
- var _trustProxy = false;
123
+ // Framework-wide client-IP resolver (built at init). The grant row's `ip`
124
+ // field is a security control — a grant is pinned to the IP that minted it
125
+ // and re-checked on redeem. X-Forwarded-For is forgeable, so the binding is
126
+ // meaningful only when resolution is peer-gated: operators declare their
127
+ // reverse proxies via init({ trustedProxies }) or own resolution via
128
+ // init({ clientIpResolver }). Default resolves the socket address only.
129
+ var _ipResolver = requestHelpers.trustedClientIp();
127
130
 
128
131
  // Factor lockout — wrap auth.lockout so a hostile actor brute-forcing
129
132
  // TOTP codes against break-glass gets shut out after a few failures.
@@ -457,28 +460,45 @@ async function migrate(table, opts) {
457
460
  * @related b.breakGlass.policy.set, b.breakGlass.grant
458
461
  *
459
462
  * One-shot boot wiring. Clears the in-memory policy cache, resets the
460
- * factor-lockout counter, and records the framework-wide trustProxy
461
- * boundary so subsequent `grant()` calls populate the grant row's `ip`
462
- * field from `X-Forwarded-For` only when proxies are trusted. Operators
463
- * call this once at boot, before any policy / grant / unseal call —
464
- * every other primitive throws `breakglass/not-initialized` until init
465
- * has run.
463
+ * factor-lockout counter, and records how the grant row's `ip` field is
464
+ * resolved. That IP is a security binding — the grant pins to it at mint
465
+ * and re-checks it on redeem so resolution is peer-gated: declare your
466
+ * reverse proxies via `trustedProxies` (CIDRs; X-Forwarded-For honored
467
+ * only from a trusted peer) or own resolution via `clientIpResolver`. A
468
+ * bare `trustProxy` is refused — a forgeable pin is no pin. Operators call
469
+ * this once at boot, before any policy / grant / unseal call — every other
470
+ * primitive throws `breakglass/not-initialized` until init has run.
466
471
  *
467
472
  * @opts
468
- * trustProxy: boolean, // honor X-Forwarded-For when populating grant.ip (default false)
473
+ * trustedProxies: string|string[], // CIDRs of your reverse proxies — peer-gates X-Forwarded-For
474
+ * clientIpResolver: function(req): string|null, // own grant-IP resolution
469
475
  *
470
476
  * @example
471
- * b.breakGlass.init({ trustProxy: true });
477
+ * b.breakGlass.init({ trustedProxies: ["10.0.0.0/8"] });
472
478
  * // → undefined (init returns nothing; throws on bad opts)
473
479
  */
474
480
  function init(opts) {
475
481
  opts = opts || {};
476
- validateOpts(opts, ["trustProxy"], "breakGlass.init");
482
+ validateOpts(opts, ["trustProxy", "trustedProxies", "clientIpResolver"], "breakGlass.init");
483
+ var resolver;
484
+ try {
485
+ resolver = requestHelpers.trustedClientIp({
486
+ trustedProxies: opts.trustedProxies,
487
+ clientIpResolver: opts.clientIpResolver,
488
+ });
489
+ } catch (e) {
490
+ throw new BreakGlassError("breakglass/bad-opt", e.message);
491
+ }
492
+ if ((opts.trustProxy === true || typeof opts.trustProxy === "number") && !resolver.peerGated) {
493
+ throw new BreakGlassError("breakglass/bad-opt",
494
+ "trustProxy is spoofable — a grant pinned to a forgeable X-Forwarded-For is no " +
495
+ "pin at all. Declare your reverse proxies via trustedProxies: [\"10.0.0.0/8\", …] " +
496
+ "or supply clientIpResolver(req).");
497
+ }
477
498
  initialized = true;
478
499
  policyCache.clear();
479
500
  _factorLockout = null;
480
- _trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
481
- ? opts.trustProxy : false;
501
+ _ipResolver = resolver;
482
502
  }
483
503
 
484
504
  function _resetForTest() {
@@ -490,7 +510,7 @@ function _resetForTest() {
490
510
  }
491
511
  _factorLockout = null;
492
512
  _factorLockoutCache = null;
493
- _trustProxy = false;
513
+ _ipResolver = requestHelpers.trustedClientIp();
494
514
  }
495
515
 
496
516
  function _requireInit() {
@@ -1119,11 +1139,10 @@ async function grant(opts) {
1119
1139
  var nowMs = Date.now();
1120
1140
  var grantId = "bg-" + generateToken(GRANT_ID_BYTES);
1121
1141
  var sessionId = (opts.req && opts.req.session && opts.req.session.id) || null;
1122
- // Honor the framework-wide trustProxy setting from init() same
1123
- // boundary as middleware. Without trustProxy, X-Forwarded-For is
1124
- // ignored as attacker-forgeable, and the grant pins to the socket
1125
- // remoteAddress only.
1126
- var ipFromReq = requestHelpers.clientIp(opts.req, { trustProxy: _trustProxy });
1142
+ // Peer-gated client-IP resolution from init() (trustedProxies /
1143
+ // clientIpResolver). Without it, X-Forwarded-For is ignored as
1144
+ // attacker-forgeable and the grant pins to the socket remoteAddress only.
1145
+ var ipFromReq = _ipResolver.resolve(opts.req);
1127
1146
 
1128
1147
  var grantRow = {
1129
1148
  _id: grantId,
@@ -1218,7 +1237,7 @@ function _enforceGrantPins(policy, grantRow, redeemReq, actorFor) {
1218
1237
  "captured at mint (fail-closed) — re-mint from a request whose client " +
1219
1238
  "IP the framework can resolve", true);
1220
1239
  }
1221
- var redeemIp = requestHelpers.clientIp(redeemReq, { trustProxy: _trustProxy });
1240
+ var redeemIp = _ipResolver.resolve(redeemReq);
1222
1241
  if (redeemIp !== grantRow.ip) {
1223
1242
  audit.safeEmit({
1224
1243
  action: "breakglass.unsealrow",