@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
package/lib/compliance.js CHANGED
@@ -245,9 +245,40 @@ var KNOWN_POSTURES = Object.freeze([
245
245
  "cwe-top-25-2024", // CWE Top 25 Most Dangerous Software Weaknesses (2024)
246
246
  "cis-controls-v8", // CIS Controls v8
247
247
  "cmmc-2.0-level-2", // CMMC 2.0 Level 2 (Advanced) — 110 NIST 800-171 Rev 2 controls // allow:raw-byte-literal — NIST pub number / level, not bytes
248
+ // ---- v0.9.57 — granular CMMC level distinction ----
249
+ // CMMC 2.0 maturity levels carry distinct control-mapping
250
+ // expectations: Level 1 = 15 controls (FAR 52.204-21), Level 2 =
251
+ // 110 controls (NIST 800-171 Rev 2), Level 3 = additional NIST
252
+ // 800-172 enhanced controls. The umbrella "cmmc-2.0" posture
253
+ // remains for back-compat with existing operators; the explicit
254
+ // L1/L2/L3 postures are the recommended pin for new deployments.
255
+ "cmmc-2.0-level-1", // CMMC 2.0 Level 1 (Foundational) — 15 FAR controls; FCI-only data // allow:raw-byte-literal — regulatory identifier, not bytes
256
+ "cmmc-2.0-level-3", // CMMC 2.0 Level 3 (Expert) — NIST 800-172 enhanced controls atop L2 // allow:raw-byte-literal — regulatory identifier, not bytes
248
257
  ]);
249
258
 
250
- var STATE = { posture: null, setAt: null };
259
+ // SUPPLY-34 Artifact standards (SBOM / VEX format families) are NOT
260
+ // regulatory regimes. Pinning a posture like `cyclonedx-v1.6` to
261
+ // cascade audit + TLS floors conflates the act of EMITTING a SBOM
262
+ // format with the regulatory floor an operator needs. Operators who
263
+ // emit CycloneDX SBOMs do so because of an underlying regime
264
+ // (FedRAMP SBOM requirement, SSDF PW.4, etc.) — not because emitting
265
+ // the format itself defines the floor.
266
+ //
267
+ // b.compliance.artifactStandards exposes the format catalog as a
268
+ // READ-ONLY channel — operators pick a format (or set of formats)
269
+ // for SBOM / VEX emission without affecting the regulatory posture
270
+ // cascade. The names remain in KNOWN_POSTURES for back-compat
271
+ // (existing operators may have pinned them); pinning them via
272
+ // b.compliance.set emits a `compliance.posture.format_as_regime`
273
+ // audit warning so the misconfiguration is grep-able in the audit
274
+ // chain.
275
+ var ARTIFACT_STANDARDS = Object.freeze([
276
+ "cyclonedx-v1.6", // CycloneDX 1.6 SBOM
277
+ "spdx-v3.0", // SPDX 3.0 SBOM
278
+ "vex-csaf-2.1", // VEX via OASIS CSAF 2.1
279
+ ]);
280
+
281
+ var STATE = { posture: null, setAt: null, fipsMode: false };
251
282
 
