@blamejs/core 0.14.26 → 0.15.0

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 (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
package/CHANGELOG.md CHANGED
@@ -6,8 +6,14 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.15.x
10
+
11
+ - v0.15.0 (2026-06-08) — **A chainable SQL builder, MySQL as a first-class data backend, and a keyed lookup-hash default — the data layer goes tri-dialect.** This release makes the framework's data layer dialect-portable. `b.sql` is a new chainable query builder that quotes every identifier by construction, binds every value as a placeholder, and emits dialect-correct SQL for SQLite, Postgres, and MySQL; `b.guardSql` validates result rows against NUL bytes, quote-jump sequences, and per-column / total size boundaries. The entire framework data layer — the signed audit chain, cluster leadership and fencing, sessions, break-glass, the local queue, cache, scheduler, migrations, consent, and mail storage — is rebuilt on `b.sql`, which makes MySQL a supported external-database backend alongside Postgres and SQLite. Running the data layer on a real Postgres server surfaced two latent correctness bugs that only a non-SQLite backend exposes: identifiers were emitted unquoted at DDL time but read back camelCase (Postgres folds unquoted names to lowercase, silently breaking the audit chain, consent chain, cluster fencing, and vault-key consistency), and sealed columns coerced Buffer / object payloads through `String()` before encryption (corrupting non-string ciphertext); both are fixed by quoting every identifier and coercing per the column's declared type. Derived-hash columns — the blind-index lookup columns registered through `registerTable` — now default to a keyed MAC (SHAKE256 under the vault's per-deployment MAC key) instead of an unkeyed salted hash, so an exfiltrated database can no longer be brute-forced or rainbow-tabled to recover the indexed values; existing salted-hash indexes are read through a dual-read path and migrated forward, so the change is non-breaking. Audit-signing key rotation now preserves every historical checkpoint (rotation previously stranded checkpoints signed under the prior key). New cross-border data-residency postures (appi-jp, pdpa-sg, uk-gdpr) enforce a mandatory storage vacuum after erasure. Outbound TLS appends classical X25519 to its key-exchange preference so it can complete a handshake with a peer that advertises no post-quantum hybrid — previously the hybrids-only preference failed those handshakes outright — and emits a `tls.classical_downgrade` audit event whenever a connection lands on a classical group. Outbound HTTP/2 negotiation falls back to HTTP/1.1 against servers that only speak h1, the network heartbeat honors a target's permitted protocols instead of pinning cleartext targets down, a WebSocket connection closes cleanly on a peer's TCP half-close instead of wedging open, and the Azure blob backend encodes object-key path segments correctly. **Added:** *b.sql — a dialect-aware chainable SQL builder* — `b.sql` composes SELECT / INSERT / UPDATE / UPSERT / DELETE / DDL from a fluent chain. Every identifier is validated and quoted by construction (`"name"` on SQLite/Postgres, `` `name` `` on MySQL), every value binds as a placeholder rather than interpolating, and the emitted statement is validated as a single balanced, single-statement query before it leaves the builder. Pass `{ dialect: "postgres" | "mysql" | "sqlite" }` (default SQLite) to target a backend; `upsert` emits the dialect-final conflict syntax. It composes `b.safeSql` for the identifier and placeholder primitives, so a SQL string can no longer be assembled by hand inside the framework. · *b.guardSql — result-row output validation* — `b.guardSql` gates query result rows against embedded NUL bytes, quote-jump sequences, and configurable per-column and total-size boundaries, with the standard guard profiles (strict / balanced / permissive) and compliance postures (hipaa / pci-dss / gdpr / soc2). It is the output-side complement to the input-side `b.safeSql`. · *MySQL is a first-class data backend* — MySQL joins Postgres and SQLite as a supported external-database backend. The framework's schema reconciler emits MySQL DDL, and the full data layer — signed audit chain (append + verify), cluster leadership and lease fencing, sessions, break-glass, the local queue, cache, scheduler, migrations, and consent — runs against a real MySQL server. Select the backend at `b.cluster.init` / `b.externalDb` configuration via the `dialect` option; `b.clusterStorage.dialect()` exposes the configured backend dialect to composing code. · *Cross-border erasure postures: appi-jp, pdpa-sg, uk-gdpr* — Three data-residency compliance postures are added (Japan APPI, Singapore PDPA, UK GDPR). Each requires a mandatory storage vacuum after erasure (so deleted rows are reclaimed from the page store, not just tombstoned), a signed audit chain, encrypted backups, and a minimum TLS version. Pin one with `b.compliance.set`. **Fixed:** *Cross-border erasure performs the mandatory vacuum* — Erasure under the uk-gdpr, appi-jp, and pdpa-sg residency postures now runs the storage vacuum the posture mandates, reclaiming erased rows from the page store rather than leaving them recoverable as free-list tombstones. **Security:** *Lookup-hash columns default to a keyed MAC* — Derived-hash (blind-index) columns registered through `registerTable` now default to `derivedHashMode: "hmac-shake256"` — SHAKE256 keyed with the vault's per-deployment MAC key — instead of the previous unkeyed `salted-sha3`. The index value is no longer recomputable from the indexed plaintext alone, so an attacker who exfiltrates the database cannot brute-force or rainbow-table a lookup column (for example a subject-email index) without also holding the vault MAC key (CWE-916 / CWE-759). Existing `salted-sha3` indexes are read through a dual-read path and re-derived on write, so deployments upgrade without re-indexing up front. · *Postgres identifier casing no longer breaks the audit and cluster chains* — Identifiers were written unquoted in DDL but read back in camelCase. On Postgres (which folds an unquoted identifier to lowercase) this silently desynchronized the signed audit chain, the consent chain, cluster leadership and fencing, and vault-key consistency — each reads a column the server had stored under a lowercased name. Every framework identifier is now quoted at both DDL and query time so the stored and read names match on every dialect (CWE-670). SQLite deployments were unaffected and remain byte-compatible. · *Sealed columns preserve non-string payloads* — A sealed column coerced its value through `String()` before encryption, corrupting Buffer and object payloads (a Buffer became `"[object Object]"`-class garbage, an object its `toString`). Sealed values are now encoded per the column's declared type before the seal, so binary and structured payloads round-trip intact (CWE-704). · *Audit-signing key rotation preserves historical checkpoints* — Rotating the audit-signing key stranded every checkpoint signed under the prior key — `verifyCheckpoints` ignored the per-fingerprint history file the rotation writes, so post-rotation verification failed on otherwise-valid historical checkpoints. Verification now resolves each checkpoint's signing key by fingerprint (`getPublicKeyByFingerprint`) across the rotation history, so the full chain verifies after a key rotation. · *Outbound TLS reaches classical-only peers and audits the downgrade* — The framework's outbound TLS offered only ML-KEM hybrid key-exchange groups, so a handshake with a peer that does not advertise a post-quantum hybrid — most of today's internet — failed outright (`handshake_failure`), leaving outbound connections to webhooks, OAuth providers, ACME directories, object stores, and DoT/DoH/SMTP/Redis-over-TLS unable to complete. Classical X25519 is now appended to the group preference so the hybrid is still negotiated whenever the peer supports it, and the connection completes over classical X25519 when it does not. Every connection that lands on a classical group rather than the post-quantum hybrid emits a `tls.classical_downgrade` audit event (carrying the negotiated group) so operators can observe and alert on which peers are not yet post-quantum-capable. · *Transport reachability and correctness* — Outbound HTTP/2 negotiation now falls back to HTTP/1.1 when a TLS server offers only h1 (an ALPN protocol_version alert no longer fails the request). The network heartbeat honors a target's permitted protocols instead of dropping non-default ones, so a cleartext `http://` health target is no longer reported permanently down. A WebSocket connection closes cleanly when a peer half-closes its TCP socket (a bare FIN) instead of wedging the connection open. The Azure blob backend percent-encodes each object-key path segment, so a key containing reserved characters can no longer corrupt the request URL. **Migration:** *Derived-hash default change is non-breaking; MySQL is opt-in* — Lookup-hash columns default to the keyed MAC on new writes and migrate existing rows on access through the dual-read path — no upfront re-indexing, no operator action required. To pin the previous unkeyed index (for example to keep a column byte-compatible with an external system), pass `derivedHashMode: "salted-sha3"` to `registerTable`. MySQL as a data backend is opt-in: existing SQLite and Postgres deployments are unchanged unless you set `dialect: "mysql"`. The Postgres identifier-casing and sealed-column-coercion fixes change the emitted DDL and the at-rest encoding of non-string sealed values; a Postgres deployment created before this release reconciles its schema to the quoted identifiers on the next schema-ensure pass.
12
+
9
13
  ## v0.14.x
10
14
 
15
+ - v0.14.27 (2026-06-06) — **Security-hardening sweep: exclusive temp creation, request path confinement, prototype-safe parsing, cross-tenant authentication, telemetry and error redaction, and posture enforcement floors.** A broad security-hardening release closing classes of bug across the request, storage, identity, and data-governance surfaces. Files that stage data before an atomic rename (the atomic-write substrate, the HTTP client's downloads, the static file server's cache) now create those files exclusively and refuse to follow a symlink, so a same-user pre-planted file or symlink can no longer be truncated or written through. The static file server re-confines every request-derived path to its served root at the filesystem call, and its content-safety read is anchored to a single no-follow descriptor. The request body parser, the WebSocket extension parser, and the cookie parser build their maps from key/value pairs instead of attacker-named computed writes, so a field named after an Object prototype member can neither pollute the prototype nor surface inherited members. The SSRF guard compares cloud-metadata addresses canonically; OIDC federation derives a subordinate's policy and keys from the superior-signed statement rather than self-published config; the JMAP listener rejects a cross-tenant account id with accountNotFound before dispatch; the agent event bus authenticates each envelope's tenant and posture with a vault-keyed MAC; and the audit query no longer under-logs concurrent reads. Telemetry attributes exported over OTLP and the 5xx error record written to the signed audit chain are now redacted, closing two egress paths that previously shipped secrets in plaintext. Regulated compliance postures enforce a minimum seal-envelope strength at table registration, warn when a pinned posture has no content-safety overlay or no outbound DLP wired, and expose region-tag normalization helpers. The local queue's Redis backend no longer wedges a caller on a failed connect or double-schedules reconnects, file uploads audit every content-safety skip, the Azure blob backend percent-encodes object keys, the XML parser rejects prototype-poisoning names, and router path-scoped middleware (`use(path, mw)`) works as documented instead of dropping the gate. **Security:** *Staged writes are created exclusively and refuse symlinks* — The atomic-write substrate (`b.atomicFile`), the HTTP client's download staging, and the static file server's pre-serve read now open files with `O_CREAT | O_EXCL | O_NOFOLLOW` at owner-only `0o600` (the vault-rotation pipeline adopted the same in v0.14.26). A pre-planted file or a symlink at a staging path is now a hard failure rather than a truncated or followed write (CWE-377 / CWE-379 / CWE-59). The temp+rename atomicity and download streaming semantics are unchanged. · *Static file server re-confines request paths and reads through one no-follow descriptor* — Every request-derived path is re-resolved under the served root and refused if it escapes (`startsWith(root + sep)`) at the filesystem call, not only upstream, and the content-safety gate reads size and bytes from a single `O_NOFOLLOW` descriptor so a final-component symlink swap between stat and read cannot redirect it (CWE-22 / CWE-367). Percent-encoded traversal, NUL bytes, and absolute/drive-letter paths are refused before any filesystem access. · *Body, WebSocket, and cookie parsers build maps without attacker-named writes* — The request body parser (Content-Type/Content-Disposition params, multipart headers and fields), the WebSocket `Sec-WebSocket-Extensions` parser, and the CSRF cookie parser now collect key/value pairs and materialize them via `Object.fromEntries` onto a null-prototype accumulator with prototype-poisoning keys dropped, instead of a request-keyed computed write. A field or parameter named `__proto__` / `constructor` / `prototype` can no longer pollute the prototype or surface inherited members (CWE-915 / CWE-1321). Parsed object shapes are unchanged for all legitimate input. · *Prototype-safe XML parsing* — `b.safeXml` builds its element, attribute, and grouping accumulators with `Object.create(null)` and rejects element/attribute names `__proto__` / `constructor` / `prototype` with `xml/forbidden-name` (CWE-1321), matching the TOML/YAML/INI parsers. The returned tree has a null prototype; consumers using bracket/dot access, `Object.keys`, or `JSON.stringify` are unaffected. · *SSRF guard compares cloud-metadata addresses canonically* — The SSRF guard now canonicalizes addresses before comparing against the cloud metadata endpoints, so a non-canonical encoding of a link-local / metadata address can no longer slip past the block to reach an instance metadata service (CWE-918). · *OIDC federation trusts the superior-signed statement, not self-config* — A subordinate entity's `metadata_policy` is now read from the superior's signed entity statement, and trust-chain building verifies each subordinate against attested keys in a two-phase walk rather than the subordinate's self-published JWKS, closing a trust-substitution gap (RFC 9068 / OpenID Federation). A subordinate that publishes no attested keys is refused. · *Cross-tenant authentication on the JMAP listener and agent event bus* — The JMAP listener rejects a client-supplied `accountId` outside the actor's permitted set with `accountNotFound` before any method or blob handler runs (RFC 8620 §3.6.1). The agent event bus authenticates each envelope's tenant id and posture with a vault-keyed HMAC and drops a forged or tampered envelope before routing. The audit query no longer suppresses its own `audit.read` self-log under concurrency, so concurrent reads are each recorded (PCI-DSS 10.2). · *Telemetry and error records are redacted before egress* — Span and metric attributes exported over OTLP now pass through a redactor (`b.observability.setRedactor`, defaulting to `b.redact`) before leaving the process, and the 5xx error record written to the durable, signed audit chain now uses the redacting `audit.safeEmit` rather than the raw `audit.emit` — closing two egress paths that previously shipped a secret embedded in a span attribute or an exception message/stack in plaintext (CWE-532). The error response itself is byte-identical. · *Regulated postures enforce a seal-envelope floor* — Compliance postures now carry a `sealEnvelopeFloor`; `hipaa` and `pci-dss` require at least AEAD additional-data binding. Registering a table that seals columns under a weaker envelope while such a posture is pinned is refused at configuration time, so a regulated deployment can no longer register a copy-paste-vulnerable plain-sealed table (CWE-311 / CWE-326; 45 CFR 164.312, PCI-DSS v4 Req. 3.5). · *Redis backend connect/reconnect robustness* — The local queue's Redis backend always settles its connect promise (resolve on ready, reject on error/timeout) so a caller can no longer wedge on a failed connect, schedules at most one reconnect per failure (a lost socket fires both error and close), and gives up idempotently — fixing a caller hang and a reconnect storm on a flapping backend (CWE-833 / CWE-400). · *File-upload skip auditing and Azure object-key encoding* — Every content-safety skip path in `b.fileUpload` now emits a `content_safety_skipped` audit naming why the scan was bypassed (opt-out / no gate for the extension / over the reassembly cap), so an unscanned upload is visible in the audit log (CWE-778). The Azure blob backend percent-encodes each object-key path segment before URL interpolation, so a key containing `?` / `#` / spaces can no longer corrupt the request or escape its container path (CWE-20). · *Router path-scoped middleware works as documented* — `router.use(pathPrefix, middleware)` — documented across the security middleware but previously unimplemented (the path string was pushed as the middleware, 500-ing every request, or the gate silently never ran) — now mounts the middleware scoped to a segment-boundary path prefix, preserving registration order and refusing a non-string prefix or non-function middleware at configuration time (CWE-670). **Detectors:** *Recurrence guards for the fixed shapes* — The pattern catalog gains detectors for the exclusive-temp-create (atomic-file, http-client, static), request-path confinement (static serve + gate), request-keyed map writes (body-parser, WebSocket), prototype-safe XML accumulators, Azure object-key encoding, file-upload skip auditing, OTLP attribute redaction, JMAP account gating, the seal-envelope floor, and the router path-scoped mount. The data-flow and timing shapes (SSRF canonicalization, federation trust-chain, audit self-log concurrency, event-bus envelope MAC, Redis reconnect scheduling, error-record redaction, posture-overlay warnings) are guarded by request-driven tests where a precise regex would false-positive. **Migration:** *Behavior changes to review* — Several hardening changes alter behavior, all fail-closed: (1) the agent event bus now requires a shared vault to authenticate envelopes and refuses to publish/deliver without one — single-process callers without a vault must pass `requireMac: false`; (2) OIDC federation subordinates must publish attested keys (a subordinate relying on self-published JWKS is refused); (3) `b.safeXml` throws `xml/forbidden-name` on element/attribute names `__proto__`/`constructor`/`prototype`; (4) registering a table that seals columns below the pinned regulated posture's seal-envelope floor throws at configuration time — add `{ aad: true }` or a per-row key; (5) OTLP telemetry attributes are now redacted by default — install a domain redactor via `b.observability.setRedactor` if you need different handling (it cannot be disabled outright); (6) `router.use(path, mw)` now path-scopes instead of applying globally or 500-ing. New advisory audit rows appear when a pinned posture has no content-safety overlay or no outbound DLP wired; `b.compliance.set` does not auto-install outbound DLP (call `b.redact.installForPosture` with your client/mail/webhook handles).
16
+
11
17
  - v0.14.26 (2026-06-06) — **Break-glass IP and session pins fail closed, DSR ticket PII is sealed and erasable, and queue jobs are sealed at rest from the first write.** Break-glass grant pins (`pinIp` / `sessionPin`, documented default-ON) were enforced only when the grant had captured a binding at mint time; a grant minted without an IP or session was redeemable from anywhere, so the pin failed open exactly when it mattered. Pins now fail closed: a grant carrying no binding is refused at redemption, and the redeeming client IP falls back to `req.ip` when not threaded explicitly. The Data Subject Request ticket store kept the subject's identifiers and the raw request body in plaintext, so an erasure request could not destroy the very PII it was processing; those columns are now sealed under the vault with `(table, rowId)` additional-data binding, erasure purges the ticket on completion, and an in-place schema upgrade seals existing stores. Queue jobs were sealed at rest only after a table had been registered, which did not happen until the first explicit registration — the queue now self-registers its job table on `init`, so jobs are sealed from the first write. Rounding out the bundle: the OAuth back-channel-logout `logout_token` is parsed under a byte ceiling, the SD-JWT-VC holder key-binding JWT signs with an algorithm asserted against the holder key's type (an RSA or OKP holder no longer mints a self-invalid token), and a pushed authorization request carrying a signed request object emits `authorization_details` as a native array. **Security:** *Break-glass IP and session pins fail closed* — `b.breakGlass` grants document `pinIp` and `sessionPin` as default-ON, and grant minting captures the issuing IP and session at that time. Redemption now refuses a grant whose binding was never captured (`pinIp` on but no IP recorded, or `sessionPin` on but no session) instead of treating the absent binding as 'nothing to check' and allowing the redemption from any origin. The redeeming client IP is resolved from the redemption request and falls back to `req.ip` when the caller does not thread an explicit address. The one-time-code replay floor is keyed per `(actor, secret)` so a code consumed under one actor cannot be replayed under another, and the accepted TOTP step is reserved atomically as part of acceptance, so two concurrent grants presenting the same in-window code cannot both pass. · *DSR ticket store seals subject identifiers and request payload, and erasure purges the ticket* — `b.dsr` with the database-backed ticket store now seals the data subject's identifiers and the raw request payload via `b.cryptoField.registerTable` with `(table, rowId)` additional-data binding, so the PII a request processes is encrypted at rest and is destroyed when the ticket's row key is shredded. An erasure request purges its own ticket on completion rather than leaving the subject's identifiers behind. Existing ticket stores are upgraded in place on the next `init` — sealed columns are added via `ALTER TABLE ADD COLUMN` and the legacy `payload NOT NULL` constraint relaxes to `DEFAULT ''` so the add succeeds without data loss. Wrapped ticket keys are re-sealed under the new root on vault keypair rotation (`b.dsr.reseal`), so rotation does not strand tickets. Rows written before the upgrade (plaintext subject, no lookup hash) are backfilled on the next `init` — their hashes are computed and their subject columns sealed — so a subject lookup still finds them and an erasure request can purge them. · *Queue jobs are sealed at rest from the first write* — The local queue backend self-registers its job table for sealing on `init` rather than on first explicit registration, so a job persisted before any other code touched the seal table is encrypted at rest instead of written in plaintext. `b.cryptoField.sealRow` is a silent no-op against an unregistered table; the self-register on `init` closes that fail-open window for the queue's own rows. · *OAuth back-channel logout is bounded; SD-JWT-VC holder binding matches the holder key type* — The OAuth back-channel-logout endpoint parses the `logout_token` under an explicit byte ceiling, so an unauthenticated caller cannot exhaust memory with an oversized body. The SD-JWT-VC holder key-binding JWT now signs with an algorithm asserted against the holder key's type, so a holder presenting an RSA or OKP key no longer mints a key-binding JWT that fails its own verification. A pushed authorization request (PAR) that carries a signed request object emits `authorization_details` as a native JSON array per RFC 9396, not a JSON-encoded string. · *Vault keypair rotation writes its staging files with exclusive, no-follow create* — Every file the rotation pipeline writes into its staging directory — the re-encrypted database, the resealed vault and database keys, additional sealed files, the derived-hash material, and the transient plaintext database — is now created with exclusive, symlink-refusing semantics (`O_CREAT | O_EXCL | O_NOFOLLOW`, owner-only `0o600`), and the fsync-by-path step refuses to follow a symlink. A same-user pre-planted file or symlink swap in the staging directory is now a hard failure rather than a followed write, closing a local tamper window during rotation (CWE-377 / CWE-379 / CWE-59) on top of the directory's existing `0o700` owner-only permissions. **Detectors:** *break-glass-pin-fails-open-on-null-binding* — The pattern catalog refuses a break-glass pin comparison guarded by a `grantRow.ip != null &&` (or `sessionId`) short-circuit — a guard that skips enforcement when the captured binding is absent, which is precisely the fail-open. Pin enforcement must refuse a grant with no captured binding, not wave it through. · *dsr-ticket-store-pii-must-be-sealed* — The catalog requires the DSR database ticket store to register its seal table, so the subject identifiers and request payload it holds cannot regress to plaintext-at-rest and remain un-erasable. The queue self-register, the bounded logout parse, and the holder key-type parity are covered by their expanded request-driven tests and the existing fixed-classical-algorithm-default guard. **Migration:** *Break-glass grants now require a resolvable binding to redeem under default pins* — With `pinIp` left at its default (on), a grant is now refused at redemption unless the issuing IP was captured and the redeeming client IP can be resolved. In-tree redemption paths thread the request and resolve the IP automatically. If your redemption path does not surface a client address and you do not intend to bind to IP, set `pinIp: false` (and `sessionPin: false`) explicitly on the grant; the previous behavior silently accepted such grants from any origin. · *DSR ticket stores are sealed in place on next init* — An existing database-backed DSR ticket store gains sealed columns via `ALTER TABLE ADD COLUMN` on the next `init`, and the legacy `payload NOT NULL` constraint relaxes to `DEFAULT ''` to permit the in-place add. No data is lost; tickets written before the upgrade remain readable and become sealed as they are rewritten.
12
18
 
13
19
  - v0.14.25 (2026-06-06) — **Per-row crypto-shred is real and AAD-bound, and the idempotency fingerprint key is sealed at rest.** The per-row encryption-key feature that backs `b.subject.eraseHard` crypto-shred was both non-functional and, as documented, unsound: the per-row key (`K_row`) was derived deterministically from a salt that is stored in plaintext in the data directory, so an actor with disk access could re-derive it and decrypt 'shredded' residual ciphertext (WAL / replica / backup) — and the key was never actually materialized on insert, so no table ever used it. Per-row keys are now derived from a fresh 32-byte CSPRNG secret stored only in `_blamejs_per_row_keys`, wrapped under the vault with AEAD additional-data binding `(table, rowId)`, and materialized on insert for tables that declare them; destroying the wrapped secret now genuinely renders the row's residual ciphertext undecryptable. The same plaintext-salt class is fixed for the idempotency request-fingerprint HMAC, which now seeds off the sealed-at-rest `vault.getDerivedHashMacKey()`. There is no migration: the per-row-key table was empty in every deployment, so keyed tables are correct from their first write. **Security:** *Per-row crypto-shred: random secret, AAD-bound wrap, materialized on insert* — `b.cryptoField.declarePerRowKey` tables now get a per-row key derived from a fresh `b.crypto.generateBytes(32)` secret — never from the plaintext per-deployment salt — so the key has no input recomputable from disk. The secret is stored in `_blamejs_per_row_keys` wrapped via `b.vault.aad.seal` with additional-data bound to `(table, rowId)`, so a wrapped key copied onto another row fails its Poly1305 tag. Sealed columns are encrypted under the per-row key and tagged with a `vault.row:` envelope (`b.cryptoField.isRowSealed`); the residency-tag column is never key-sealed so the write-boundary residency gate can still read it. The key is materialized on insert (it previously never was) and re-derived once per read; `b.subject.eraseHard` and the retention sweep destroy the wrapped secret, after which the row's residual ciphertext reads as absent and cannot be recovered even if the vault root is later compromised. Wrapped keys are re-sealed under the new root on vault keypair rotation, so rotation does not strand keyed rows. · *Idempotency request-fingerprint HMAC seeds off the sealed MAC key* — The `fingerprintSeal` HMAC over the request fingerprint was keyed from the plaintext per-deployment salt, so a disk-access actor could recompute the key and brute-force the low-entropy preimage (method + URL + body) offline. It now seeds off `b.vault.getDerivedHashMacKey()`, which is sealed at rest. Fingerprints cached before the upgrade no longer match, so a replayed request re-executes once (the safe direction). **Detectors:** *kdf-key-from-plaintext-derived-hash-salt* — The pattern catalog refuses a key-derivation (`kdf(... getDerivedHashSalt() ...)`) that seeds a per-row shred key or a vault-secret-promising keyed-MAC off the plaintext-on-disk derived-hash salt. Such a key is re-derivable by anyone with the data directory, defeating the shred / secrecy it advertises; the correct sources are a CSPRNG secret or the sealed `getDerivedHashMacKey()`. The deterministic salted-SHA3 equality index is a distinct shape and is unaffected. **Migration:** *No action required* — No data migration: the per-row-key table was empty in every deployment, so tables that declare per-row keys are correct from their first write going forward. The only observable change is that the first request after upgrade matching a pre-upgrade idempotency fingerprint re-executes once.
package/README.md CHANGED
@@ -64,7 +64,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
64
64
  - **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`; a column-membership gate (`db.init({ columnGate })`, default reject) fails a query closed when it names a column the table never declared, and `whereRaw` refuses an embedded string literal so values bind through placeholders
65
65
  - **Mongo-style document-store facade** — `b.db.collection(name, opts?)` with `$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`; schemaless-document opts via `overflow: "<col>"` (folds unknown fields into a JSON-text column; rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` (auto-stringify on write + parse via `b.safeJson` on read), `sealedFields: { email: "emailHash" }` (co-locates a `b.cryptoField` sealed-column / derived-hash declaration so plaintext lookups auto-rewrite to hash-column lookups)
66
66
  - **DB lifecycle** — in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
67
- - **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); an opt-in `requireTls` transport posture refuses a non-TLS backend at boot, and query / transaction / read traces carry OpenTelemetry `db.*` attributes
67
+ - **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); an opt-in `requireTls` transport posture refuses a non-TLS backend at boot, and query / transaction / read traces carry OpenTelemetry `db.*` attributes. The framework's own data layer — the signed audit chain, cluster leadership and lease fencing, sessions, break-glass, and the local queue / cache / scheduler — is composed through the dialect-aware `b.sql` builder (every identifier quoted by construction, every value bound as a placeholder, dialect-correct SQLite / Postgres / MySQL output), so the framework's tables run on a Postgres or MySQL backend, not only local SQLite; `b.guardSql` validates result rows against NUL bytes, quote-jump sequences, and per-column / total-size boundaries
68
68
  - **Object store** — S3 / R2 / B2 / GCS / Azure with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS); S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`)
69
69
  - **Queues + cache** — durable queue with priority + cron + flows on local SQLite, shared Redis, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 (`b.queue`, `b.jobs`) — the local backend can target an operator-supplied database / table / schema; cluster-shared cache (`b.cache`)
70
70
  ### Identity & access
@@ -97,7 +97,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
97
97
 
98
98
  - **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
99
99
  - **Power-on self-test** — `b.crypto.selfTest()` runs FIPS 140-3-style integrity checks: NIST FIPS 202 known-answer tests (SHA3-256/512, SHAKE256), AEAD round-trip + tamper-detect, and ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f pairwise-consistency + negative tests; fails closed (throws) on any mismatch
100
- - **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column and per-row data residency tagging enforced at the write boundary (cross-border DML refused under GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA postures) + per-row keys (`K_row = HKDF(K_table, rowId)`) so erasing the per-row key makes WAL / replica residuals undecryptable (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowResidency`, `b.cryptoField.declarePerRowKey`)
100
+ - **Field-level + crypto-shred** — `b.cryptoField.eraseRow`; per-column and per-row data residency tagging enforced at the write boundary (cross-border DML refused under GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA postures) + per-row keys (each row's key derives from a CSPRNG row-secret sealed under the vault root, never from an on-disk value) so destroying a row's wrapped secret leaves its WAL / replica / backup residual ciphertext undecryptable even with the vault root key (`b.cryptoField.declareColumnResidency`, `b.cryptoField.declarePerRowResidency`, `b.cryptoField.declarePerRowKey`)
101
101
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`). The database encryption key is sealed the same way — bound to its purpose, data directory, and key path — so a relocated key file fails to unseal; an older unbound key upgrades itself on first load. A vault-key rotation re-seals every AAD-bound cell, the database key, and tenant archives under the new keypair and refuses rather than silently orphaning a store it cannot reach (`b.vaultRotate`, `b.vault.aad.resealRoot`, `b.archive.rewrapTenant`)
102
102
  - **Keyed lookup hashes** — sealed-column equality-lookup hashes default to salted SHA3-512 and can opt into a keyed `hmac-shake256` MAC off a per-deployment key (`cryptoField.registerTable({ derivedHashMode })`, `b.vault.getDerivedHashMacKey`), making the lookup hash unforgeable and un-correlatable across deployments
103
103
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
package/index.js CHANGED
@@ -120,6 +120,7 @@ var clusterStorage = require("./lib/cluster-storage");
120
120
  var safeAsync = require("./lib/safe-async");
121
121
  var handlers = require("./lib/handlers");
122
122
  var safeSql = require("./lib/safe-sql");
123
+ var sql = require("./lib/sql");
123
124
  var chainWriter = require("./lib/chain-writer");
124
125
  var safeBuffer = require("./lib/safe-buffer");
125
126
  var safeDecompress = require("./lib/safe-decompress").safeDecompress;
@@ -181,6 +182,7 @@ var guardSvg = require("./lib/guard-svg");
181
182
  var guardFilename = require("./lib/guard-filename");
182
183
  var guardMessageId = require("./lib/guard-message-id");
183
184
  var guardSmtpCommand = require("./lib/guard-smtp-command");
185
+ var guardSql = require("./lib/guard-sql");
184
186
  var guardImapCommand = require("./lib/guard-imap-command");
185
187
  var guardPop3Command = require("./lib/guard-pop3-command");
186
188
  var guardManageSieveCommand = require("./lib/guard-managesieve-command");
@@ -520,6 +522,7 @@ module.exports = {
520
522
  safeAsync: safeAsync,
521
523
  handlers: handlers,
522
524
  safeSql: safeSql,
525
+ sql: sql,
523
526
  chainWriter: chainWriter,
524
527
  safeBuffer: safeBuffer,
525
528
  safeDecompress: safeDecompress,
@@ -569,6 +572,7 @@ module.exports = {
569
572
  guardFilename: guardFilename,
570
573
  guardMessageId: guardMessageId,
571
574
  guardSmtpCommand: guardSmtpCommand,
575
+ guardSql: guardSql,
572
576
  guardImapCommand: guardImapCommand,
573
577
  guardPop3Command: guardPop3Command,
574
578
  guardManageSieveCommand: guardManageSieveCommand,
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ /**
3
+ * b.agent._envelopeMac — internal shared keyed-MAC mechanism for
4
+ * authenticating agent cross-process envelopes (`b.agent.postureChain`
5
+ * delegation envelopes, `b.agent.eventBus` wire envelopes, and any
6
+ * future agent boundary that carries security-relevant fields over a
7
+ * shared transport).
8
+ *
9
+ * The threat is uniform across these boundaries: an attacker with write
10
+ * access to the shared transport (queue / pubsub) can forge or tamper an
11
+ * envelope's authority-bearing fields — posture set, tenant id, topic —
12
+ * because schema/shape validation alone proves nothing about
13
+ * authenticity. The defense is a keyed MAC (HMAC-SHA3-512) over the
14
+ * canonical bytes of exactly those fields, keyed off the vault master so
15
+ * an attacker without the vault key cannot forge it and a vault rotation
16
+ * invalidates every in-flight MAC.
17
+ *
18
+ * Each calling domain supplies a stable `label` (domain separation) and
19
+ * the canonical bytes of the fields it protects; the key derivation,
20
+ * HMAC construction, and constant-time comparison live here so there is
21
+ * a single mechanism to audit, not one per agent module.
22
+ *
23
+ * Internal — operator-facing surface is each primitive's envelope
24
+ * sign/verify behaviour; this is the implementation detail.
25
+ */
26
+
27
+ var nodeCrypto = require("node:crypto");
28
+ var lazyRequire = require("./lazy-require");
29
+ var { defineClass } = require("./framework-error");
30
+ var bCrypto = require("./crypto");
31
+
32
+ var vault = lazyRequire(function () { return require("./vault"); });
33
+
34
+ var AgentEnvelopeMacError = defineClass("AgentEnvelopeMacError", { alwaysPermanent: true });
35
+
36
+ var ENVELOPE_MAC_KEY_BYTES = 32; // HMAC-SHA3-512 keyed bytes
37
+
38
+ // Per-label memoized keys — each domain-separation label derives its own
39
+ // sub-key from the vault master so a MAC minted for one boundary can
40
+ // never validate on another. Memoization is process-local; a vault
41
+ // rotation implies an operator restart (which clears the cache).
42
+ var _macKeyCache = Object.create(null);
43
+
44
+ // Resolve (and memoize) the MAC sub-key for a domain-separation `label`,
45
+ // derived from the vault master. Throws when the vault is not
46
+ // initialized — there is no key to authenticate with, so callers MUST
47
+ // treat that as fail-closed for any cross-tenant / authority decision
48
+ // rather than proceeding unauthenticated.
49
+ function resolveKey(label) {
50
+ if (typeof label !== "string" || label.length === 0) {
51
+ throw new AgentEnvelopeMacError("agent-envelope-mac/bad-label",
52
+ "resolveKey: label must be a non-empty string");
53
+ }
54
+ if (_macKeyCache[label]) return _macKeyCache[label];
55
+ var v;
56
+ try { v = vault(); } catch (_e) { v = null; }
57
+ if (!v || typeof v.getKeysJson !== "function") {
58
+ throw new AgentEnvelopeMacError("agent-envelope-mac/vault-not-initialized",
59
+ "envelope MAC: vault must be initialized before agent envelopes can be authenticated " +
60
+ "(operator wires b.vault.init() at boot)");
61
+ }
62
+ var keysJson;
63
+ try { keysJson = v.getKeysJson(); }
64
+ catch (e) {
65
+ throw new AgentEnvelopeMacError("agent-envelope-mac/vault-not-initialized",
66
+ "envelope MAC: vault.getKeysJson threw — " + (e && e.message ? e.message : String(e)));
67
+ }
68
+ var rootBytes = Buffer.from(bCrypto.sha3Hash(keysJson), "hex");
69
+ var input = Buffer.concat([
70
+ Buffer.from(label, "utf8"),
71
+ Buffer.from([0x00]),
72
+ rootBytes,
73
+ ]);
74
+ _macKeyCache[label] = bCrypto.kdf(input, ENVELOPE_MAC_KEY_BYTES);
75
+ return _macKeyCache[label];
76
+ }
77
+
78
+ // Compute the base64 HMAC-SHA3-512 of `canonicalBytes` under the
79
+ // `label`'s vault-derived sub-key.
80
+ function sign(label, canonicalBytes) {
81
+ var key = resolveKey(label);
82
+ return nodeCrypto.createHmac("sha3-512", key).update(canonicalBytes).digest().toString("base64");
83
+ }
84
+
85
+ // Constant-time verify that `mac` (base64) is the correct HMAC for
86
+ // `canonicalBytes` under the `label`'s sub-key. Returns false for a
87
+ // missing / malformed `mac`; propagates AgentEnvelopeMacError from
88
+ // resolveKey when the vault is absent so the caller fails closed rather
89
+ // than treating a missing key as a verification pass.
90
+ function verify(label, canonicalBytes, mac) {
91
+ if (typeof mac !== "string" || mac.length === 0) return false;
92
+ var expected = sign(label, canonicalBytes);
93
+ return bCrypto.timingSafeEqual(mac, expected);
94
+ }
95
+
96
+ module.exports = {
97
+ resolveKey: resolveKey,
98
+ sign: sign,
99
+ verify: verify,
100
+ AgentEnvelopeMacError: AgentEnvelopeMacError,
101
+ ENVELOPE_MAC_KEY_BYTES: ENVELOPE_MAC_KEY_BYTES,
102
+ // Test-only — flush the memoized per-label MAC keys after a vault reset.
103
+ _resetForTest: function () { _macKeyCache = Object.create(null); },
104
+ };
@@ -67,11 +67,25 @@ var { defineClass } = require("./framework-error");
67
67
  var guardEventBusTopic = require("./guard-event-bus-topic");
68
68
  var guardEventBusPayload = require("./guard-event-bus-payload");
69
69
  var agentAudit = require("./agent-audit");
70
+ var envelopeMac = require("./agent-envelope-mac");
71
+ var safeJson = require("./safe-json");
72
+ var bCrypto = require("./crypto");
70
73
 
71
74
  var audit = lazyRequire(function () { return require("./audit"); });
72
75
 
73
76
  var AgentEventBusError = defineClass("AgentEventBusError", { alwaysPermanent: true });
74
77
 
78
+ // Wire-envelope authentication. An attacker with pubsub write access can
79
+ // set _tenantId to a victim subscriber's tenant + a schema-valid payload
80
+ // and forge a cross-tenant event; the tenant/posture/schema checks at the
81
+ // consumer prove SHAPE, not authenticity. Defense is a keyed MAC over the
82
+ // envelope's authority-bearing fields, minted at publish and verified at
83
+ // the consumer BEFORE the tenant/schema checks. The key derivation +
84
+ // HMAC live in the shared b.agent.envelopeMac mechanism (one keyed-MAC
85
+ // mechanism for every agent boundary); this label domain-separates the
86
+ // event-bus MAC from the posture-chain MAC.
87
+ var ENVELOPE_MAC_LABEL = "blamejs.agent.eventBus/v1";
88
+
75
89
  /**
76
90
  * @primitive b.agent.eventBus.create
77
91
  * @signature b.agent.eventBus.create(opts)
@@ -88,6 +102,7 @@ var AgentEventBusError = defineClass("AgentEventBusError", { alwaysPermanent: tr
88
102
  * pubsub: { publish, subscribe, unsubscribe }, // required
89
103
  * audit: b.audit namespace, // optional
90
104
  * permissions: b.permissions instance, // optional
105
+ * requireMac: boolean, // default: true — keyed-MAC envelope auth; false only for single-process unit tests with no vault
91
106
  *
92
107
  * @example
93
108
  * var bus = b.agent.eventBus.create({ pubsub: myPubsub });
@@ -109,12 +124,18 @@ function create(opts) {
109
124
  var auditImpl = opts.audit || audit();
110
125
  var permissions = opts.permissions || null;
111
126
  var topics = new Map();
127
+ // Envelope MAC (M6): default ON. Only single-process unit tests with no
128
+ // vault should opt out. When off, the cross-tenant MAC gate is bypassed
129
+ // and that is audit-visible at publish + delivery; production /
130
+ // multi-process / queue-spanning deployments leave the default so a
131
+ // pubsub-write attacker can't forge a cross-tenant event.
132
+ var requireMac = opts.requireMac !== false;
112
133
 
113
134
  return {
114
135
  registerTopic: function (name, topicOpts) { return _registerTopic(topics, name, topicOpts || {}, auditImpl); },
115
136
  unregisterTopic: function (name) { return _unregisterTopic(topics, name, auditImpl); },
116
- publish: function (name, payload, pOpts) { return _publish(topics, opts.pubsub, name, payload, pOpts || {}, permissions, auditImpl); },
117
- subscribe: function (name, handler, sOpts) { return _subscribe(topics, opts.pubsub, name, handler, sOpts || {}, permissions, auditImpl); },
137
+ publish: function (name, payload, pOpts) { return _publish(topics, opts.pubsub, name, payload, pOpts || {}, permissions, auditImpl, requireMac); },
138
+ subscribe: function (name, handler, sOpts) { return _subscribe(topics, opts.pubsub, name, handler, sOpts || {}, permissions, auditImpl, requireMac); },
118
139
  listTopics: function (args) { return _listTopics(topics, args || {}, permissions); },
119
140
  AgentEventBusError: AgentEventBusError,
120
141
  guards: {
@@ -201,7 +222,30 @@ function _listTopics(topics, args, permissions) {
201
222
 
202
223
  // ---- Publish --------------------------------------------------------------
203
224
 
204
- async function _publish(topics, pubsub, name, payload, pOpts, permissions, auditImpl) {
225
+ // Canonical bytes the MAC covers: _topic, _tenantId, _posture,
226
+ // _publishedAt, and a hash of the payload (so the payload can't be
227
+ // swapped without invalidating the MAC, without copying the whole
228
+ // payload into the signed preimage). Field set matches the consumer's
229
+ // authority decision inputs. Built as an ordered [key,value] tuple list
230
+ // so the canonical preimage is stable regardless of source key order.
231
+ function _macField(value, kind) {
232
+ if (kind === "string") return typeof value === "string" ? value : null;
233
+ if (kind === "number") return typeof value === "number" ? value : null;
234
+ return value === undefined ? null : value; // pass-through (posture)
235
+ }
236
+ function _envelopeMacBytes(wrapped) {
237
+ var payloadForHash = wrapped.payload === undefined ? null : wrapped.payload;
238
+ var tuples = [
239
+ ["_topic", _macField(wrapped._topic, "string")],
240
+ ["_tenantId", _macField(wrapped._tenantId, "string")],
241
+ ["_posture", _macField(wrapped._posture, "any")],
242
+ ["_publishedAt", _macField(wrapped._publishedAt, "number")],
243
+ ["payloadHash", bCrypto.sha3Hash(safeJson.canonical(payloadForHash))],
244
+ ];
245
+ return Buffer.from(safeJson.canonical(tuples), "utf8");
246
+ }
247
+
248
+ async function _publish(topics, pubsub, name, payload, pOpts, permissions, auditImpl, requireMac) {
205
249
  guardEventBusTopic.validate(name);
206
250
  var entry = topics.get(name);
207
251
  if (!entry) {
@@ -256,6 +300,32 @@ async function _publish(topics, pubsub, name, payload, pOpts, permissions, audit
256
300
  _publishedAt: Date.now(),
257
301
  payload: payload,
258
302
  };
303
+ // Authenticate the envelope's authority-bearing fields with a keyed MAC
304
+ // (M6). The consumer verifies this BEFORE the tenant/schema checks, so a
305
+ // pubsub-write attacker can't forge a cross-tenant event. If the vault
306
+ // isn't initialized there's no key to mint with — fail closed at publish
307
+ // (requireMac default) rather than emit an unauthenticatable envelope
308
+ // onto the bus. requireMac:false is the single-process unit-test escape
309
+ // hatch and is audit-visible.
310
+ try {
311
+ wrapped._mac = envelopeMac.sign(ENVELOPE_MAC_LABEL, _envelopeMacBytes(wrapped));
312
+ } catch (e) {
313
+ if (requireMac) {
314
+ _safeAudit(auditImpl, "agent.event_bus.publish_denied", pOpts.actor || null, {
315
+ topic: name, reason: "envelope-mac-unavailable",
316
+ });
317
+ throw new AgentEventBusError("agent-event-bus/envelope-mac-unavailable",
318
+ "publish: cannot authenticate the event envelope — " +
319
+ ((e && e.message) || String(e)) +
320
+ " (vault must be initialized so the bus MAC key is derivable, or " +
321
+ "set requireMac:false for single-process unit tests)");
322
+ }
323
+ // Escape hatch: no key + requireMac disabled → emit unauthenticated.
324
+ wrapped._mac = null;
325
+ _safeAudit(auditImpl, "agent.event_bus.mac_bypassed", pOpts.actor || null, {
326
+ topic: name, reason: "require-mac-disabled", phase: "publish",
327
+ });
328
+ }
259
329
  await pubsub.publish(name, wrapped);
260
330
  _safeAudit(auditImpl, "agent.event_bus.published", pOpts.actor, {
261
331
  topic: name, posture: entry.posture,
@@ -265,7 +335,7 @@ async function _publish(topics, pubsub, name, payload, pOpts, permissions, audit
265
335
 
266
336
  // ---- Subscribe ------------------------------------------------------------
267
337
 
268
- async function _subscribe(topics, pubsub, name, handler, sOpts, permissions, auditImpl) {
338
+ async function _subscribe(topics, pubsub, name, handler, sOpts, permissions, auditImpl, requireMac) {
269
339
  guardEventBusTopic.validate(name);
270
340
  var entry = topics.get(name);
271
341
  if (!entry) {
@@ -320,6 +390,37 @@ async function _subscribe(topics, pubsub, name, handler, sOpts, permissions, aud
320
390
  { topic: name, reason: "malformed-envelope" });
321
391
  return;
322
392
  }
393
+ // Envelope authentication FIRST (M6): verify the keyed MAC over the
394
+ // authority-bearing fields (_topic / _tenantId / _posture /
395
+ // _publishedAt / payload-hash) BEFORE trusting _tenantId / _posture
396
+ // for any routing or schema decision. A pubsub-write attacker who
397
+ // forges _tenantId (cross-tenant routing) or tampers _posture / the
398
+ // payload produces a MAC mismatch and the delivery drops. If the
399
+ // vault key is unavailable, verify() throws — we fail CLOSED (drop),
400
+ // never deliver an unauthenticatable envelope cross-tenant.
401
+ // requireMac:false is the single-process unit-test escape hatch and
402
+ // is audit-visible.
403
+ if (requireMac) {
404
+ var macOk = false;
405
+ try {
406
+ macOk = envelopeMac.verify(ENVELOPE_MAC_LABEL, _envelopeMacBytes(wrapped), wrapped._mac);
407
+ } catch (_e) {
408
+ macOk = false;
409
+ }
410
+ if (!macOk) {
411
+ _safeAudit(auditImpl, "agent.event_bus.cross_tenant_drop", sOpts.actor, {
412
+ topic: name,
413
+ publisherTenant: typeof wrapped._tenantId === "string" ? wrapped._tenantId : null,
414
+ subscriberTenant: subscriberTenant,
415
+ reason: "envelope-mac-invalid",
416
+ });
417
+ return;
418
+ }
419
+ } else {
420
+ _safeAudit(auditImpl, "agent.event_bus.mac_bypassed", sOpts.actor, {
421
+ topic: name, reason: "require-mac-disabled", phase: "delivery",
422
+ });
423
+ }
323
424
  // Tenant-scope check: subscriber's tenantId must match the
324
425
  // publisher's tenantId from the wire envelope. If the envelope
325
426
  // lacks _tenantId (publisher omitted), that's a tampered or
@@ -51,15 +51,13 @@
51
51
  */
52
52
 
53
53
  var lazyRequire = require("./lazy-require");
54
- var nodeCrypto = require("node:crypto");
55
54
  var { defineClass } = require("./framework-error");
56
55
  var guardPostureChain = require("./guard-posture-chain");
57
56
  var agentAudit = require("./agent-audit");
58
57
  var safeJson = require("./safe-json");
59
- var bCrypto = require("./crypto");
58
+ var envelopeMac = require("./agent-envelope-mac");
60
59
 
61
60
  var audit = lazyRequire(function () { return require("./audit"); });
62
- var vault = lazyRequire(function () { return require("./vault"); });
63
61
 
64
62
  var AgentPostureChainError = defineClass("AgentPostureChainError", { alwaysPermanent: true });
65
63
 
@@ -70,44 +68,16 @@ var BUILTIN_REGIMES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
70
68
  // strips postureSet to [] and re-sends a saga / sub-agent envelope
71
69
  // can bypass the downgrade refusal in _validate (which only checks
72
70
  // SHAPE, not authenticity). Defense is a keyed MAC over the canonical
73
- // envelope bytes, computed at appendHop and verified at validate.
71
+ // envelope bytes, computed at appendHop and verified at validate. The
72
+ // key derivation + HMAC construction live in the shared
73
+ // b.agent.envelopeMac mechanism (one keyed-MAC mechanism for every
74
+ // agent boundary); this label domain-separates the posture-chain MAC.
74
75
  var ENVELOPE_MAC_LABEL = "blamejs.agent.postureChain/v1";
75
- var ENVELOPE_MAC_KEY_BYTES = 32; // HMAC-SHA3-512 keyed bytes
76
76
  // Hop count cap defends infinite recursion across
77
77
  // agent delegation. 16 is the spec default; operators can lower via
78
78
  // opts.maxHopCount but never raise (audit fan-out without a cap is a
79
79
  // DoS class).
80
80
  var DEFAULT_MAX_HOP_COUNT = 16; // hop count cap
81
- var _macKeyCache = null; // memoized per-vault-master key
82
-
83
- function _resolveMacKey() {
84
- // Lazy derivation keyed off the vault master. Operator rotating
85
- // vault keys invalidates every in-flight MAC — desired property.
86
- // Memoization is process-local; if vault rotates within the same
87
- // process the operator restarts (vault rotation already implies it).
88
- if (_macKeyCache) return _macKeyCache;
89
- var v;
90
- try { v = vault(); } catch (_e) { v = null; }
91
- if (!v || typeof v.getKeysJson !== "function") {
92
- throw new AgentPostureChainError("agent-posture-chain/vault-not-initialized",
93
- "envelope MAC: vault must be initialized before posture-chain envelopes can be authenticated " +
94
- "(operator wires b.vault.init() at boot)");
95
- }
96
- var keysJson;
97
- try { keysJson = v.getKeysJson(); }
98
- catch (e) {
99
- throw new AgentPostureChainError("agent-posture-chain/vault-not-initialized",
100
- "envelope MAC: vault.getKeysJson threw — " + (e && e.message ? e.message : String(e)));
101
- }
102
- var rootBytes = Buffer.from(bCrypto.sha3Hash(keysJson), "hex");
103
- var input = Buffer.concat([
104
- Buffer.from(ENVELOPE_MAC_LABEL, "utf8"),
105
- Buffer.from([0x00]),
106
- rootBytes,
107
- ]);
108
- _macKeyCache = bCrypto.kdf(input, ENVELOPE_MAC_KEY_BYTES);
109
- return _macKeyCache;
110
- }
111
81
 
112
82
  function _envelopeMacBytes(envelope) {
113
83
  // Sign every field that downstream consumers verify off the wire,
@@ -124,15 +94,11 @@ function _envelopeMacBytes(envelope) {
124
94
  }
125
95
 
126
96
  function _signEnvelope(envelope) {
127
- var key = _resolveMacKey();
128
- var mac = nodeCrypto.createHmac("sha3-512", key).update(_envelopeMacBytes(envelope)).digest();
129
- return mac.toString("base64");
97
+ return envelopeMac.sign(ENVELOPE_MAC_LABEL, _envelopeMacBytes(envelope));
130
98
  }
131
99
 
132
100
  function _verifyEnvelopeMac(envelope) {
133
- if (typeof envelope._mac !== "string" || envelope._mac.length === 0) return false;
134
- var expected = _signEnvelope(envelope);
135
- return bCrypto.timingSafeEqual(envelope._mac, expected);
101
+ return envelopeMac.verify(ENVELOPE_MAC_LABEL, _envelopeMacBytes(envelope), envelope._mac);
136
102
  }
137
103
 
138
104
  /**
@@ -362,5 +328,5 @@ module.exports = {
362
328
  chain: guardPostureChain,
363
329
  },
364
330
  // Test-only — flush the memoized MAC key after a vault reset.
365
- _resetForTest: function () { _macKeyCache = null; },
331
+ _resetForTest: function () { envelopeMac._resetForTest(); },
366
332
  };
@@ -43,6 +43,7 @@ var lazyRequire = require("./lazy-require");
43
43
  var contentCredentials = lazyRequire(function () { return require("./content-credentials"); });
44
44
  var audit = require("./audit");
45
45
  var { defineClass } = require("./framework-error");
46
+ var gateContract = require("./gate-contract");
46
47
 
47
48
  var AiContentDetectError = defineClass("AiContentDetectError", { alwaysPermanent: true });
48
49
 
@@ -64,16 +65,14 @@ var COMPLIANCE_POSTURES = Object.freeze({
64
65
  "nist-ai-rmf": "balanced",
65
66
  });
66
67
 
67
- function _resolveProfile(opts) {
68
- if (opts && typeof opts.posture === "string") {
69
- var profile = COMPLIANCE_POSTURES[opts.posture];
70
- if (profile) return PROFILES[profile];
71
- }
72
- if (opts && typeof opts.profile === "string" && PROFILES[opts.profile]) {
73
- return PROFILES[opts.profile];
74
- }
75
- return PROFILES[DEFAULT_PROFILE];
76
- }
68
+ var _resolveProfile = gateContract.makeProfileResolver({
69
+ profiles: PROFILES,
70
+ postures: COMPLIANCE_POSTURES,
71
+ defaults: DEFAULT_PROFILE,
72
+ errorClass: AiContentDetectError,
73
+ codePrefix: "ai-content-detect",
74
+ byObject: true,
75
+ });
77
76
 
78
77
  /**
79
78
  * @primitive b.ai.aiContentDetect.report