@blamejs/core 0.14.25 → 0.14.27

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - 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).
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - v0.14.24 (2026-06-05) — **Per-row data residency enforced at the write boundary, and the long-advertised column-residency gate is wired.** Rows can now carry their own residency tag, and the database write paths enforce it: under a cross-border regulated posture (GDPR / UK-GDPR / DPDP / PIPL / LGPD / APPI / PDPA) a row tagged for one region is refused before it lands on a backend in another. `b.cryptoField.declarePerRowResidency` declares which column carries the tag; local SQLite writes check it against the deployment's `dataResidency` region set, and `b.externalDb.query` / `transaction` DML to a residency-tagged backend takes the tag per call via `rowResidencyTag`. The column-scoped gate (`assertColumnResidency`) — exported and documented since v0.7.27 but never composed into any write path — is now enforced on the same boundary. Unregulated deployments are unaffected: every gate passes with an advisory audit instead of a refusal, so operators can observe before adopting a posture. Note: v0.14.23 was tagged but not published to npm (its publish run timed out in validation); its changes — the MX DATA-phase SPF/DKIM/DMARC gate and the inbound mail authentication pipeline — are included in this package, and the publish-validation timeout is fixed here. **Added:** *`b.cryptoField.declarePerRowResidency` / `getPerRowResidency` — row-scoped residency tags* — Declares the plaintext column carrying each row's residency tag plus the whitelist of valid tag values (`{ residencyColumn, allowedTags }`). On INSERT to a declared table the tag is required and must be in `allowedTags`; rows tagged `global` or `unrestricted` pass any backend. The declaration registry mirrors `declareColumnResidency`, and `clearResidencyForTest` now clears both. The tag comes from application logic (the user's declared region) — the framework never infers it from request metadata. · *Local write gate on `b.db.from(...).insertOne` / `update`* — Runs on the plaintext row before sealing, so the tag column stays inspectable when sibling columns seal. Under a cross-border regulated posture, a row whose tag falls outside the deployment's region set (`dataResidency.region` plus `allowedStorageRegions`) is refused with `db-query/row-residency-local-mismatch`; a missing or out-of-whitelist tag refuses with `db-query/row-residency-tag-missing` / `-tag-invalid` regardless of posture. UPDATEs gate when the change set touches the residency column — an update that doesn't move residency is not a transfer. Refusals are typed `DbQueryError`s (new error class) and land in the audit chain (`db.residency.gate.rejected`); unregulated postures emit `db.residency.gate.advisory` and pass. · *External write gate — `rowResidencyTag` on `b.externalDb.query` / `transaction`* — External-db takes raw SQL, not row objects, so the row's tag travels as `opts.rowResidencyTag` (validated at transaction entry too). Under a regulated posture, a write to a residency-tagged backend requires the tag (`RESIDENCY_GATE_REQUIRED`) and refuses a mismatch (`RESIDENCY_TAG_MISMATCH`) before the statement reaches the wire. The gate classifies by what a statement does, not its leading keyword: it resolves the effective verb behind a `WITH` (CTE) or `EXPLAIN ANALYZE` prefix and treats `INSERT`/`UPDATE`/`DELETE`/`MERGE`/`REPLACE`, `CALL`/`EXECUTE`/`DO`, and `COPY ... FROM` as writes — only recognized pure reads (`SELECT`, `SHOW`/`DESCRIBE`/`PRAGMA`, a `COPY ... TO` export, plan-only `EXPLAIN`, and session/transaction statements) pass untagged. A statement whose class can't be resolved, or a multi-statement string that hides a trailing write behind a harmless prefix, is refused (`STATEMENT_UNRESOLVED_REFUSED` / `MULTI_STATEMENT_REFUSED`). Inside `transaction()`, the transaction-level tag applies to every statement and a per-call override on `tx.query(sql, params, opts)` wins for that statement; a refusal rolls the transaction back. Replica reads that carry a tag refuse routing to an incompatible replica (`REPLICA_RESIDENCY_INCOMPATIBLE`) unless the replica is configured `allowCrossBorder: true`, which is audited (`db.residency.replica.cross_border` at read time, `db.residency.replica.cross_border_allowed` at config time). Unrestricted backends are not gated, and the migration runner's own tracking writes are region-neutral so migrations run unaffected on a residency-tagged backend. · *`b.compliance.isCrossBorderRegulated` — shared posture vocabulary* — The cross-border regulated posture set (gdpr, uk-gdpr, dpdp, pipl-cn, lgpd-br, appi-jp, pdpa-sg) now lives on `b.compliance` (`CROSS_BORDER_REGULATED_POSTURES` + the membership helper), one source of truth shared by the local gate, the external gate, and the existing replica-topology boot check. **Fixed:** *`assertColumnResidency` is now actually enforced* — `b.cryptoField.declareColumnResidency` / `assertColumnResidency` shipped in v0.7.27 documenting a write-time gate, but no write path ever called the assertion — column tags were recorded and never checked. The local write paths now run it against the deployment region: a mismatch refuses with `db-query/column-residency-mismatch` under a regulated posture and emits an advisory otherwise. Operators tag columns with the region value their `dataResidency` declares. · *Cross-border-allowed replica audit event is now recorded* — The config-time audit event for a consciously-permitted cross-border replica used a malformed action name that the audit validator silently dropped, so a compliance reviewer saw no record of the `allowCrossBorder` decision in the audit chain. It now emits as `db.residency.replica.cross_border_allowed` and lands like every other audit row. · *Publish-validation timeout that blocked the v0.14.23 npm release* — The v0.14.23 publish run timed out in release validation — the pattern-catalog self-test crossed a per-file watchdog budget as the codebase grew, and the same self-test, which scans the whole library and runs far longer on the slower macOS CI runner, hit the same wall on this package's first CI run. The validation budgets are corrected so a genuinely stuck file still fails fast while the catalog completes. v0.14.23 exists as a signed tag; npm goes 0.14.22 → 0.14.24 carrying both releases' changes. **Detectors:** *db-query-write-without-residency-gate* — Every local write method that seals a row must run the residency gates on the plaintext first — a future write path (upsert, bulk) inherits the requirement automatically. · *Residency-gates-wired catalog check* — The pattern catalog now pins the wiring itself: the local write methods call the gate, the external query/transaction paths call theirs, and `assertColumnResidency` has a real caller — the declared-but-never-enforced class cannot silently reappear. **Migration:** *No action required unless you adopt the gates* — Tables without a residency declaration, deployments without a `dataResidency` region, and unregulated postures behave exactly as before (advisory audit events at most). Adopting: declare per-row residency on mixed-region tables, pass `rowResidencyTag` on external DML, and set a cross-border posture (`b.compliance.set("gdpr")`) to turn refusals on.
@@ -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
  };
