@blamejs/blamejs-shop 0.4.30 → 0.4.32

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 (338) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/checkout.js +8 -0
  4. package/lib/order.js +71 -11
  5. package/lib/vendor/MANIFEST.json +392 -278
  6. package/lib/vendor/blamejs/.github/workflows/ci.yml +34 -3
  7. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +21 -4
  8. package/lib/vendor/blamejs/.gitignore +6 -0
  9. package/lib/vendor/blamejs/CHANGELOG.md +26 -0
  10. package/lib/vendor/blamejs/MIGRATING.md +43 -0
  11. package/lib/vendor/blamejs/README.md +8 -6
  12. package/lib/vendor/blamejs/SECURITY.md +19 -3
  13. package/lib/vendor/blamejs/api-snapshot.json +2190 -664
  14. package/lib/vendor/blamejs/docker/caddy/localstack.Caddyfile +19 -0
  15. package/lib/vendor/blamejs/docker/init/generate-certs.sh +1 -1
  16. package/lib/vendor/blamejs/docker/otel/config.yaml +42 -0
  17. package/lib/vendor/blamejs/docker/otel/export/.gitkeep +0 -0
  18. package/lib/vendor/blamejs/docker/postgres/initdb/10-replication.sh +15 -0
  19. package/lib/vendor/blamejs/docker/postgres/replica-entrypoint.sh +38 -0
  20. package/lib/vendor/blamejs/docker/toxiproxy/toxiproxy.json +14 -0
  21. package/lib/vendor/blamejs/docker-compose.test.yml +209 -0
  22. package/lib/vendor/blamejs/examples/wiki/lib/page-generator.js +132 -0
  23. package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +221 -61
  24. package/lib/vendor/blamejs/examples/wiki/lib/source-doc-parser.js +144 -9
  25. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +99 -0
  26. package/lib/vendor/blamejs/fuzz/guard-sql.fuzz.js +36 -0
  27. package/lib/vendor/blamejs/index.js +4 -0
  28. package/lib/vendor/blamejs/lib/agent-envelope-mac.js +104 -0
  29. package/lib/vendor/blamejs/lib/agent-event-bus.js +105 -4
  30. package/lib/vendor/blamejs/lib/agent-posture-chain.js +8 -42
  31. package/lib/vendor/blamejs/lib/ai-content-detect.js +9 -10
  32. package/lib/vendor/blamejs/lib/api-key.js +158 -77
  33. package/lib/vendor/blamejs/lib/atomic-file.js +62 -4
  34. package/lib/vendor/blamejs/lib/audit-chain.js +47 -11
  35. package/lib/vendor/blamejs/lib/audit-sign.js +77 -2
  36. package/lib/vendor/blamejs/lib/audit-tools.js +79 -51
  37. package/lib/vendor/blamejs/lib/audit.js +259 -123
  38. package/lib/vendor/blamejs/lib/auth/oauth.js +53 -9
  39. package/lib/vendor/blamejs/lib/auth/openid-federation.js +108 -47
  40. package/lib/vendor/blamejs/lib/auth/saml.js +6 -8
  41. package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +31 -5
  42. package/lib/vendor/blamejs/lib/backup/index.js +45 -10
  43. package/lib/vendor/blamejs/lib/break-glass.js +355 -147
  44. package/lib/vendor/blamejs/lib/cache.js +174 -105
  45. package/lib/vendor/blamejs/lib/chain-writer.js +38 -16
  46. package/lib/vendor/blamejs/lib/cli.js +19 -14
  47. package/lib/vendor/blamejs/lib/cluster-provider-db.js +130 -104
  48. package/lib/vendor/blamejs/lib/cluster-storage.js +119 -22
  49. package/lib/vendor/blamejs/lib/cluster.js +119 -71
  50. package/lib/vendor/blamejs/lib/codepoint-class.js +23 -0
  51. package/lib/vendor/blamejs/lib/compliance.js +206 -4
  52. package/lib/vendor/blamejs/lib/consent.js +82 -29
  53. package/lib/vendor/blamejs/lib/constants.js +27 -11
  54. package/lib/vendor/blamejs/lib/crypto-field.js +916 -156
  55. package/lib/vendor/blamejs/lib/db-declare-row-policy.js +35 -22
  56. package/lib/vendor/blamejs/lib/db-file-lifecycle.js +3 -2
  57. package/lib/vendor/blamejs/lib/db-query.js +882 -260
  58. package/lib/vendor/blamejs/lib/db-schema.js +228 -44
  59. package/lib/vendor/blamejs/lib/db.js +249 -99
  60. package/lib/vendor/blamejs/lib/dsr.js +385 -55
  61. package/lib/vendor/blamejs/lib/error-page.js +14 -1
  62. package/lib/vendor/blamejs/lib/external-db-migrate.js +239 -137
  63. package/lib/vendor/blamejs/lib/external-db.js +549 -34
  64. package/lib/vendor/blamejs/lib/file-upload.js +52 -7
  65. package/lib/vendor/blamejs/lib/framework-error.js +20 -1
  66. package/lib/vendor/blamejs/lib/framework-files.js +73 -0
  67. package/lib/vendor/blamejs/lib/framework-schema.js +695 -394
  68. package/lib/vendor/blamejs/lib/gate-contract.js +659 -1
  69. package/lib/vendor/blamejs/lib/guard-agent-registry.js +26 -44
  70. package/lib/vendor/blamejs/lib/guard-all.js +1 -0
  71. package/lib/vendor/blamejs/lib/guard-auth.js +42 -112
  72. package/lib/vendor/blamejs/lib/guard-cidr.js +33 -154
  73. package/lib/vendor/blamejs/lib/guard-csv.js +46 -113
  74. package/lib/vendor/blamejs/lib/guard-domain.js +34 -157
  75. package/lib/vendor/blamejs/lib/guard-dsn.js +27 -43
  76. package/lib/vendor/blamejs/lib/guard-email.js +47 -69
  77. package/lib/vendor/blamejs/lib/guard-envelope.js +19 -32
  78. package/lib/vendor/blamejs/lib/guard-event-bus-payload.js +24 -42
  79. package/lib/vendor/blamejs/lib/guard-event-bus-topic.js +25 -43
  80. package/lib/vendor/blamejs/lib/guard-filename.js +42 -106
  81. package/lib/vendor/blamejs/lib/guard-graphql.js +42 -123
  82. package/lib/vendor/blamejs/lib/guard-html.js +53 -108
  83. package/lib/vendor/blamejs/lib/guard-idempotency-key.js +24 -42
  84. package/lib/vendor/blamejs/lib/guard-image.js +46 -103
  85. package/lib/vendor/blamejs/lib/guard-imap-command.js +18 -32
  86. package/lib/vendor/blamejs/lib/guard-jmap.js +16 -30
  87. package/lib/vendor/blamejs/lib/guard-json.js +38 -108
  88. package/lib/vendor/blamejs/lib/guard-jsonpath.js +38 -171
  89. package/lib/vendor/blamejs/lib/guard-jwt.js +49 -179
  90. package/lib/vendor/blamejs/lib/guard-list-id.js +25 -41
  91. package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +27 -43
  92. package/lib/vendor/blamejs/lib/guard-mail-compose.js +24 -42
  93. package/lib/vendor/blamejs/lib/guard-mail-move.js +26 -44
  94. package/lib/vendor/blamejs/lib/guard-mail-query.js +28 -46
  95. package/lib/vendor/blamejs/lib/guard-mail-reply.js +24 -42
  96. package/lib/vendor/blamejs/lib/guard-mail-sieve.js +24 -42
  97. package/lib/vendor/blamejs/lib/guard-managesieve-command.js +17 -31
  98. package/lib/vendor/blamejs/lib/guard-markdown.js +37 -104
  99. package/lib/vendor/blamejs/lib/guard-message-id.js +26 -45
  100. package/lib/vendor/blamejs/lib/guard-mime.js +39 -151
  101. package/lib/vendor/blamejs/lib/guard-oauth.js +54 -135
  102. package/lib/vendor/blamejs/lib/guard-pdf.js +45 -101
  103. package/lib/vendor/blamejs/lib/guard-pop3-command.js +21 -31
  104. package/lib/vendor/blamejs/lib/guard-posture-chain.js +24 -42
  105. package/lib/vendor/blamejs/lib/guard-regex.js +33 -107
  106. package/lib/vendor/blamejs/lib/guard-saga-config.js +24 -42
  107. package/lib/vendor/blamejs/lib/guard-shell.js +42 -172
  108. package/lib/vendor/blamejs/lib/guard-smtp-command.js +48 -54
  109. package/lib/vendor/blamejs/lib/guard-snapshot-envelope.js +24 -42
  110. package/lib/vendor/blamejs/lib/guard-sql.js +1491 -0
  111. package/lib/vendor/blamejs/lib/guard-stream-args.js +24 -43
  112. package/lib/vendor/blamejs/lib/guard-svg.js +47 -65
  113. package/lib/vendor/blamejs/lib/guard-template.js +35 -172
  114. package/lib/vendor/blamejs/lib/guard-tenant-id.js +26 -45
  115. package/lib/vendor/blamejs/lib/guard-time.js +32 -154
  116. package/lib/vendor/blamejs/lib/guard-trace-context.js +25 -44
  117. package/lib/vendor/blamejs/lib/guard-uuid.js +32 -153
  118. package/lib/vendor/blamejs/lib/guard-xml.js +38 -113
  119. package/lib/vendor/blamejs/lib/guard-yaml.js +51 -163
  120. package/lib/vendor/blamejs/lib/http-client.js +37 -9
  121. package/lib/vendor/blamejs/lib/inbox.js +120 -107
  122. package/lib/vendor/blamejs/lib/legal-hold.js +121 -50
  123. package/lib/vendor/blamejs/lib/log-stream-cloudwatch.js +47 -31
  124. package/lib/vendor/blamejs/lib/log-stream-otlp.js +32 -18
  125. package/lib/vendor/blamejs/lib/mail-auth.js +236 -0
  126. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +2 -6
  127. package/lib/vendor/blamejs/lib/mail-dkim.js +1 -0
  128. package/lib/vendor/blamejs/lib/mail-greylist.js +2 -6
  129. package/lib/vendor/blamejs/lib/mail-helo.js +2 -6
  130. package/lib/vendor/blamejs/lib/mail-journal.js +85 -64
  131. package/lib/vendor/blamejs/lib/mail-rbl.js +2 -6
  132. package/lib/vendor/blamejs/lib/mail-scan.js +2 -6
  133. package/lib/vendor/blamejs/lib/mail-server-jmap.js +117 -12
  134. package/lib/vendor/blamejs/lib/mail-server-mx.js +276 -7
  135. package/lib/vendor/blamejs/lib/mail-spam-score.js +2 -6
  136. package/lib/vendor/blamejs/lib/mail-store.js +293 -154
  137. package/lib/vendor/blamejs/lib/mail.js +8 -4
  138. package/lib/vendor/blamejs/lib/middleware/body-parser.js +71 -25
  139. package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +19 -8
  140. package/lib/vendor/blamejs/lib/middleware/dpop.js +10 -1
  141. package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +17 -7
  142. package/lib/vendor/blamejs/lib/middleware/idempotency-key.js +75 -51
  143. package/lib/vendor/blamejs/lib/middleware/rate-limit.js +102 -32
  144. package/lib/vendor/blamejs/lib/middleware/security-headers.js +21 -5
  145. package/lib/vendor/blamejs/lib/migrations.js +108 -66
  146. package/lib/vendor/blamejs/lib/network-heartbeat.js +7 -0
  147. package/lib/vendor/blamejs/lib/network-proxy.js +24 -1
  148. package/lib/vendor/blamejs/lib/nonce-store.js +31 -9
  149. package/lib/vendor/blamejs/lib/object-store/azure-blob-bucket-ops.js +9 -4
  150. package/lib/vendor/blamejs/lib/object-store/azure-blob.js +57 -3
  151. package/lib/vendor/blamejs/lib/object-store/gcs.js +4 -1
  152. package/lib/vendor/blamejs/lib/object-store/sigv4-bucket-ops.js +5 -2
  153. package/lib/vendor/blamejs/lib/object-store/sigv4.js +38 -6
  154. package/lib/vendor/blamejs/lib/observability-otlp-exporter.js +9 -1
  155. package/lib/vendor/blamejs/lib/observability.js +124 -0
  156. package/lib/vendor/blamejs/lib/otel-export.js +12 -3
  157. package/lib/vendor/blamejs/lib/outbox.js +184 -83
  158. package/lib/vendor/blamejs/lib/parsers/safe-xml.js +47 -7
  159. package/lib/vendor/blamejs/lib/pqc-agent.js +44 -0
  160. package/lib/vendor/blamejs/lib/pubsub-cluster.js +42 -20
  161. package/lib/vendor/blamejs/lib/queue-local.js +225 -140
  162. package/lib/vendor/blamejs/lib/queue-redis.js +9 -1
  163. package/lib/vendor/blamejs/lib/queue-sqs.js +6 -0
  164. package/lib/vendor/blamejs/lib/queue.js +7 -0
  165. package/lib/vendor/blamejs/lib/redact.js +68 -11
  166. package/lib/vendor/blamejs/lib/redis-client.js +160 -31
  167. package/lib/vendor/blamejs/lib/request-helpers.js +7 -0
  168. package/lib/vendor/blamejs/lib/retention.js +101 -40
  169. package/lib/vendor/blamejs/lib/router.js +212 -5
  170. package/lib/vendor/blamejs/lib/safe-dns.js +29 -45
  171. package/lib/vendor/blamejs/lib/safe-ical.js +18 -33
  172. package/lib/vendor/blamejs/lib/safe-icap.js +27 -43
  173. package/lib/vendor/blamejs/lib/safe-sieve.js +21 -40
  174. package/lib/vendor/blamejs/lib/safe-sql.js +212 -3
  175. package/lib/vendor/blamejs/lib/safe-url.js +170 -3
  176. package/lib/vendor/blamejs/lib/safe-vcard.js +18 -33
  177. package/lib/vendor/blamejs/lib/scheduler.js +35 -12
  178. package/lib/vendor/blamejs/lib/seeders.js +122 -74
  179. package/lib/vendor/blamejs/lib/session-stores.js +42 -14
  180. package/lib/vendor/blamejs/lib/session.js +175 -77
  181. package/lib/vendor/blamejs/lib/sql.js +3842 -0
  182. package/lib/vendor/blamejs/lib/sse.js +26 -0
  183. package/lib/vendor/blamejs/lib/ssrf-guard.js +151 -4
  184. package/lib/vendor/blamejs/lib/static.js +177 -34
  185. package/lib/vendor/blamejs/lib/subject.js +96 -49
  186. package/lib/vendor/blamejs/lib/vault/index.js +3 -2
  187. package/lib/vendor/blamejs/lib/vault/passphrase-ops.js +3 -2
  188. package/lib/vendor/blamejs/lib/vault/rotate.js +168 -108
  189. package/lib/vendor/blamejs/lib/vault-aad.js +6 -0
  190. package/lib/vendor/blamejs/lib/vendor-data.js +2 -0
  191. package/lib/vendor/blamejs/lib/websocket.js +35 -5
  192. package/lib/vendor/blamejs/lib/worker-pool.js +11 -0
  193. package/lib/vendor/blamejs/package.json +2 -2
  194. package/lib/vendor/blamejs/release-notes/v0.14.x.json +1503 -0
  195. package/lib/vendor/blamejs/release-notes/v0.15.0.json +77 -0
  196. package/lib/vendor/blamejs/release-notes/v0.15.1.json +22 -0
  197. package/lib/vendor/blamejs/release-notes/v0.15.2.json +22 -0
  198. package/lib/vendor/blamejs/release-notes/v0.15.3.json +39 -0
  199. package/lib/vendor/blamejs/release-notes/v0.15.4.json +39 -0
  200. package/lib/vendor/blamejs/release-notes/v0.15.5.json +22 -0
  201. package/lib/vendor/blamejs/release-notes/v0.15.6.json +59 -0
  202. package/lib/vendor/blamejs/scripts/check-services.js +21 -0
  203. package/lib/vendor/blamejs/scripts/gen-migrating.js +51 -0
  204. package/lib/vendor/blamejs/scripts/release.js +398 -38
  205. package/lib/vendor/blamejs/test/00-primitives.js +117 -0
  206. package/lib/vendor/blamejs/test/10-state.js +140 -14
  207. package/lib/vendor/blamejs/test/20-db.js +65 -2
  208. package/lib/vendor/blamejs/test/helpers/db.js +9 -0
  209. package/lib/vendor/blamejs/test/helpers/drivers.js +27 -15
  210. package/lib/vendor/blamejs/test/helpers/services.js +21 -0
  211. package/lib/vendor/blamejs/test/integration/audit-actor-binding-pg.test.js +246 -0
  212. package/lib/vendor/blamejs/test/integration/audit-chain-external-db.test.js +517 -0
  213. package/lib/vendor/blamejs/test/integration/audit-stack-mysql.test.js +639 -0
  214. package/lib/vendor/blamejs/test/integration/audit-stack-postgres.test.js +832 -0
  215. package/lib/vendor/blamejs/test/integration/backup-restore-objectstore.test.js +453 -0
  216. package/lib/vendor/blamejs/test/integration/data-layer-cluster-mysql.test.js +649 -0
  217. package/lib/vendor/blamejs/test/integration/data-layer-cluster-pg.test.js +770 -0
  218. package/lib/vendor/blamejs/test/integration/data-layer-mysql-privacy.test.js +630 -0
  219. package/lib/vendor/blamejs/test/integration/data-layer-mysql.test.js +610 -0
  220. package/lib/vendor/blamejs/test/integration/data-layer-pg.test.js +577 -0
  221. package/lib/vendor/blamejs/test/integration/data-layer-postgres.test.js +771 -0
  222. package/lib/vendor/blamejs/test/integration/db-layer-mysql.test.js +549 -0
  223. package/lib/vendor/blamejs/test/integration/db-layer-postgres.test.js +598 -0
  224. package/lib/vendor/blamejs/test/integration/distributed-scheduler-fencing-pg.test.js +602 -0
  225. package/lib/vendor/blamejs/test/integration/external-db-postgres.test.js +576 -0
  226. package/lib/vendor/blamejs/test/integration/framework-schema-mysql.test.js +353 -0
  227. package/lib/vendor/blamejs/test/integration/log-stream-cloudwatch.test.js +224 -0
  228. package/lib/vendor/blamejs/test/integration/mail-crypto-smime.test.js +142 -17
  229. package/lib/vendor/blamejs/test/integration/network-heartbeat.test.js +25 -10
  230. package/lib/vendor/blamejs/test/integration/object-store-azure.test.js +101 -0
  231. package/lib/vendor/blamejs/test/integration/object-store-gcs.test.js +239 -0
  232. package/lib/vendor/blamejs/test/integration/object-store-sigv4.test.js +35 -16
  233. package/lib/vendor/blamejs/test/integration/object-store-worm-lock.test.js +291 -0
  234. package/lib/vendor/blamejs/test/integration/pubsub.test.js +14 -0
  235. package/lib/vendor/blamejs/test/integration/queue-sqs.test.js +322 -0
  236. package/lib/vendor/blamejs/test/integration/redis-reconnect-toxiproxy.test.js +300 -0
  237. package/lib/vendor/blamejs/test/integration/sql-fts5-catalog-sqlite.test.js +154 -0
  238. package/lib/vendor/blamejs/test/integration/tls-classical-downgrade-audit.test.js +71 -0
  239. package/lib/vendor/blamejs/test/layer-0-primitives/agent-event-bus.test.js +175 -12
  240. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-exclusive-temp.test.js +216 -0
  241. package/lib/vendor/blamejs/test/layer-0-primitives/audit-checkpoint-false-rollback.test.js +203 -0
  242. package/lib/vendor/blamejs/test/layer-0-primitives/audit-query-self-log.test.js +126 -0
  243. package/lib/vendor/blamejs/test/layer-0-primitives/audit-safeemit-redacts-secrets.test.js +196 -0
  244. package/lib/vendor/blamejs/test/layer-0-primitives/audit-signing-key-rotation.test.js +197 -0
  245. package/lib/vendor/blamejs/test/layer-0-primitives/audit-verifybundle-tamper.test.js +209 -0
  246. package/lib/vendor/blamejs/test/layer-0-primitives/azure-blob-key-encoding.test.js +121 -0
  247. package/lib/vendor/blamejs/test/layer-0-primitives/backup-residency-posture.test.js +168 -0
  248. package/lib/vendor/blamejs/test/layer-0-primitives/backup-scheduletest-drill.test.js +318 -0
  249. package/lib/vendor/blamejs/test/layer-0-primitives/break-glass.test.js +233 -7
  250. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +1120 -14
  251. package/lib/vendor/blamejs/test/layer-0-primitives/compliance.test.js +229 -0
  252. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +24 -7
  253. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-dual-read-migrate.test.js +165 -0
  254. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-per-row-key.test.js +350 -0
  255. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-unseal-rate-cap.test.js +27 -9
  256. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-upgrade-dialect.test.js +76 -0
  257. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-interop-oracles.test.js +392 -0
  258. package/lib/vendor/blamejs/test/layer-0-primitives/csrf-protect.test.js +159 -0
  259. package/lib/vendor/blamejs/test/layer-0-primitives/db-column-gate.test.js +180 -1
  260. package/lib/vendor/blamejs/test/layer-0-primitives/db-query-cross-schema.test.js +5 -2
  261. package/lib/vendor/blamejs/test/layer-0-primitives/db-query-sealed-field-in.test.js +101 -0
  262. package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +128 -0
  263. package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +38 -5
  264. package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-reconcile-emittable.test.js +127 -0
  265. package/lib/vendor/blamejs/test/layer-0-primitives/db-stream-and-payload-shape.test.js +267 -0
  266. package/lib/vendor/blamejs/test/layer-0-primitives/db-worm.test.js +150 -0
  267. package/lib/vendor/blamejs/test/layer-0-primitives/defineguard-default-gate-posture-caps.test.js +30 -0
  268. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-replaystore-required.test.js +46 -0
  269. package/lib/vendor/blamejs/test/layer-0-primitives/dsr.test.js +218 -0
  270. package/lib/vendor/blamejs/test/layer-0-primitives/erase-posture-vacuum.test.js +210 -0
  271. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +4 -1
  272. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-migrate.test.js +48 -2
  273. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +237 -5
  274. package/lib/vendor/blamejs/test/layer-0-primitives/fetch-metadata.test.js +20 -9
  275. package/lib/vendor/blamejs/test/layer-0-primitives/file-upload-content-safety-skip-audit.test.js +193 -0
  276. package/lib/vendor/blamejs/test/layer-0-primitives/guard-csv.test.js +90 -0
  277. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +85 -0
  278. package/lib/vendor/blamejs/test/layer-0-primitives/idempotency-key.test.js +10 -6
  279. package/lib/vendor/blamejs/test/layer-0-primitives/inbox.test.js +15 -4
  280. package/lib/vendor/blamejs/test/layer-0-primitives/legal-hold.test.js +146 -0
  281. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +189 -0
  282. package/lib/vendor/blamejs/test/layer-0-primitives/mail-journal.test.js +3 -1
  283. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-jmap.test.js +123 -4
  284. package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-mx.test.js +207 -2
  285. package/lib/vendor/blamejs/test/layer-0-primitives/mail-store.test.js +74 -0
  286. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +43 -0
  287. package/lib/vendor/blamejs/test/layer-0-primitives/otel-export.test.js +133 -0
  288. package/lib/vendor/blamejs/test/layer-0-primitives/otlp-attr-redaction.test.js +101 -0
  289. package/lib/vendor/blamejs/test/layer-0-primitives/outbox-inflight-reaper.test.js +136 -0
  290. package/lib/vendor/blamejs/test/layer-0-primitives/parsers-standalone.test.js +83 -0
  291. package/lib/vendor/blamejs/test/layer-0-primitives/passkey-real-vectors.test.js +429 -0
  292. package/lib/vendor/blamejs/test/layer-0-primitives/pqc-agent-curve.test.js +21 -11
  293. package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +40 -0
  294. package/lib/vendor/blamejs/test/layer-0-primitives/redact-dlp.test.js +83 -0
  295. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +113 -0
  296. package/lib/vendor/blamejs/test/layer-0-primitives/retention-dryrun-no-vacuum.test.js +99 -0
  297. package/lib/vendor/blamejs/test/layer-0-primitives/router-use-path-scope.test.js +255 -0
  298. package/lib/vendor/blamejs/test/layer-0-primitives/safe-url-canonicalize.test.js +309 -0
  299. package/lib/vendor/blamejs/test/layer-0-primitives/safe-xml.test.js +143 -0
  300. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js +287 -0
  301. package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc-ecdsa-p1363.test.js +79 -0
  302. package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc.test.js +50 -0
  303. package/lib/vendor/blamejs/test/layer-0-primitives/security-headers.test.js +31 -4
  304. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +45 -0
  305. package/lib/vendor/blamejs/test/layer-0-primitives/sigv4-bucket-ops.test.js +49 -0
  306. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +595 -0
  307. package/lib/vendor/blamejs/test/layer-0-primitives/sse-backpressure.test.js +91 -0
  308. package/lib/vendor/blamejs/test/layer-0-primitives/ssrf-guard.test.js +69 -0
  309. package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +194 -2
  310. package/lib/vendor/blamejs/test/layer-0-primitives/websocket-extension-header.test.js +88 -0
  311. package/lib/vendor/blamejs/test/layer-0-primitives/worker-pool-recycle-race.test.js +66 -0
  312. package/lib/vendor/blamejs/test/layer-1-state/api-key.test.js +84 -0
  313. package/lib/vendor/blamejs/test/layer-5-integration/external-db-residency.test.js +638 -0
  314. package/lib/vendor/blamejs/test/layer-5-integration/guard-host-integration.test.js +21 -0
  315. package/lib/vendor/blamejs/test/smoke.js +79 -21
  316. package/package.json +1 -1
  317. package/lib/vendor/blamejs/release-notes/v0.14.0.json +0 -43
  318. package/lib/vendor/blamejs/release-notes/v0.14.1.json +0 -60
  319. package/lib/vendor/blamejs/release-notes/v0.14.10.json +0 -54
  320. package/lib/vendor/blamejs/release-notes/v0.14.11.json +0 -72
  321. package/lib/vendor/blamejs/release-notes/v0.14.12.json +0 -95
  322. package/lib/vendor/blamejs/release-notes/v0.14.13.json +0 -52
  323. package/lib/vendor/blamejs/release-notes/v0.14.14.json +0 -31
  324. package/lib/vendor/blamejs/release-notes/v0.14.16.json +0 -45
  325. package/lib/vendor/blamejs/release-notes/v0.14.17.json +0 -57
  326. package/lib/vendor/blamejs/release-notes/v0.14.18.json +0 -127
  327. package/lib/vendor/blamejs/release-notes/v0.14.19.json +0 -61
  328. package/lib/vendor/blamejs/release-notes/v0.14.2.json +0 -18
  329. package/lib/vendor/blamejs/release-notes/v0.14.20.json +0 -73
  330. package/lib/vendor/blamejs/release-notes/v0.14.21.json +0 -98
  331. package/lib/vendor/blamejs/release-notes/v0.14.22.json +0 -91
  332. package/lib/vendor/blamejs/release-notes/v0.14.3.json +0 -18
  333. package/lib/vendor/blamejs/release-notes/v0.14.4.json +0 -18
  334. package/lib/vendor/blamejs/release-notes/v0.14.5.json +0 -18
  335. package/lib/vendor/blamejs/release-notes/v0.14.6.json +0 -60
  336. package/lib/vendor/blamejs/release-notes/v0.14.7.json +0 -77
  337. package/lib/vendor/blamejs/release-notes/v0.14.8.json +0 -27
  338. package/lib/vendor/blamejs/release-notes/v0.14.9.json +0 -40