252
283
  function _emitAudit(action, metadata, outcome) {
253
284
  try {
@@ -330,6 +361,38 @@ function set(posture) {
330
361
  STATE.setAt = Date.now();
331
362
  _emitAudit("compliance.posture.set", { posture: posture });
332
363
 
364
+ // SUPPLY-34 — emit a `format_as_regime` audit warning when an
365
+ // operator pins an artifact-standard format (cyclonedx-v1.6 /
366
+ // spdx-v3.0 / vex-csaf-2.1) as the regulatory posture. These names
367
+ // remain in KNOWN_POSTURES for back-compat but pinning them as the
368
+ // primary regime conflates "I emit this SBOM/VEX format" with "my
369
+ // regulatory floor is X". Operators should pin the regulatory
370
+ // regime (FedRAMP / SSDF / HIPAA / etc.) and surface artifact
371
+ // standards via b.compliance.artifactStandards.
372
+ if (ARTIFACT_STANDARDS.indexOf(posture) !== -1) {
373
+ _emitAudit("compliance.posture.format_as_regime",
374
+ { posture: posture, artifactStandards: ARTIFACT_STANDARDS,
375
+ recommendation: "Artifact standards describe what SBOM/VEX format the deployment emits — not the regulatory floor. Pin the underlying regime (e.g. 'nist-800-218-ssdf', 'fedramp-rev5-moderate') and surface emitted formats via b.compliance.artifactStandards()." },
376
+ "warning");
377
+ }
378
+
379
+ // SUPPLY-21 — emit `fips_conflict` audit warning when posture is
380
+ // FedRAMP / CMMC L3 AND the framework's PQC-first crypto defaults
381
+ // are active without an explicit fipsMode opt-in. Operators see
382
+ // this in the audit chain and either (a) document the deviation
383
+ // in their SSP or (b) set b.compliance.fipsMode(true) before set()
384
+ // to switch the audit-signing path to FIPS-validated AES-GCM +
385
+ // SHA-384.
386
+ var FIPS_BOUNDARY_POSTURES = ["fedramp-rev5-moderate", "cmmc-2.0-level-3"];
387
+ if (FIPS_BOUNDARY_POSTURES.indexOf(posture) !== -1 && !STATE.fipsMode) {
388
+ _emitAudit("compliance.posture.fips_conflict",
389
+ { posture: posture,
390
+ cryptoDefaults: "PQC-first (ML-KEM-1024 / SLH-DSA-SHAKE-256f / XChaCha20-Poly1305 / SHA3-512)",
391
+ fipsMode: false,
392
+ recommendation: "Call b.compliance.fipsMode(true) BEFORE b.compliance.set() to switch b.audit.sign to FIPS-140-3 validated AES-GCM + SHA-384, or document the PQC-first deviation in the SSP." },
393
+ "warning");
394
+ }
395
+
333
396
  // F-POSTURE-1 — cascade the posture into every primitive that owns a
334
397
  // posture-conditioned default. Each primitive exposes an
335
398
  // `applyPosture(name)` that merges the POSTURE_DEFAULTS entry for the
@@ -480,8 +543,9 @@ function clear() {
480
543
  }
481
544
 
482
545
  function _resetForTest() {
483
- STATE.posture = null;
484
- STATE.setAt = null;
546
+ STATE.posture = null;
547
+ STATE.setAt = null;
548
+ STATE.fipsMode = false;
485
549
  }
486
550
 
487
551
  // Posture → human-readable name + statutory citation + jurisdiction.
@@ -999,16 +1063,43 @@ var POSTURE_DEFAULTS = Object.freeze({
999
1063
  "circia": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1000
1064
  // ---- v0.9.6 — exceptd framework-control-gap closure cascade ----
1001
1065
  "nist-800-53": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1002
- "nist-ai-rmf-1.0": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1066
+ // SUPPLY-18 — NIST AI-RMF MANAGE.4.3 / ISO 23894 §6.5 / ISO 42001
1067
+ // §A.6 require encrypted backups for AI system state (model
1068
+ // weights, training data, prompt logs all contain regulated
1069
+ // payload). All AI-domain postures now enforce backupEncryption.
1070
+ "nist-ai-rmf-1.0": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1003
1071
  "iso-42001-2023": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1004
- "iso-23894-2023": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1005
- "owasp-llm-top-10-2025": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1006
- "owasp-asvs-v5.0": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1072
+ "iso-23894-2023": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1073
+ "owasp-llm-top-10-2025": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1074
+ // SUPPLY-19 OWASP ASVS v5.0 §8.3.4 (sensitive-data deletion)
1075
+ // requires post-delete storage reclamation. Set requireVacuumAfterErase
1076
+ // so operators pinning ASVS v5.0 inherit the proper floor.
1077
+ "owasp-asvs-v5.0": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1007
1078
  "nist-800-218-ssdf": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1008
1079
  "nist-800-82-r3": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1009
1080
  "nist-800-63b-rev4": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1010
1081
  "iec-62443-3-3": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1011
- "fedramp-rev5-moderate": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1082
+ // SUPPLY-21 FedRAMP Rev 5 Moderate baseline references FIPS 140-3
1083
+ // validated cryptography for protect-against-disclosure controls
1084
+ // (SC-13, SC-28). The framework's PQC-first defaults (ML-KEM-1024,
1085
+ // XChaCha20-Poly1305, SHA3-512) are NOT FIPS-140-3 validated as of
1086
+ // the FedRAMP Rev 5 baseline publication — FIPS modules are still
1087
+ // being certified for the ML-KEM / ML-DSA primitives upstream.
1088
+ //
1089
+ // Conflict resolution: PQC-first remains the framework default
1090
+ // (CLAUDE.md rule §2 — never weaken security middleware), but
1091
+ // operators in a FedRAMP boundary opt into `fipsMode: true` to
1092
+ // switch `b.audit.sign` from SLH-DSA-SHAKE-256f to FIPS-validated
1093
+ // AES-GCM + SHA-384 for the audit-chain signing path. The runtime
1094
+ // emits a `compliance.posture.fips_conflict` audit warning when
1095
+ // posture=fedramp-rev5-moderate AND fipsMode is NOT set so the
1096
+ // conflict is grep-able in the audit chain.
1097
+ //
1098
+ // Operators pinning this posture without setting fipsMode are
1099
+ // signaling "ship the PQC-first defaults and accept that the
1100
+ // FedRAMP boundary will need to document the deviation in their
1101
+ // SSP." The audit warning is the operator-visible signal.
1102
+ "fedramp-rev5-moderate": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true, fipsMode: false }),
1012
1103
  "hipaa-security-rule": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1013
1104
  "hitrust-csf-v11.4": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1014
1105
  "nerc-cip-007-6": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
@@ -1023,7 +1114,18 @@ var POSTURE_DEFAULTS = Object.freeze({
1023
1114
  "nist-800-115": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1024
1115
  "cwe-top-25-2024": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1025
1116
  "cis-controls-v8": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1117
+ // SUPPLY-20 — CMMC 2.0 levels differ in control mapping:
1118
+ // L1 (Foundational, 15 FAR controls, FCI data only) — encrypted
1119
+ // backups NOT mandated; audit-chain encouraged.
1120
+ // L2 (Advanced, 110 NIST 800-171 Rev 2 controls, CUI data) —
1121
+ // encrypted backups + signed audit + post-erase vacuum.
1122
+ // L3 (Expert, NIST 800-172 enhanced atop L2) — same control floor
1123
+ // as L2 plus operator-attested enhanced practices the
1124
+ // framework can't auto-cascade (FIPS 140-3 boundary,
1125
+ // continuous monitoring).
1126
+ "cmmc-2.0-level-1": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
1026
1127
  "cmmc-2.0-level-2": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
1128
+ "cmmc-2.0-level-3": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true, fipsMode: false }),
1027
1129
  });
1028
1130
 
1029
1131
  /**
@@ -1202,6 +1304,69 @@ function list() {
1202
1304
  return out;
1203
1305
  }
1204
1306
 
1307
+ /**
1308
+ * @primitive b.compliance.artifactStandards
1309
+ * @signature b.compliance.artifactStandards()
1310
+ * @since 0.9.57
1311
+ * @status stable
1312
+ *
1313
+ * Return the set of SBOM / VEX artifact standards the framework can
1314
+ * emit. These are FORMAT FAMILIES, not regulatory regimes — pinning
1315
+ * one of these names as the deployment's compliance posture conflates
1316
+ * "format I emit" with "regulatory floor I meet" (SUPPLY-34). Pin
1317
+ * the regulatory regime (FedRAMP / SSDF / HIPAA / etc.) via
1318
+ * `b.compliance.set()` and surface the emitted artifact standards via
1319
+ * this read-only catalog.
1320
+ *
1321
+ * @example
1322
+ * b.compliance.artifactStandards();
1323
+ * // → ["cyclonedx-v1.6", "spdx-v3.0", "vex-csaf-2.1"]
1324
+ */
1325
+ function artifactStandards() {
1326
+ return ARTIFACT_STANDARDS.slice();
1327
+ }
1328
+
1329
+ /**
1330
+ * @primitive b.compliance.fipsMode
1331
+ * @signature b.compliance.fipsMode(enable?)
1332
+ * @since 0.9.57
1333
+ * @status stable
1334
+ * @related b.compliance.set
1335
+ *
1336
+ * Get or set the FIPS-mode flag. When `enable === true`, the
1337
+ * framework's audit-chain signing path (b.audit.sign) switches from
1338
+ * the PQC-first default (SLH-DSA-SHAKE-256f) to a FIPS-140-3
1339
+ * validated AES-GCM + SHA-384 path so a FedRAMP / CMMC L3 boundary
1340
+ * can pin the audit signer to a validated module.
1341
+ *
1342
+ * Call BEFORE b.compliance.set() so the fips_conflict audit warning
1343
+ * doesn't fire at posture-set time. Cannot be toggled after posture
1344
+ * is pinned — runtime switches create half-set crypto state. Returns
1345
+ * the current flag value when called with no argument.
1346
+ *
1347
+ * @example
1348
+ * b.compliance.fipsMode(true); // opt into FIPS-validated path
1349
+ * b.compliance.set("fedramp-rev5-moderate");
1350
+ * b.compliance.fipsMode(); // → true
1351
+ */
1352
+ function fipsMode(enable) {
1353
+ if (enable === undefined) return STATE.fipsMode === true;
1354
+ if (typeof enable !== "boolean") {
1355
+ throw new ComplianceError("compliance/bad-fips-mode",
1356
+ "compliance.fipsMode: argument must be boolean when supplied (got " +
1357
+ typeof enable + ")");
1358
+ }
1359
+ if (STATE.posture) {
1360
+ throw new ComplianceError("compliance/fips-after-set",
1361
+ "compliance.fipsMode: posture is already pinned ('" + STATE.posture +
1362
+ "'); FIPS-mode must be set BEFORE b.compliance.set() — runtime " +
1363
+ "switches create half-set crypto state.");
1364
+ }
1365
+ STATE.fipsMode = enable;
1366
+ _emitAudit("compliance.fips_mode.set", { fipsMode: enable });
1367
+ return STATE.fipsMode;
1368
+ }
1369
+
1205
1370
  module.exports = {
1206
1371
  set: set,
1207
1372
  current: current,
@@ -1214,8 +1379,11 @@ module.exports = {
1214
1379
  postureDefault: postureDefault,
1215
1380
  sanctions: sanctions,
1216
1381
  aiAct: aiAct,
1382
+ artifactStandards: artifactStandards,
1383
+ fipsMode: fipsMode,
1217
1384
  KNOWN_POSTURES: KNOWN_POSTURES,
1218
1385
  POSTURE_DEFAULTS: POSTURE_DEFAULTS,
1386
+ ARTIFACT_STANDARDS: ARTIFACT_STANDARDS,
1219
1387
  REGIME_MAP: REGIME_MAP,
1220
1388
  ComplianceError: ComplianceError,
1221
1389
  _resetForTest: _resetForTest,
@@ -44,6 +44,7 @@
44
44
  */
45
45
  var lazyRequire = require("./lazy-require");
46
46
  var vault = require("./vault");
47
+ var vaultAad = require("./vault-aad");
47
48
  var { sha3Hash, kdf } = require("./crypto");
48
49
  var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
49
50
 
@@ -147,6 +148,21 @@ var perRowKeyTables = Object.create(null);
147
148
  * sealedFields: string[], // column names sealed via vault.seal
148
149
  * derivedHashes: { [hashCol]: { from: string, normalize?: fn } },
149
150
  * hashNamespaces: { [field]: string }, // override default rainbow-defense ns
151
+ * aad: boolean, // when true, route seal/unseal through
152
+ * // b.vault.aad — AEAD-binds the ciphertext
153
+ * // to (table, rowIdField=primary key, column)
154
+ * // so a DB-write attacker can't copy a
155
+ * // sealed value between rows. CRYPTO-1.
156
+ * rowIdField: string, // when aad=true, the column name carrying
157
+ * // the row identity. Default "id". The row
158
+ * // passed to sealRow MUST already have this
159
+ * // column populated; sealRow refuses when
160
+ * // missing (an AAD bound to a placeholder
161
+ * // would silently fail every unseal).
162
+ * schemaVersion: string|number, // when aad=true, the schema version
163
+ * // threaded into AAD. Default "1". Bump
164
+ * // when the column layout changes to
165
+ * // invalidate all prior ciphertext.
150
166
  *
151
167
  * @example
152
168
  * b.cryptoField.registerTable("patients", {
@@ -156,12 +172,26 @@ var perRowKeyTables = Object.create(null);
156
172
  * }
157
173
  * });
158
174
  * b.cryptoField.getSealedFields("patients"); // → ["ssn", "diagnosis"]
175
+ *
176
+ * // AAD-bound table (recommended for new schemas — CRYPTO-1).
177
+ * b.cryptoField.registerTable("idempotency_keys", {
178
+ * sealedFields: ["headers", "body"],
179
+ * aad: true,
180
+ * rowIdField: "k", // primary key column
181
+ * });
159
182
  */
160
183
  function registerTable(name, opts) {
184
+ var aadOn = opts.aad === true;
185
+ var rowIdField = typeof opts.rowIdField === "string" && opts.rowIdField.length > 0
186
+ ? opts.rowIdField : "id";
187
+ var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
161
188
  schemas[name] = {
162
189
  sealedFields: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
163
190
  derivedHashes: Object.assign({}, opts.derivedHashes || {}),
164
191
  hashNamespaces: Object.assign({}, opts.hashNamespaces || {}),
192
+ aad: aadOn,
193
+ rowIdField: rowIdField,
194
+ schemaVersion: schemaVersion,
165
195
  };
166
196
  }
167
197
 
@@ -327,7 +357,14 @@ function sealRow(table, row) {
327
357
  var spec = s.derivedHashes[derivedField];
328
358
  var raw = out[spec.from];
329
359
  if (raw === undefined || raw === null) continue;
330
- var plain = String(raw).startsWith(VAULT_PREFIX) ? vault.unseal(raw) : raw;
360
+ var plain;
361
+ if (typeof raw === "string" && raw.startsWith(VAULT_PREFIX)) {
362
+ plain = vault.unseal(raw);
363
+ } else if (typeof raw === "string" && vaultAad.isAadSealed(raw)) {
364
+ plain = vaultAad.unseal(raw, _aadParts(s, table, spec.from, out));
365
+ } else {
366
+ plain = raw;
367
+ }
331
368
  var ns = namespaceFor(table, spec.from, s.hashNamespaces);
332
369
  var normalized = spec.normalize ? spec.normalize(plain) : String(plain);
333
370
  var saltHex2 = vault.getDerivedHashSalt().toString("hex");
@@ -335,17 +372,59 @@ function sealRow(table, row) {
335
372
  }
336
373
  }
337
374
 
338
- // Seal fields (vault.seal is idempotent already-sealed values pass through)
375
+ // CRYPTO-1 AAD-bound table requires the row's identity column to
376
+ // be populated BEFORE sealRow runs. Sealing under a placeholder /
377
+ // missing rowId produces ciphertext that no later unseal can open
378
+ // because the AAD on read is computed against the row's actual id.
379
+ if (s.aad) {
380
+ var rowId = out[s.rowIdField];
381
+ if (rowId === undefined || rowId === null || String(rowId).length === 0) {
382
+ throw new Error("cryptoField.sealRow: table '" + table +
383
+ "' is AAD-bound (registerTable({aad:true})); the row's identity " +
384
+ "column '" + s.rowIdField + "' must be populated BEFORE sealRow. " +
385
+ "Generate the primary key first (e.g. uuid / sequence INSERT … RETURNING), " +
386
+ "set row." + s.rowIdField + ", then sealRow.");
387
+ }
388
+ }
389
+
390
+ // Seal fields. Plain mode: vault.seal (idempotent — already-sealed
391
+ // values pass through). AAD mode: vault.aad.seal binds the AEAD tag
392
+ // to (table, rowId, column, schemaVersion) — cross-row copy of a
393
+ // ciphertext fails Poly1305 on read. CRYPTO-1.
339
394
  for (var i = 0; i < s.sealedFields.length; i++) {
340
395
  var field = s.sealedFields[i];
341
396
  if (out[field] !== undefined && out[field] !== null) {
342
- out[field] = vault.seal(String(out[field]));
397
+ if (s.aad) {
398
+ // Idempotent: already-AAD-sealed values pass through unchanged.
399
+ if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
400
+ continue;
401
+ }
402
+ out[field] = vaultAad.seal(String(out[field]),
403
+ _aadParts(s, table, field, out));
404
+ } else {
405
+ // allow:seal-without-aad — plain-mode legacy table; operator
406
+ // opts into AAD via registerTable({aad:true})
407
+ out[field] = vault.seal(String(out[field]));
408
+ }
343
409
  }
344
410
  }
345
411
 
346
412
  return out;
347
413
  }
