@blamejs/core 0.8.0 → 0.8.5
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 +26 -0
- package/lib/audit-sign.js +1 -1
- package/lib/audit.js +62 -2
- package/lib/auth/jwt.js +13 -0
- package/lib/auth/lockout.js +16 -3
- package/lib/auth/oauth.js +15 -1
- package/lib/auth/password.js +22 -2
- package/lib/auth/sd-jwt-vc-issuer.js +2 -2
- package/lib/auth/sd-jwt-vc.js +7 -2
- package/lib/break-glass.js +53 -14
- package/lib/cache-redis.js +1 -1
- package/lib/cache.js +6 -1
- package/lib/cli.js +3 -3
- package/lib/cluster.js +24 -1
- package/lib/compliance-ai-act-logging.js +7 -3
- package/lib/compliance.js +10 -2
- package/lib/config-drift.js +2 -2
- package/lib/crypto-field.js +21 -1
- package/lib/crypto.js +82 -1
- package/lib/db.js +35 -4
- package/lib/dev.js +30 -3
- package/lib/dual-control.js +19 -1
- package/lib/external-db.js +10 -0
- package/lib/file-upload.js +30 -3
- package/lib/flag.js +1 -1
- package/lib/guard-all.js +33 -16
- package/lib/guard-csv.js +16 -2
- package/lib/guard-html.js +35 -0
- package/lib/guard-svg.js +20 -0
- package/lib/http-client.js +57 -11
- package/lib/inbox.js +34 -10
- package/lib/log-stream-syslog.js +8 -0
- package/lib/log-stream.js +1 -1
- package/lib/mail.js +40 -0
- package/lib/middleware/attach-user.js +25 -2
- package/lib/middleware/bearer-auth.js +71 -6
- package/lib/middleware/body-parser.js +13 -0
- package/lib/middleware/cors.js +10 -0
- package/lib/middleware/csrf-protect.js +34 -3
- package/lib/middleware/dpop.js +3 -3
- package/lib/middleware/host-allowlist.js +1 -1
- package/lib/middleware/index.js +3 -0
- package/lib/middleware/require-aal.js +2 -2
- package/lib/middleware/require-mtls.js +179 -0
- package/lib/middleware/trace-propagate.js +1 -1
- package/lib/mtls-ca.js +23 -29
- package/lib/mtls-engine-default.js +21 -1
- package/lib/network-tls.js +21 -6
- package/lib/object-store/sigv4-bucket-ops.js +41 -0
- package/lib/observability-otlp-exporter.js +35 -2
- package/lib/outbox.js +3 -3
- package/lib/permissions.js +10 -1
- package/lib/pqc-agent.js +22 -1
- package/lib/pubsub.js +8 -4
- package/lib/redact.js +26 -1
- package/lib/retention.js +26 -0
- package/lib/router.js +1 -0
- package/lib/scheduler.js +57 -1
- package/lib/session.js +3 -3
- package/lib/ssrf-guard.js +19 -4
- package/lib/static.js +12 -0
- package/lib/totp.js +16 -0
- package/lib/ws-client.js +158 -9
- package/package.json +3 -2
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,32 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **0.8.5** (2026-05-06) — Vendor-currency CI gate + `b.middleware.requireMtls` primitive. **Vendor-currency check** — `scripts/check-vendor-currency.js` + new CI job in `ci.yml` assert every npm-mapped vendored bundle in `lib/vendor/MANIFEST.json` matches the latest published version on the npm registry. Per-component check on meta-bundles (e.g. `peculiar-pki` → `@peculiar/x509` + `pkijs`). Master-branch corpus entries (`SecLists`) are checked against the GitHub Commits API for the bundled file's path on the source repo's default branch — if the upstream has commits newer than the manifest's `bundledAt` date, the gate fails. Registry errors stay advisory unless `BLAMEJS_VENDOR_CURRENCY_STRICT=1`. Operators run locally with `npm run check:vendor-currency`. **`b.middleware.requireMtls`** — new soft-enforcement middleware that rejects requests without an authenticated client certificate. Composes with `b.crypto.hashCertFingerprint` / `isCertRevoked` (added in v0.8.4) so operators pass `fingerprintAllowList: [...]` and `denyList: [...]` and the middleware does the constant-time match. `req.peerCert` + `req.peerFingerprint` are attached for downstream handlers. Audits `mtls.required.allowed` / `mtls.required.refused` with reason metadata.
|
|
12
|
+
|
|
13
|
+
- **0.8.4** (2026-05-06) — Supply-chain scanner findings + outbound HTTP posture + npm-publish unblock. **Outbound network surface** — `b.observability.otlpExporter` no longer defaults to `globalThis.fetch`; the default transport is now `b.httpClient` (`node:https` through the framework's PQC-hybrid agent + cert-pinning + SSRF guard). The prior default leaked an outbound network surface that supply-chain scanners flagged because it sat outside the framework's TLS posture; operators on fetch-only edge runtimes still override via `opts.fetchImpl`. **Dev-mode child-process isolation** — `b.dev.create()` now lazy-requires `child_process` (was top-level — flagged on every install regardless of whether `b.dev` was used) and refuses to construct when `NODE_ENV=production` unless the operator passes `opts.allowProduction:true` with an audited reason. A misconfigured production deploy that accidentally wires the dev-mode restart loop now crashes loudly at boot rather than spawning shells on every save. **Outbound posture additions** — `b.httpClient.request` adds `responseMode: "always-resolve"` (every response resolves with `{statusCode, headers, body}` regardless of HTTP status) plus `onRedirect({from, to, hop, headersStripped, statusCode, method})` hook (operators can throw to abort or rewrite the redirect chain). `b.wsClient.connect` adds `urlFor(attempt)` and `tlsOptsFor(attempt)` per-dial overrides for between-reconnect URL / TLS rotation; the new URL is re-validated through `ssrfGuard` so a hostile upstream can't redirect a reconnecting client at a private address. `b.wsClient` swallows post-`close()` `ECONNRESET` / `EPIPE` so a clean shutdown doesn't surface a noisy unhandled-error event. `b.pqcAgent.create({ ecdhCurve })` accepts a caller-supplied stricter list — operators can drop a group from the framework default but cannot widen with non-PQ groups (the prior hardcoded value blocked legitimate per-deployment narrowing). **Crypto helpers** — `b.crypto.hashCertFingerprint(pem|der)` returns `{ hex, colon }` SHA3-512 digests and `b.crypto.isCertRevoked(pemOrDer, denyList)` does a constant-time match. **Scheduler surface** — `b.scheduler.register(name, intervalMs, fn)` shorthand for the every-N-ms registration shape; `b.scheduler.getStatus()` returns an aggregate health surface for probes / dashboards (started flag, isLeader, per-task list, totals). **npm-publish unblock** — `test/layer-0-primitives/sd-jwt-vc.test.js` was asserting `DEFAULT_ALG === "ES256"` after v0.8.1 flipped the default to `ML-DSA-87`; the assertion now matches the lib (and `DEFAULT_HASH_ALG` for `sha3-512`). v0.8.1 / v0.8.2 / v0.8.3 all failed the npm-publish gate on this single test; this release re-enables the publish workflow.
|
|
14
|
+
|
|
15
|
+
- **0.8.3** (2026-05-06) — Release-gate fixes + post-v0.8.2 hardening. Wiki primitive-section validator gate flagged `b.middleware.csrfProtect` opts-key drift — the `requireOrigin` opt added in v0.8.1 wasn't documented in the wiki seeder; now listed alongside `checkOrigin` / `allowedOrigins`. Gitleaks secret-scan gate flagged `{ privateKey, cipherText }` KEM-envelope shapes in `lib/crypto.js` error messages + `CHANGELOG.md` v0.8.0 entry as generic-api-key false positives — `.gitleaks.toml` adds an explicit regex allowlist for the parameter-name shape and pins the v0.8.0 commit fingerprints. Functional additions: `b.httpClient.request` adds `responseMode: "always-resolve"` opt — every request resolves with `{ statusCode, headers, body }` regardless of HTTP status (operators using the framework as an inbound-proxy upstream no longer have to wrap each call in a try/catch to recover the body of a 4xx/5xx). `b.wsClient` swallows post-close `ECONNRESET` / `EPIPE` errors so a clean `close()` doesn't surface a noisy unhandled-error event when the kernel races the FIN with an in-flight write. `SECURITY.md` documents the `allowInternal: true` test-pattern (legitimate same-host integration tests opt in explicitly with audited reason — never as a production default).
|
|
16
|
+
|
|
17
|
+
- **0.8.2** (2026-05-06) — eslint fixes for v0.8.1 npm-publish gate. `lib/guard-csv.js` bidi-prefix regex now uses explicit `\uXXXX` escapes (was tripping `no-irregular-whitespace` + `no-misleading-character-class` on the literal-codepoint form). `lib/redact.js` URL-bearer-query detector drops a redundant `\-` escape inside a character class. Functional behaviour unchanged from 0.8.1.
|
|
18
|
+
|
|
19
|
+
- **0.8.1** (2026-05-06) — Hardening sweep across audit emission, crypto defaults, auth bypass closure, storage / SQLi, HTTP/network surface, supply-chain pin, and observability gaps. Defense-in-depth fixes — no new operator-facing primitives.
|
|
20
|
+
|
|
21
|
+
**Audit emission** — `audit.safeEmit` now normalises non-canonical outcomes (`ok` / `fail` / `warning` / `duplicate` / `skip` → `success` / `failure`) and replaces hyphens in action-name segments with underscores. The strict regex enforced by `audit.record` was silently dropping events from `b.flag` / `b.outbox` / `b.inbox` / `b.session` (idle / absolute / fingerprint-drift) / `b.db` (integrity-check) / `b.compliance.aiAct` (every Annex III biometric-id log on the operator-facing kinds with hyphens) / `b.config-drift` (low-severity drift) / `b.log-stream` (sink-failure) / `b.pubsub` (publish + publish-failed — also fixes a positional-signature bug at the call site). Two new `codebase-patterns` detectors (`audit-action-with-hyphen`, `non-canonical-audit-outcome`) catch new sites at gate time. Chain-write integrity failures now emit `system.audit.chain_write_dropped` to observability so operators alerting on rate-drop see something even when audit itself is the broken sink.
|
|
22
|
+
|
|
23
|
+
**Crypto defaults** — `b.mtlsCa` `caKeySealedMode` default flipped from `"auto"` to `"required"`; the legacy `"auto"` mode (load whichever exists, fall back to plaintext when no sealed file pre-exists) is removed. Operators wanting plaintext explicitly opt in via `caKeySealedMode: "disabled"` with audited reason. `b.network.tls` default key-share preference list now leads with `SecP384r1MLKEM1024` (the highest-PQ hybrid registered in `TLS_GROUP_PREFERENCE`) and drops `secp256r1` (P-256 was forbidden as a default per the framework's PQC-first hard rule). `b.auth.sdJwtVc` defaults to `ML-DSA-87` + `sha3-512`; the previous `ES256` + `sha-256` defaults shipped classical P-256 + SHA-256 credentials operators would have to re-issue at the EU eIDAS deadline. `b.crypto.encrypt` now emits `system.crypto.hybrid_disabled` audit when called with only an ML-KEM public key (silent fallback to KEM-only used to mask the missing P-384 leg). `b.auth.totp` emits `auth.totp.algorithm_downgraded` audit on every SHA-256 enrolment / verification.
|
|
24
|
+
|
|
25
|
+
**Auth bypass closure** — `b.breakGlass` `policy.requireScope` is now actually enforced at `grant()` time (was accepted, persisted, and surfaced via `policyGet` but never consulted). `b.permissions` `requireMfa: true` defaults to a 15-minute `mfaWindowMs` floor when neither route nor role supplies one (a stolen long-lived cookie with stale `mfaAt` no longer walks past the gate). `b.middleware.attachUser` now threads `req` through `session.verify` so the documented fingerprint-drift / IP-UA pin / anomaly-score defenses actually fire on the standard middleware path. `b.middleware.bearerAuth` returns 401 with `WWW-Authenticate: Bearer error="invalid_request"` when an `Authorization` header is present but doesn't parse against the configured scheme (was falling through to cookie-session); `realm` is CRLF-validated at create-time. `b.bearerAuth` sets `req._bearerAuthHandled` after success so `b.middleware.attachUser` skips re-reading the same header. `b.breakGlass.unsealRow` SELECTs the target row before incrementing `rowsConsumed` so a typo'd row id no longer exhausts a `maxRowsPerGrant: 1` grant. `b.auth.password` HIBP path fail-closes when more than half the response lines are unparseable (poisoned-mirror defense). `b.auth.jwt.sign` auto-mints a `jti` when `expiresInSec` is set and the operator didn't supply one; the silent-replay-window where the verifier was configured for replay-defense but the token shipped without a jti is closed. `b.auth.oauth.exchangeCode` requires `nonce` on OIDC flows when `authorizationUrl()` produced one (silent skip on operator-forgot-to-thread is closed; explicit `skipNonceCheck: true` for legacy IdPs). `b.auth.lockout` cache-error signal now also rides the audit chain (was observability-only — a deployment with no observability + a degraded cache had no signal that brute-force protection was disabled). `b.middleware.csrfProtect` cookie regex tightened from `{2,}` to `{64}` hex chars (matches `forms.generateCsrfToken` output length so a sibling-subdomain XSS can't plant `csrf=ab` and submit matching `X-CSRF-Token`); new `requireOrigin: true` opt for browser-only routes; `csrf.bad_cookie_value` audit on planted-short-cookie refusals.
|
|
26
|
+
|
|
27
|
+
**Storage / SQLi** — `b.retention` calls `safeSql.validateIdentifier` on every operator-supplied table name, age field, soft-delete field, legal-hold field, and cascade FK before reaching SQL string concatenation (operator-config-fed name like `users"; DROP TABLE audit_log;--` no longer breaks out of the quoted identifier). `b.db` at-rest `db.enc` envelope binds `(data dir, node identity)` AAD so two deployments sharing the operator passphrase can't swap `db.enc` files; old envelopes still decrypt via a one-release backwards-compat fallback. `b.externalDb` `SET LOCAL` GUC values capped at 4 KiB (prevents log bloat / parser pressure from operator-controlled tenant-id payloads). `b.cache` set path swapped from `JSON.stringify` to `safeJson.stringify` (refuses Buffer / circular / Date round-trip ambiguity). `b.objectStore.setObjectRetention` does a `getObjectRetention` pre-check and refuses client-side when the existing retention mode is `COMPLIANCE` and the operator (or attacker with `s3:PutObjectRetention`) tries to shorten it or pass `bypassGovernance: true`. `b.inbox` rejects NUL + C0 controls in `messageId` / `source` (closes the dedupe-collision attack on truncating drivers).
|
|
28
|
+
|
|
29
|
+
**HTTP / network** — `b.wsClient.connect` now wires `b.ssrfGuard` symmetric to `b.httpClient` (cloud-metadata / private / loopback / link-local / reserved IPs are hard-deny by default; `allowInternal: true` opts in for legitimate internal targets). `b.wsClient` outbound `tls.connect` explicitly pins `minVersion: "TLSv1.3"` matching every other outbound TLS site. `b.app` HTTP/2 server pins `maxOutstandingPings: 10` (CVE-2019-9512 ping-flood class). `b.middleware.cors` always appends `Vary: Origin` when the request carried an Origin (closes the cache-poisoning surface where unmatched-origin responses could be served from a previously-matched cache entry). `b.staticServe` adds `maxRangeBytes` cap (default 64 MiB) — refuses single-range requests larger than the cap with 416 (slowloris-range defense). `b.middleware.bodyParser` per-part header bytes now count toward `totalSize` (closes the 120 × 16 KiB amplification surface for many-parts uploads). `b.ssrfGuard` `_ipv4ToInt` strict octet validation refuses non-numeric segments instead of silently coercing to 0 (closes the CIDR-typo collapse-to-0.0.0.0 footgun). `b.cluster` heartbeat picks up ±20% per-tick jitter on followers (closes the thundering-herd lease-acquire race on lease expiry); `MIN_LEASE_TTL` bumped from 5s to 10s.
|
|
30
|
+
|
|
31
|
+
**Supply-chain** — `scripts/vendor-update.sh` now auto-runs `scripts/refresh-vendor-manifest.js` so MANIFEST.json sha256 hashes track the on-disk bundle without a separate operator step.
|
|
32
|
+
|
|
33
|
+
**Content-safety** — `b.guardHtml._extractScheme` + `b.guardSvg._extractScheme` now decode HTML5 named entities (`	` / `
` / `:` / `/` / etc.) before scheme-allowlist matching (closes the `java	script:` bypass class). `b.guardCsv` strips ZWSP / RTLO / LRM / RLM / BOM at cell-start before the formula-prefix scan (closes the bidi-prefix formula-injection bypass). `b.guardAll._verifyParity` now walks `STANDALONE_GUARDS` too (filename / domain / uuid / cidr / time / mime / jwt / oauth / graphql / shell / regex / jsonpath / template / image / pdf / auth) so missing PROFILES / COMPLIANCE_POSTURES on a standalone guard surfaces at boot rather than at `b.guardAll.allGuards()` lookup. `b.fileUpload._checkAllowedFileType` cross-checks the operator-supplied claimed MIME against magic-byte detection and refuses on family mismatch. `b.mail` attachment validation now wires `b.guardFilename.validate({ profile: "strict" })` + magic-byte / claimed-MIME cross-check (defense-in-depth on outbound mail attachments).
|
|
34
|
+
|
|
35
|
+
**Observability** — `b.redact` SENSITIVE_FIELDS now covers `x-api-key` / `x-apikey` / `x_api_key` / `api-key` (substring-match via lowercased field-name was missing the hyphen + underscore variants) plus DPoP / OAuth 2.1 fields (`jwk`, `dpop`, `proof`, `assertion`, `client_assertion`, `id_token_hint`, `code_verifier`, `client_secret`, `refresh_token`, `access_token`); new value-shape detector redacts query-string `?token=` / `?access_token=` / `?api_key=` patterns inside URL fields. `b.logStream` syslog sink strips CR / LF from MSG content per RFC 5424 §6.4 (closes the SIEM-record-injection surface where an operator-controlled `record.message` could spawn a fake separate-priority record). `b.compliance.set` rejection emits `compliance.posture.set_rejected` audit on `unknown-posture` and `already-set` paths.
|
|
36
|
+
|
|
11
37
|
- **0.8.0** (2026-05-06) — Minor release. New: **`b.mail.arc.sign({ rfc822, instance, authservId, domain, selector, privateKey, algorithm, cv, authResults, headersToSign, timestamp })`** ships the relay-side RFC 8617 ARC chain construction (companion to `b.mail.arc.verify`); produces AAR + AMS + AS headers, prepends them in RFC-recommended order; enforces cv= rules (`i=1` requires `cv=none`, `i>=2` requires `cv=pass`/`cv=fail`); chain-gap detection (`i=N` requires N-1 prior hops); rsa-sha256 + ed25519-sha256 algorithms; CRLF-injection refused on operator-supplied authResults; audit emission `dkim.arc.signed`. **`b.inbox.create({ externalDb, table, retentionDays, audit })`** transactional dedupe-on-receive (companion to `b.outbox`); guarantees exactly-once handling by recording every (source, messageId) pair in the same transaction as the business state change — duplicate delivery short-circuits via the (source, message_id) PRIMARY KEY constraint; high-level `handle(opts, fn)` API wraps externalDb.transaction; low-level `recordReceive` / `markProcessed` for operators managing transactions directly; `declareSchema` / `sweep(retentionDays)` / `getStats` / `isFresh`; postgres + sqlite dialect support; audit emissions `inbox.received` / `handled` / `handle_failed` / `swept`. **`b.openapi.parse(jsonStringOrObject)`** + **`b.asyncapi.parse(jsonStringOrObject)`** validate external specs (currently only the build path existed); returns `{ doc, errors[], valid }`; covers version, info, paths/channels/operations, response.description, path-parameter required:true, dangling security references. **`b.crypto.decryptMlkem768X25519(ciphertext, { privateKey, x25519PrivateKey })`** symmetric named helper alongside the existing `encryptMlkem768X25519`; rejects ciphertexts under any other KEM ID at the head with a clear error rather than the generic dispatch path. **`b.wsClient`** hardening + extensions: `handshakeGuid` opt mirrors the server (operators with non-RFC-6455 GUIDs); decompression-bomb defense via `zlib.inflateRawSync` `maxOutputLength` cap (small compressed frame can no longer expand to GBs); fatal UTF-8 validation on text frames + close-frame reasons (RFC 6455 §5.6); RFC 6455 §5.5 control-frame ≤125-byte cap + FIN=1 enforcement; RSV1-on-continuation rejection (RFC 7692 §6.1); permanent-error classifier so 4xx handshake responses / accept-mismatch / bad-subprotocol / bad-upgrade / bad-status-line / message-too-big skip reconnect (no auth-failure hammering); `close(code, reason)` truncates >123-byte UTF-8 reasons at codepoint boundaries; permessage-deflate `server_max_window_bits` parsing with [8, 15] range enforcement; audit metadata enrichment — `bytesSent` / `bytesReceived` / `attempt` / `peerCertFingerprint` / `serverWindowBits` / `tls` / `permanent` flag. **`client.cancelReconnect()`** new operator API stops in-flight reconnect timers — `close()` mid-reconnect now also cancels the pending timer rather than returning early on the closed state; CRLF validation on the operator-supplied `Origin:` header matches the existing custom-header validation. **`b.vault.aad`** + **`b.wsClient`** + integration test from v0.7.114 are also part of this minor since v0.7.114 was the patch immediately preceding the version bump.
|
|
12
38
|
|
|
13
39
|
- **0.7.114** (2026-05-06) — `b.vault.aad` AAD-bound sealed columns + `b.wsClient` outbound WebSocket client. **`b.vault.aad.seal(plaintext, aadParts)`** / **`b.vault.aad.unseal(value, aadParts)`** binds the seal to an AAD tuple `(table, rowId, column, schemaVersion)` so the AEAD tag fails on any decrypt where the AAD differs — copy-paste between rows, replay across schema-version bumps, and table-mismatch attacks all surface as a refused decrypt. Symmetric key derived per-row via SHAKE256 over `("vault.aad/v1/" || vault-root || canonical-AAD)` with the AAD threaded into XChaCha20-Poly1305's tag. `buildColumnAad` / `buildContextAad` helpers produce canonical (sorted-keys, length-prefixed) AAD bytes; `reseal(value, fromAad, toAad)` re-binds a value to a new context after authenticating the source. Audit emissions: `vault.aad.sealed` / `vault.aad.unseal_failed`. **`b.wsClient.connect(url, opts)`** ships the outbound RFC 6455 WebSocket client — companion to `b.websocket` (server-side). HTTP/1.1 Upgrade with Sec-WebSocket-Key generation + Sec-WebSocket-Accept verification (rejects on hash mismatch); subprotocol + permessage-deflate (RFC 7692) negotiation; client-side frame masking (RFC 6455 §5.3); TLS via `b.network.tls.pqc` (X25519MLKEM768 hybrid handshake, security-defaults-on); heartbeat ping/pong with pongDeadline tracking; auto-reconnect with exponential-backoff + full jitter; CRLF-injection defense on operator-supplied headers; configurable `maxMessageBytes` / `maxFrameBytes` / `pingMs` / `pongMs` / `handshakeTimeoutMs` / `reconnect: { maxAttempts, baseMs, maxMs }`. EventEmitter API: `open` / `message` / `close` / `error` / `reconnecting`. Reuses `FrameParser` and `serializeFrame` from `lib/websocket.js` so the wire layer is identical to the server. **Integration test** `test/integration/ws-client-roundtrip.test.js` boots a real `http.Server` driven by `b.websocket` primitives and dials it with `b.wsClient`, exercising plain ws:// handshake + subprotocol negotiation + text/binary echo + ping/pong heartbeat + close round-trip + permessage-deflate compress/inflate end-to-end. Audit emissions: `wsclient.connected` / `wsclient.closed` / `wsclient.error`.
|
package/lib/audit-sign.js
CHANGED
|
@@ -90,7 +90,7 @@ var SIGNING_KEY_SCHEMA = {
|
|
|
90
90
|
properties: {
|
|
91
91
|
publicKey: { type: "string" },
|
|
92
92
|
privateKey: { type: "string" },
|
|
93
|
-
algorithm: { type: "string" }, //
|
|
93
|
+
algorithm: { type: "string" }, // load-time-required — _initPlaintext + _initWrapped both throw KEY_FILE_MISSING_ALG / UNWRAPPED_MISSING_ALG when the field is absent (legacy implicit-default-to-ml-dsa-87 was removed in the pre-v1 compat-shim sweep). Schema's `required` keeps publicKey + privateKey only so the runtime checks fire with the precise error codes operators have wired alerting on.
|
|
94
94
|
},
|
|
95
95
|
};
|
|
96
96
|
|
package/lib/audit.js
CHANGED
|
@@ -206,6 +206,8 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
206
206
|
"cache", // b.cache
|
|
207
207
|
"compliance", // b.compliance (compliance.posture.set / cleared)
|
|
208
208
|
"config", // b.configDrift (config.baseline.captured / config.drift.detected / config.baseline.tamper / config.baseline.unreadable)
|
|
209
|
+
"csrf", // b.middleware.csrfProtect (csrf.bad_cookie_value)
|
|
210
|
+
// (system.crypto.hybrid_disabled rides under "system" so no separate namespace)
|
|
209
211
|
"db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
|
|
210
212
|
// (role-switching, RLS-shaped events)
|
|
211
213
|
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
@@ -213,6 +215,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
213
215
|
"dsr", // b.dsr (Data Subject Rights workflow: dsr.ticket.* / dsr.source.*)
|
|
214
216
|
"dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
|
|
215
217
|
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
218
|
+
"mtls", // b.mtlsCa engine algorithm-selection audit (mtls.engine.algorithm_selected)
|
|
216
219
|
"network", // b.middleware.networkAllowlist (network.gate.denied)
|
|
217
220
|
"notify", // b.notify
|
|
218
221
|
"objectstore", // b.objectStore.bucketOps (objectstore.bucket.* / objectstore.object.*)
|
|
@@ -702,10 +705,12 @@ function _ensureHandler() {
|
|
|
702
705
|
// into a database that no longer represents the chain those
|
|
703
706
|
// items were emitted against. Early-exit drops them; the
|
|
704
707
|
// alternative is silent corruption of the next chain.
|
|
708
|
+
var droppedThisBatch = 0;
|
|
705
709
|
for (var i = 0; i < batch.length; i++) {
|
|
706
710
|
if (ctx && ctx.isShutdown && ctx.isShutdown()) return;
|
|
707
711
|
try { await record(batch[i]); }
|
|
708
712
|
catch (e) {
|
|
713
|
+
droppedThisBatch += 1;
|
|
709
714
|
// Per-item failure shouldn't drop the whole batch; log and
|
|
710
715
|
// continue. The handler's onError gets called for batch-
|
|
711
716
|
// wide failures only.
|
|
@@ -714,6 +719,14 @@ function _ensureHandler() {
|
|
|
714
719
|
" (action=" + (batch[i] && batch[i].action) + ")");
|
|
715
720
|
}
|
|
716
721
|
}
|
|
722
|
+
// Surface chain-write integrity failures via observability so
|
|
723
|
+
// operators alerting on rate-drop see something. The audit
|
|
724
|
+
// chain itself can't carry the signal — the chain is what's
|
|
725
|
+
// broken — so observability is the only sink left.
|
|
726
|
+
if (droppedThisBatch > 0) {
|
|
727
|
+
observability.safeEvent("system.audit.chain_write_dropped",
|
|
728
|
+
droppedThisBatch, { batchSize: batch.length });
|
|
729
|
+
}
|
|
717
730
|
},
|
|
718
731
|
});
|
|
719
732
|
return _auditHandler;
|
|
@@ -723,6 +736,53 @@ function emit(event) {
|
|
|
723
736
|
_ensureHandler().emit(event);
|
|
724
737
|
}
|
|
725
738
|
|
|
739
|
+
// Outcome normalization — drop-silent on a strict `outcome` mismatch
|
|
740
|
+
// dropped a class of audit rows across the framework (every
|
|
741
|
+
// non-{success, failure, denied} outcome from b.flag / b.outbox /
|
|
742
|
+
// b.inbox / b.session / b.db / b.config-drift / b.compliance-aiAct
|
|
743
|
+
// landed in the handler's catch-and-log path instead of the chain).
|
|
744
|
+
// safeEmit owns the normalization; record() stays strict so direct
|
|
745
|
+
// callers see the typo loudly.
|
|
746
|
+
var OUTCOME_NORMALIZE = {
|
|
747
|
+
ok: "success",
|
|
748
|
+
okay: "success",
|
|
749
|
+
pass: "success",
|
|
750
|
+
passed: "success",
|
|
751
|
+
success: "success",
|
|
752
|
+
succeeded: "success",
|
|
753
|
+
warn: "success",
|
|
754
|
+
warning: "success",
|
|
755
|
+
duplicate: "success",
|
|
756
|
+
skip: "success",
|
|
757
|
+
skipped: "success",
|
|
758
|
+
fail: "failure",
|
|
759
|
+
failed: "failure",
|
|
760
|
+
failure: "failure",
|
|
761
|
+
err: "failure",
|
|
762
|
+
error: "failure",
|
|
763
|
+
denied: "denied",
|
|
764
|
+
refused: "denied",
|
|
765
|
+
deny: "denied",
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
function _normalizeOutcome(o) {
|
|
769
|
+
if (typeof o !== "string") return "success";
|
|
770
|
+
var n = OUTCOME_NORMALIZE[o.toLowerCase()];
|
|
771
|
+
return n || "success";
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Hyphens in action segments fall outside the underscore-only
|
|
775
|
+
// regex enforced by record(). Replace at the segment boundary so
|
|
776
|
+
// "compliance.aiact.biometric-id-categorisation" lands as
|
|
777
|
+
// "compliance.aiact.biometric_id_categorisation" and reaches the
|
|
778
|
+
// chain instead of dropping. The action namespace prefix (the part
|
|
779
|
+
// before the first dot) is left strict — namespaces are
|
|
780
|
+
// operator-registered and should be plain identifiers.
|
|
781
|
+
function _normalizeAction(action) {
|
|
782
|
+
if (typeof action !== "string") return action;
|
|
783
|
+
return action.replace(/-/g, "_");
|
|
784
|
+
}
|
|
785
|
+
|
|
726
786
|
// safeEmit — fire-and-forget audit emit with safe defaults + try/catch.
|
|
727
787
|
//
|
|
728
788
|
// Most modules wrap emit() in their own _emit helper that fills in
|
|
@@ -761,9 +821,9 @@ function safeEmit(event) {
|
|
|
761
821
|
} catch (_e) { /* fall through with original values */ }
|
|
762
822
|
_ensureHandler().emit({
|
|
763
823
|
actor: actor,
|
|
764
|
-
action: event.action,
|
|
824
|
+
action: _normalizeAction(event.action),
|
|
765
825
|
resource: event.resource || null,
|
|
766
|
-
outcome: event.outcome
|
|
826
|
+
outcome: _normalizeOutcome(event.outcome),
|
|
767
827
|
reason: reason,
|
|
768
828
|
metadata: metadata,
|
|
769
829
|
requestId: event.requestId || null,
|
package/lib/auth/jwt.js
CHANGED
|
@@ -147,6 +147,19 @@ async function sign(claims, opts) {
|
|
|
147
147
|
if (typeof opts.expiresInSec === "number" && payload.exp === undefined) {
|
|
148
148
|
payload.exp = nowSec + opts.expiresInSec;
|
|
149
149
|
}
|
|
150
|
+
// Auto-mint jti when the token has an expiry but no operator-set
|
|
151
|
+
// jti. The replay-defense path on verify() requires every replay-
|
|
152
|
+
// protected token to carry a jti; without auto-mint, an operator
|
|
153
|
+
// who configured replayStore on verify but forgot to set jti on
|
|
154
|
+
// sign produces tokens that never replay-protect — and the
|
|
155
|
+
// failure surfaces only at first replay attempt (via the
|
|
156
|
+
// verifier's "missing-jti" throw). Auto-mint closes the silent
|
|
157
|
+
// hole; operators who explicitly want a deterministic jti pass
|
|
158
|
+
// opts.jti themselves.
|
|
159
|
+
if (payload.exp !== undefined && payload.jti === undefined) {
|
|
160
|
+
var fwCryptoJti = require("../crypto"); // allow:inline-require — circular-load defense (crypto imports jwt? no — but use lazy form to keep parity)
|
|
161
|
+
payload.jti = fwCryptoJti.generateBytes(C.BYTES.bytes(16)).toString("base64url");
|
|
162
|
+
}
|
|
150
163
|
if (typeof opts.notBeforeSec === "number" && payload.nbf === undefined) {
|
|
151
164
|
payload.nbf = nowSec + opts.notBeforeSec;
|
|
152
165
|
}
|
package/lib/auth/lockout.js
CHANGED
|
@@ -227,12 +227,25 @@ function create(opts) {
|
|
|
227
227
|
} catch (_e) { /* audit best-effort */ }
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// Cache failures fail-OPEN by design (per the framework's
|
|
231
|
+
// documented brute-force-lockout posture — rather than crash the
|
|
232
|
+
// request, allow the attempt). The signal MUST land somewhere
|
|
233
|
+
// visible regardless of operator wiring: observability picks it up
|
|
234
|
+
// when wired, and audit picks it up when wired. Without the audit
|
|
235
|
+
// path a deployment running with no observability + a degraded
|
|
236
|
+
// cache silently gets brute-force-protection-disabled.
|
|
237
|
+
function _signalCacheError(op) {
|
|
238
|
+
_emitObs("auth.lockout.cache_error", { namespace: namespace, op: op });
|
|
239
|
+
_emitAudit("auth.lockout.cache_error", "<system>", "failure",
|
|
240
|
+
{ namespace: namespace, op: op }, null);
|
|
241
|
+
}
|
|
242
|
+
|
|
230
243
|
async function _readState(key) {
|
|
231
244
|
try {
|
|
232
245
|
var raw = await cache.get(_scopedKey(key));
|
|
233
246
|
return raw || null;
|
|
234
247
|
} catch (_e) {
|
|
235
|
-
|
|
248
|
+
_signalCacheError("get");
|
|
236
249
|
return null;
|
|
237
250
|
}
|
|
238
251
|
}
|
|
@@ -241,7 +254,7 @@ function create(opts) {
|
|
|
241
254
|
try {
|
|
242
255
|
await cache.set(_scopedKey(key), state, { ttlMs: ttlMs });
|
|
243
256
|
} catch (_e) {
|
|
244
|
-
|
|
257
|
+
_signalCacheError("set");
|
|
245
258
|
}
|
|
246
259
|
}
|
|
247
260
|
|
|
@@ -249,7 +262,7 @@ function create(opts) {
|
|
|
249
262
|
try {
|
|
250
263
|
await cache.del(_scopedKey(key));
|
|
251
264
|
} catch (_e) {
|
|
252
|
-
|
|
265
|
+
_signalCacheError("del");
|
|
253
266
|
}
|
|
254
267
|
}
|
|
255
268
|
|
package/lib/auth/oauth.js
CHANGED
|
@@ -482,6 +482,20 @@ function create(opts) {
|
|
|
482
482
|
throw new OAuthError("auth-oauth/no-verifier",
|
|
483
483
|
"exchangeCode: opts.verifier is required when PKCE is on (default)");
|
|
484
484
|
}
|
|
485
|
+
// Nonce enforcement on OIDC paths. authorizationUrl() always
|
|
486
|
+
// emits a nonce when isOidc; if the operator forgot to thread it
|
|
487
|
+
// through to exchangeCode, _normalizeTokens silently skipped the
|
|
488
|
+
// nonce check on the ID token and a captured token from another
|
|
489
|
+
// browser session could be replayed without detection. Throw
|
|
490
|
+
// loudly so the operator sees the bug at config time, not at
|
|
491
|
+
// first-replay-attempt time.
|
|
492
|
+
if (isOidc && eopts.nonce === undefined && eopts.skipNonceCheck !== true) {
|
|
493
|
+
throw new OAuthError("auth-oauth/no-nonce",
|
|
494
|
+
"exchangeCode: nonce is required on OIDC flows. Pass the " +
|
|
495
|
+
"value returned from authorizationUrl() through to exchangeCode " +
|
|
496
|
+
"({ code, state, verifier, nonce }). Operators with a deliberate " +
|
|
497
|
+
"no-nonce flow must pass `skipNonceCheck: true` (audited reason).");
|
|
498
|
+
}
|
|
485
499
|
var endpoint = await _resolveEndpoint("tokenEndpoint");
|
|
486
500
|
var body = new URLSearchParams();
|
|
487
501
|
body.set("grant_type", "authorization_code");
|
|
@@ -492,7 +506,7 @@ function create(opts) {
|
|
|
492
506
|
if (eopts.verifier) body.set("code_verifier", eopts.verifier);
|
|
493
507
|
|
|
494
508
|
var tokens = await _postForm(endpoint, body);
|
|
495
|
-
return await _normalizeTokens(tokens, { nonce: eopts.nonce });
|
|
509
|
+
return await _normalizeTokens(tokens, { nonce: eopts.nonce, skipNonceCheck: eopts.skipNonceCheck });
|
|
496
510
|
}
|
|
497
511
|
|
|
498
512
|
async function refreshAccessToken(refreshToken) {
|
package/lib/auth/password.js
CHANGED
|
@@ -448,20 +448,40 @@ function policy(opts) {
|
|
|
448
448
|
}
|
|
449
449
|
var bodyText = Buffer.isBuffer(resp.body) ? resp.body.toString("utf8") : String(resp.body);
|
|
450
450
|
var lines = bodyText.split(/\r?\n/);
|
|
451
|
+
var goodLines = 0;
|
|
452
|
+
var badLines = 0;
|
|
451
453
|
for (var li = 0; li < lines.length; li++) {
|
|
452
454
|
var line = lines[li].trim();
|
|
453
455
|
if (line.length === 0) continue;
|
|
454
456
|
var colon = line.indexOf(":");
|
|
455
|
-
if (colon < 0) continue;
|
|
457
|
+
if (colon < 0) { badLines += 1; continue; }
|
|
456
458
|
var hashSuffix = line.slice(0, colon).toUpperCase();
|
|
457
459
|
var count = parseInt(line.slice(colon + 1), 10);
|
|
460
|
+
if (!isFinite(count)) { badLines += 1; continue; }
|
|
461
|
+
goodLines += 1;
|
|
458
462
|
if (timingSafeEqual(Buffer.from(hashSuffix, "utf8"), Buffer.from(suffix, "utf8")) &&
|
|
459
|
-
|
|
463
|
+
count >= p.breachThreshold) {
|
|
460
464
|
return _fail("breached",
|
|
461
465
|
"plaintext appears in HaveIBeenPwned with count " + count +
|
|
462
466
|
" (threshold " + p.breachThreshold + ")");
|
|
463
467
|
}
|
|
464
468
|
}
|
|
469
|
+
// If a hostile / poisoned mirror returned a response shaped like
|
|
470
|
+
// HIBP but with mostly-unparseable counts, the original loop
|
|
471
|
+
// skipped them silently and reported breachCheckCount=0 — i.e.
|
|
472
|
+
// the operator saw "looks fine" against a body that was never
|
|
473
|
+
// actually verifiable. When more than half the lines fail to
|
|
474
|
+
// parse, treat the response as unverifiable and apply the
|
|
475
|
+
// operator's fail-closed posture.
|
|
476
|
+
if (goodLines + badLines > 0 && badLines * 2 > goodLines) {
|
|
477
|
+
if (p.failClosed) {
|
|
478
|
+
return _fail("breach-check-failed",
|
|
479
|
+
"HIBP response was mostly-unparseable (good=" + goodLines +
|
|
480
|
+
", bad=" + badLines + ") — possible poisoned mirror");
|
|
481
|
+
}
|
|
482
|
+
return _ok({ breachCheckSkipped: true,
|
|
483
|
+
breachCheckSkipReason: "hibp-response-mostly-unparseable" });
|
|
484
|
+
}
|
|
465
485
|
return _ok({ breachCheckCount: 0 });
|
|
466
486
|
}
|
|
467
487
|
return _ok();
|
|
@@ -95,7 +95,7 @@ function create(opts) {
|
|
|
95
95
|
"issuer.create: activeKid \"" + activeKid + "\" is not in the keys array");
|
|
96
96
|
}
|
|
97
97
|
var defaultTtlMs = opts.defaultTtlMs || C.TIME.days(90);
|
|
98
|
-
var defaultHashAlg = opts.defaultHashAlg || "
|
|
98
|
+
var defaultHashAlg = opts.defaultHashAlg || "sha3-512";
|
|
99
99
|
var auditOn = opts.auditOn !== false;
|
|
100
100
|
|
|
101
101
|
var stats = {
|
|
@@ -137,7 +137,7 @@ function create(opts) {
|
|
|
137
137
|
claims: spec.claims || {},
|
|
138
138
|
selectivelyDisclosed: spec.selectivelyDisclosed || [],
|
|
139
139
|
issuerKey: key.privateKey,
|
|
140
|
-
algorithm: key.algorithm || "
|
|
140
|
+
algorithm: key.algorithm || "ML-DSA-87",
|
|
141
141
|
hashAlg: spec.hashAlg || defaultHashAlg,
|
|
142
142
|
ttlMs: spec.ttlMs || defaultTtlMs,
|
|
143
143
|
holderKey: spec.holderKey || null,
|
package/lib/auth/sd-jwt-vc.js
CHANGED
|
@@ -80,8 +80,13 @@ var SUPPORTED_HASH_ALGS = Object.freeze({
|
|
|
80
80
|
"sha3-512": "sha3-512",
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
// Defaults are PQC-first per the framework's hard rule §2 — operators
|
|
84
|
+
// who must interop with ES256-only verifiers today opt in via the
|
|
85
|
+
// `compatibilityProfile: "spec-default"` shape on the issuer/holder
|
|
86
|
+
// surfaces, OR pass `algorithm: "ES256"` + `hashAlg: "sha-256"`
|
|
87
|
+
// explicitly with an audited reason.
|
|
88
|
+
var DEFAULT_ALG = "ML-DSA-87";
|
|
89
|
+
var DEFAULT_HASH_ALG = "sha3-512";
|
|
85
90
|
|
|
86
91
|
function _b64uEncode(str) {
|
|
87
92
|
return Buffer.from(str, "utf8").toString("base64url");
|
package/lib/break-glass.js
CHANGED
|
@@ -693,6 +693,45 @@ async function grant(opts) {
|
|
|
693
693
|
"grant: no authenticated actor on request (req.user.id / req.apiKey.id required)", true);
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
// Scope-gate enforcement — when the policy declares requireScope,
|
|
697
|
+
// the actor must carry the named scope (or matching wildcard via
|
|
698
|
+
// b.permissions.match) before the framework will mint a grant.
|
|
699
|
+
// Without this, every TOTP-passing actor could glass-unseal PHI
|
|
700
|
+
// even when the operator explicitly declared `requireScope:
|
|
701
|
+
// "phi:admin"`.
|
|
702
|
+
if (policy.requireScope) {
|
|
703
|
+
var actorScopes = (opts.req && opts.req.user && Array.isArray(opts.req.user.scopes))
|
|
704
|
+
? opts.req.user.scopes
|
|
705
|
+
: ((opts.req && opts.req.apiKey && Array.isArray(opts.req.apiKey.scopes))
|
|
706
|
+
? opts.req.apiKey.scopes
|
|
707
|
+
: []);
|
|
708
|
+
var scopeOk = false;
|
|
709
|
+
for (var sci = 0; sci < actorScopes.length; sci += 1) {
|
|
710
|
+
if (actorScopes[sci] === policy.requireScope) { scopeOk = true; break; }
|
|
711
|
+
// Wildcard support: "phi:*" matches "phi:admin" and "phi:read".
|
|
712
|
+
if (typeof actorScopes[sci] === "string" &&
|
|
713
|
+
actorScopes[sci].length > 0 &&
|
|
714
|
+
actorScopes[sci].charAt(actorScopes[sci].length - 1) === "*") {
|
|
715
|
+
var prefix = actorScopes[sci].slice(0, -1);
|
|
716
|
+
if (typeof policy.requireScope === "string" &&
|
|
717
|
+
policy.requireScope.indexOf(prefix) === 0) {
|
|
718
|
+
scopeOk = true; break;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (!scopeOk) {
|
|
723
|
+
audit.safeEmit({
|
|
724
|
+
action: "breakglass.grant.requested",
|
|
725
|
+
outcome: "denied",
|
|
726
|
+
actor: actor,
|
|
727
|
+
reason: "missing-scope",
|
|
728
|
+
metadata: { table: table, requireScope: policy.requireScope },
|
|
729
|
+
});
|
|
730
|
+
throw new BreakGlassError("breakglass/missing-scope",
|
|
731
|
+
"grant: actor does not carry required scope '" + policy.requireScope + "'", true);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
696
735
|
// Factor verification + lockout
|
|
697
736
|
var factorType = opts.factor && opts.factor.type;
|
|
698
737
|
if (!factorType || policy.factors.indexOf(factorType) === -1) {
|
|
@@ -897,6 +936,20 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
897
936
|
grantRow.maxRowsPerGrant + " allowed rows", true);
|
|
898
937
|
}
|
|
899
938
|
|
|
939
|
+
// SELECT-before-increment — fetch the target row FIRST. If the row
|
|
940
|
+
// doesn't exist (operator typo, race with row-deletion, etc.), the
|
|
941
|
+
// grant should not be consumed. Without this ordering, a single
|
|
942
|
+
// typo against `maxRowsPerGrant: 1` (the default) exhausts the
|
|
943
|
+
// grant and forces the operator to re-do the step-up ceremony.
|
|
944
|
+
var rows = await clusterStorage.executeAll(
|
|
945
|
+
"SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
|
|
946
|
+
[String(rowId)]
|
|
947
|
+
);
|
|
948
|
+
if (!rows || rows.length === 0) {
|
|
949
|
+
throw new BreakGlassError("breakglass/row-not-found",
|
|
950
|
+
"unsealRow: " + table + "[" + rowId + "] not found", true);
|
|
951
|
+
}
|
|
952
|
+
|
|
900
953
|
// Increment rowsConsumed (atomic UPDATE with WHERE rowsConsumed < cap
|
|
901
954
|
// so concurrent unseals can't both pass the runtime check above).
|
|
902
955
|
var updateRes = await clusterStorage.execute(
|
|
@@ -925,20 +978,6 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
925
978
|
"unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
|
|
926
979
|
}
|
|
927
980
|
void updateRes;
|
|
928
|
-
|
|
929
|
-
// Fetch + unseal the target row. Model A goes straight through
|
|
930
|
-
// cryptoField; Model B reads the row, lets cryptoField unseal the
|
|
931
|
-
// non-glass-locked columns, and then decryptCell handles the
|
|
932
|
-
// glass-locked columns separately (their ciphertext was written
|
|
933
|
-
// by encryptCell at app-write time, not by cryptoField.sealRow).
|
|
934
|
-
var rows = await clusterStorage.executeAll(
|
|
935
|
-
"SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
|
|
936
|
-
[String(rowId)]
|
|
937
|
-
);
|
|
938
|
-
if (!rows || rows.length === 0) {
|
|
939
|
-
throw new BreakGlassError("breakglass/row-not-found",
|
|
940
|
-
"unsealRow: " + table + "[" + rowId + "] not found", true);
|
|
941
|
-
}
|
|
942
981
|
var policy = await policyGet(table);
|
|
943
982
|
var unsealedRow;
|
|
944
983
|
if (policy && policy.cryptographic) {
|
package/lib/cache-redis.js
CHANGED
|
@@ -96,7 +96,7 @@ function create(cfg) {
|
|
|
96
96
|
|
|
97
97
|
async function set(key, value, expiresAt, meta) {
|
|
98
98
|
await _ensureConnected();
|
|
99
|
-
var json =
|
|
99
|
+
var json = safeJson.stringify(value);
|
|
100
100
|
|
|
101
101
|
// Drop any prior tag membership for this key (tags may have changed
|
|
102
102
|
// across sets). The reverse-tag set tells us which tag SETs need
|
package/lib/cache.js
CHANGED
|
@@ -491,7 +491,12 @@ function _clusterBackend(cfg) {
|
|
|
491
491
|
}
|
|
492
492
|
|
|
493
493
|
async function set(key, value, expiresAt, meta) {
|
|
494
|
-
|
|
494
|
+
// safeJson.stringify refuses Buffer / circular / Date round-trip
|
|
495
|
+
// ambiguity that vanilla JSON.stringify silently flattens. The
|
|
496
|
+
// failure mode without this is "cache returns a structurally-
|
|
497
|
+
// changed value, app code treats it as the original" — a subtle
|
|
498
|
+
// freshness bug that's hard to debug.
|
|
499
|
+
var json = safeJson.stringify(value);
|
|
495
500
|
var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
|
|
496
501
|
var now = clock();
|
|
497
502
|
var ck = _composedKey(key);
|
package/lib/cli.js
CHANGED
|
@@ -1370,9 +1370,9 @@ async function _runMtls(args, ctx) {
|
|
|
1370
1370
|
if (vaultMode !== "wrapped" && vaultMode !== "plaintext") {
|
|
1371
1371
|
return report.error("--vault-mode must be 'wrapped' or 'plaintext'", 2);
|
|
1372
1372
|
}
|
|
1373
|
-
var sealedMode = args.flags["sealed-mode"] || "
|
|
1374
|
-
if (["
|
|
1375
|
-
return report.error("--sealed-mode must be '
|
|
1373
|
+
var sealedMode = args.flags["sealed-mode"] || "required";
|
|
1374
|
+
if (["required", "disabled"].indexOf(sealedMode) === -1) {
|
|
1375
|
+
return report.error("--sealed-mode must be 'required' or 'disabled'", 2);
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
1378
|
var booted;
|
package/lib/cluster.js
CHANGED
|
@@ -69,7 +69,17 @@ var vault = lazyRequire(function () { return require("./vault"); });
|
|
|
69
69
|
|
|
70
70
|
var DEFAULT_LEASE_TTL = C.TIME.seconds(30);
|
|
71
71
|
var DEFAULT_HEARTBEAT = C.TIME.seconds(10);
|
|
72
|
-
|
|
72
|
+
// MIN_LEASE_TTL bumped from 5s → 10s. With 5s leases + 1s heartbeats,
|
|
73
|
+
// a network glitch + GC pause can leave the old leader believing it
|
|
74
|
+
// still holds the lease (4s remaining on its clock) while a new
|
|
75
|
+
// leader has already acquired. Old-leader writes during that window
|
|
76
|
+
// only land on framework state with a fencingToken WHERE clause
|
|
77
|
+
// (audit-tip CHECK catches it); operator-supplied writes through
|
|
78
|
+
// b.externalDb.transaction outside the audit chain DON'T carry the
|
|
79
|
+
// clause and can be accepted by both leaders. 10s leaves more room
|
|
80
|
+
// for the framework's audit-tip fencing to catch the split-brain
|
|
81
|
+
// before consequential writes reach durable state.
|
|
82
|
+
var MIN_LEASE_TTL = C.TIME.seconds(10);
|
|
73
83
|
var MIN_HEARTBEAT = C.TIME.seconds(1);
|
|
74
84
|
|
|
75
85
|
var initialized = false;
|
|
@@ -476,7 +486,20 @@ async function _tryAcquire() {
|
|
|
476
486
|
|
|
477
487
|
async function _heartbeat() {
|
|
478
488
|
if (!initialized) return;
|
|
489
|
+
// ±20% per-tick jitter on followers — without it, N followers
|
|
490
|
+
// polling on a deterministic cadence all fire _tryAcquire at the
|
|
491
|
+
// same wall-clock instant on lease expiry, producing thundering-
|
|
492
|
+
// herd INSERT/UPDATE pressure on the leader-election row at
|
|
493
|
+
// exactly the worst time. Leader-renewal path doesn't jitter
|
|
494
|
+
// (a missed renewal hands the lease to a follower; the timing
|
|
495
|
+
// budget is in `leaseTtl - heartbeatMs`, not in the jitter
|
|
496
|
+
// window).
|
|
479
497
|
if (!lease) {
|
|
498
|
+
var jitterMs = Math.floor(Math.random() * (heartbeatMs * 0.4)); // allow:math-random-noncrypto — heartbeat jitter, not security-bearing
|
|
499
|
+
if (jitterMs > 0) {
|
|
500
|
+
await safeAsync.sleep(jitterMs);
|
|
501
|
+
}
|
|
502
|
+
if (!initialized) return;
|
|
480
503
|
// Not currently leader — try to acquire (lease may have expired
|
|
481
504
|
// on the previous holder).
|
|
482
505
|
await _tryAcquire();
|
|
@@ -125,11 +125,15 @@ function emit(event) {
|
|
|
125
125
|
"compliance.aiAct.logging.emit: event must be an object");
|
|
126
126
|
}
|
|
127
127
|
// Funnel into the framework audit chain so the record rides the
|
|
128
|
-
// tamper-evident PQC-signed chain.
|
|
128
|
+
// tamper-evident PQC-signed chain. The operator-facing kind vocabulary
|
|
129
|
+
// (from RFC-style slug identifiers in the AI-Act-Notice header — e.g.
|
|
130
|
+
// "biometric-id-categorisation") carries hyphens; the audit action
|
|
131
|
+
// namespace uses underscores, so the kind is rewritten before emit.
|
|
129
132
|
try {
|
|
133
|
+
var kindCanonical = String(event.kind || "log").replace(/-/g, "_");
|
|
130
134
|
audit().safeEmit({
|
|
131
|
-
action: "compliance.aiact." +
|
|
132
|
-
outcome: event.outcome
|
|
135
|
+
action: "compliance.aiact." + kindCanonical,
|
|
136
|
+
outcome: event.outcome || "success",
|
|
133
137
|
actor: event.actor || null,
|
|
134
138
|
metadata: event,
|
|
135
139
|
});
|
package/lib/compliance.js
CHANGED
|
@@ -67,11 +67,11 @@ var KNOWN_POSTURES = Object.freeze([
|
|
|
67
67
|
|
|
68
68
|
var STATE = { posture: null, setAt: null };
|
|
69
69
|
|
|
70
|
-
function _emitAudit(action, metadata) {
|
|
70
|
+
function _emitAudit(action, metadata, outcome) {
|
|
71
71
|
try {
|
|
72
72
|
audit().safeEmit({
|
|
73
73
|
action: action,
|
|
74
|
-
outcome: "success",
|
|
74
|
+
outcome: outcome || "success",
|
|
75
75
|
metadata: metadata,
|
|
76
76
|
});
|
|
77
77
|
} catch (_e) { /* audit best-effort */ }
|
|
@@ -84,11 +84,19 @@ function set(posture) {
|
|
|
84
84
|
JSON.stringify(posture));
|
|
85
85
|
}
|
|
86
86
|
if (KNOWN_POSTURES.indexOf(posture) === -1) {
|
|
87
|
+
_emitAudit("compliance.posture.set_rejected",
|
|
88
|
+
{ reason: "unknown-posture", posture: posture }, "denied");
|
|
87
89
|
throw new ComplianceError("compliance/unknown-posture",
|
|
88
90
|
"compliance.set: unknown posture '" + posture + "'; expected one of " +
|
|
89
91
|
KNOWN_POSTURES.join(", "));
|
|
90
92
|
}
|
|
91
93
|
if (STATE.posture && STATE.posture !== posture) {
|
|
94
|
+
// Audit the rejection so an attacker (or operator misconfig) trying
|
|
95
|
+
// to downgrade an already-set posture produces a chain row
|
|
96
|
+
// operators can alert on.
|
|
97
|
+
_emitAudit("compliance.posture.set_rejected",
|
|
98
|
+
{ reason: "already-set", current: STATE.posture, attempted: posture },
|
|
99
|
+
"denied");
|
|
92
100
|
throw new ComplianceError("compliance/already-set",
|
|
93
101
|
"compliance.set: posture is already '" + STATE.posture + "' (set at " +
|
|
94
102
|
new Date(STATE.setAt).toISOString() + "). Runtime switches are " +
|
package/lib/config-drift.js
CHANGED
|
@@ -193,7 +193,7 @@ function create(opts) {
|
|
|
193
193
|
if (existing && existing.unreadable) {
|
|
194
194
|
_emit("config.baseline.unreadable",
|
|
195
195
|
{ sidecar: sidecarPath, reason: "sidecar present but malformed or wrong version" },
|
|
196
|
-
"
|
|
196
|
+
"failure");
|
|
197
197
|
_writeSidecar(snapshot, newDigest);
|
|
198
198
|
return { signed: true, drifted: false, tamper: false, previousAt: null, reason: "sidecar-unreadable-rewritten" };
|
|
199
199
|
}
|
|
@@ -260,7 +260,7 @@ function create(opts) {
|
|
|
260
260
|
keysRemoved: diff.removed,
|
|
261
261
|
severity: severity,
|
|
262
262
|
},
|
|
263
|
-
severity === "high" ? "failure" : "
|
|
263
|
+
severity === "high" ? "failure" : "success");
|
|
264
264
|
_writeSidecar(snapshot, newDigest);
|
|
265
265
|
return {
|
|
266
266
|
signed: true,
|
package/lib/crypto-field.js
CHANGED
|
@@ -116,7 +116,27 @@ function unsealRow(table, row) {
|
|
|
116
116
|
for (var i = 0; i < s.sealedFields.length; i++) {
|
|
117
117
|
var field = s.sealedFields[i];
|
|
118
118
|
if (out[field]) {
|
|
119
|
-
var unsealed
|
|
119
|
+
var unsealed;
|
|
120
|
+
try {
|
|
121
|
+
unsealed = vault.unseal(out[field]);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// A DB-write attacker who can write `vault:<crafted>`
|
|
124
|
+
// payloads to sealed columns can force ML-KEM
|
|
125
|
+
// decapsulation on attacker-controlled bytes via this read
|
|
126
|
+
// path. Surface the failure as a chain row so operators
|
|
127
|
+
// alert on burst patterns; null the field so downstream
|
|
128
|
+
// code sees "no value" instead of crashing the request.
|
|
129
|
+
try {
|
|
130
|
+
var auditMod = require("./audit"); // allow:inline-require — circular-load defense
|
|
131
|
+
auditMod.safeEmit({
|
|
132
|
+
action: "system.crypto.unseal_failed",
|
|
133
|
+
outcome: "failure",
|
|
134
|
+
metadata: { table: table, field: field, rowId: row && row._id || null,
|
|
135
|
+
reason: (e && e.message) || String(e) },
|
|
136
|
+
});
|
|
137
|
+
} catch (_e) { /* drop-silent */ }
|
|
138
|
+
unsealed = null;
|
|
139
|
+
}
|
|
120
140
|
// If the value wasn't actually sealed, vault.unseal returns the input
|
|
121
141
|
// unchanged — keep the original.
|
|
122
142
|
out[field] = unsealed !== undefined && unsealed !== null ? unsealed : out[field];
|