@@ -15,21 +15,34 @@
15
15
  * random nonce, so two seals of the same plaintext never collide.
16
16
  *
17
17
  * Per-row key (K_row) derivation is opt-in via `declarePerRowKey`.
18
- * Tables that opt in get a fresh K_row per INSERT, stored sealed in
19
- * `_blamejs_per_row_keys`. AAD on the K_row binds (table, rowId,
20
- * info-label) copying a wrapped K_row from one row to another
21
- * fails Poly1305 verification, so a DB-write attacker cannot move
22
- * ciphertext between rows to bypass row-scoped erasure. This is the
23
- * crypto-shred substrate for `b.subject.eraseHard`: deleting the
24
- * K_row entry leaves WAL / replica residual ciphertext mathematically
25
- * undecryptable.
18
+ * Tables that opt in get a fresh K_row per INSERT: the framework
19
+ * generates a 32-byte CSPRNG row-secret, derives
20
+ * `K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":"
21
+ * || info)`, and stores the SECRET (never K_row) AAD-sealed in
22
+ * `_blamejs_per_row_keys.wrappedKey`. Because the secret is random
23
+ * not a function of any on-disk salt an attacker with full disk
24
+ * access cannot re-derive K_row once the wrapped secret is gone. The
25
+ * AAD on the wrap binds (table, rowId, column, schemaVersion):
26
+ * copying a wrapped secret from one row to another fails Poly1305
27
+ * verification, so a DB-write attacker cannot move it between rows to
28
+ * bypass row-scoped erasure. Sealed columns on a keyed row carry the
29
+ * `vault.row:` prefix and are XChaCha20-Poly1305 ciphertext under
30
+ * K_row, AEAD-bound to the same (table, rowId, column) tuple. This is
31
+ * the crypto-shred substrate for `b.subject.eraseHard` /
32
+ * `b.retention`: destroying the wrapped secret leaves WAL / replica
33
+ * residual ciphertext mathematically undecryptable — even with the
34
+ * vault root key — because K_row is gone everywhere it ever lived.
26
35
  *
