@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.
- package/CHANGELOG.md +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +107 -74
- package/lib/atomic-file.js +62 -4
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +249 -123
- package/lib/auth/openid-federation.js +108 -47
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +169 -4
- package/lib/consent.js +73 -24
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +474 -92
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +497 -255
- package/lib/db-schema.js +209 -44
- package/lib/db.js +176 -95
- package/lib/error-page.js +14 -1
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +14 -1
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +649 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +37 -9
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-server-jmap.js +117 -12
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +287 -154
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +57 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/outbox.js +136 -82
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/retention.js +82 -39
- package/lib/router.js +212 -5
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +109 -72
- package/lib/sql.js +3885 -0
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +177 -34
- package/lib/subject.js +55 -17
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +35 -5
- package/package.json +1 -1
- 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 (
|
|
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
|
+
};
|
package/lib/agent-event-bus.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 () {
|
|
331
|
+
_resetForTest: function () { envelopeMac._resetForTest(); },
|
|
366
332
|
};
|
package/lib/ai-content-detect.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|