@@ -167,6 +167,30 @@ function fsyncDir(dirPath) {
167
167
  function _fsync(fd) { return fsync(fd); }
168
168
  function _fsyncDir(dirPath) { return fsyncDir(dirPath); }
169
169
 
170
+ // Exclusive, no-follow create of the sibling temp file that every
171
+ // atomic write stages bytes into before the rename. CWE-377
172
+ // (insecure temporary file) / CWE-59 (symlink-following): the legacy
173
+ // "w" flag is O_WRONLY|O_CREAT|O_TRUNC — it happily opens (and
174
+ // truncates, or writes through) a file an attacker pre-created at the
175
+ // temp path, including a symlink pointing at a victim file the process
176
+ // can write but the attacker can't. O_EXCL makes the open fail with
177
+ // EEXIST if anything already exists at tmpPath, so a planted file /
178
+ // symlink / FIFO is refused instead of followed; O_NOFOLLOW rejects a
179
+ // symlink in the final path component on platforms that define it
180
+ // (Windows leaves it undefined, hence the `|| 0`). The temp name
181
+ // already carries a CSPRNG token (generateToken), so EEXIST is a
182
+ // hostile-collision signal, not a benign retry. The fd is returned for
183
+ // the caller to write + fsync; mode is applied at create time so the
184
+ // bytes are never world-readable even briefly.
185
+ function _openExclTemp(tmpPath, fileMode) {
186
+ return nodeFs.openSync(
187
+ tmpPath,
188
+ nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
189
+ nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0),
190
+ fileMode
191
+ );
192
+ }
193
+
170
194
  /**
171
195
  * @primitive b.atomicFile.ensureDir
172
196
  * @signature b.atomicFile.ensureDir(dirPath, mode)
@@ -406,7 +430,7 @@ function writeSync(filepath, data, opts) {
406
430
  var tmpPath = filepath + ".tmp-" + generateToken(C.BYTES.bytes(8));
407
431
  var renamed = false;
408
432
  try {
409
- var fd = nodeFs.openSync(tmpPath, "w", opts.fileMode);
433
+ var fd = _openExclTemp(tmpPath, opts.fileMode);
410
434
  try {
411
435
  var pos = 0;
412
436
  while (pos < buf.length) {
@@ -530,7 +554,7 @@ async function write(filepath, data, opts) {
530
554
  var tmpPath = filepath + ".tmp-" + generateToken(C.BYTES.bytes(8));
531
555
  var renamed = false;
532
556
  try {
533
- var fd = nodeFs.openSync(tmpPath, "w", opts.fileMode);
557
+ var fd = _openExclTemp(tmpPath, opts.fileMode);
534
558
  try {
535
559
  var pos = 0;
536
560
  while (pos < buf.length) {
@@ -659,9 +683,15 @@ function _readSyncCore(filepath, opts) {
659
683
  // can't swap the file between size-check and read because the fd
660
684
  // is anchored to the original inode. ENOENT surfaces from open()
661
685
  // rather than the previous existsSync() pre-check.
686
+ //
687
+ // The third argument pins an owner-only mode (0o600). The flag is
688
+ // read-only ("r" → O_RDONLY, no O_CREAT) so the mode is inert on
689
+ // disk, but specifying it keeps this open out of the insecure-temp-
690
+ // file class (CWE-377): the read can never create a world/group-
691
+ // accessible file even when `filepath` is rooted under a temp dir.
662
692
  var fd;
663
693
  try {
664
- fd = nodeFs.openSync(filepath, "r");
694
+ fd = nodeFs.openSync(filepath, "r", 0o600);
665
695
  } catch (openErr) {
666
696
  if (openErr && openErr.code === "ENOENT") {
667
697
  var e = new AtomicFileError("file not found: " + filepath, "atomic-file/not-found");
package/lib/audit.js CHANGED
@@ -617,11 +617,18 @@ function useStore(store) {
617
617
  //
618
618
  // Self-logging (PCI DSS 10.2.3): every read of audit_log is itself recorded
619
619
  // as an 'audit.read' event before the query runs, so an exfiltration attempt
620
- // is forensically visible. The recursion guard (_selfLogging flag) prevents
621
- // the audit.read recording from triggering its own self-log; queries
622
- // SPECIFICALLY filtering for action='audit.read' don't auto-log either
623
- // (otherwise legitimate audit auditing produces a Russell-set spiral).
624
- var _selfLogging = false;
620
+ // is forensically visible. The self-log goes through record() (which never
621
+ // re-enters query()), so the only re-entrancy to suppress is a query whose
622
+ // own criteria filters for action='audit.read' — those don't auto-log
623
+ // (otherwise legitimate audit-of-audits produces a Russell-set spiral, and
624
+ // the self-read itself would log a read). That suppression is decided PER
625
+ // INVOCATION from the call's own criteria.action, never from shared mutable
626
+ // state: a prior design used a module-global `_selfLogging` boolean toggled
627
+ // across record()'s await (chain mutex + SQL yield), so a CONCURRENT
628
+ // query() racing a mid-flight self-log observed the flag set and silently
629
+ // skipped emitting its own audit.read — under-logging reads exactly when
630
+ // load is highest. b.audit.query is reachable from concurrent request
631
+ // handlers, so the guard MUST be invocation-local.
625
632
 
626
633
  /**
627
634
  * @primitive b.audit.query
@@ -633,9 +640,10 @@ var _selfLogging = false;
633
640
  * Read audit rows matching the criteria, returning unsealed rows for
634
641
  * the auditor's view. Every call self-logs an `audit.read` event before
635
642
  * returning (PCI DSS 10.2.3) so exfiltration attempts are forensically
636
- * visible; recursion is guarded so the self-log doesn't trigger its own
637
- * self-log. Plain-field criteria translate into derived-hash equality
638
- * where the column is sealed.
643
+ * visible; the self-log is suppressed per-invocation only for a query
644
+ * whose own criteria targets `action: "audit.read"`, so concurrent reads
645
+ * each record their own `audit.read`. Plain-field criteria translate into
646
+ * derived-hash equality where the column is sealed.
639
647
  *
640
648
  * @opts
641
649
  * from: number | Date | string, // recordedAt >=
@@ -658,21 +666,21 @@ var _selfLogging = false;
658
666
  */