27
36
  * Derived hashes (`derivedHashes`) provide indexed lookup for sealed
28
- * columns: a normalized SHA3 of the plaintext, salted by the vault's
29
- * per-deployment salt + a per-field namespace, so dictionary /
30
- * rainbow attacks across fields and across deployments fail. Sealed
31
- * columns without a derived hash are unindexable — queries on them
32
- * silently return zero rows.
37
+ * columns. The default digest is a keyed MAC
38
+ * (`hmac-shake256`: SHAKE256 under the vault's per-deployment MAC key) +
39
+ * a per-field namespace, so an attacker who recovers the salt alone
40
+ * cannot correlate low-entropy plaintexts across fields or across
41
+ * deployments. Operators keeping byte-compatibility with an existing
42
+ * salted index opt out per-table (`derivedHashMode: "salted-sha3"`) or
43
+ * per-column (`derivedHashes.<col>.mode`). Sealed columns without a
44
+ * derived hash are unindexable — queries on them silently return zero
45
+ * rows.
33
46
  *
34
47
  * Per-column residency (`declareColumnResidency`) declares EU / US /
35
48
  * global tags; the storage-write gate (`assertColumnResidency`)
@@ -47,9 +60,12 @@ var vault = require("./vault");
47
60
  var vaultAad = require("./vault-aad");
48
61
  var validateOpts = require("./validate-opts");
49
62
  var numericBounds = require("./numeric-bounds");
63
+ var safeJson = require("./safe-json");
64
+ var frameworkSchema = require("./framework-schema");
65
+ var sql = require("./sql");
50
66
  var { defineClass } = require("./framework-error");
51
- var { sha3Hash, kdf } = require("./crypto");
52
- var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
67
+ var { sha3Hash, kdf, generateBytes, encryptPacked, decryptPacked, generateToken } = require("./crypto");
68
+ var { HASH_PREFIX, VAULT_PREFIX, ROW_PREFIX, TIME } = require("./constants");
53
69
 
54
70
  // Typed refusal raised when a (actor, table, column) tuple exceeds the
55
71
  // opt-in unseal-failure rate cap and is in cooldown. alwaysPermanent —
@@ -58,6 +74,38 @@ var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
58
74
  var CryptoFieldRateError = defineClass("CryptoFieldRateError", { alwaysPermanent: true });
59
75
  var CryptoFieldError = defineClass("CryptoFieldError", { alwaysPermanent: true });
60
76
 
77
+ // Typed-value codec for sealed columns. Sealing previously String()-coerced
78
+ // every value before encryption, which silently corrupts a Buffer (lossy
79
+ // UTF-8 round-trip) or an object ("[object Object]"). This codec preserves
80
+ // byte/type fidelity through a sealed column so unseal restores the original
81
+ // type. Backward-compatible: a plain string is stored VERBATIM (pre-codec
82
+ // cells decode unchanged) - only a non-string value, or the rare string that
83
+ // itself begins with the sentinel, is wrapped. The NUL-led sentinel never
84
+ // occurs at the start of a normal stored string. number / boolean / bigint
85
+ // keep the existing String() contract (they round-trip as strings as before).
86
+ var TYPED_SENTINEL = String.fromCharCode(0) + "bjsv1:";
87
+
88
+ function _encodeTyped(value) {
89
+ if (typeof value === "string") {
90
+ return value.indexOf(TYPED_SENTINEL) === 0 ? TYPED_SENTINEL + "S:" + value : value;
91
+ }
92
+ if (Buffer.isBuffer(value)) return TYPED_SENTINEL + "B:" + value.toString("base64");
93
+ if (value instanceof Uint8Array) return TYPED_SENTINEL + "B:" + Buffer.from(value).toString("base64");
94
+ if (typeof value === "object" && value !== null) return TYPED_SENTINEL + "J:" + JSON.stringify(value);
95
+ return String(value);
96
+ }
97
+
98
+ function _decodeTyped(str) {
99
+ if (typeof str !== "string" || str.indexOf(TYPED_SENTINEL) !== 0) return str;
100
+ var body = str.slice(TYPED_SENTINEL.length);
101
+ var tag = body.slice(0, 2);
102
+ var payload = body.slice(2);
103
+ if (tag === "B:") return Buffer.from(payload, "base64");
104
+ if (tag === "J:") return safeJson.parse(payload); // plaintext is AEAD-verified; safeJson blocks proto-pollution defensively
105
+ if (tag === "S:") return payload;
106
+ return str; // unknown tag - return the raw decrypted string defensively
107
+ }
108
+
61
109
  var compliance = lazyRequire(function () { return require("./compliance"); });
62
110
  var db = lazyRequire(function () { return require("./db"); });
63
111
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -132,15 +180,117 @@ var schemas = Object.create(null);
132
180
  //
133
181
  // { tableName: { columnName: "eu" | "us" | "global" | <tag> } }
134
182
  var columnResidency = Object.create(null);
183
+ // Per-ROW residency registry — table → { residencyColumn, allowedTags }.
184
+ // The row-level sibling of columnResidency: one plaintext column on each
185
+ // row carries that row's residency tag; write gates refuse a tagged row
186
+ // landing on an incompatible backend.
187
+ var perRowResidency = Object.create(null);
135
188
 
136
189
  // Per-row key declaration registry. For tables that opt
137
- // into per-row keying, b.subject.eraseHard deletes the wrapped K_row
138
- // from _blamejs_per_row_keys, leaving WAL/replica residual ciphertext
139
- // undecryptable.
190
+ // into per-row keying, b.subject.eraseHard / b.retention destroy the
191
+ // wrapped row-secret from _blamejs_per_row_keys, leaving WAL/replica
192
+ // residual ciphertext undecryptable.
140
193
  //
141
- // { tableName: { keySize, info, residencyTag } }
194
+ // { tableName: { keySize, info } }
142
195
  var perRowKeyTables = Object.create(null);
143
196
 
197
+ // Seal-envelope strength ranking. A regulated posture can declare a
198
+ // sealEnvelopeFloor in b.compliance POSTURE_DEFAULTS; registerTable
199
+ // refuses a table that seals columns under a weaker envelope than the
200
+ // floor when that posture is the globally-pinned one. Higher rank =
201
+ // stronger binding:
202
+ // plain — vault.seal: XChaCha20-Poly1305 under the vault root,
203
+ // no AAD; a DB-write attacker can copy a cell to another
204
+ // row undetected (CWE-311 / CWE-326).
205
+ // aad — vault.aad.seal: AEAD-bound to (table,row,column,
206
+ // schemaVersion); a relocated cell fails Poly1305.
207
+ // per-row-key — K_row crypto-shred: aad binding PLUS a per-row key,
208
+ // so destroying the row-secret renders residue
209
+ // mathematically undecryptable.
210
+ var SEAL_ENVELOPE_RANK = Object.freeze({
211
+ "plain": 0,
212
+ "aad": 1,
213
+ "per-row-key": 2,
214
+ });
215
+
216
+ // The framework registry table that holds each row's AAD-sealed
217
+ // row-secret. Named once so the seal-side AAD (materializePerRowKey),
218
+ // the read-side AAD (unsealRow's K_row fetch), and rotate's reseal all
219
+ // quote the byte-identical (table, rowId, column, schemaVersion) tuple.
220
+ // Canonical LOGICAL name for the per-row-key registry. It is the AAD-tuple
221
+ // table component (so seal / unseal / rotate quote a byte-identical tuple)
222
+ // and the frameworkSchema.tableName key the local-handle SQL resolves
223
+ // through. allow:hand-rolled-sql — canonical logical-name declaration.
224
+ var PER_ROW_KEYS_TABLE = "_blamejs_per_row_keys"; // allow:hand-rolled-sql
225
+ var PER_ROW_KEYS_COLUMN = "wrappedKey";
226
+ var PER_ROW_KEYS_SCHEMA_VERSION = "1";
227
+
228
+ // The per-row-key registry is read/written against the LOCAL db() / dbHandle
229
+ // handle directly (not clusterStorage), so SQL composed for it uses the
230
+ // RESOLVED name (prefix-aware via frameworkSchema.tableName) and quoteName so
231
+ // b.sql emits the quoted identifier the single-node path expects — the same
232
+ // shape db-query.js's _sqlOpts and db.js's own local-handle b.sql calls use.
233
+ var _PER_ROW_SQL_OPTS = { dialect: "sqlite", quoteName: true };
234
+ function _perRowKeysTableName() {
235
+ return frameworkSchema.tableName(PER_ROW_KEYS_TABLE);
236
+ }
237
+
238
+ // Build the canonical AAD parts for a row-secret wrap in
239
+ // _blamejs_per_row_keys. One source of truth so seal / unseal / rotate
240
+ // never drift. `rowId` is the app row's _id (the same value
241
+ // destroyPerRowKey + subject.eraseHard delete on).
242
+ function _wrappedKeyAad(rowId) {
243
+ return vaultAad.buildColumnAad({
244
+ table: PER_ROW_KEYS_TABLE,
245
+ rowId: rowId,
246
+ column: PER_ROW_KEYS_COLUMN,
247
+ schemaVersion: PER_ROW_KEYS_SCHEMA_VERSION,
248
+ });
249
+ }
250
+
251
+ // Build the canonical AAD parts for a K_row-sealed data cell. Binds the
252
+ // ciphertext to (table, rowId, column, schemaVersion) under K_row so a
253
+ // cell pasted into a different row / column fails Poly1305 — the same
254
+ // copy-protection the AAD-bound vault.aad: path gives, but keyed by the
255
+ // row-scoped K_row rather than the vault root.
256
+ function _rowCellAad(schema, table, column, rowId) {
257
+ return vaultAad.buildColumnAad({
258
+ table: table,
259
+ rowId: rowId,
260
+ column: column,
261
+ schemaVersion: (schema && schema.schemaVersion) || "1",
262
+ });
263
+ }
264
+
265
+ // Encode a buildColumnAad parts object into the byte form
266
+ // encryptPacked / decryptPacked thread into the AEAD tag. The vault.aad
267
+ // canonicalizer (length-prefixed, sorted-keys) is the one encoder so a
268
+ // K_row cell sealed here and a wrapped-secret sealed via vaultAad.seal
269
+ // agree byte-for-byte on the same logical AAD.
270
+ function _aadBytes(parts) {
271
+ return vaultAad.canonicalizeAad(parts);
272
+ }
273
+
274
+ /**
275
+ * @primitive b.cryptoField.isRowSealed
276
+ * @signature b.cryptoField.isRowSealed(value)
277
+ * @since 0.14.25
278
+ * @related b.cryptoField.sealRow, b.cryptoField.unsealRow
279
+ *
280
+ * Returns `true` when `value` is a string carrying the per-row-key
281
+ * sealed-cell prefix (`vault.row:`), `false` otherwise. The row-keyed
282
+ * sibling of `b.vault.aad.isAadSealed` — the read path uses it to route
283
+ * a cell to its K_row decrypt instead of the vault-root unseal.
284
+ *
285
+ * @example
286
+ * b.cryptoField.isRowSealed("vault.row:AAAA"); // → true
287
+ * b.cryptoField.isRowSealed("vault:AAAA"); // → false
288
+ * b.cryptoField.isRowSealed(null); // → false
289
+ */
290
+ function isRowSealed(value) {
291
+ return typeof value === "string" && value.indexOf(ROW_PREFIX) === 0;
292
+ }
293
+
144
294
  /**
145
295
  * @primitive b.cryptoField.registerTable
146
296
  * @signature b.cryptoField.registerTable(name, opts)
@@ -154,6 +304,14 @@ var perRowKeyTables = Object.create(null);
154
304
  * hash namespaces. Subsequent `sealRow` / `unsealRow` / `eraseRow`
155
305
  * calls dispatch through this registry.
156
306
  *
307
+ * Seal-envelope floor: when a compliance posture that declares a
308
+ * `sealEnvelopeFloor` is globally pinned (`b.compliance.set` — today
309
+ * `hipaa` / `pci-dss` require at least an AAD-bound envelope), a table
310
+ * that seals columns under a weaker envelope throws
311
+ * `crypto-field/seal-envelope-below-floor` here at registration so the
312
+ * operator catches the under-protected schema at boot. Unpinned and
313
+ * non-regulated deployments register unchanged.
314
+ *
157
315
  * @opts
158
316
  * sealedFields: string[], // column names sealed via vault.seal
159
317
  * derivedHashes: { [hashCol]: { from: string, normalize?: fn } },
@@ -195,11 +353,20 @@ function registerTable(name, opts) {
195
353
  var rowIdField = typeof opts.rowIdField === "string" && opts.rowIdField.length > 0
196
354
  ? opts.rowIdField : "id";
197
355
  var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
198
- var derivedHashMode = opts.derivedHashMode || "salted-sha3";
356
+ // Derived-hash mode default-on flip (v0.15.0): the per-table default is
357
+ // the keyed MAC "hmac-shake256" (SHAKE256 under vault.getDerivedHashMacKey),
358
+ // so an attacker who recovers the per-deployment salt alone cannot
359
+ // correlate two low-entropy plaintexts across the indexed-lookup column.
360
+ // Operators who need the deterministic-per-deployment salted digest (e.g.
361
+ // to keep byte-compatibility with an existing salted-sha3 index) opt out
362
+ // explicitly with registerTable({ derivedHashMode: "salted-sha3" }), or
363
+ // per-column via derivedHashes.<col>.mode. GDPR Art. 4(5) pseudonymisation;
364
+ // HIPAA 45 CFR 164.514(b); FIPS 202; NIST SP 800-185.
365
+ var derivedHashMode = opts.derivedHashMode || "hmac-shake256";
199
366
  if (derivedHashMode !== "salted-sha3" && derivedHashMode !== "hmac-shake256") {
200
367
  throw new CryptoFieldError("crypto-field/bad-derived-hash-mode",
201
- "registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
202
- "'hmac-shake256', got " + JSON.stringify(derivedHashMode));
368
+ "registerTable: derivedHashMode must be 'hmac-shake256' (default) or " +
369
+ "'salted-sha3', got " + JSON.stringify(derivedHashMode));
203
370
  }
204
371
  var derivedHashes = Object.assign({}, opts.derivedHashes || {});
205
372
  for (var col in derivedHashes) {
@@ -211,8 +378,25 @@ function registerTable(name, opts) {
211
378
  "'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
212
379
  }
213
380
  }
381
+ var sealedFields = Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [];
382
+ // Seal-envelope floor gate. Only fires when ALL hold:
383
+ // (1) a posture is globally pinned (b.compliance.set) — read via
384
+ // compliance().current(), the same source the residency write
385
+ // gates read; an UNPINNED deployment is untouched (back-compat),
386
+ // (2) that posture declares a sealEnvelopeFloor in POSTURE_DEFAULTS
387
+ // (only regulated regimes do — hipaa / pci-dss), and
388
+ // (3) the table actually seals columns under an envelope WEAKER than
389
+ // the floor.
390
+ // A non-sealing table, an unpinned deployment, or a posture without a
391
+ // floor all pass through exactly as before. Config-time / entry-point
392
+ // tier: THROW so the operator catches the under-protected schema at
393
+ // boot rather than shipping PHI/PCI under a relocatable plain seal
394
+ // (CWE-311 / CWE-326).
395
+ if (sealedFields.length > 0) {
396
+ _assertSealEnvelopeFloor(name, aadOn);
397
+ }
214
398
  schemas[name] = {
215
- sealedFields: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
399
+ sealedFields: sealedFields,
216
400
  derivedHashes: derivedHashes,
217
401
  hashNamespaces: Object.assign({}, opts.hashNamespaces || {}),
218
402
  aad: aadOn,
@@ -222,28 +406,94 @@ function registerTable(name, opts) {
222
406
  };
223
407
  }
224
408
 
409
+ // _assertSealEnvelopeFloor — config-time guard for registerTable. Reads
410
+ // the globally-pinned posture (compliance().current()) and its declared
411
+ // sealEnvelopeFloor; throws when `table` seals columns under a weaker
412
+ // envelope. No-op when no posture is pinned, the posture declares no
413
+ // floor, or compliance isn't loaded — so unpinned/non-regulated
414
+ // deployments register exactly as before.
415
+ function _assertSealEnvelopeFloor(table, aadOn) {
416
+ var posture;
417
+ var floor;
418
+ try {
419
+ var c = compliance();
420
+ posture = c.current();
421
+ if (typeof posture !== "string" || posture.length === 0) return;
422
+ floor = c.postureDefault(posture, "sealEnvelopeFloor");
423
+ } catch (_e) {
424
+ // compliance not loaded / unavailable — record nothing, gate nothing.
425
+ return;
426
+ }
427
+ if (typeof floor !== "string" || !Object.prototype.hasOwnProperty.call(SEAL_ENVELOPE_RANK, floor)) {
428
+ return; // posture pins no recognised floor → back-compat pass-through
429
+ }
430
+ // Declared envelope for this table: per-row-key beats aad beats plain.
431
+ // declarePerRowKey may run before or after registerTable; honour it
432
+ // when it ran first.
433
+ var declared = perRowKeyTables[table] ? "per-row-key" : (aadOn ? "aad" : "plain");
434
+ if (SEAL_ENVELOPE_RANK[declared] < SEAL_ENVELOPE_RANK[floor]) {
435
+ throw new CryptoFieldError("crypto-field/seal-envelope-below-floor",
436
+ "registerTable: table '" + table + "' seals columns under the '" +
437
+ declared + "' envelope, but the pinned compliance posture '" +
438
+ posture + "' requires at least '" + floor + "'. " +
439
+ (floor === "aad"
440
+ ? "Pass registerTable({ aad: true, rowIdField: <pk> }) so each " +
441
+ "cell is AEAD-bound to (table, row, column) and cannot be " +
442
+ "relocated between rows"
443
+ : "Call b.cryptoField.declarePerRowKey('" + table + "', ...) " +
444
+ "before registerTable so each row gets a crypto-shred K_row") +
445
+ " (CWE-311 / CWE-326). Unpinned or non-regulated deployments are " +
446
+ "unaffected; this gate fires only under a posture that declares a " +
447
+ "sealEnvelopeFloor.");
448
+ }
449
+ }
450
+
225
451
  // Derived-hash digest width for the keyed (hmac-shake256) mode: 32
226
452
  // bytes -> 64 hex chars.
227
453
  var DERIVED_HASH_BYTES = 32;
228
454
 
229
455
  // Compute the indexed-lookup digest for a derived-hash column.
230
- // - "salted-sha3" (default): SHA3-512 over <per-deployment salt> + ns
231
- // + value (128 hex). Deterministic per deployment.
232
- // - "hmac-shake256": SHAKE256(<vault-sealed MAC key> || ns + value)
233
- // truncated to 32 bytes (64 hex). The key is a vault-derived secret,
234
- // NOT a static salt, so an attacker who recovers the salt alone
235
- // can't correlate two low-entropy plaintexts; the sponge has no
236
- // length-extension weakness. (b.crypto.hmacSha3 (HMAC-SHA3-512) was
237
- // considered; SHAKE256(key||msg) is chosen for the fixed-width keyed
238
- // digest with the same MAC-grade guarantee.) FIPS 202; NIST SP
239
- // 800-185; GDPR Art. 4(5) pseudonymisation; HIPAA 45 CFR 164.514(b).
456
+ // - "hmac-shake256" (registerTable default since v0.15.0):
457
+ // SHAKE256(<vault-sealed MAC key> || ns + value) truncated to 32 bytes
458
+ // (64 hex). The key is a vault-derived secret, NOT a static salt, so an
459
+ // attacker who recovers the salt alone can't correlate two low-entropy
460
+ // plaintexts; the sponge has no length-extension weakness.
461
+ // (b.crypto.hmacSha3 (HMAC-SHA3-512) was considered; SHAKE256(key||msg)
462
+ // is chosen for the fixed-width keyed digest with the same MAC-grade
463
+ // guarantee.) FIPS 202; NIST SP 800-185; GDPR Art. 4(5)
464
+ // pseudonymisation; HIPAA 45 CFR 164.514(b).
465
+ // - "salted-sha3" (opt-out / pre-v0.15.0 legacy index): SHA3-512 over
466
+ // <per-deployment salt> + ns + value (128 hex). Deterministic per
467
+ // deployment, byte-compatible with the legacy index.
468
+ // The bare-fallback (`|| "salted-sha3"`) applies only when NEITHER the
469
+ // per-column spec.mode NOR a table mode is supplied — an ad-hoc caller that
470
+ // named no mode; registerTable always records a derivedHashMode, so a
471
+ // registered table is never bare-fallthrough.
240
472
  function _computeDerivedHash(spec, tableMode, ns, normalized) {
241
- var mode = (spec && spec.mode) || tableMode || "salted-sha3";
473
+ var mode = _resolveDerivedHashMode(spec, tableMode);
242
474
  if (mode === "hmac-shake256") {
243
475
  var macKey = vault.getDerivedHashMacKey();
244
476
  return kdf(Buffer.concat([macKey, Buffer.from(ns + normalized, "utf8")]),
245
477
  DERIVED_HASH_BYTES).toString("hex");
246
478
  }
479
+ return _legacyDerivedHash(ns, normalized);
480
+ }
481
+
482
+ // Resolve the effective derived-hash mode for a (spec, tableMode) pair —
483
+ // per-column override beats the table mode beats the bare salted-sha3
484
+ // fallback (the ad-hoc-no-mode case; see _computeDerivedHash).
485
+ function _resolveDerivedHashMode(spec, tableMode) {
486
+ return (spec && spec.mode) || tableMode || "salted-sha3";
487
+ }
488
+
489
+ // The legacy (pre-v0.15.0 default) salted-sha3 digest — SHA3-512 over the
490
+ // per-deployment salt + namespace + normalized value (128 hex). Factored out
491
+ // so the dual-read LOOKUP path and the upgrade-on-read auto-migrate can
492
+ // recompute the OLD-default hash for a (ns, value) regardless of the table's
493
+ // current keyed-MAC mode: a row written before the default flipped still
494
+ // carries this digest in its derived-hash column, and a lookup that only
495
+ // computed the keyed-MAC would miss it.
496
+ function _legacyDerivedHash(ns, normalized) {
247
497
  return sha3Hash(vault.getDerivedHashSalt().toString("hex") + ns + normalized);
248
498
  }
249
499
 
@@ -420,6 +670,33 @@ function namespaceFor(table, field, registered) {
420
670
  *
421
671
  * b.cryptoField.computeDerived("users", "email", null); // → null
422
672
  */
673
+ // Build the derived-hash result for a (schema, derivedField, spec,
674
+ // sourceField, value) tuple — the single source of truth for both
675
+ // computeDerived and lookupHash. Returns `{ field, value, legacyValue? }`.
676
+ //
677
+ // value — the digest under the column's ACTIVE mode (keyed-MAC for a
678
+ // v0.15.0-default table; salted-sha3 when opted out). New
679
+ // writes index under this, so it stays the primary equality
680
+ // value every existing caller already reads.
681
+ // legacyValue — present ONLY when the active mode is the keyed MAC: the
682
+ // byte-form a row written under the PRE-v0.15.0 salted-sha3
683
+ // default would carry. A dual-read lookup matches EITHER
684
+ // value so the keyed-default flip doesn't silently lose
685
+ // pre-flip rows; the upgrade-on-read auto-migrate in
686
+ // unsealRow re-hashes a row found via the legacy digest.
687
+ function _derivedHashResult(s, table, derivedField, spec, sourceField, value) {
688
+ var ns = namespaceFor(table, sourceField, s.hashNamespaces);
689
+ var normalized = spec.normalize ? spec.normalize(value) : String(value);
690
+ var mode = _resolveDerivedHashMode(spec, s.derivedHashMode);
691
+ var primary = _computeDerivedHash(spec, s.derivedHashMode, ns, normalized);
692
+ var out = { field: derivedField, value: primary };
693
+ if (mode === "hmac-shake256") {
694
+ var legacy = _legacyDerivedHash(ns, normalized);
695
+ if (legacy !== primary) out.legacyValue = legacy;
696
+ }
697
+ return out;
698
+ }
699
+
423
700
  function computeDerived(table, sourceField, sourceValue) {
424
701
  if (sourceValue === undefined || sourceValue === null) return null;
425
702
  var s = schemas[table];
@@ -428,9 +705,7 @@ function computeDerived(table, sourceField, sourceValue) {
428
705
  for (var derivedField in s.derivedHashes) {
429
706
  var spec = s.derivedHashes[derivedField];
430
707
  if (spec.from === sourceField) {
431
- var ns = namespaceFor(table, sourceField, s.hashNamespaces);
432
- var normalized = spec.normalize ? spec.normalize(sourceValue) : String(sourceValue);
433
- return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
708
+ return _derivedHashResult(s, table, derivedField, spec, sourceField, sourceValue);
434
709
  }
435
710
  }
436
711
  return null;
@@ -450,18 +725,50 @@ function computeDerived(table, sourceField, sourceValue) {
450
725
  // tuple are refused for `cooldownMs` with a typed CryptoFieldRateError and
451
726
  // a distinct system.crypto.unseal_rate_exceeded audit row.
452
727
  //
453
- // Default OFFwhen no cap is configured, unsealRow behaves exactly as
454
- // before (null-the-field + audit-only). Composes the same timestamp-array
455
- // sliding-window shape used by b.mail.server.rateLimit (_pruneWindow):
456
- // count-based, lazily pruned on read, no background timer.
728
+ // Default-ON (v0.15.0)the cap is armed at module load with the
729
+ // DEFAULT_RATE_CAP below, so a forged-ciphertext unseal-oracle is bounded
730
+ // out of the box. Operators who want the prior audit-only behaviour opt
731
+ // out explicitly with configureUnsealRateCap(null) / { disabled: true }.
732
+ // Composes the same timestamp-array sliding-window shape used by
733
+ // b.mail.server.rateLimit (_pruneWindow): count-based, lazily pruned on
734
+ // read, no background timer.
457
735
  //
458
736
  // CWE-307 (Improper Restriction of Excessive Authentication Attempts —
459
737
  // generalized here to excessive decryption-oracle attempts); OWASP ASVS
460
738
  // v5 §2.2.1 (anti-automation); NIST SP 800-63B §5.2.2 (rate limiting).
461
- var _rateCap = null; // null = disabled
739
+ //
740
+ // DEFAULT_RATE_CAP — the secure baseline the cap arms with at module load.
741
+ // 10 forged-ciphertext failures for one (actor, table, column) inside a
742
+ // 1-minute window trip a 5-minute cooldown. Generous enough that no
743
+ // legitimate read pattern hits it (a real ciphertext never fails the
744
+ // AEAD), tight enough that an oracle-hammering attacker is shut off fast.
745
+ var DEFAULT_RATE_CAP_THRESHOLD = 10;
746
+ var DEFAULT_RATE_CAP_WINDOW_MS = TIME.minutes(1);
747
+ var DEFAULT_RATE_CAP_COOLDOWN_MS = TIME.minutes(5);
748
+ var _rateCap = null; // installed by _installDefaultRateCap() below
462
749
  var _rateFailWindows = new Map(); // "actor\x00table\x00column" → [tsMs, ...]
463
750
  var _rateCooldowns = new Map(); // same key → cooldownUntilMs
464
751
 
752
+ // Build the default cap record (Date.now clock, framework-audit sink).
753
+ // Separated so module-load and clearRateCapForTest install the identical
754
+ // secure baseline.
755
+ function _defaultRateCapRecord() {
756
+ return {
757
+ threshold: DEFAULT_RATE_CAP_THRESHOLD,
758
+ windowMs: DEFAULT_RATE_CAP_WINDOW_MS,
759
+ cooldownMs: DEFAULT_RATE_CAP_COOLDOWN_MS,
760
+ now: function () { return Date.now(); },
761
+ onAudit: null,
762
+ };
763
+ }
764
+ function _installDefaultRateCap() {
765
+ _rateCap = _defaultRateCapRecord();
766
+ _rateFailWindows.clear();
767
+ _rateCooldowns.clear();
768
+ }
769
+ // Arm the secure default at module load (security-on, not opt-in).
770
+ _installDefaultRateCap();
771
+
465
772
  // Tuple key. \x00 is not a legal column / table identifier byte and is
466
773
  // vanishingly unlikely in an actor id, so the join is unambiguous; the
467
774
  // composite is only ever a Map key (never an object property), so no
@@ -477,23 +784,25 @@ function _rateKey(actor, table, column) {
477
784
  * @compliance hipaa, gdpr, pci-dss
478
785
  * @related b.cryptoField.unsealRow, b.cryptoField.clearRateCapForTest
479
786
  *
480
- * Opt into a per-(actor, table, column) cap on sealed-column unseal
481
- * FAILURES. By default (unconfigured) `unsealRow` only nulls the field
482
- * and emits `system.crypto.unseal_failed` on a forged-ciphertext read
483
- * an attacker who can write `vault:<crafted>` payloads can hammer the
484
- * KEM-decapsulation / AEAD-verify oracle indefinitely, and only an
485
- * off-band operator alert rule catches the burst. With a cap configured,
486
- * once a single tuple accrues `threshold` failures inside `windowMs`,
487
- * every subsequent `unsealRow` touching that tuple is REFUSED for
488
- * `cooldownMs` with a `CryptoFieldRateError` and a distinct
787
+ * Tune the per-(actor, table, column) cap on sealed-column unseal
788
+ * FAILURES. The cap is ON BY DEFAULT (default-on, v0.15.0): the framework
789
+ * arms it at module load (threshold 10 / 1-minute window / 5-minute
790
+ * cooldown) so a forged-ciphertext oracle is bounded with no operator
791
+ * action. Once a single tuple accrues `threshold` failures inside
792
+ * `windowMs`, every subsequent `unsealRow` touching that tuple is REFUSED
793
+ * for `cooldownMs` with a `CryptoFieldRateError` and a distinct
489
794
  * `system.crypto.unseal_rate_exceeded` audit row, bounding the oracle.
490
- *
491
- * Pass `null` (or `{ disabled: true }`) to turn the cap back off. This is
492
- * a behaviour-changing refusal gate, so it is opt-in: unconfigured
493
- * deployments keep today's audit-only behaviour with full back-compat.
494
- * Validation is config-time / entry-point tier bad `threshold` /
495
- * `windowMs` / `cooldownMs` THROW so an operator catches the typo at
496
- * boot rather than silently disabling the cap.
795
+ * Without the cap, an attacker who can write `vault:<crafted>` payloads
796
+ * can hammer the KEM-decapsulation / AEAD-verify oracle indefinitely and
797
+ * only an off-band operator alert rule catches the burst.
798
+ *
799
+ * Pass an opts object to RAISE/lower the thresholds. Pass `null` (or
800
+ * `{ disabled: true }`) to turn the cap off entirely and fall back to
801
+ * audit-only (the pre-v0.15.0 behaviour) the documented opt-out for the
802
+ * rare deployment that needs an unbounded read path. Validation is
803
+ * config-time / entry-point tier — bad `threshold` / `windowMs` /
804
+ * `cooldownMs` THROW so an operator catches the typo at boot rather than
805
+ * silently mis-configuring the cap.
497
806
  *
498
807
  * CWE-307 (excessive-attempt restriction); OWASP ASVS v5 §2.2.1;
499
808
  * NIST SP 800-63B §5.2.2.
@@ -616,27 +925,26 @@ function _rateNoteFailure(actor, table, column) {
616
925
  * @status experimental
617
926
  * @related b.cryptoField.configureUnsealRateCap
618
927
  *
619
- * Test-only helper. Disables the unseal-failure rate cap and drops every
620
- * in-flight sliding-window + cooldown entry so a fixture can re-configure
621
- * the cap between cases. Operator code never calls this — production
622
- * deployments configure the cap once at boot.
928
+ * Test-only helper. Restores the secure DEFAULT cap (default-on baseline)
929
+ * and drops every in-flight sliding-window + cooldown entry so a fixture
930
+ * can re-configure the cap between cases from a known-good starting point.
931
+ * Operator code never calls this production deployments inherit the
932
+ * default cap at boot and tune or disable it via configureUnsealRateCap.
623
933
  *
624
934
  * @example
625
935
  * b.cryptoField.configureUnsealRateCap({ threshold: 3 });
626
936
  * b.cryptoField.clearRateCapForTest();
627
- * // cap is off again; windows + cooldowns cleared
937
+ * // cap is back at the secure default; windows + cooldowns cleared
628
938
  */
629
939
  function clearRateCapForTest() {
630
- _rateCap = null;
631
- _rateFailWindows.clear();
632
- _rateCooldowns.clear();
940
+ _installDefaultRateCap();
633
941
  }
634
942
 
635
943
  // ---- Row sealing / unsealing ----
636
944
 
637
945
  /**
638
946
  * @primitive b.cryptoField.sealRow
639
- * @signature b.cryptoField.sealRow(table, row)
947
+ * @signature b.cryptoField.sealRow(table, row, opts?)
640
948
  * @since 0.4.0
641
949
  * @compliance hipaa, gdpr, pci-dss
642
950
  * @related b.cryptoField.unsealRow, b.cryptoField.eraseRow, b.vault.seal
@@ -649,6 +957,20 @@ function clearRateCapForTest() {
649
957
  * computed BEFORE sealing the source so the indexed lookup column
650
958
  * captures the plaintext digest.
651
959
  *
960
+ * When `opts.kRow` (a row-scoped key Buffer from
961
+ * `materializePerRowKey`) is supplied — wired automatically by the
962
+ * db-query write boundary for `declarePerRowKey` tables — sealed
963
+ * columns are instead XChaCha20-Poly1305-encrypted under K_row and
964
+ * emitted with the `vault.row:` prefix, AEAD-bound to (table, rowId,
965
+ * column, schemaVersion). The residency-tag column (when the table
966
+ * declares per-row residency) is NEVER K_row-sealed: the write gate
967
+ * and reads must see it in plaintext.
968
+ *
969
+ * @opts
970
+ * kRow: Buffer, // row-scoped key from materializePerRowKey; when present,
971
+ * // sealed columns emit vault.row: cells under K_row
972
+ * rowId: string, // the row's _id; required when kRow is present (AAD term)
973
+ *
652
974
  * @example
653
975
  * b.cryptoField.registerTable("patients", {
654
976
  * sealedFields: ["ssn"],
@@ -660,11 +982,29 @@ function clearRateCapForTest() {
660
982
  * typeof sealed.ssnHash; // → "string"
661
983
  * row.ssn; // → "123-45-6789" (input untouched)
662
984
  */
663
- function sealRow(table, row) {
985
+ function sealRow(table, row, opts) {
664
986
  if (!row) return row;
665
987
  var s = schemas[table];
666
988
  if (!s) return row;
667
989
  var out = Object.assign({}, row);
990
+ opts = opts || {};
991
+ var kRow = Buffer.isBuffer(opts.kRow) ? opts.kRow : null;
992
+ // The per-row-key path needs the row identity for the cell AAD. Prefer
993
+ // the explicit opts.rowId; fall back to the row's _id. A K_row with no
994
+ // rowId can't build a stable AAD, so refuse rather than seal under a
995
+ // placeholder that no later unseal could open.
996
+ var kRowId = kRow
997
+ ? String(opts.rowId != null ? opts.rowId : (out._id != null ? out._id : ""))
998
+ : null;
999
+ if (kRow && kRowId.length === 0) {
1000
+ throw new CryptoFieldError("crypto-field/seal-row-krow-rowid-missing",
1001
+ "cryptoField.sealRow: opts.kRow supplied but no rowId (set opts.rowId " +
1002
+ "or row._id) — the K_row cell AAD binds (table, rowId, column)");
1003
+ }
1004
+ // Residency tag column must stay plaintext even under a K_row seal —
1005
+ // the write gate reads it before sealRow and reads surface it verbatim.
1006
+ var residencySpec = perRowResidency[table];
1007
+ var residencyCol = residencySpec ? residencySpec.residencyColumn : null;
668
1008
 
669
1009
  // Compute derived hashes from plaintext source values BEFORE sealing those
670
1010
  // sources. If a source value arrives already sealed (e.g. from an internal
@@ -700,29 +1040,44 @@ function sealRow(table, row) {
700
1040
  "' is AAD-bound (registerTable({aad:true})); the row's identity " +
701
1041
  "column '" + s.rowIdField + "' must be populated BEFORE sealRow. " +
702
1042
  "Generate the primary key first (e.g. uuid / sequence INSERT … RETURNING), " +
703
- "set row." + s.rowIdField + ", then sealRow.");
1043
+ "set row." + s.rowIdField + ", then sealRow."); // allow:hand-rolled-sql — error-message prose, not SQL
704
1044
  }
705
1045
  }
706
1046
 
707
- // Seal fields. Plain mode: vault.seal (idempotent — already-sealed
708
- // values pass through). AAD mode: vault.aad.seal binds the AEAD tag
709
- // to (table, rowId, column, schemaVersion) — cross-row copy of a
710
- // ciphertext fails Poly1305 on read.
1047
+ // Seal fields. Three shapes:
1048
+ // - K_row (opts.kRow present): XChaCha20-Poly1305 under the row-
1049
+ // scoped key, vault.row: prefix, AEAD-bound (table, rowId, column,
1050
+ // schemaVersion). Crypto-shred: destroying the wrapped row-secret
1051
+ // leaves these cells undecryptable.
1052
+ // - AAD mode (registerTable({aad:true})): vault.aad.seal binds the
1053
+ // tag to (table, rowId, column, schemaVersion) under the vault root.
1054
+ // - plain mode: vault.seal (idempotent — already-sealed pass through).
711
1055
  for (var i = 0; i < s.sealedFields.length; i++) {
712
1056
  var field = s.sealedFields[i];
713
- if (out[field] !== undefined && out[field] !== null) {
714
- if (s.aad) {
715
- // Idempotent: already-AAD-sealed values pass through unchanged.
716
- if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
717
- continue;
718
- }
719
- out[field] = vaultAad.seal(String(out[field]),
720
- _aadParts(s, table, field, out));
721
- } else {
722
- // allow:seal-without-aad plain-mode legacy table; operator
723
- // opts into AAD via registerTable({aad:true})
724
- out[field] = vault.seal(String(out[field]));
1057
+ if (out[field] === undefined || out[field] === null) continue;
1058
+ if (kRow && field === residencyCol) continue; // residency tag stays plaintext
1059
+ if (kRow) {
1060
+ // Idempotent: an already-K_row-sealed value passes through.
1061
+ if (isRowSealed(out[field])) continue;
1062
+ var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
1063
+ // Encode the value type-faithfully (Buffer / object preserved, not
1064
+ // String()-mangled), then UTF-8 to bytes for the AEAD. The typed
1065
+ // encoding of a string / base64 / JSON is pure ASCII-or-UTF8, so the
1066
+ // Buffer.from(str, "utf8") round-trips losslessly.
1067
+ var plainStr = _encodeTyped(out[field]);
1068
+ out[field] = ROW_PREFIX +
1069
+ encryptPacked(Buffer.from(plainStr, "utf8"), kRow, cellAad).toString("base64");
1070
+ } else if (s.aad) {
1071
+ // Idempotent: already-AAD-sealed values pass through unchanged.
1072
+ if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
1073
+ continue;
725
1074
  }
1075
+ out[field] = vaultAad.seal(_encodeTyped(out[field]),
1076
+ _aadParts(s, table, field, out));
1077
+ } else {
1078
+ // allow:seal-without-aad — plain-mode legacy table; operator
1079
+ // opts into AAD via registerTable({aad:true})
1080
+ out[field] = vault.seal(_encodeTyped(out[field]));
726
1081
  }
727
1082
  }
728
1083
 
@@ -744,7 +1099,7 @@ function _aadParts(schema, table, column, row) {
744
1099
 
745
1100
  /**
746
1101
  * @primitive b.cryptoField.unsealRow
747
- * @signature b.cryptoField.unsealRow(table, row, actor?)
1102
+ * @signature b.cryptoField.unsealRow(table, row, actor?, dbHandle?)
748
1103
  * @since 0.4.0
749
1104
  * @compliance hipaa, gdpr, pci-dss
750
1105
  * @related b.cryptoField.sealRow, b.vault.unseal, b.cryptoField.configureUnsealRateCap
@@ -758,15 +1113,29 @@ function _aadParts(schema, table, column, row) {
758
1113
  * so downstream code sees "no value" instead of crashing the request.
759
1114
  * The input row is never mutated.
760
1115
  *
761
- * When an unseal-failure rate cap is configured via
762
- * `configureUnsealRateCap` (default off), repeated forged-ciphertext
763
- * failures for a single `(actor, table, column)` tuple trip a cooldown:
764
- * once tripped, this call THROWS `CryptoFieldRateError` and emits a
765
- * distinct `system.crypto.unseal_rate_exceeded` audit instead of
766
- * exercising the decryption oracle again (CWE-307). `actor` identifies
767
- * the caller for that tuple (e.g. session subject / API key id); it
768
- * defaults to an anonymous bucket when omitted, and is ignored entirely
769
- * when no cap is configured (full back-compat for the 2-arg call).
1116
+ * `vault.row:`-prefixed cells (per-row-key tables, `declarePerRowKey`)
1117
+ * are decrypted under the row's K_row: a `dbHandle` (the db-query layer
1118
+ * passes `this._db`) is used to fetch the row's wrapped secret from
1119
+ * `_blamejs_per_row_keys`, unwrap it, and derive K_row once per call.
1120
+ * When a caller passes no `dbHandle` (e.g. `b.breakGlass.unsealRow`,
1121
+ * which reads the row via clusterStorage), the framework's local db is
1122
+ * resolved automatically the wrapped secret always lives in the local
1123
+ * `_blamejs_per_row_keys`, so keyed reads work on every path.
1124
+ * A missing wrapped row (crypto-shredded by `eraseHard` / `retention`)
1125
+ * makes the unwrap throw → the field nulls + `system.crypto.unseal_failed`
1126
+ * fires, which is correct: shredded data reads as absent.
1127
+ *
1128
+ * The unseal-failure rate cap is ON BY DEFAULT (default-on, v0.15.0):
1129
+ * repeated forged-ciphertext failures for a single `(actor, table,
1130
+ * column)` tuple trip a cooldown (threshold 10 / 1-minute window /
1131
+ * 5-minute cooldown out of the box; tune or disable via
1132
+ * `configureUnsealRateCap`). Once tripped, this call THROWS
1133
+ * `CryptoFieldRateError` and emits a distinct
1134
+ * `system.crypto.unseal_rate_exceeded` audit instead of exercising the
1135
+ * decryption oracle again (CWE-307). `actor` identifies the caller for
1136
+ * that tuple (e.g. session subject / API key id); it defaults to an
1137
+ * anonymous bucket when omitted, and is ignored entirely when the cap is
1138
+ * disabled (full back-compat for the 2-arg call).
770
1139
  *
771
1140
  * @example
772
1141
  * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
@@ -774,7 +1143,7 @@ function _aadParts(schema, table, column, row) {
774
1143
  * var clear = b.cryptoField.unsealRow("patients", sealed);
775
1144
  * clear.ssn; // → "123-45-6789"
776
1145
  */
777
- function unsealRow(table, row, actor) {
1146
+ function unsealRow(table, row, actor, dbHandle) {
778
1147
  if (!row) return row;
779
1148
  var s = schemas[table];
780
1149
  if (!s || s.sealedFields.length === 0) return row;
@@ -782,15 +1151,65 @@ function unsealRow(table, row, actor) {
782
1151
  var capActor = (actor === undefined || actor === null || String(actor).length === 0)
783
1152
  ? "_anon" : String(actor);
784
1153
 
1154
+ // Lazy K_row: derive at most once per unsealRow call, only if a cell
1155
+ // actually carries the vault.row: prefix. Cached across fields (and
1156
+ // the failure case is cached too, so a shredded row doesn't re-query
1157
+ // _blamejs_per_row_keys for every sealed column). The row identity for
1158
+ // both the cell AAD and the wrapped-secret lookup is the row's _id —
1159
+ // the same value the seal side (write boundary) passed as rowId and
1160
+ // that destroyPerRowKey / eraseHard delete on.
1161
+ var kRowId = out._id != null ? String(out._id) : "";
1162
+ var keyedTable = hasPerRowKey(table);
1163
+ var _kRowCache; // undefined = not yet derived; null = derive failed
1164
+ function _kRowOnce() {
1165
+ if (_kRowCache !== undefined) return _kRowCache;
1166
+ _kRowCache = null;
1167
+ if (!keyedTable || kRowId.length === 0) return null;
1168
+ // Resolve a prepared-statement source for the wrapped-secret lookup.
1169
+ // Prefer the caller's dbHandle (the db-query read layer threads it on
1170
+ // first()/all()/stream()); otherwise resolve the framework's local
1171
+ // db ourselves. A DIRECT caller — e.g. b.breakGlass.unsealRow, which
1172
+ // fetches the target row via clusterStorage and calls unsealRow with
1173
+ // no handle — would otherwise null every K_row cell on a keyed table
1174
+ // even though the wrapped secret still exists. The secret always
1175
+ // lives in the local _blamejs_per_row_keys, so keyed reads must work
1176
+ // on every path, not only db-query's. Any failure (db not yet
1177
+ // initialized, unusable handle) → null, and the field reads as absent
1178
+ // exactly as a shredded row would (the caller audits it).
1179
+ var spec = perRowKeyTables[table];
1180
+ var wrap;
1181
+ try {
1182
+ var prep = (dbHandle && typeof dbHandle.prepare === "function")
1183
+ ? dbHandle.prepare.bind(dbHandle)
1184
+ : db().prepare;
1185
+ var wrapSelBuilt = sql.select(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
1186
+ .columns(["wrappedKey"])
1187
+ .where("tableName", table)
1188
+ .where("rowId", kRowId)
1189
+ .toSql();
1190
+ var wrapStmt = prep(wrapSelBuilt.sql);
1191
+ wrap = wrapStmt.get.apply(wrapStmt, wrapSelBuilt.params);
1192
+ } catch (_e) {
1193
+ return null;
1194
+ }
1195
+ if (!wrap || wrap.wrappedKey == null) return null; // shredded / never materialized
1196
+ _kRowCache = _deriveKRow(_unwrapRowSecret(wrap.wrappedKey, kRowId), table, kRowId, spec);
1197
+ return _kRowCache;
1198
+ }
1199
+
785
1200
  for (var i = 0; i < s.sealedFields.length; i++) {
786
1201
  var field = s.sealedFields[i];
787
1202
  if (out[field]) {
788
- // Opt-in cap: if this (actor, table, column) tuple is in cooldown
1203
+ // Per-cell envelope shape for audit metadata (operators write alert
1204
+ // rules off it): "row" = K_row cell, "aad" = vault.aad: cell on an
1205
+ // AAD table, "plain" otherwise.
1206
+ var shape = isRowSealed(out[field]) ? "row" : (s.aad ? "aad" : "plain");
1207
+ // Default-on cap: if this (actor, table, column) tuple is in cooldown
789
1208
  // from prior forged-ciphertext failures, refuse before touching the
790
1209
  // decryption oracle again (CWE-307). No-op when the cap is disabled.
791
1210
  if (_rateInCooldown(capActor, table, field)) {
792
1211
  _emitRateAudit({
793
- table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
1212
+ table: table, field: field, actor: capActor, shape: shape,
794
1213
  threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
795
1214
  });
796
1215
  throw new CryptoFieldRateError("crypto-field/unseal-rate-exceeded",
@@ -802,11 +1221,26 @@ function unsealRow(table, row, actor) {
802
1221
  // Auto-detect the envelope shape so an AAD-bound table that
803
1222
  // contains pre-migration plain-vault rows still reads. Read-
804
1223
  // side migration is lazy; the next sealRow re-emits AAD-bound.
805
- if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
806
- unsealed = vaultAad.unseal(out[field],
807
- _aadParts(s, table, field, out));
1224
+ if (typeof out[field] === "string" && isRowSealed(out[field])) {
1225
+ // Per-row-key cell: derive K_row (lazy, once), then decrypt
1226
+ // under it with the (table, rowId, column, schemaVersion) AAD.
1227
+ // A null K_row means the wrapped secret is gone (shredded) or
1228
+ // unreadable — throw so the catch nulls the field + audits.
1229
+ var kRow = _kRowOnce();
1230
+ if (!kRow) {
1231
+ throw new CryptoFieldError("crypto-field/row-key-unavailable",
1232
+ "unsealRow: per-row key for '" + table + "' row '" + kRowId +
1233
+ "' is unavailable (shredded or never materialized)");
1234
+ }
1235
+ var cellAad = _aadBytes(_rowCellAad(s, table, field, kRowId));
1236
+ unsealed = _decodeTyped(decryptPacked(
1237
+ Buffer.from(out[field].slice(ROW_PREFIX.length), "base64"), kRow, cellAad
1238
+ ).toString("utf8"));
1239
+ } else if (typeof out[field] === "string" && vaultAad.isAadSealed(out[field])) {
1240
+ unsealed = _decodeTyped(vaultAad.unseal(out[field],
1241
+ _aadParts(s, table, field, out)));
808
1242
  } else if (typeof out[field] === "string" && out[field].startsWith(VAULT_PREFIX)) {
809
- unsealed = vault.unseal(out[field]);
1243
+ unsealed = _decodeTyped(vault.unseal(out[field]));
810
1244
  } else {
811
1245
  // Not a sealed value — pass through.
812
1246
  unsealed = out[field];
@@ -829,18 +1263,18 @@ function unsealRow(table, row, actor) {
829
1263
  table: table,
830
1264
  field: field,
831
1265
  rowId: out[s.rowIdField] || out._id || null,
832
- shape: s.aad ? "aad" : "plain",
1266
+ shape: shape,
833
1267
  reason: (e && e.message) || String(e),
834
1268
  },
835
1269
  });
836
1270
  } catch (_e) { /* drop-silent */ }
837
- // Opt-in rate cap: account this failure against the (actor,
1271
+ // Default-on rate cap: account this failure against the (actor,
838
1272
  // table, column) tuple. When it trips the threshold, arm the
839
1273
  // cooldown + emit the distinct rate-exceeded audit once on the
840
1274
  // transition. No-op when the cap is disabled.
841
1275
  if (_rateNoteFailure(capActor, table, field)) {
842
1276
  _emitRateAudit({
843
- table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
1277
+ table: table, field: field, actor: capActor, shape: shape,
844
1278
  threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
845
1279
  });
846
1280
  }
@@ -857,9 +1291,100 @@ function unsealRow(table, row, actor) {
857
1291
  }
858
1292
  }
859
1293
 
1294
+ // Upgrade-on-read auto-migrate for the keyed-MAC derived-hash default
1295
+ // flip (v0.15.0). A row written BEFORE the default moved from salted-sha3
1296
+ // to hmac-shake256 carries the legacy salted digest in its derived-hash
1297
+ // column; a keyed-only lookup would miss it (the dual-read in
1298
+ // lookupHashCandidates is what FINDS it). When such a row is unsealed and
1299
+ // we now hold the source plaintext, recompute the keyed-MAC digest and, if
1300
+ // the stored column still holds the legacy salted-sha3 value, re-write that
1301
+ // column to the keyed form so the row is keyed-indexed from now on and the
1302
+ // candidate set collapses back to a single value over time. Best-effort:
1303
+ // the returned row always carries the upgraded hash; the durable rewrite
1304
+ // happens only when a writable dbHandle is available + the row has an _id.
1305
+ _upgradeDerivedHashesOnRead(s, table, out, dbHandle);
1306
+
860
1307
  return out;
861
1308
  }
862
1309
 
1310
+ // Re-hash any legacy-salted derived-hash columns on a just-unsealed row to
1311
+ // the active keyed-MAC form. Pure-detect + in-place upgrade on the returned
1312
+ // `out` object; when `dbHandle` exposes a writable .prepare(), the upgrade is
1313
+ // also persisted with one UPDATE per row keyed on `_id`. Never throws — a
1314
+ // failed durable rewrite leaves the row matchable via the legacy digest (the
1315
+ // dual-read still finds it next time).
1316
+ function _upgradeDerivedHashesOnRead(s, table, out, dbHandle) {
1317
+ if (!s.derivedHashes) return;
1318
+ var rowId = out._id != null ? String(out._id) : "";
1319
+ var upgrades = null; // { derivedField: keyedValue } to persist
1320
+ for (var derivedField in s.derivedHashes) {
1321
+ if (!Object.prototype.hasOwnProperty.call(s.derivedHashes, derivedField)) continue;
1322
+ var spec = s.derivedHashes[derivedField];
1323
+ // Only the keyed-MAC mode has a distinct legacy form to migrate from.
1324
+ if (_resolveDerivedHashMode(spec, s.derivedHashMode) !== "hmac-shake256") continue;
1325
+ var stored = out[derivedField];
1326
+ if (typeof stored !== "string" || stored.length === 0) continue;
1327
+ var plain = out[spec.from];
1328
+ if (plain === undefined || plain === null) continue; // source erased / absent — nothing to re-hash
1329
+ var ns = namespaceFor(table, spec.from, s.hashNamespaces);
1330
+ var normalized = spec.normalize ? spec.normalize(plain) : String(plain);
1331
+ var keyed = _computeDerivedHash(spec, s.derivedHashMode, ns, normalized);
1332
+ if (stored === keyed) continue; // already keyed-indexed
1333
+ var legacy = _legacyDerivedHash(ns, normalized);
1334
+ if (stored !== legacy) continue; // not the legacy digest — leave untouched
1335
+ // Found a legacy-indexed row: surface the keyed hash on the returned row
1336
+ // and queue the durable rewrite.
1337
+ out[derivedField] = keyed;
1338
+ if (!upgrades) upgrades = {};
1339
+ upgrades[derivedField] = keyed;
1340
+ }
1341
+ if (!upgrades) return;
1342
+ // Persist when we can resolve a writable local handle + have a row identity.
1343
+ // The derived-hash columns + the app table live on the LOCAL db (the same
1344
+ // handle the per-row-key registry uses); the rewrite is a plain UPDATE.
1345
+ if (rowId.length === 0) return;
1346
+ var handle = (dbHandle && typeof dbHandle.prepare === "function")
1347
+ ? dbHandle
1348
+ : _resolveLocalDbHandle();
1349
+ if (!handle) return;
1350
+ try {
1351
+ // The rewrite runs on whatever handle resolved the read. The local b.db is
1352
+ // node:sqlite; a caller-supplied external handle declares its dialect on
1353
+ // handle.dialect ("postgres" | "mysql"), so the UPDATE must quote
1354
+ // identifiers for THAT dialect — a sqlite-quoted UPDATE ("users") is parsed
1355
+ // as a string literal by MySQL (which expects `users`) and the durable
1356
+ // re-hash silently no-ops. Resolve the dialect the way db-query._dialect()
1357
+ // does (validated set, sqlite default).
1358
+ var handleDialect = (handle.dialect === "postgres" || handle.dialect === "mysql" ||
1359
+ handle.dialect === "sqlite") ? handle.dialect : "sqlite";
1360
+ var updBuilt = sql.update(table, { dialect: handleDialect, quoteName: true })
1361
+ .set(upgrades)
1362
+ .where("_id", rowId)
1363
+ .toSql();
1364
+ var stmt = handle.prepare(updBuilt.sql);
1365
+ stmt.run.apply(stmt, updBuilt.params);
1366
+ } catch (_e) {
1367
+ // Best-effort — DB not initialized, read-only handle, or the app table
1368
+ // isn't on this handle (cluster mode where the row came from the external
1369
+ // backend). The returned row still carries the upgraded hash; the legacy
1370
+ // digest stays matchable via lookupHashCandidates until a writable read
1371
+ // path re-hashes it.
1372
+ }
1373
+ }
1374
+
1375
+ // Resolve the framework's local db handle for the upgrade-on-read rewrite.
1376
+ // Mirrors the K_row read path's fallback: prefer an explicit dbHandle, else
1377
+ // the framework's own db(). Returns null when no .prepare()-capable handle
1378
+ // is reachable (db not initialized yet) so the caller skips the durable write.
1379
+ function _resolveLocalDbHandle() {
1380
+ try {
1381
+ var inst = db();
1382
+ return (inst && typeof inst.prepare === "function") ? inst : null;
1383
+ } catch (_e) {
1384
+ return null;
1385
+ }
1386
+ }
1387
+
863
1388
  // ---- Erasure (GDPR Art. 17 / "right to be forgotten") ----
864
1389
  //
865
1390
  // eraseRow(table, row) returns a tombstoned copy of the row: every
@@ -994,6 +1519,16 @@ function eraseRow(table, row) {
994
1519
  * every encryption uses a fresh random nonce, so the ciphertext alone
995
1520
  * cannot anchor a query.
996
1521
  *
1522
+ * `value` is the digest under the column's ACTIVE mode (keyed-MAC by
1523
+ * default since v0.15.0; salted-sha3 when opted out), so existing callers
1524
+ * that emit `where(result.field, result.value)` are unchanged. When the
1525
+ * active mode is the keyed MAC, the result ALSO carries `legacyValue` — the
1526
+ * byte-form a row written under the pre-v0.15.0 salted-sha3 default would
1527
+ * hold. Callers that can issue a match-EITHER query (or that prefer the
1528
+ * ready-made candidate list) use `b.cryptoField.lookupHashCandidates`; the
1529
+ * upgrade-on-read auto-migrate in `unsealRow` re-hashes any row found via
1530
+ * the legacy digest to the keyed-MAC form.
1531
+ *
997
1532
  * @example
998
1533
  * b.cryptoField.registerTable("users", {
999
1534
  * sealedFields: ["email"],
@@ -1011,14 +1546,52 @@ function lookupHash(table, field, value) {
1011
1546
  for (var derivedField in s.derivedHashes) {
1012
1547
  var spec = s.derivedHashes[derivedField];
1013
1548
  if (spec.from === field) {
1014
- var ns = namespaceFor(table, field, s.hashNamespaces);
1015
- var normalized = spec.normalize ? spec.normalize(value) : String(value);
1016
- return { field: derivedField, value: _computeDerivedHash(spec, s.derivedHashMode, ns, normalized) };
1549
+ return _derivedHashResult(s, table, derivedField, spec, field, value);
1017
1550
  }
1018
1551
  }
1019
1552
  return null;
1020
1553
  }
1021
1554
 
1555
+ /**
1556
+ * @primitive b.cryptoField.lookupHashCandidates
1557
+ * @signature b.cryptoField.lookupHashCandidates(table, field, value)
1558
+ * @since 0.15.0
1559
+ * @compliance gdpr, hipaa
1560
+ * @related b.cryptoField.lookupHash, b.cryptoField.unsealRow
1561
+ *
1562
+ * Dual-read sibling of `lookupHash`. Returns `{ field, values }` where
1563
+ * `values` is the list of derived-hash digests that should ALL be treated
1564
+ * as a match for `value` — the digest under the column's active mode FIRST,
1565
+ * plus (when the active mode is the keyed MAC) the pre-v0.15.0 salted-sha3
1566
+ * digest a row written under the old default would carry. A caller that can
1567
+ * issue an `IN (…)` / `OR` equality over `field` finds both the new
1568
+ * keyed-indexed rows and the legacy salted-indexed rows in one query, so the
1569
+ * keyed-MAC default flip never silently drops pre-flip rows. Returns null
1570
+ * when no derived hash is declared for `field`.
1571
+ *
1572
+ * Pair it with the upgrade-on-read auto-migrate: `unsealRow` re-hashes any
1573
+ * row whose stored derived-hash matches the legacy digest to the keyed-MAC
1574
+ * form, so the candidate list shrinks back to a single value as rows are
1575
+ * read over time.
1576
+ *
1577
+ * @example
1578
+ * b.cryptoField.registerTable("users", {
1579
+ * sealedFields: ["email"],
1580
+ * derivedHashes: { emailHash: { from: "email" } },
1581
+ * });
1582
+ * var c = b.cryptoField.lookupHashCandidates("users", "email", "alice@example.com");
1583
+ * c.field; // → "emailHash"
1584
+ * c.values.length; // → 2 (keyed-MAC + legacy salted-sha3)
1585
+ * // → b.db.from("users").where(c.field, "IN", c.values)
1586
+ */
1587
+ function lookupHashCandidates(table, field, value) {
1588
+ var r = lookupHash(table, field, value);
1589
+ if (!r) return null;
1590
+ var values = [r.value];
1591
+ if (r.legacyValue && r.legacyValue !== r.value) values.push(r.legacyValue);
1592
+ return { field: r.field, values: values };
1593
+ }
1594
+
1022
1595
  /**
1023
1596
  * @primitive b.cryptoField.declareColumnResidency
1024
1597
  * @signature b.cryptoField.declareColumnResidency(table, opts)
@@ -1105,7 +1678,7 @@ function getColumnResidency(table) {
1105
1678
  * @signature b.cryptoField.assertColumnResidency(table, row, args)
1106
1679
  * @since 0.7.27
1107
1680
  * @compliance gdpr
1108
- * @related b.cryptoField.declareColumnResidency
1681
+ * @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowResidency
1109
1682
  *
1110
1683
  * Storage-write gate. Storage backends call this with the proposed
1111
1684
  * row before the SQL hits the wire; refusal under regulated postures
@@ -1161,6 +1734,137 @@ function assertColumnResidency(table, row, args) {
1161
1734
  return null;
1162
1735
  }
1163
1736
 
1737
+ /**
1738
+ * @primitive b.cryptoField.declarePerRowResidency
1739
+ * @signature b.cryptoField.declarePerRowResidency(table, opts)
1740
+ * @since 0.14.24
1741
+ * @compliance gdpr
1742
+ * @related b.cryptoField.getPerRowResidency, b.cryptoField.declareColumnResidency
1743
+ *
1744
+ * Declares per-ROW data residency for `table`: one plaintext column on
1745
+ * each row carries that row's residency tag, and the write gates
1746
+ * refuse a tagged row landing on an incompatible backend. The sibling
1747
+ * of `declareColumnResidency` — columns answer "which fields are
1748
+ * region-bound", rows answer "which region does THIS record belong
1749
+ * to" (an EU user's row next to a US user's row in the same table).
1750
+ * Local writes (`b.db.from(...).insertOne` / `.update`) enforce the
1751
+ * tag against the deployment's `dataResidency` region set under
1752
+ * cross-border regulated postures; external writes
1753
+ * (`b.externalDb.query`) take the tag per call via
1754
+ * `opts.rowResidencyTag` because raw SQL carries no row object. Rows
1755
+ * tagged "global" or "unrestricted" pass any backend. Throws on bad
1756
+ * input (config-time fail-loud).
1757
+ *
1758
+ * @opts
1759
+ * residencyColumn: string, // plaintext column carrying the row's tag
1760
+ * allowedTags: string[], // whitelist of valid tag values ("eu", "us", "global", region names)
1761
+ *
1762
+ * @example
1763
+ * b.cryptoField.declarePerRowResidency("users", {
1764
+ * residencyColumn: "dataRegion",
1765
+ * allowedTags: ["eu-west-1", "us-east-1", "global"],
1766
+ * });
1767
+ * var spec = b.cryptoField.getPerRowResidency("users");
1768
+ * spec.residencyColumn; // → "dataRegion"
1769
+ */
1770
+ function declarePerRowResidency(table, opts) {
1771
+ validateOpts.requireNonEmptyString(table, "declarePerRowResidency: table",
1772
+ CryptoFieldError, "crypto-field/per-row-residency-table-empty");
1773
+ validateOpts.requireObject(opts, "declarePerRowResidency",
1774
+ CryptoFieldError, "crypto-field/per-row-residency-opts-not-object");
1775
+ validateOpts(opts, ["residencyColumn", "allowedTags"], "cryptoField.declarePerRowResidency");
1776
+ validateOpts.requireNonEmptyString(opts.residencyColumn,
1777
+ "declarePerRowResidency: opts.residencyColumn",
1778
+ CryptoFieldError, "crypto-field/per-row-residency-column-invalid");
1779
+ if (!Array.isArray(opts.allowedTags) || opts.allowedTags.length === 0) {
1780
+ throw new CryptoFieldError("crypto-field/per-row-residency-tags-invalid",
1781
+ "declarePerRowResidency: opts.allowedTags must be a non-empty array of tag strings");
1782
+ }
1783
+ validateOpts.optionalNonEmptyStringArray(opts.allowedTags,
1784
+ "declarePerRowResidency: opts.allowedTags",
1785
+ CryptoFieldError, "crypto-field/per-row-residency-tag-empty");
1786
+ // The residency tag column MUST stay plaintext — the write gate reads
1787
+ // it on every INSERT / UPDATE before sealRow, and reads return it
1788
+ // verbatim. A sealed residency column would be ciphertext the gate
1789
+ // can't compare and reads can't surface. Refuse the misconfiguration
1790
+ // at declaration time when the table's sealed-field set is already
1791
+ // known (registration order permitting).
1792
+ var sealed = getSealedFields(table);
1793
+ if (Array.isArray(sealed) && sealed.indexOf(opts.residencyColumn) !== -1) {
1794
+ throw new CryptoFieldError("crypto-field/per-row-residency-sealed-conflict",
1795
+ "declarePerRowResidency: residencyColumn '" + opts.residencyColumn +
1796
+ "' is a sealed field on table '" + table + "' — the residency tag must " +
1797
+ "stay plaintext so the write gate can read it. Choose a non-sealed column");
1798
+ }
1799
+ perRowResidency[table] = {
1800
+ residencyColumn: opts.residencyColumn,
1801
+ allowedTags: opts.allowedTags.slice(),
1802
+ };
1803
+ return {
1804
+ table: table,
1805
+ residencyColumn: opts.residencyColumn,
1806
+ allowedTags: opts.allowedTags.slice(),
1807
+ };
1808
+ }
1809
+
1810
+ /**
1811
+ * @primitive b.cryptoField.getPerRowResidency
1812
+ * @signature b.cryptoField.getPerRowResidency(table)
1813
+ * @since 0.14.24
1814
+ * @related b.cryptoField.declarePerRowResidency
1815
+ *
1816
+ * Returns the per-row residency spec declared for `table`
1817
+ * (`{ residencyColumn, allowedTags }`), or null when the table has no
1818
+ * declaration. Read-only — storage backends call this at the write
1819
+ * boundary to decide whether the row-residency gate applies.
1820
+ *
1821
+ * @example
1822
+ * b.cryptoField.declarePerRowResidency("users", {
1823
+ * residencyColumn: "dataRegion",
1824
+ * allowedTags: ["eu-west-1", "global"],
1825
+ * });
1826
+ * b.cryptoField.getPerRowResidency("users").allowedTags; // → ["eu-west-1", "global"]
1827
+ * b.cryptoField.getPerRowResidency("unknown"); // → null
1828
+ */
1829
+ function getPerRowResidency(table) {
1830
+ var spec = perRowResidency[table];
1831
+ if (!spec) return null;
1832
+ return { residencyColumn: spec.residencyColumn, allowedTags: spec.allowedTags.slice() };
1833
+ }
1834
+
1835
+ /**
1836
+ * @primitive b.cryptoField.listPerRowResidency
1837
+ * @signature b.cryptoField.listPerRowResidency()
1838
+ * @since 0.15.4
1839
+ * @related b.cryptoField.getPerRowResidency, b.cryptoField.declarePerRowResidency
1840
+ *
1841
+ * Enumerate every table opted into per-row residency. Returns one entry per
1842
+ * declared table — `{ table, residencyColumn, allowedTags }` — where
1843
+ * `allowedTags` lists the regions that table's rows may be tagged to.
1844
+ * Read-only. Consumers that must reason about residency across the whole
1845
+ * deployment rather than one table use this: `b.backup.create` enumerates it
1846
+ * to surface the per-row cross-border regions a deployment-level region
1847
+ * compare is blind to.
1848
+ *
1849
+ * @example
1850
+ * b.cryptoField.declarePerRowResidency("residents", {
1851
+ * residencyColumn: "region",
1852
+ * allowedTags: ["eu-west-1", "us-east-1"],
1853
+ * });
1854
+ * b.cryptoField.listPerRowResidency();
1855
+ * // → [ { table: "residents", residencyColumn: "region",
1856
+ * // allowedTags: ["eu-west-1", "us-east-1"] } ]
1857
+ */
1858
+ function listPerRowResidency() {
1859
+ return Object.keys(perRowResidency).map(function (t) {
1860
+ return {
1861
+ table: t,
1862
+ residencyColumn: perRowResidency[t].residencyColumn,
1863
+ allowedTags: perRowResidency[t].allowedTags.slice(),
1864
+ };
1865
+ });
1866
+ }
1867
+
1164
1868
  /**
1165
1869
  * @primitive b.cryptoField.declarePerRowKey
1166
1870
  * @signature b.cryptoField.declarePerRowKey(table, opts)
@@ -1169,13 +1873,16 @@ function assertColumnResidency(table, row, args) {
1169
1873
  * @related b.cryptoField.materializePerRowKey, b.cryptoField.destroyPerRowKey, b.subject.eraseHard
1170
1874
  *
1171
1875
  * Opts a table into per-row keying (K_row crypto-shred substrate).
1172
- * After registration, every INSERT generates a fresh K_row and stores
1173
- * it sealed in `_blamejs_per_row_keys (table, rowId, wrapped)`. AAD on
1174
- * the K_row binds (table, rowId, info-label) — copy-row attacks fail
1175
- * Poly1305 verification. `b.subject.eraseHard(subjectId)` deletes the
1176
- * per-row key entries for the subject's rows; WAL / replica residual
1177
- * ciphertext becomes mathematically undecryptable because K_row is
1178
- * gone everywhere it ever lived. Throws on bad input (config-time
1876
+ * After registration, every INSERT generates a fresh 32-byte CSPRNG
1877
+ * row-secret, derives K_row from it, and stores the SECRET (never
1878
+ * K_row) AAD-sealed in `_blamejs_per_row_keys (tableName, rowId,
1879
+ * wrappedKey)`. AAD on the wrap binds (table, rowId, column,
1880
+ * schemaVersion) a wrapped secret copied to a different row fails
1881
+ * Poly1305 verification. `b.subject.eraseHard(subjectId)` /
1882
+ * `b.retention` destroy the per-row entries for the subject's rows; WAL
1883
+ * / replica residual ciphertext becomes mathematically undecryptable
1884
+ * because the random row-secret — the only seed for K_row — is gone
1885
+ * everywhere it ever lived. Throws on bad input (config-time
1179
1886
  * fail-loud).
1180
1887
  *
1181
1888
  * @opts
@@ -1237,15 +1944,22 @@ function hasPerRowKey(table) {
1237
1944
  * @compliance gdpr, hipaa
1238
1945
  * @related b.cryptoField.declarePerRowKey, b.cryptoField.destroyPerRowKey
1239
1946
  *
1240
- * Derive-and-store: called by the storage backend on INSERT. Generates
1241
- * `K_row = SHAKE256(vaultSalt + table + rowId + info, keySize)`, seals
1242
- * it via `vault.seal`, and inserts into `_blamejs_per_row_keys`.
1243
- * Returns the unwrapped K_row Buffer for the caller to use to encrypt
1244
- * sealed columns under the row-scoped key. Idempotent on UPSERT — if
1245
- * a K_row already exists for (table, rowId), returns the unwrapped
1246
- * existing key. The AAD-bound envelope rejects copy-row attacks: a
1247
- * wrapped K_row pasted under a different rowId fails Poly1305
1248
- * verification at unseal time.
1947
+ * Derive-and-store: called by the storage backend on INSERT (the
1948
+ * db-query write boundary, gated on `hasPerRowKey`). Generates a fresh
1949
+ * 32-byte CSPRNG row-secret, derives
1950
+ * `K_row = SHAKE256(rowSecret || ":" || table || ":" || rowId || ":"
1951
+ * || info, keySize)`, AAD-seals the SECRET (base64) into
1952
+ * `_blamejs_per_row_keys.wrappedKey` via `b.vault.aad.seal`, and
1953
+ * returns the unwrapped K_row Buffer for the caller to encrypt sealed
1954
+ * columns under the row-scoped key. The secret is random — never a
1955
+ * function of any on-disk salt — so destroying the wrapped secret
1956
+ * makes K_row unrecoverable even with full disk + vault-root access.
1957
+ * Idempotent on UPSERT — if a secret already exists for (table,
1958
+ * rowId), unwraps it and re-derives the same K_row. The AAD-bound wrap
1959
+ * rejects copy-row attacks: a wrapped secret pasted under a different
1960
+ * rowId fails Poly1305 verification at unseal time. `dbHandle` is a
1961
+ * b.db handle (`.prepare`); rowId MUST be the row's `_id` (the value
1962
+ * `destroyPerRowKey` / `b.subject.eraseHard` delete on).
1249
1963
  *
1250
1964
  * @example
1251
1965
  * b.cryptoField.declarePerRowKey("orders", { keySize: 32 });
@@ -1265,30 +1979,66 @@ function materializePerRowKey(table, rowId, dbHandle) {
1265
1979
  throw new CryptoFieldError("crypto-field/materialize-per-row-key-no-db",
1266
1980
  "materializePerRowKey: dbHandle (b.db) is required");
1267
1981
  }
1268
- // Existing key? Re-use to support idempotent UPSERTs.
1269
- var existing = dbHandle.prepare(
1270
- 'SELECT wrappedKey FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
1271
- ).get(table, rowId);
1982
+ var ridStr = String(rowId);
1983
+ // Existing secret? Unwrap + re-derive to support idempotent UPSERTs.
1984
+ var existingSelBuilt = sql.select(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
1985
+ .columns(["wrappedKey"])
1986
+ .where("tableName", table)
1987
+ .where("rowId", ridStr)
1988
+ .toSql();
1989
+ var existingStmt = dbHandle.prepare(existingSelBuilt.sql);
1990
+ var existing = existingStmt.get.apply(existingStmt, existingSelBuilt.params);
1272
1991
  if (existing) {
1273
- return vault.unseal(existing.wrappedKey);
1992
+ return _deriveKRow(_unwrapRowSecret(existing.wrappedKey, ridStr), table, ridStr, spec);
1274
1993
  }
1275
- // Derive K_row from the table-level vault key salt + rowId via
1276
- // SHAKE256 expand. This is a one-shot derivation (HKDF-shaped) that
1277
- // matches the framework's PQC-first kdf no HMAC-SHA3 dependency.
1278
- var saltHex = vault.getDerivedHashSalt().toString("hex");
1279
- var ikm = Buffer.from(saltHex + ":" + table + ":" + rowId + ":" + spec.info, "utf8");
1280
- var kRow = kdf(ikm, spec.keySize);
1281
- // allow:seal-without-aad per-row K_row wrap; row identity is the
1282
- // K_row KDF input, not the AEAD AAD on the wrap. Copy-attacks fail
1283
- // because the wrapped K_row only decrypts data sealed under it.
1284
- var sealed = vault.seal(kRow.toString("base64"));
1285
- dbHandle.prepare(
1286
- 'INSERT INTO "_blamejs_per_row_keys" (tableName, rowId, wrappedKey, createdAt) ' +
1287
- 'VALUES (?, ?, ?, ?)'
1288
- ).run(table, rowId, sealed, Date.now());
1994
+ // Fresh random row-secret. CRITICAL: this is CSPRNG, not a function
1995
+ // of any on-disk value (the pre-v0.14.25 design derived K_row from
1996
+ // the plaintext-on-disk derivedHash salt, so an attacker with disk
1997
+ // access re-derived it and deleting the wrap shred nothing). With a
1998
+ // random secret, K_row is unrecoverable once the wrap is destroyed.
1999
+ var rowSecret = generateBytes(32);
2000
+ var kRow = _deriveKRow(rowSecret, table, ridStr, spec);
2001
+ // Store the SECRET (never K_row), AAD-sealed under the vault root so a
2002
+ // wrapped secret copied to a different (table, rowId) fails Poly1305.
2003
+ var sealed = vaultAad.seal(rowSecret.toString("base64"), _wrappedKeyAad(ridStr));
2004
+ // _id is the rotation pipeline's pagination/UPDATE key (the natural
2005
+ // identity is the composite (tableName, rowId)). A fresh token keeps
2006
+ // it unique per registry row.
2007
+ var insBuilt = sql.insert(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
2008
+ .values({
2009
+ _id: generateToken(16),
2010
+ tableName: table,
2011
+ rowId: ridStr,
2012
+ wrappedKey: sealed,
2013
+ createdAt: Date.now(),
2014
+ })
2015
+ .toSql();
2016
+ var insStmt = dbHandle.prepare(insBuilt.sql);
2017
+ insStmt.run.apply(insStmt, insBuilt.params);
1289
2018
  return kRow;
1290
2019
  }
1291
2020
 
2021
+ // Derive the row-scoped key from the random row-secret. SHAKE256 expand
2022
+ // (HKDF-shaped, matches the framework's PQC-first kdf) over
2023
+ // rowSecret || ":" || table || ":" || rowId || ":" || info — the
2024
+ // non-secret context terms domain-separate two rows that (astronomically
2025
+ // improbably) drew the same secret; the secret is the entropy source.
2026
+ function _deriveKRow(rowSecret, table, rowId, spec) {
2027
+ var ikm = Buffer.concat([
2028
+ rowSecret,
2029
+ Buffer.from(":" + table + ":" + rowId + ":" + spec.info, "utf8"),
2030
+ ]);
2031
+ return kdf(ikm, spec.keySize);
2032
+ }
2033
+
2034
+ // Unwrap a stored row-secret back to its 32 raw bytes. The wrap is
2035
+ // AAD-bound to (PER_ROW_KEYS_TABLE, rowId, wrappedKey, schemaVersion);
2036
+ // a tampered / copied wrap throws here, which the read path surfaces as
2037
+ // system.crypto.unseal_failed (shredded data reads as absent).
2038
+ function _unwrapRowSecret(wrapped, rowId) {
2039
+ return Buffer.from(vaultAad.unseal(wrapped, _wrappedKeyAad(rowId)), "base64");
2040
+ }
2041
+
1292
2042
  /**
1293
2043
  * @primitive b.cryptoField.destroyPerRowKey
1294
2044
  * @signature b.cryptoField.destroyPerRowKey(table, rowId, dbHandle)
@@ -1296,13 +2046,14 @@ function materializePerRowKey(table, rowId, dbHandle) {
1296
2046
  * @compliance gdpr, hipaa
1297
2047
  * @related b.cryptoField.materializePerRowKey, b.subject.eraseHard
1298
2048
  *
1299
- * Crypto-shred: drops the per-row K_row entry from
1300
- * `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` for each
1301
- * row mapped to the erased subject. Returns
2049
+ * Crypto-shred: drops the row's wrapped row-secret from
2050
+ * `_blamejs_per_row_keys`. Called by `b.subject.eraseHard` and
2051
+ * `b.retention` for each row mapped to the erased subject. Returns
1302
2052
  * `{ destroyed: <rowsAffected> }`. After destruction, any WAL /
1303
2053
  * replica residual ciphertext for the row is mathematically
1304
- * undecryptable — even with the vault root key — because K_row is
1305
- * gone everywhere it ever lived. No-op when the table is not
2054
+ * undecryptable — even with the vault root key — because the random
2055
+ * row-secret (the only seed for K_row) is gone everywhere it ever
2056
+ * lived. `rowId` MUST be the row's `_id`. No-op when the table is not
1306
2057
  * registered for per-row keying.
1307
2058
  *
1308
2059
  * @example
@@ -1322,9 +2073,12 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1322
2073
  throw new CryptoFieldError("crypto-field/destroy-per-row-key-no-db",
1323
2074
  "destroyPerRowKey: dbHandle (b.db) is required");
1324
2075
  }
1325
- var result = dbHandle.prepare(
1326
- 'DELETE FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
1327
- ).run(table, rowId);
2076
+ var delBuilt = sql.delete(_perRowKeysTableName(), _PER_ROW_SQL_OPTS)
2077
+ .where("tableName", table)
2078
+ .where("rowId", String(rowId))
2079
+ .toSql();
2080
+ var delStmt = dbHandle.prepare(delBuilt.sql);
2081
+ var result = delStmt.run.apply(delStmt, delBuilt.params);
1328
2082
  return { destroyed: (result && result.changes) || 0 };
1329
2083
  }
1330
2084
 
@@ -1336,10 +2090,10 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1336
2090
  * @related b.cryptoField.declareColumnResidency, b.cryptoField.declarePerRowKey
1337
2091
  *
1338
2092
  * Test-only helper. Drops every entry from the per-column residency
1339
- * registry AND the per-row-key registry so a test fixture can
1340
- * re-declare both between cases. Operator code never calls this —
1341
- * production declarations come from `b.db.init({ schema })` once at
1342
- * boot.
2093
+ * registry, the per-row residency registry, and the per-row-key
2094
+ * registry so a test fixture can re-declare them between cases.
2095
+ * Operator code never calls this production declarations come from
2096
+ * `b.db.init({ schema })` once at boot.
1343
2097
  *
1344
2098
  * @example
1345
2099
  * b.cryptoField.declareColumnResidency("users", {
@@ -1351,6 +2105,7 @@ function destroyPerRowKey(table, rowId, dbHandle) {
1351
2105
  function clearResidencyForTest() {
1352
2106
  for (var t in columnResidency) delete columnResidency[t];
1353
2107
  for (var u in perRowKeyTables) delete perRowKeyTables[u];
2108
+ for (var v in perRowResidency) delete perRowResidency[v];
1354
2109
  }
1355
2110
 
1356
2111
  module.exports = {
@@ -1359,6 +2114,7 @@ module.exports = {
1359
2114
  getSealedFields: getSealedFields,
1360
2115
  sealRow: sealRow,
1361
2116
  unsealRow: unsealRow,
2117
+ isRowSealed: isRowSealed,
1362
2118
  configureUnsealRateCap: configureUnsealRateCap,
1363
2119
  clearRateCapForTest: clearRateCapForTest,
1364
2120
  CryptoFieldRateError: CryptoFieldRateError,
@@ -1378,10 +2134,14 @@ module.exports = {
1378
2134
  computeDerived: computeDerived,
1379
2135
  computeNamespacedHash: computeNamespacedHash,
1380
2136
  lookupHash: lookupHash,
2137
+ lookupHashCandidates: lookupHashCandidates,
1381
2138
  clearForTest: clearForTest,
1382
2139
  declareColumnResidency: declareColumnResidency,
1383
2140
  getColumnResidency: getColumnResidency,
1384
2141
  assertColumnResidency: assertColumnResidency,
2142
+ declarePerRowResidency: declarePerRowResidency,
2143
+ getPerRowResidency: getPerRowResidency,
2144
+ listPerRowResidency: listPerRowResidency,
1385
2145
  declarePerRowKey: declarePerRowKey,
1386
2146
  hasPerRowKey: hasPerRowKey,
1387
2147
  materializePerRowKey: materializePerRowKey,