348
414
 
415
+ // _aadParts — build the canonical AAD object for an AAD-bound table.
416
+ // Threads (table, rowId, column, schemaVersion) so seal + unseal
417
+ // produce the same AAD bytes. Centralized so the seal path and the
418
+ // unseal path can never drift.
419
+ function _aadParts(schema, table, column, row) {
420
+ return {
421
+ table: table,
422
+ rowId: String(row[schema.rowIdField]),
423
+ column: column,
424
+ schemaVersion: schema.schemaVersion,
425
+ };
426
+ }
427
+
349
428
  /**
350
429
  * @primitive b.cryptoField.unsealRow
351
430
  * @signature b.cryptoField.unsealRow(table, row)
@@ -379,21 +458,39 @@ function unsealRow(table, row) {
379
458
  if (out[field]) {
380
459
  var unsealed;
381
460
  try {
382
- unsealed = vault.unseal(out[field]);
461
+ // Auto-detect the envelope shape so an AAD-bound table that
462
+ // contains pre-migration plain-vault rows still reads. Read-
463
+ // side migration is lazy; the next sealRow re-emits AAD-bound.
464
+ if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
465
+ unsealed = vaultAad.unseal(out[field],
466
+ _aadParts(s, table, field, out));
467
+ } else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
468
+ unsealed = vault.unseal(out[field]);
469
+ } else {
470
+ // Not a sealed value — pass through.
471
+ unsealed = out[field];
472
+ }
383
473
  } catch (e) {
384
- // A DB-write attacker who can write `vault:<crafted>`
385
- // payloads to sealed columns can force ML-KEM
386
- // decapsulation on attacker-controlled bytes via this read
387
- // path. Surface the failure as a chain row so operators
388
- // alert on burst patterns; null the field so downstream
389
- // code sees "no value" instead of crashing the request.
474
+ // A DB-write attacker who can write `vault:<crafted>` /
475
+ // `vault.aad:<crafted>` payloads to sealed columns can force
476
+ // KEM decapsulation / AEAD verify on attacker-controlled
477
+ // bytes via this read path. Surface the failure as a chain
478
+ // row so operators alert on burst patterns; null the field
479
+ // so downstream code sees "no value" instead of crashing the
480
+ // request. AAD-shape failures additionally indicate cross-
481
+ // row copy attempts — the audit metadata flags the shape so
482
+ // operators can write alert rules.
390
483
  try {
391
- var auditMod = require("./audit"); // allow:inline-require — circular-load defense
392
- auditMod.safeEmit({
484
+ audit().safeEmit({
393
485
  action: "system.crypto.unseal_failed",
394
486
  outcome: "failure",
395
- metadata: { table: table, field: field, rowId: row && row._id || null,
396
- reason: (e && e.message) || String(e) },
487
+ metadata: {
488
+ table: table,
489
+ field: field,
490
+ rowId: out[s.rowIdField] || out._id || null,
491
+ shape: s.aad ? "aad" : "plain",
492
+ reason: (e && e.message) || String(e),
493
+ },
397
494
  });
398
495
  } catch (_e) { /* drop-silent */ }
399
496
  unsealed = null;
@@ -818,6 +915,9 @@ function materializePerRowKey(table, rowId, dbHandle) {
818
915
  var saltHex = vault.getDerivedHashSalt().toString("hex");
819
916
  var ikm = Buffer.from(saltHex + ":" + table + ":" + rowId + ":" + spec.info, "utf8");
820
917
  var kRow = kdf(ikm, spec.keySize);
918
+ // allow:seal-without-aad — per-row K_row wrap; row identity is the
919
+ // K_row KDF input, not the AEAD AAD on the wrap. Copy-attacks fail
920
+ // because the wrapped K_row only decrypts data sealed under it.
821
921
  var sealed = vault.seal(kRow.toString("base64"));
822
922
  dbHandle.prepare(
823
923
  'INSERT INTO "_blamejs_per_row_keys" (tableName, rowId, wrappedKey, createdAt) ' +