659
667
  async function query(criteria) {
660
668
  criteria = criteria || {};
661
- if (!_selfLogging && criteria.action !== "audit.read") {
662
- _selfLogging = true;
663
- try {
664
- await record({
665
- actor: criteria.actor || {},
666
- action: "audit.read",
667
- outcome: "success",
668
- metadata: {
669
- criteria: _redactCriteria(criteria),
670
- traceId: criteria.traceId || null,
671
- },
672
- });
673
- } finally {
674
- _selfLogging = false;
675
- }
669
+ // Suppress the self-log ONLY for a query whose own criteria targets
670
+ // action='audit.read' (the self-read's shape + audit-of-audits). This is
671
+ // derived from THIS call's criteria — no shared flag — so concurrent
672
+ // reads each emit their own audit.read instead of one racing read
673
+ // swallowing another's self-log.
674
+ if (criteria.action !== "audit.read") {
675
+ await record({
676
+ actor: criteria.actor || {},
677
+ action: "audit.read",
678
+ outcome: "success",
679
+ metadata: {
680
+ criteria: _redactCriteria(criteria),
681
+ traceId: criteria.traceId || null,
682
+ },
683
+ });
676
684
  }
677
685
 
678
686
  // In single-node mode the query builder gives us field-crypto unsealing
package/lib/auth/oauth.js CHANGED
@@ -2016,13 +2016,19 @@ function create(opts) {
2016
2016
  if (uopts.prompt) authzParams.prompt = uopts.prompt;
2017
2017
  if (uopts.loginHint) authzParams.login_hint = uopts.loginHint;
2018
2018
  if (uopts.maxAge != null) authzParams.max_age = String(uopts.maxAge);
2019
- // RFC 9396 — push the fine-grained authorization request through PAR
2020
- // identically to the redirect path (validated, then JSON-serialized).
2019
+ // RFC 9396 — push the fine-grained authorization request through PAR.
2020
+ // On the plain-form branch the value is a form parameter (JSON STRING);
2021
+ // on the signed-request-object branch it becomes a JAR claim and MUST
2022
+ // be the native JSON ARRAY (RFC 9101/9396) — a conforming AS rejects a
2023
+ // string-valued authorization_details claim. Carry the validated array
2024
+ // and serialize ONLY when it travels as a form param.
2021
2025
  var requestedAuthzDetails = null;
2022
2026
  if (uopts.authorizationDetails !== undefined) {
2023
2027
  requestedAuthzDetails = _validateAuthorizationDetailsArray(
2024
2028
  uopts.authorizationDetails, "pushAuthorizationRequest");
2025
- authzParams.authorization_details = JSON.stringify(requestedAuthzDetails);
2029
+ authzParams.authorization_details = sro
2030
+ ? requestedAuthzDetails // JAR claim — native array
2031
+ : JSON.stringify(requestedAuthzDetails); // form param — JSON string
2026
2032
  }
2027
2033
  if (uopts.extraParams && typeof uopts.extraParams === "object") {
2028
2034
  var ek = Object.keys(uopts.extraParams);
@@ -2160,9 +2166,18 @@ function create(opts) {
2160
2166
  // store — operators wire b.cache or b.db.
2161
2167
  async function verifyBackchannelLogoutToken(logoutToken, vopts) {
2162
2168
  vopts = vopts || {};
2163
- if (typeof logoutToken !== "string" || logoutToken.length === 0) {
2169
+ // Type / non-empty / length-cap gate, folded into one bounds check.
2170
+ // The cap runs BEFORE the split + base64url decode — an attacker-
2171
+ // reachable endpoint can POST an arbitrarily large logout_token, and
2172
+ // bounding it first stops the decode from allocating unbounded memory.
2173
+ var logoutTokenIsString = typeof logoutToken === "string";
2174
+ if (!logoutTokenIsString || logoutToken.length === 0) {
2164
2175
  throw new OAuthError("auth-oauth/bad-logout-token",
2165
2176
  "verifyBackchannelLogoutToken: logoutToken must be a non-empty string");
2177
+ } else if (logoutToken.length > OAUTH_MAX_RESPONSE_BYTES) {
2178
+ throw new OAuthError("auth-oauth/logout-token-too-large",
2179
+ "verifyBackchannelLogoutToken: logout_token exceeds " +
2180
+ OAUTH_MAX_RESPONSE_BYTES + " bytes");
2166
2181
  }
2167
2182
  var parts = logoutToken.split(".");
2168
2183
  if (parts.length !== 3) {
@@ -2170,7 +2185,12 @@ function create(opts) {
2170
2185
  "verifyBackchannelLogoutToken: logout_token must be a 3-segment JWS");
2171
2186
  }
2172
2187
  var headerObj;
2173
- try { headerObj = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8")); } // allow:bare-json-parse pre-verify header parse to look up the typ; the JWS signature is verified by verifyIdToken below
2188
+ // Route the pre-verify header parse through safeJson (size-bounded) like
2189
+ // the in-module id_token / JWS-header siblings — the bare JSON.parse on
2190
+ // an attacker-reachable, not-yet-signature-checked header was the one
2191
+ // unbounded parse on this surface. The JWS signature is verified by
2192
+ // verifyIdToken below.
2193
+ try { headerObj = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
2174
2194
  catch (_e) {
2175
2195
  throw new OAuthError("auth-oauth/bad-logout-header",
2176
2196
  "verifyBackchannelLogoutToken: malformed header");