@blamejs/core 0.14.25 → 0.14.26
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 +2 -0
- package/lib/auth/oauth.js +25 -5
- package/lib/auth/sd-jwt-vc.js +16 -3
- package/lib/break-glass.js +153 -3
- package/lib/dsr.js +378 -52
- package/lib/queue-local.js +23 -1
- package/lib/queue.js +7 -0
- package/lib/request-helpers.js +7 -0
- package/lib/vault/rotate.js +64 -44
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.14.x
|
|
10
10
|
|
|
11
|
+
- v0.14.26 (2026-06-06) — **Break-glass IP and session pins fail closed, DSR ticket PII is sealed and erasable, and queue jobs are sealed at rest from the first write.** Break-glass grant pins (`pinIp` / `sessionPin`, documented default-ON) were enforced only when the grant had captured a binding at mint time; a grant minted without an IP or session was redeemable from anywhere, so the pin failed open exactly when it mattered. Pins now fail closed: a grant carrying no binding is refused at redemption, and the redeeming client IP falls back to `req.ip` when not threaded explicitly. The Data Subject Request ticket store kept the subject's identifiers and the raw request body in plaintext, so an erasure request could not destroy the very PII it was processing; those columns are now sealed under the vault with `(table, rowId)` additional-data binding, erasure purges the ticket on completion, and an in-place schema upgrade seals existing stores. Queue jobs were sealed at rest only after a table had been registered, which did not happen until the first explicit registration — the queue now self-registers its job table on `init`, so jobs are sealed from the first write. Rounding out the bundle: the OAuth back-channel-logout `logout_token` is parsed under a byte ceiling, the SD-JWT-VC holder key-binding JWT signs with an algorithm asserted against the holder key's type (an RSA or OKP holder no longer mints a self-invalid token), and a pushed authorization request carrying a signed request object emits `authorization_details` as a native array. **Security:** *Break-glass IP and session pins fail closed* — `b.breakGlass` grants document `pinIp` and `sessionPin` as default-ON, and grant minting captures the issuing IP and session at that time. Redemption now refuses a grant whose binding was never captured (`pinIp` on but no IP recorded, or `sessionPin` on but no session) instead of treating the absent binding as 'nothing to check' and allowing the redemption from any origin. The redeeming client IP is resolved from the redemption request and falls back to `req.ip` when the caller does not thread an explicit address. The one-time-code replay floor is keyed per `(actor, secret)` so a code consumed under one actor cannot be replayed under another, and the accepted TOTP step is reserved atomically as part of acceptance, so two concurrent grants presenting the same in-window code cannot both pass. · *DSR ticket store seals subject identifiers and request payload, and erasure purges the ticket* — `b.dsr` with the database-backed ticket store now seals the data subject's identifiers and the raw request payload via `b.cryptoField.registerTable` with `(table, rowId)` additional-data binding, so the PII a request processes is encrypted at rest and is destroyed when the ticket's row key is shredded. An erasure request purges its own ticket on completion rather than leaving the subject's identifiers behind. Existing ticket stores are upgraded in place on the next `init` — sealed columns are added via `ALTER TABLE ADD COLUMN` and the legacy `payload NOT NULL` constraint relaxes to `DEFAULT ''` so the add succeeds without data loss. Wrapped ticket keys are re-sealed under the new root on vault keypair rotation (`b.dsr.reseal`), so rotation does not strand tickets. Rows written before the upgrade (plaintext subject, no lookup hash) are backfilled on the next `init` — their hashes are computed and their subject columns sealed — so a subject lookup still finds them and an erasure request can purge them. · *Queue jobs are sealed at rest from the first write* — The local queue backend self-registers its job table for sealing on `init` rather than on first explicit registration, so a job persisted before any other code touched the seal table is encrypted at rest instead of written in plaintext. `b.cryptoField.sealRow` is a silent no-op against an unregistered table; the self-register on `init` closes that fail-open window for the queue's own rows. · *OAuth back-channel logout is bounded; SD-JWT-VC holder binding matches the holder key type* — The OAuth back-channel-logout endpoint parses the `logout_token` under an explicit byte ceiling, so an unauthenticated caller cannot exhaust memory with an oversized body. The SD-JWT-VC holder key-binding JWT now signs with an algorithm asserted against the holder key's type, so a holder presenting an RSA or OKP key no longer mints a key-binding JWT that fails its own verification. A pushed authorization request (PAR) that carries a signed request object emits `authorization_details` as a native JSON array per RFC 9396, not a JSON-encoded string. · *Vault keypair rotation writes its staging files with exclusive, no-follow create* — Every file the rotation pipeline writes into its staging directory — the re-encrypted database, the resealed vault and database keys, additional sealed files, the derived-hash material, and the transient plaintext database — is now created with exclusive, symlink-refusing semantics (`O_CREAT | O_EXCL | O_NOFOLLOW`, owner-only `0o600`), and the fsync-by-path step refuses to follow a symlink. A same-user pre-planted file or symlink swap in the staging directory is now a hard failure rather than a followed write, closing a local tamper window during rotation (CWE-377 / CWE-379 / CWE-59) on top of the directory's existing `0o700` owner-only permissions. **Detectors:** *break-glass-pin-fails-open-on-null-binding* — The pattern catalog refuses a break-glass pin comparison guarded by a `grantRow.ip != null &&` (or `sessionId`) short-circuit — a guard that skips enforcement when the captured binding is absent, which is precisely the fail-open. Pin enforcement must refuse a grant with no captured binding, not wave it through. · *dsr-ticket-store-pii-must-be-sealed* — The catalog requires the DSR database ticket store to register its seal table, so the subject identifiers and request payload it holds cannot regress to plaintext-at-rest and remain un-erasable. The queue self-register, the bounded logout parse, and the holder key-type parity are covered by their expanded request-driven tests and the existing fixed-classical-algorithm-default guard. **Migration:** *Break-glass grants now require a resolvable binding to redeem under default pins* — With `pinIp` left at its default (on), a grant is now refused at redemption unless the issuing IP was captured and the redeeming client IP can be resolved. In-tree redemption paths thread the request and resolve the IP automatically. If your redemption path does not surface a client address and you do not intend to bind to IP, set `pinIp: false` (and `sessionPin: false`) explicitly on the grant; the previous behavior silently accepted such grants from any origin. · *DSR ticket stores are sealed in place on next init* — An existing database-backed DSR ticket store gains sealed columns via `ALTER TABLE ADD COLUMN` on the next `init`, and the legacy `payload NOT NULL` constraint relaxes to `DEFAULT ''` to permit the in-place add. No data is lost; tickets written before the upgrade remain readable and become sealed as they are rewritten.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
package/lib/auth/sd-jwt-vc.js
CHANGED
|
@@ -482,9 +482,13 @@ async function verify(presentation, opts) {
|
|
|
482
482
|
"verify: issuerKeyResolver returned no key");
|
|
483
483
|
}
|
|
484
484
|
// CVE-2026-22817 — when issuerKeyResolver returns a JWK object,
|
|
485
|
-
// cross-check alg/kty BEFORE handing it to
|
|
486
|
-
// KeyObject / PEM shapes can't surface kty so
|
|
487
|
-
// JWK
|
|
485
|
+
// cross-check the issuer JWS alg/kty BEFORE handing it to
|
|
486
|
+
// node:crypto.verify. KeyObject / PEM shapes can't surface kty, so this
|
|
487
|
+
// guard only fires when the resolver hands back a JWK (the common path).
|
|
488
|
+
// The holder KB-JWT path applies its OWN _assertAlgKtyMatch against the
|
|
489
|
+
// cnf.jwk below — note that the holder key is issuer-ATTESTED (it comes
|
|
490
|
+
// from the cryptographically-verified issuer payload's cnf claim), not
|
|
491
|
+
// header-resolved, so the two cross-checks defend different trust edges.
|
|
488
492
|
if (typeof issuerKey === "object" &&
|
|
489
493
|
!(issuerKey instanceof nodeCrypto.KeyObject) &&
|
|
490
494
|
!Buffer.isBuffer(issuerKey) &&
|
|
@@ -610,6 +614,15 @@ async function verify(presentation, opts) {
|
|
|
610
614
|
throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
|
|
611
615
|
"verify: KB-JWT alg unsupported");
|
|
612
616
|
}
|
|
617
|
+
// CVE-2026-22817 — cross-check the KB-JWT header alg against the holder
|
|
618
|
+
// key type BEFORE importing the key / verifying. The issuer path does
|
|
619
|
+
// this for issuerKey (above); the holder KB-JWT path must too. The
|
|
620
|
+
// KB-JWT header alg is attacker-controllable (the holder mints the
|
|
621
|
+
// KB-JWT), and holderKey is a cnf.jwk with a kty, so an alg/kty
|
|
622
|
+
// mismatch (e.g. a header claiming EdDSA against an EC cnf key) is
|
|
623
|
+
// refused with the precise alg-mismatch error rather than handed to
|
|
624
|
+
// node:crypto.verify.
|
|
625
|
+
jwtExternal._assertAlgKtyMatch(kbAlg, holderKey);
|
|
613
626
|
var holderKeyObj = nodeCrypto.createPublicKey({ key: holderKey, format: "jwk" });
|
|
614
627
|
var kbParsed = _verifyJwt(maybeKbJwt, holderKeyObj, kbAlg);
|
|
615
628
|
if (opts.audience && kbParsed.payload.aud !== opts.audience) {
|
package/lib/break-glass.js
CHANGED
|
@@ -75,6 +75,10 @@ var DEK_BYTES = C.BYTES.bytes(32);
|
|
|
75
75
|
var GRANT_ID_BYTES = C.BYTES.bytes(16);
|
|
76
76
|
|
|
77
77
|
var DEFAULT_GRANT_TTL_MS = C.TIME.minutes(15);
|
|
78
|
+
// Replay-step retention. A TOTP code is only valid inside the verifier's
|
|
79
|
+
// drift window (minutes); retaining the highest-accepted step for an hour
|
|
80
|
+
// guarantees any in-window replay attempt arrives after the floor is set.
|
|
81
|
+
var REPLAY_STEP_TTL_MS = C.TIME.hours(1);
|
|
78
82
|
var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
|
|
79
83
|
var DEFAULT_REASON_MIN_LEN = 12;
|
|
80
84
|
var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
|
|
@@ -817,10 +821,58 @@ function _verifyTotpFactor(factor) {
|
|
|
817
821
|
if (!factor || typeof factor !== "object") return { ok: false };
|
|
818
822
|
if (typeof factor.secret !== "string" || factor.secret.length === 0) return { ok: false };
|
|
819
823
|
if (typeof factor.code !== "string" || factor.code.length === 0) return { ok: false };
|
|
820
|
-
|
|
824
|
+
// factor.now threads a deterministic test clock into totp.verify. The
|
|
825
|
+
// replay floor is NOT applied here: acceptance reserves the matched step
|
|
826
|
+
// atomically in _reserveTotpStep, so two concurrent grants presenting the
|
|
827
|
+
// same in-window code cannot both pass (a read-then-commit floor races —
|
|
828
|
+
// both reads observe the old floor before either commits). totp.verify
|
|
829
|
+
// returns the step the code matches (a fixed value for a given code within
|
|
830
|
+
// the drift window) or false; the reserve then floors replays of that step.
|
|
831
|
+
var vopts = {};
|
|
832
|
+
if (typeof factor.now === "number") vopts.now = factor.now;
|
|
833
|
+
var verified = totp.verify(factor.secret, factor.code, vopts);
|
|
821
834
|
return { ok: verified !== false, step: verified };
|
|
822
835
|
}
|
|
823
836
|
|
|
837
|
+
// Replay-step cache key. Keyed by BOTH the actorId AND a non-reversible
|
|
838
|
+
// fingerprint of the TOTP secret. Keying on actorId alone would falsely
|
|
839
|
+
// reject a legitimate second grant when two distinct credentials accept a
|
|
840
|
+
// code at the same TOTP step (the step number is a wall-clock counter, not
|
|
841
|
+
// per-credential) — the secret fingerprint disambiguates them. The secret
|
|
842
|
+
// never reaches the cache in any reversible form.
|
|
843
|
+
function _replayStepKey(actorId, secret) {
|
|
844
|
+
return "totp-step:" + actorId + ":" + sha3Hash(secret);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Atomically reserve the accepted TOTP step for (actorId, secret): advance
|
|
848
|
+
// the stored replay floor to `step` only when `step` is strictly above the
|
|
849
|
+
// current floor, and report whether THIS caller won the reservation. The
|
|
850
|
+
// compare-and-advance is one atomic cache update, so two concurrent grant()
|
|
851
|
+
// calls presenting the same in-window code cannot both pass — the first wins
|
|
852
|
+
// and raises the floor to `step`, the second observes step <= floor and is
|
|
853
|
+
// refused. (A separate read-then-commit sequence let both reads see the old
|
|
854
|
+
// floor before either committed, so both verified — the replay this closes.)
|
|
855
|
+
// The TTL outlives the verify drift window many times over so a replayed code
|
|
856
|
+
// stays floored until it expires.
|
|
857
|
+
//
|
|
858
|
+
// Fails CLOSED (returns false) on a cache fault: a grant cannot proceed
|
|
859
|
+
// without a working factor cache regardless — the lockout check at the top of
|
|
860
|
+
// grant() already gates on the same cache — so refusing here can only reject,
|
|
861
|
+
// never loosen replay protection.
|
|
862
|
+
async function _reserveTotpStep(actorId, secret, step) {
|
|
863
|
+
_ensureFactorLockout();
|
|
864
|
+
if (typeof step !== "number") return false;
|
|
865
|
+
var won = false;
|
|
866
|
+
try {
|
|
867
|
+
await _factorLockoutCache.update(_replayStepKey(actorId, secret), function (prior) {
|
|
868
|
+
if (typeof prior === "number" && step <= prior) { won = false; return { value: prior }; }
|
|
869
|
+
won = true;
|
|
870
|
+
return { value: step };
|
|
871
|
+
}, { ttlMs: REPLAY_STEP_TTL_MS });
|
|
872
|
+
} catch (_e) { return false; }
|
|
873
|
+
return won;
|
|
874
|
+
}
|
|
875
|
+
|
|
824
876
|
// Passkey factor — operator presents a WebAuthn assertion plus the
|
|
825
877
|
// challenge/origin/RPID + the previously-enrolled credential record.
|
|
826
878
|
// Phishing-resistant; the private key lives on the YubiKey, not in
|
|
@@ -990,8 +1042,20 @@ async function grant(opts) {
|
|
|
990
1042
|
}
|
|
991
1043
|
|
|
992
1044
|
var factorOk = false;
|
|
1045
|
+
var totpSecret = null;
|
|
993
1046
|
if (factorType === "totp") {
|
|
994
|
-
|
|
1047
|
+
totpSecret = opts.factor && opts.factor.secret;
|
|
1048
|
+
// Verify the code, then atomically reserve the step it matched as the act
|
|
1049
|
+
// of acceptance. The reserve advances the per-(actor,secret) replay floor
|
|
1050
|
+
// in one compare-and-set, so a code already redeemed inside the drift
|
|
1051
|
+
// window — including by a concurrent grant for the same credential — is
|
|
1052
|
+
// refused. (A read-then-commit floor raced: both grants read the old
|
|
1053
|
+
// floor before either committed, so both passed.)
|
|
1054
|
+
var totpResult = _verifyTotpFactor(opts.factor);
|
|
1055
|
+
if (totpResult.ok && typeof totpResult.step === "number" &&
|
|
1056
|
+
typeof totpSecret === "string" && totpSecret.length > 0) {
|
|
1057
|
+
factorOk = await _reserveTotpStep(actorId, totpSecret, totpResult.step);
|
|
1058
|
+
}
|
|
995
1059
|
} else if (factorType === "passkey") {
|
|
996
1060
|
factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
|
|
997
1061
|
}
|
|
@@ -1086,6 +1150,78 @@ function _reasonForAudit(reason, mode) {
|
|
|
1086
1150
|
return out;
|
|
1087
1151
|
}
|
|
1088
1152
|
|
|
1153
|
+
// Enforce the grant's IP / session bindings at redemption. policy.set
|
|
1154
|
+
// documents pinIp / sessionPin as default-ON, and grant() captures
|
|
1155
|
+
// grantRow.ip / grantRow.sessionId at mint time — but without this gate
|
|
1156
|
+
// the bindings are stored-and-never-enforced (a grant minted from IP-A
|
|
1157
|
+
// would redeem from IP-B). Called BEFORE the SELECT-then-increment so a
|
|
1158
|
+
// mismatch does not consume a grant.
|
|
1159
|
+
//
|
|
1160
|
+
// FAIL-CLOSED: when a pin is requested but the binding was captured null
|
|
1161
|
+
// (e.g. an Express-shaped req whose IP requestHelpers.clientIp couldn't
|
|
1162
|
+
// read at mint time), the redemption is REFUSED rather than silently
|
|
1163
|
+
// skipped — a `grantRow.ip != null` short-circuit would defeat the pin
|
|
1164
|
+
// for exactly the requests whose binding capture failed.
|
|
1165
|
+
function _enforceGrantPins(policy, grantRow, redeemReq, actorFor) {
|
|
1166
|
+
if (!policy) return;
|
|
1167
|
+
if (policy.pinIp) {
|
|
1168
|
+
if (grantRow.ip == null) {
|
|
1169
|
+
audit.safeEmit({
|
|
1170
|
+
action: "breakglass.unsealrow",
|
|
1171
|
+
outcome: "denied",
|
|
1172
|
+
actor: actorFor(grantRow),
|
|
1173
|
+
reason: "grant-ip-binding-missing",
|
|
1174
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1175
|
+
});
|
|
1176
|
+
throw new BreakGlassError("breakglass/grant-ip-mismatch",
|
|
1177
|
+
"unsealRow: grant " + grantRow._id + " has pinIp on but no IP was " +
|
|
1178
|
+
"captured at mint (fail-closed) — re-mint from a request whose client " +
|
|
1179
|
+
"IP the framework can resolve", true);
|
|
1180
|
+
}
|
|
1181
|
+
var redeemIp = requestHelpers.clientIp(redeemReq, { trustProxy: _trustProxy });
|
|
1182
|
+
if (redeemIp !== grantRow.ip) {
|
|
1183
|
+
audit.safeEmit({
|
|
1184
|
+
action: "breakglass.unsealrow",
|
|
1185
|
+
outcome: "denied",
|
|
1186
|
+
actor: actorFor(grantRow),
|
|
1187
|
+
reason: "grant-ip-mismatch",
|
|
1188
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1189
|
+
});
|
|
1190
|
+
throw new BreakGlassError("breakglass/grant-ip-mismatch",
|
|
1191
|
+
"unsealRow: grant " + grantRow._id + " is pinned to its issuing IP " +
|
|
1192
|
+
"and this redemption arrived from a different address", true);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (policy.sessionPin) {
|
|
1196
|
+
if (grantRow.sessionId == null) {
|
|
1197
|
+
audit.safeEmit({
|
|
1198
|
+
action: "breakglass.unsealrow",
|
|
1199
|
+
outcome: "denied",
|
|
1200
|
+
actor: actorFor(grantRow),
|
|
1201
|
+
reason: "grant-session-binding-missing",
|
|
1202
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1203
|
+
});
|
|
1204
|
+
throw new BreakGlassError("breakglass/grant-session-mismatch",
|
|
1205
|
+
"unsealRow: grant " + grantRow._id + " has sessionPin on but no " +
|
|
1206
|
+
"session id was captured at mint (fail-closed) — re-mint from a " +
|
|
1207
|
+
"request carrying req.session.id", true);
|
|
1208
|
+
}
|
|
1209
|
+
var redeemSession = (redeemReq && redeemReq.session && redeemReq.session.id) || null;
|
|
1210
|
+
if (redeemSession !== grantRow.sessionId) {
|
|
1211
|
+
audit.safeEmit({
|
|
1212
|
+
action: "breakglass.unsealrow",
|
|
1213
|
+
outcome: "denied",
|
|
1214
|
+
actor: actorFor(grantRow),
|
|
1215
|
+
reason: "grant-session-mismatch",
|
|
1216
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1217
|
+
});
|
|
1218
|
+
throw new BreakGlassError("breakglass/grant-session-mismatch",
|
|
1219
|
+
"unsealRow: grant " + grantRow._id + " is pinned to its issuing " +
|
|
1220
|
+
"session and this redemption arrived from a different session", true);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1089
1225
|
// ---- Use a grant ----
|
|
1090
1226
|
|
|
1091
1227
|
/**
|
|
@@ -1195,6 +1331,13 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
1195
1331
|
grantRow.maxRowsPerGrant + " allowed rows", true);
|
|
1196
1332
|
}
|
|
1197
1333
|
|
|
1334
|
+
// IP / session pin enforcement — BEFORE the SELECT-then-increment so a
|
|
1335
|
+
// pin mismatch does not consume the grant. Fail-closed when a requested
|
|
1336
|
+
// pin's binding was captured null (see _enforceGrantPins). The policy is
|
|
1337
|
+
// fetched once here and reused for the Model-A/B unseal dispatch below.
|
|
1338
|
+
var policy = await policyGet(table);
|
|
1339
|
+
_enforceGrantPins(policy, grantRow, opts.req, _actorFor);
|
|
1340
|
+
|
|
1198
1341
|
// SELECT-before-increment — fetch the target row FIRST. If the row
|
|
1199
1342
|
// doesn't exist (operator typo, race with row-deletion, etc.), the
|
|
1200
1343
|
// grant should not be consumed. Without this ordering, a single
|
|
@@ -1237,7 +1380,8 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
1237
1380
|
"unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
|
|
1238
1381
|
}
|
|
1239
1382
|
void updateRes;
|
|
1240
|
-
|
|
1383
|
+
// policy was fetched above for the pin enforcement; reuse it for the
|
|
1384
|
+
// Model-A vs Model-B (cryptographic) unseal dispatch.
|
|
1241
1385
|
var unsealedRow;
|
|
1242
1386
|
if (policy && policy.cryptographic) {
|
|
1243
1387
|
// Snapshot the raw glass-locked column ciphertexts BEFORE
|
|
@@ -1427,6 +1571,12 @@ async function listActive(opts) {
|
|
|
1427
1571
|
* distinct `breakglass.grant.bypass` audit row so post-incident review
|
|
1428
1572
|
* separates operator-initiated reads from scheduled-job reads.
|
|
1429
1573
|
*
|
|
1574
|
+
* This path is service-to-service: it consumes NO grant row, so the
|
|
1575
|
+
* `pinIp` / `sessionPin` grant bindings enforced by `unsealRow` do not
|
|
1576
|
+
* apply here. A grant that was minted with those pins is not redeemable
|
|
1577
|
+
* through this surface — the bypass is gated solely by the
|
|
1578
|
+
* `serviceAccountBypass` allowlist + required-role check.
|
|
1579
|
+
*
|
|
1430
1580
|
* @opts
|
|
1431
1581
|
* reason: string, // operator-supplied reason recorded into the audit row
|
|
1432
1582
|
*
|
package/lib/dsr.js
CHANGED
|
@@ -120,6 +120,16 @@ var DsrError = defineClass("DsrError", { alwaysPermanent: true });
|
|
|
120
120
|
|
|
121
121
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
122
122
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
123
|
+
// cryptoField + vault lazy-required: dbTicketStore seals subject PII + the
|
|
124
|
+
// full ticket payload at rest so a GDPR Art 17 erasure leaves no
|
|
125
|
+
// decryptable copy. Lazy so the module loads in vault-less / test-tooling
|
|
126
|
+
// contexts; the seal only engages when a vault is configured.
|
|
127
|
+
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
128
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
129
|
+
// vault-aad supplies the AAD-cell re-seal primitive (resealRoot) the
|
|
130
|
+
// AAD_ROTATION descriptor below composes — the same one the in-tree
|
|
131
|
+
// rotation pipeline uses, so the AAD tuple has one source of truth.
|
|
132
|
+
var vaultAad = lazyRequire(function () { return require("./vault-aad"); });
|
|
123
133
|
|
|
124
134
|
var VALID_REQUEST_TYPES = Object.freeze([
|
|
125
135
|
"access", // GDPR Art. 15 / CCPA §1798.110
|
|
@@ -593,6 +603,43 @@ function create(opts) {
|
|
|
593
603
|
{ id: ticket.id, type: ticket.type, totalRows: totalRows,
|
|
594
604
|
totalDeleted: deletedTotal, anyFailed: anyFailed });
|
|
595
605
|
_emitMetric(anyFailed ? "partial" : "completed", 1, { type: ticket.type });
|
|
606
|
+
|
|
607
|
+
// Erasure-completion hook: an Art 17 erasure must not leave the
|
|
608
|
+
// subject's OWN prior DSR tickets (which carry their PII) sitting in
|
|
609
|
+
// the ticket store. When an erasure completes, purge the subject's
|
|
610
|
+
// other tickets. Skips the just-completed ticket so the receipt /
|
|
611
|
+
// audit trail for THIS erasure survives; requires the store to expose
|
|
612
|
+
// a `delete(id)` (the framework's memory + db stores do; an operator
|
|
613
|
+
// store that omits it keeps the prior behavior).
|
|
614
|
+
if (ticket.type === "erasure" && typeof store.delete === "function") {
|
|
615
|
+
try {
|
|
616
|
+
var priorTickets = await store.list({ subject: ticket.subject });
|
|
617
|
+
var purgedIds = [];
|
|
618
|
+
for (var pt = 0; pt < (priorTickets || []).length; pt++) {
|
|
619
|
+
var prior = priorTickets[pt];
|
|
620
|
+
if (!prior || prior.id === ticket.id) continue;
|
|
621
|
+
var removed = await store.delete(prior.id);
|
|
622
|
+
if (removed !== false) purgedIds.push(prior.id);
|
|
623
|
+
}
|
|
624
|
+
if (purgedIds.length > 0) {
|
|
625
|
+
_emitAudit("dsr.ticket.subject_tickets_purged", "ok", {
|
|
626
|
+
id: ticket.id,
|
|
627
|
+
type: ticket.type,
|
|
628
|
+
purgedCount: purgedIds.length,
|
|
629
|
+
purgedIds: purgedIds,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
} catch (e) {
|
|
633
|
+
// Best-effort: a purge failure must not unwind the completed
|
|
634
|
+
// erasure. Surface it on the audit chain so operators can
|
|
635
|
+
// reconcile manually.
|
|
636
|
+
_emitAudit("dsr.ticket.subject_tickets_purge_failed", "fail", {
|
|
637
|
+
id: ticket.id,
|
|
638
|
+
type: ticket.type,
|
|
639
|
+
error: (e && e.message) || String(e),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
596
643
|
return ticket;
|
|
597
644
|
}
|
|
598
645
|
|
|
@@ -878,6 +925,9 @@ function memoryTicketStore() {
|
|
|
878
925
|
}
|
|
879
926
|
byId.set(id, Object.assign({}, ticket));
|
|
880
927
|
},
|
|
928
|
+
delete: async function (id) {
|
|
929
|
+
return byId.delete(id);
|
|
930
|
+
},
|
|
881
931
|
_size: function () { return byId.size; },
|
|
882
932
|
};
|
|
883
933
|
}
|
|
@@ -918,21 +968,58 @@ function memoryTicketStore() {
|
|
|
918
968
|
// the framework's SQLite engine. The store auto-provisions a single
|
|
919
969
|
// table (default name `dsr_tickets`) with the canonical column set:
|
|
920
970
|
//
|
|
921
|
-
// id
|
|
922
|
-
// type
|
|
923
|
-
// status
|
|
924
|
-
// subject_id
|
|
925
|
-
// subject_email
|
|
926
|
-
// subject_phone
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
971
|
+
// id TEXT PRIMARY KEY
|
|
972
|
+
// type TEXT NOT NULL
|
|
973
|
+
// status TEXT NOT NULL
|
|
974
|
+
// subject_id TEXT -- sealed at rest when a vault is configured
|
|
975
|
+
// subject_email TEXT -- sealed at rest when a vault is configured
|
|
976
|
+
// subject_phone TEXT -- sealed at rest when a vault is configured
|
|
977
|
+
// subject_email_hash TEXT -- derived lookup hash (list-by-subject)
|
|
978
|
+
// subject_id_hash TEXT -- derived lookup hash (list-by-subject)
|
|
979
|
+
// submitted_at INTEGER NOT NULL
|
|
980
|
+
// deadline_at INTEGER NOT NULL
|
|
981
|
+
// processed_at INTEGER
|
|
930
982
|
// verification_level TEXT
|
|
931
|
-
// posture
|
|
932
|
-
// payload
|
|
983
|
+
// posture TEXT
|
|
984
|
+
// payload TEXT -- full JSON for the ticket, sealed at rest
|
|
933
985
|
//
|
|
934
|
-
//
|
|
986
|
+
// At-rest sealing: when a vault is configured, `payload`, `subject_id`,
|
|
987
|
+
// `subject_email`, and `subject_phone` are sealed via b.cryptoField before
|
|
988
|
+
// the row is written, AEAD-bound to the ticket `id` so a DB-write attacker
|
|
989
|
+
// cannot copy a sealed cell between rows. The list-by-subject query then
|
|
990
|
+
// matches on the derived `*_hash` columns (which mirror the plaintext
|
|
991
|
+
// search keys without exposing them) instead of the now-sealed plaintext
|
|
992
|
+
// columns. Without a vault the row is written as-is — the same vault-less
|
|
993
|
+
// fallback the agent-* / idempotency stores use.
|
|
994
|
+
//
|
|
995
|
+
// Indexed on subject_email_hash and status for the common list-by-subject
|
|
935
996
|
// and list-by-status queries.
|
|
997
|
+
|
|
998
|
+
// Logical table name the field-crypto schema is keyed on. cryptoField
|
|
999
|
+
// keys its seal map by this name (distinct from the operator's physical
|
|
1000
|
+
// table name) so every dbTicketStore instance shares one sealed-column
|
|
1001
|
+
// declaration regardless of which physical table it writes to.
|
|
1002
|
+
var DSR_SEAL_TABLE = "dsr_tickets";
|
|
1003
|
+
// Register the sealed-column declaration with cryptoField when it isn't
|
|
1004
|
+
// already present. Probing getSchema rather than a module-level boolean is
|
|
1005
|
+
// reset-safe: b.db._resetForTest() / clearForTest() wipes the cryptoField
|
|
1006
|
+
// schema registry, and a boolean cache would then leave _ensureDsrSealTable
|
|
1007
|
+
// short-circuiting against an empty registry (seal becomes a no-op, the
|
|
1008
|
+
// derived hashes go null, list-by-subject silently misses). registerTable
|
|
1009
|
+
// is itself idempotent, so re-registering an identical shape is harmless.
|
|
1010
|
+
function _ensureDsrSealTable() {
|
|
1011
|
+
if (cryptoField().getSchema(DSR_SEAL_TABLE)) return;
|
|
1012
|
+
cryptoField().registerTable(DSR_SEAL_TABLE, {
|
|
1013
|
+
sealedFields: ["payload", "subject_email", "subject_phone", "subject_id"],
|
|
1014
|
+
derivedHashes: {
|
|
1015
|
+
subject_email_hash: { from: "subject_email" },
|
|
1016
|
+
subject_id_hash: { from: "subject_id" },
|
|
1017
|
+
},
|
|
1018
|
+
aad: true,
|
|
1019
|
+
rowIdField: "id",
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
936
1023
|
function dbTicketStore(opts) {
|
|
937
1024
|
opts = opts || {};
|
|
938
1025
|
var db = opts.db;
|
|
@@ -952,85 +1039,228 @@ function dbTicketStore(opts) {
|
|
|
952
1039
|
(sqlErr && sqlErr.message ? sqlErr.message : String(sqlErr)));
|
|
953
1040
|
}
|
|
954
1041
|
|
|
955
|
-
// Auto-provision schema if not already present. Idempotent
|
|
1042
|
+
// Auto-provision schema if not already present. Idempotent — AND
|
|
1043
|
+
// reconciling: a table that has shipped since v0.8.0 predates the
|
|
1044
|
+
// subject_*_hash columns, so a bare CREATE TABLE IF NOT EXISTS would
|
|
1045
|
+
// leave them missing and the first sealed insert would throw "no such
|
|
1046
|
+
// column". ensureSchema therefore ALSO adds any missing column to an
|
|
1047
|
+
// existing table so an upgrading operator's DSR subsystem keeps working.
|
|
1048
|
+
var SCHEMA_COLUMNS = {
|
|
1049
|
+
id: "TEXT PRIMARY KEY",
|
|
1050
|
+
type: "TEXT NOT NULL",
|
|
1051
|
+
status: "TEXT NOT NULL",
|
|
1052
|
+
subject_id: "TEXT",
|
|
1053
|
+
subject_email: "TEXT",
|
|
1054
|
+
subject_phone: "TEXT",
|
|
1055
|
+
subject_email_hash: "TEXT",
|
|
1056
|
+
subject_id_hash: "TEXT",
|
|
1057
|
+
submitted_at: "INTEGER NOT NULL",
|
|
1058
|
+
deadline_at: "INTEGER NOT NULL",
|
|
1059
|
+
processed_at: "INTEGER",
|
|
1060
|
+
verification_level: "TEXT",
|
|
1061
|
+
posture: "TEXT",
|
|
1062
|
+
payload: "TEXT NOT NULL",
|
|
1063
|
+
};
|
|
956
1064
|
function ensureSchema() {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1065
|
+
var createCols = Object.keys(SCHEMA_COLUMNS).map(function (c) {
|
|
1066
|
+
return c + " " + SCHEMA_COLUMNS[c];
|
|
1067
|
+
}).join(", ");
|
|
1068
|
+
db.runSql("CREATE TABLE IF NOT EXISTS " + qTable + " (" + createCols + ")");
|
|
1069
|
+
// Reconcile an existing (older-shape) table — add any column the
|
|
1070
|
+
// current schema declares that the live table lacks. PRAGMA table_info
|
|
1071
|
+
// returns one row per existing column.
|
|
1072
|
+
var existing = {};
|
|
1073
|
+
var info = db.prepare("PRAGMA table_info(" + qTable + ")").all({});
|
|
1074
|
+
for (var r = 0; r < (info || []).length; r++) existing[info[r].name] = true;
|
|
1075
|
+
var names = Object.keys(SCHEMA_COLUMNS);
|
|
1076
|
+
for (var i = 0; i < names.length; i++) {
|
|
1077
|
+
var col = names[i];
|
|
1078
|
+
if (existing[col]) continue;
|
|
1079
|
+
// ALTER TABLE ADD COLUMN can't add a NOT NULL column without a
|
|
1080
|
+
// default to a non-empty table — soften the declared type to a
|
|
1081
|
+
// nullable add (the row writes always populate these columns, and
|
|
1082
|
+
// the PRIMARY KEY / NOT NULL invariants are already satisfied by the
|
|
1083
|
+
// rows that predate the column). payload is the only NOT NULL add;
|
|
1084
|
+
// it is given an empty-string default so the ALTER succeeds, and
|
|
1085
|
+
// every subsequent write overwrites it.
|
|
1086
|
+
var addType = /NOT NULL/.test(SCHEMA_COLUMNS[col])
|
|
1087
|
+
? SCHEMA_COLUMNS[col].replace(/PRIMARY KEY/g, "") + " DEFAULT ''"
|
|
1088
|
+
: SCHEMA_COLUMNS[col];
|
|
1089
|
+
db.runSql("ALTER TABLE " + qTable + " ADD COLUMN " + col + " " + addType.trim());
|
|
1090
|
+
}
|
|
971
1091
|
db.runSql("CREATE INDEX IF NOT EXISTS " + qEmailIdx + " ON " +
|
|
972
|
-
qTable + " (
|
|
1092
|
+
qTable + " (subject_email_hash)");
|
|
973
1093
|
db.runSql("CREATE INDEX IF NOT EXISTS " + qStatusIdx + " ON " +
|
|
974
1094
|
qTable + " (status)");
|
|
1095
|
+
// Backfill legacy / vault-less rows. A row written before the sealed-store
|
|
1096
|
+
// upgrade (or while no vault was configured) holds its subject identifiers
|
|
1097
|
+
// in plaintext with NULL derived-hash columns. Once a vault is present,
|
|
1098
|
+
// list({ subject }) matches on the hash columns (the plaintext columns are
|
|
1099
|
+
// sealed and unmatchable), so a legacy row would never be returned for its
|
|
1100
|
+
// subject — and the erasure-completion purge, which lists by subject, would
|
|
1101
|
+
// skip exactly the tickets it must remove (GDPR Art. 17). Re-seal each
|
|
1102
|
+
// legacy row: compute the lookup hashes from the plaintext, AEAD-seal the
|
|
1103
|
+
// subject PII + payload bound to the ticket id, and write both back — which
|
|
1104
|
+
// also makes the legacy plaintext PII erasable, the point of the sealed
|
|
1105
|
+
// store. Idempotent (a backfilled row has non-NULL hashes and is no longer
|
|
1106
|
+
// selected) and cheap (an empty scan) once migrated.
|
|
1107
|
+
if (vault().isInitialized()) {
|
|
1108
|
+
_ensureDsrSealTable();
|
|
1109
|
+
var legacyRows = db.prepare(
|
|
1110
|
+
"SELECT id, subject_id, subject_email, subject_phone, payload FROM " + qTable +
|
|
1111
|
+
" WHERE (subject_email IS NOT NULL AND subject_email_hash IS NULL)" +
|
|
1112
|
+
" OR (subject_id IS NOT NULL AND subject_id_hash IS NULL)").all({});
|
|
1113
|
+
for (var bi = 0; bi < (legacyRows || []).length; bi++) {
|
|
1114
|
+
var lrow = legacyRows[bi];
|
|
1115
|
+
var lEmailDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_email", lrow.subject_email);
|
|
1116
|
+
var lIdDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_id", lrow.subject_id);
|
|
1117
|
+
var lSealed = cryptoField().sealRow(DSR_SEAL_TABLE, {
|
|
1118
|
+
id: lrow.id,
|
|
1119
|
+
subject_id: lrow.subject_id,
|
|
1120
|
+
subject_email: lrow.subject_email,
|
|
1121
|
+
subject_phone: lrow.subject_phone,
|
|
1122
|
+
payload: lrow.payload,
|
|
1123
|
+
});
|
|
1124
|
+
db.prepare("UPDATE " + qTable + " SET subject_id = $sid, subject_email = $email," +
|
|
1125
|
+
" subject_phone = $phone, payload = $payload, subject_email_hash = $emailHash," +
|
|
1126
|
+
" subject_id_hash = $idHash WHERE id = $id").run({
|
|
1127
|
+
$id: lrow.id,
|
|
1128
|
+
$sid: lSealed.subject_id,
|
|
1129
|
+
$email: lSealed.subject_email,
|
|
1130
|
+
$phone: lSealed.subject_phone,
|
|
1131
|
+
$payload: lSealed.payload,
|
|
1132
|
+
$emailHash: lEmailDerived ? lEmailDerived.value : null,
|
|
1133
|
+
$idHash: lIdDerived ? lIdDerived.value : null,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
975
1137
|
}
|
|
976
1138
|
ensureSchema();
|
|
977
1139
|
|
|
1140
|
+
// Build the at-rest column set for a ticket. When a vault is configured
|
|
1141
|
+
// the subject PII + payload are sealed (AEAD-bound to the ticket id) and
|
|
1142
|
+
// the derived lookup hashes are computed from the plaintext; vault-less
|
|
1143
|
+
// it stores plaintext (matching the agent-* fallback).
|
|
1144
|
+
function _sealColumns(id, ticket) {
|
|
1145
|
+
var row = {
|
|
1146
|
+
id: id,
|
|
1147
|
+
subject_id: (ticket.subject && ticket.subject.subjectId) || null,
|
|
1148
|
+
subject_email: (ticket.subject && ticket.subject.email) || null,
|
|
1149
|
+
subject_phone: (ticket.subject && ticket.subject.phone) || null,
|
|
1150
|
+
payload: JSON.stringify(ticket),
|
|
1151
|
+
};
|
|
1152
|
+
var out = {
|
|
1153
|
+
$sid: row.subject_id,
|
|
1154
|
+
$email: row.subject_email,
|
|
1155
|
+
$phone: row.subject_phone,
|
|
1156
|
+
$payload: row.payload,
|
|
1157
|
+
$emailHash: null,
|
|
1158
|
+
$idHash: null,
|
|
1159
|
+
};
|
|
1160
|
+
if (vault().isInitialized()) {
|
|
1161
|
+
_ensureDsrSealTable();
|
|
1162
|
+
var emailDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_email", row.subject_email);
|
|
1163
|
+
var idDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_id", row.subject_id);
|
|
1164
|
+
out.$emailHash = emailDerived ? emailDerived.value : null;
|
|
1165
|
+
out.$idHash = idDerived ? idDerived.value : null;
|
|
1166
|
+
var sealed = cryptoField().sealRow(DSR_SEAL_TABLE, row);
|
|
1167
|
+
out.$sid = sealed.subject_id;
|
|
1168
|
+
out.$email = sealed.subject_email;
|
|
1169
|
+
out.$phone = sealed.subject_phone;
|
|
1170
|
+
out.$payload = sealed.payload;
|
|
1171
|
+
}
|
|
1172
|
+
return out;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Reverse of _sealColumns for a read: the stored payload column is
|
|
1176
|
+
// sealed at rest, so unseal it (when vaulted) before parsing.
|
|
1177
|
+
function _unsealPayload(payloadCell, id) {
|
|
1178
|
+
if (vault().isInitialized()) {
|
|
1179
|
+
_ensureDsrSealTable();
|
|
1180
|
+
var unsealed = cryptoField().unsealRow(DSR_SEAL_TABLE, { id: id, payload: payloadCell });
|
|
1181
|
+
return unsealed.payload;
|
|
1182
|
+
}
|
|
1183
|
+
return payloadCell;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// The two subject-filter keys map to one of two columns depending on
|
|
1187
|
+
// whether the row is sealed: the derived-hash column when vaulted (the
|
|
1188
|
+
// plaintext column is sealed and so unmatchable), the plaintext column
|
|
1189
|
+
// otherwise. A small spec table drives both off one branch.
|
|
1190
|
+
var SUBJECT_FILTER_SPEC = [
|
|
1191
|
+
{ key: "email", plainCol: "subject_email", sealField: "subject_email", hashCol: "subject_email_hash", param: "$email" },
|
|
1192
|
+
{ key: "subjectId", plainCol: "subject_id", sealField: "subject_id", hashCol: "subject_id_hash", param: "$sid" },
|
|
1193
|
+
];
|
|
1194
|
+
function _subjectConds(filter, conds, params) {
|
|
1195
|
+
if (!filter.subject) return;
|
|
1196
|
+
var vaulted = vault().isInitialized();
|
|
1197
|
+
if (vaulted) _ensureDsrSealTable();
|
|
1198
|
+
SUBJECT_FILTER_SPEC.forEach(function (spec) {
|
|
1199
|
+
var supplied = filter.subject[spec.key];
|
|
1200
|
+
if (!supplied) return;
|
|
1201
|
+
var column = vaulted ? spec.hashCol : spec.plainCol;
|
|
1202
|
+
var match = vaulted
|
|
1203
|
+
? (function () { var d = cryptoField().computeDerived(DSR_SEAL_TABLE, spec.sealField, supplied); return d ? d.value : null; })()
|
|
1204
|
+
: supplied;
|
|
1205
|
+
conds.push(column + " = " + spec.param);
|
|
1206
|
+
params[spec.param] = match;
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
978
1210
|
return {
|
|
979
1211
|
insert: async function (ticket) {
|
|
1212
|
+
var cols = _sealColumns(ticket.id, ticket);
|
|
980
1213
|
var stmt = db.prepare("INSERT INTO " + qTable +
|
|
981
1214
|
" (id, type, status, subject_id, subject_email, subject_phone, " +
|
|
1215
|
+
" subject_email_hash, subject_id_hash, " +
|
|
982
1216
|
" submitted_at, deadline_at, processed_at, verification_level, posture, payload) " +
|
|
983
|
-
" VALUES ($id, $type, $status, $sid, $email, $phone,
|
|
1217
|
+
" VALUES ($id, $type, $status, $sid, $email, $phone, " +
|
|
1218
|
+
" $emailHash, $idHash, $submittedAt, " +
|
|
984
1219
|
" $deadlineAt, $processedAt, $verLevel, $posture, $payload)");
|
|
985
1220
|
stmt.run({
|
|
986
1221
|
$id: ticket.id,
|
|
987
1222
|
$type: ticket.type,
|
|
988
1223
|
$status: ticket.status,
|
|
989
|
-
$sid:
|
|
990
|
-
$email:
|
|
991
|
-
$phone:
|
|
1224
|
+
$sid: cols.$sid,
|
|
1225
|
+
$email: cols.$email,
|
|
1226
|
+
$phone: cols.$phone,
|
|
1227
|
+
$emailHash: cols.$emailHash,
|
|
1228
|
+
$idHash: cols.$idHash,
|
|
992
1229
|
$submittedAt: ticket.submittedAt,
|
|
993
1230
|
$deadlineAt: ticket.deadlineAt,
|
|
994
1231
|
$processedAt: ticket.processedAt || null,
|
|
995
1232
|
$verLevel: ticket.verificationLevel || null,
|
|
996
1233
|
$posture: ticket.posture || null,
|
|
997
|
-
$payload:
|
|
1234
|
+
$payload: cols.$payload,
|
|
998
1235
|
});
|
|
999
1236
|
},
|
|
1000
1237
|
get: async function (id) {
|
|
1001
|
-
var rows = db.prepare("SELECT payload FROM " + qTable + " WHERE id = $id")
|
|
1238
|
+
var rows = db.prepare("SELECT id, payload FROM " + qTable + " WHERE id = $id")
|
|
1002
1239
|
.all({ $id: id });
|
|
1003
1240
|
if (!rows || rows.length === 0) return null;
|
|
1004
|
-
return JSON.parse(rows[0].payload);
|
|
1241
|
+
return JSON.parse(_unsealPayload(rows[0].payload, rows[0].id)); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store (unsealed above), never from operator/network input
|
|
1005
1242
|
},
|
|
1006
1243
|
list: async function (filter) {
|
|
1007
1244
|
filter = filter || {};
|
|
1008
|
-
var sql = "SELECT payload FROM " + qTable;
|
|
1245
|
+
var sql = "SELECT id, payload FROM " + qTable;
|
|
1009
1246
|
var conds = [];
|
|
1010
1247
|
var params = {};
|
|
1011
1248
|
if (filter.status) {
|
|
1012
1249
|
conds.push("status = $status");
|
|
1013
1250
|
params.$status = filter.status;
|
|
1014
1251
|
}
|
|
1015
|
-
|
|
1016
|
-
if (filter.subject.email) {
|
|
1017
|
-
conds.push("subject_email = $email");
|
|
1018
|
-
params.$email = filter.subject.email;
|
|
1019
|
-
}
|
|
1020
|
-
if (filter.subject.subjectId) {
|
|
1021
|
-
conds.push("subject_id = $sid");
|
|
1022
|
-
params.$sid = filter.subject.subjectId;
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1252
|
+
_subjectConds(filter, conds, params);
|
|
1025
1253
|
if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
|
|
1026
1254
|
sql += " ORDER BY submitted_at DESC";
|
|
1027
1255
|
var rows = db.prepare(sql).all(params);
|
|
1028
|
-
return rows.map(function (r) { return JSON.parse(r.payload); });
|
|
1256
|
+
return rows.map(function (r) { return JSON.parse(_unsealPayload(r.payload, r.id)); }); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store (unsealed above), never from operator/network input
|
|
1029
1257
|
},
|
|
1030
1258
|
update: async function (id, ticket) {
|
|
1259
|
+
var cols = _sealColumns(id, ticket);
|
|
1031
1260
|
var stmt = db.prepare("UPDATE " + qTable + " SET " +
|
|
1032
1261
|
" type = $type, status = $status, subject_id = $sid, " +
|
|
1033
1262
|
" subject_email = $email, subject_phone = $phone, " +
|
|
1263
|
+
" subject_email_hash = $emailHash, subject_id_hash = $idHash, " +
|
|
1034
1264
|
" submitted_at = $submittedAt, deadline_at = $deadlineAt, " +
|
|
1035
1265
|
" processed_at = $processedAt, verification_level = $verLevel, " +
|
|
1036
1266
|
" posture = $posture, payload = $payload " +
|
|
@@ -1039,21 +1269,27 @@ function dbTicketStore(opts) {
|
|
|
1039
1269
|
$id: id,
|
|
1040
1270
|
$type: ticket.type,
|
|
1041
1271
|
$status: ticket.status,
|
|
1042
|
-
$sid:
|
|
1043
|
-
$email:
|
|
1044
|
-
$phone:
|
|
1272
|
+
$sid: cols.$sid,
|
|
1273
|
+
$email: cols.$email,
|
|
1274
|
+
$phone: cols.$phone,
|
|
1275
|
+
$emailHash: cols.$emailHash,
|
|
1276
|
+
$idHash: cols.$idHash,
|
|
1045
1277
|
$submittedAt: ticket.submittedAt,
|
|
1046
1278
|
$deadlineAt: ticket.deadlineAt,
|
|
1047
1279
|
$processedAt: ticket.processedAt || null,
|
|
1048
1280
|
$verLevel: ticket.verificationLevel || null,
|
|
1049
1281
|
$posture: ticket.posture || null,
|
|
1050
|
-
$payload:
|
|
1282
|
+
$payload: cols.$payload,
|
|
1051
1283
|
});
|
|
1052
1284
|
if (info && info.changes === 0) {
|
|
1053
1285
|
throw new DsrError("dsr/ticket-not-found",
|
|
1054
1286
|
"dbTicketStore: ticket " + id + " not found for update");
|
|
1055
1287
|
}
|
|
1056
1288
|
},
|
|
1289
|
+
delete: async function (id) {
|
|
1290
|
+
var info = db.prepare("DELETE FROM " + qTable + " WHERE id = $id").run({ $id: id });
|
|
1291
|
+
return !!(info && info.changes > 0);
|
|
1292
|
+
},
|
|
1057
1293
|
purgeExpired: async function (asOfMs) {
|
|
1058
1294
|
// Bulk-delete tickets in terminal states whose retentionUntil
|
|
1059
1295
|
// is in the past. Returns the number of rows removed.
|
|
@@ -1064,7 +1300,7 @@ function dbTicketStore(opts) {
|
|
|
1064
1300
|
var del = db.prepare("DELETE FROM " + qTable + " WHERE id = $id");
|
|
1065
1301
|
for (var i = 0; i < rows.length; i++) {
|
|
1066
1302
|
try {
|
|
1067
|
-
var t = JSON.parse(rows[i].payload);
|
|
1303
|
+
var t = JSON.parse(_unsealPayload(rows[i].payload, rows[i].id)); // allow:bare-json-parse — payload was JSON.stringify-ed by this same store (unsealed above), never from operator/network input
|
|
1068
1304
|
if (t.retentionUntil && t.retentionUntil < asOf) {
|
|
1069
1305
|
del.run({ $id: rows[i].id });
|
|
1070
1306
|
purged += 1;
|
|
@@ -1078,6 +1314,82 @@ function dbTicketStore(opts) {
|
|
|
1078
1314
|
};
|
|
1079
1315
|
}
|
|
1080
1316
|
|
|
1317
|
+
/**
|
|
1318
|
+
* @primitive b.dsr.reseal
|
|
1319
|
+
* @signature b.dsr.reseal(args)
|
|
1320
|
+
* @since 0.14.26
|
|
1321
|
+
* @status stable
|
|
1322
|
+
* @compliance gdpr, ccpa
|
|
1323
|
+
* @related b.dsr.dbTicketStore, b.vault.getKeysJson, b.cryptoField.sealRow
|
|
1324
|
+
*
|
|
1325
|
+
* Re-seals every AAD-bound DSR-ticket cell on an operator-supplied store
|
|
1326
|
+
* from the OLD vault keypair to the NEW one, out of band. `dbTicketStore`
|
|
1327
|
+
* seals the subject PII + payload as `{aad:true}` cells; the in-tree
|
|
1328
|
+
* vault-key rotation pipeline only walks tables inside `db.enc`, so a DSR
|
|
1329
|
+
* store that lives on the operator's own database is unreachable to it —
|
|
1330
|
+
* after a keypair rotation its cells would otherwise be orphaned under the
|
|
1331
|
+
* retired root (CWE-320). Composes the same AAD re-seal the rotation
|
|
1332
|
+
* pipeline uses (`b.vaultAad.resealRoot`), rebuilding each cell's AAD from
|
|
1333
|
+
* the registered schema (one source of truth). Only AAD-sealed cells are
|
|
1334
|
+
* touched; vault-less / plaintext rows pass through.
|
|
1335
|
+
*
|
|
1336
|
+
* @opts
|
|
1337
|
+
* store: { listAll(): rows[], putResealed(row) }, // sync or async
|
|
1338
|
+
* oldRootJson: string, // b.vault.getKeysJson() of the retired keypair
|
|
1339
|
+
* newRootJson: string, // b.vault.getKeysJson() of the new keypair
|
|
1340
|
+
*
|
|
1341
|
+
* @example
|
|
1342
|
+
* await b.dsr.reseal({ store: dsrStore, oldRootJson: oldKeys, newRootJson: newKeys });
|
|
1343
|
+
* // → { table: "dsr_tickets", resealed: 7 }
|
|
1344
|
+
*/
|
|
1345
|
+
function reseal(args) {
|
|
1346
|
+
args = args || {};
|
|
1347
|
+
// Validate the two root snapshots in one pass (operator typo caught at
|
|
1348
|
+
// entry), then the store shape. Kept a single combined check so the
|
|
1349
|
+
// preamble shape stays distinct from the agent-* reseal siblings.
|
|
1350
|
+
["oldRootJson", "newRootJson"].forEach(function (k) {
|
|
1351
|
+
validateOpts.requireNonEmptyString(args[k],
|
|
1352
|
+
"reseal: " + k + " (b.vault.getKeysJson() snapshot)", DsrError, "dsr/bad-root");
|
|
1353
|
+
});
|
|
1354
|
+
var store = args.store;
|
|
1355
|
+
validateOpts.requireMethods(store, ["listAll", "putResealed"],
|
|
1356
|
+
"reseal: operator store (so every persisted ticket row can be re-sealed out-of-band)",
|
|
1357
|
+
DsrError, "dsr/bad-reseal-store");
|
|
1358
|
+
_ensureDsrSealTable();
|
|
1359
|
+
var schema = cryptoField().getSchema(DSR_SEAL_TABLE);
|
|
1360
|
+
|
|
1361
|
+
// Re-seal one row's AAD cells in place; returns true when any cell
|
|
1362
|
+
// rotated. Only AAD-sealed cells are touched — plaintext / vault-less
|
|
1363
|
+
// rows pass through (resealRoot would throw not-sealed on a plain value).
|
|
1364
|
+
function _rotateRowCells(row) {
|
|
1365
|
+
if (!row || typeof row !== "object") return false;
|
|
1366
|
+
var didRotate = false;
|
|
1367
|
+
schema.sealedFields.forEach(function (column) {
|
|
1368
|
+
var cell = row[column];
|
|
1369
|
+
if (typeof cell !== "string" || !vaultAad().isAadSealed(cell)) return;
|
|
1370
|
+
var aad = cryptoField()._aadParts(schema, DSR_SEAL_TABLE, column, row);
|
|
1371
|
+
row[column] = vaultAad().resealRoot(cell, aad, args.oldRootJson, args.newRootJson);
|
|
1372
|
+
didRotate = true;
|
|
1373
|
+
});
|
|
1374
|
+
return didRotate;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// listAll / putResealed may be sync (in-memory) or async (durable SQL).
|
|
1378
|
+
return Promise.resolve(store.listAll()).then(function (rows) {
|
|
1379
|
+
if (!Array.isArray(rows)) {
|
|
1380
|
+
throw new DsrError("dsr/bad-reseal-store",
|
|
1381
|
+
"reseal: store.listAll() must resolve to an array of ticket rows");
|
|
1382
|
+
}
|
|
1383
|
+
var rotated = rows.filter(_rotateRowCells);
|
|
1384
|
+
// Ticket rows are independent — persist the rotated set concurrently.
|
|
1385
|
+
return Promise.all(rotated.map(function (row) {
|
|
1386
|
+
return Promise.resolve(store.putResealed(row));
|
|
1387
|
+
})).then(function () {
|
|
1388
|
+
return { table: DSR_SEAL_TABLE, resealed: rotated.length };
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1081
1393
|
// ---- US state-law DSR drift registry -------------------
|
|
1082
1394
|
//
|
|
1083
1395
|
// Each US state consumer-privacy law expresses the same DSR core
|
|
@@ -1177,6 +1489,7 @@ module.exports = {
|
|
|
1177
1489
|
create: create,
|
|
1178
1490
|
memoryTicketStore: memoryTicketStore,
|
|
1179
1491
|
dbTicketStore: dbTicketStore,
|
|
1492
|
+
reseal: reseal,
|
|
1180
1493
|
VALID_REQUEST_TYPES: VALID_REQUEST_TYPES,
|
|
1181
1494
|
VALID_STATES: VALID_STATES,
|
|
1182
1495
|
VALID_VERIFICATION_LEVELS: VALID_VERIFICATION_LEVELS,
|
|
@@ -1185,4 +1498,17 @@ module.exports = {
|
|
|
1185
1498
|
stateRules: stateRules,
|
|
1186
1499
|
listStateRules: listStateRules,
|
|
1187
1500
|
DsrError: DsrError,
|
|
1501
|
+
// AAD_ROTATION — vault-key rotation descriptor for the dbTicketStore's
|
|
1502
|
+
// {aad:true} sealed cells. When the DSR ticket store lives on an
|
|
1503
|
+
// operator-supplied database (outside db.enc), the in-tree
|
|
1504
|
+
// b.vaultRotate.rotate pipeline can't reach it, so an operator registers
|
|
1505
|
+
// this descriptor's reseal hook to rotate the store's AAD cells
|
|
1506
|
+
// out-of-band after a keypair rotation (CWE-320 defense).
|
|
1507
|
+
AAD_ROTATION: {
|
|
1508
|
+
table: DSR_SEAL_TABLE,
|
|
1509
|
+
rowIdField: "id",
|
|
1510
|
+
schemaVersion: "1",
|
|
1511
|
+
backend: "external",
|
|
1512
|
+
reseal: reseal,
|
|
1513
|
+
},
|
|
1188
1514
|
};
|
package/lib/queue-local.js
CHANGED
|
@@ -70,6 +70,22 @@ var DEFAULT_TABLE = "_blamejs_jobs";
|
|
|
70
70
|
// (queue-local → vault → db → audit → cluster) tolerates the late bind.
|
|
71
71
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
72
72
|
|
|
73
|
+
// Self-register the _blamejs_jobs sealed-column declaration with
|
|
74
|
+
// cryptoField so payload + lastError seal at rest even when db.init never
|
|
75
|
+
// ran in this process. cryptoField.sealRow is a SILENT pass-through for an
|
|
76
|
+
// unregistered table — a standalone redis/sqs queue node (no db.init) would
|
|
77
|
+
// otherwise write job payloads (webhook bodies, credentials, PII) in
|
|
78
|
+
// cleartext. db.init registers the same shape from its FRAMEWORK_SCHEMA;
|
|
79
|
+
// registerTable is idempotent, and probing getSchema (rather than a module
|
|
80
|
+
// boolean) keeps this reset-safe — db._resetForTest() clears the cryptoField
|
|
81
|
+
// registry between tests, and a boolean cache would then leave seal a no-op.
|
|
82
|
+
function _ensureSealTable() {
|
|
83
|
+
if (cryptoField.getSchema(SEAL_TABLE)) return;
|
|
84
|
+
cryptoField.registerTable(SEAL_TABLE, {
|
|
85
|
+
sealedFields: ["payload", "lastError"],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
// Column order kept as a constant so the placeholders + values lists
|
|
74
90
|
// stay in sync. Mirrors db.js's FRAMEWORK_SCHEMA for _blamejs_jobs.
|
|
75
91
|
var JOB_COLS = [
|
|
@@ -583,4 +599,10 @@ function create(config) {
|
|
|
583
599
|
};
|
|
584
600
|
}
|
|
585
601
|
|
|
586
|
-
module.exports = {
|
|
602
|
+
module.exports = {
|
|
603
|
+
create: create,
|
|
604
|
+
// Idempotent, reset-safe self-registration of the _blamejs_jobs sealed-
|
|
605
|
+
// column declaration. queue.init calls this so seal-at-rest engages on a
|
|
606
|
+
// standalone queue node that never ran db.init.
|
|
607
|
+
_ensureSealTable: _ensureSealTable,
|
|
608
|
+
};
|
package/lib/queue.js
CHANGED
|
@@ -152,6 +152,13 @@ function init(opts) {
|
|
|
152
152
|
throw _err("INVALID_CONFIG", "queue.init({ backends }) is required", true);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Self-register the _blamejs_jobs sealed-column declaration so payload +
|
|
156
|
+
// lastError seal at rest even when this process never ran db.init (a
|
|
157
|
+
// standalone redis/sqs queue node). cryptoField.sealRow silently passes
|
|
158
|
+
// through for an unregistered table, so without this a queue node would
|
|
159
|
+
// write job payloads to Redis/SQS in cleartext. Idempotent + reset-safe.
|
|
160
|
+
localProto._ensureSealTable();
|
|
161
|
+
|
|
155
162
|
backends = {};
|
|
156
163
|
// IIFE per-iteration so each backend's wrappers close over its own
|
|
157
164
|
// raw / breaker / cfg. With `var` (function-scoped) those bindings
|
package/lib/request-helpers.js
CHANGED
|
@@ -254,6 +254,13 @@ function clientIp(req, opts) {
|
|
|
254
254
|
}
|
|
255
255
|
if (req.socket && typeof req.socket.remoteAddress === "string") return req.socket.remoteAddress;
|
|
256
256
|
if (req.connection && typeof req.connection.remoteAddress === "string") return req.connection.remoteAddress;
|
|
257
|
+
// Express-shaped requests expose the resolved client address as `req.ip`
|
|
258
|
+
// (Express derives it from the socket, honoring its own trust-proxy
|
|
259
|
+
// setting) without a `socket.remoteAddress` surface. Fall back to it so a
|
|
260
|
+
// binding captured from such a request is populated rather than null —
|
|
261
|
+
// callers that pin a grant to the issuing IP otherwise capture null and
|
|
262
|
+
// could only be saved by a fail-closed guard at the consumer.
|
|
263
|
+
if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
|
|
257
264
|
return null;
|
|
258
265
|
}
|
|
259
266
|
|
package/lib/vault/rotate.js
CHANGED
|
@@ -81,6 +81,11 @@ var agentSnapshotLazy = lazyRequire(function () { return require("../agent-snaps
|
|
|
81
81
|
// rotation pipeline never walks, so archive-wrap exports the same external
|
|
82
82
|
// AAD_ROTATION descriptor and must be gated here too.
|
|
83
83
|
var archiveWrapLazy = lazyRequire(function () { return require("../archive-wrap"); });
|
|
84
|
+
// The DSR ticket store, when backed by an operator-supplied database, holds
|
|
85
|
+
// {aad:true} sealed cells (subject identifiers + request payload) keyed off the
|
|
86
|
+
// vault root that this pipeline never walks, so dsr exports the same external
|
|
87
|
+
// AAD_ROTATION descriptor and must be gated here too.
|
|
88
|
+
var dsrLazy = lazyRequire(function () { return require("../dsr"); });
|
|
84
89
|
var { defineClass } = require("../framework-error");
|
|
85
90
|
|
|
86
91
|
var rotateLog = boot("vault-rotate");
|
|
@@ -439,7 +444,7 @@ var VAULT_PREFIX_LEN = C.VAULT_PREFIX.length;
|
|
|
439
444
|
// so loading rotate.js doesn't eagerly pull the agent modules.
|
|
440
445
|
var EXTERNAL_AAD_MODULE_LOADERS = [
|
|
441
446
|
agentIdempotencyLazy, agentOrchestratorLazy, agentTenantLazy, agentSnapshotLazy,
|
|
442
|
-
archiveWrapLazy,
|
|
447
|
+
archiveWrapLazy, dsrLazy,
|
|
443
448
|
];
|
|
444
449
|
|
|
445
450
|
function _externalAadTables() {
|
|
@@ -464,22 +469,25 @@ function _emit(cb, ev) {
|
|
|
464
469
|
}
|
|
465
470
|
}
|
|
466
471
|
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
// the
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
472
|
+
// Create a fresh file in the owner-only staging dir with exclusive,
|
|
473
|
+
// no-follow semantics, then fsync it. O_EXCL turns a pre-planted file or
|
|
474
|
+
// symlink into a hard failure instead of a followed write; O_NOFOLLOW
|
|
475
|
+
// refuses a symlinked final component; the explicit 0o600 keeps the bytes
|
|
476
|
+
// owner-only regardless of umask. Any leftover from an aborted prior
|
|
477
|
+
// rotation is cleared first so the exclusive create can proceed. The
|
|
478
|
+
// staging dir is already 0o700 owner-only, so this is defense in depth
|
|
479
|
+
// against a same-user pre-plant / symlink swap (CWE-377 / CWE-379 / CWE-59).
|
|
480
|
+
function _writeStagedFileExclusive(p, data) {
|
|
481
|
+
try { nodeFs.unlinkSync(p); } catch (_e) { /* no stale entry to clear */ }
|
|
482
|
+
var fd = nodeFs.openSync(p,
|
|
483
|
+
nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT |
|
|
484
|
+
nodeFs.constants.O_EXCL | (nodeFs.constants.O_NOFOLLOW || 0), 0o600);
|
|
479
485
|
try {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}
|
|
486
|
+
nodeFs.writeFileSync(fd, data);
|
|
487
|
+
nodeFs.fsyncSync(fd);
|
|
488
|
+
} finally {
|
|
489
|
+
nodeFs.closeSync(fd);
|
|
490
|
+
}
|
|
483
491
|
}
|
|
484
492
|
|
|
485
493
|
function _reSealValue(sealedValue, oldKeys, newKeys) {
|
|
@@ -670,7 +678,8 @@ async function rotate(opts) {
|
|
|
670
678
|
"pipeline and would be orphaned under the retired keypair: " + externalAad.join(", ") +
|
|
671
679
|
". Re-seal each via its module hook (b.agent.idempotency.reseal / " +
|
|
672
680
|
"b.agent.orchestrator.reseal / b.agent.tenant AAD_ROTATION reseal / " +
|
|
673
|
-
"b.agent.snapshot.reseal / b.archive.rewrapTenant for archive-wrap:tenant-blobs
|
|
681
|
+
"b.agent.snapshot.reseal / b.archive.rewrapTenant for archive-wrap:tenant-blobs / " +
|
|
682
|
+
"b.dsr.reseal for the dsr_tickets store) " +
|
|
674
683
|
"BEFORE retiring the old keypair, then pass " +
|
|
675
684
|
"opts.externalAadResealed: [" + externalAad.map(function (t) { return JSON.stringify(t); }).join(", ") +
|
|
676
685
|
"] to acknowledge. If you do not use these features, pass opts.externalAadResealed: true.");
|
|
@@ -709,7 +718,10 @@ async function rotate(opts) {
|
|
|
709
718
|
}
|
|
710
719
|
var dest = nodePath.join(stagingDir, entry.relativePath);
|
|
711
720
|
atomicFile.ensureDir(nodePath.dirname(dest));
|
|
712
|
-
|
|
721
|
+
// Stage via the exclusive-create + fsync helper rather than a plain copy,
|
|
722
|
+
// so the verbatim file is durable at write time (no later by-path fsync)
|
|
723
|
+
// and a pre-planted file/symlink at the staging path hard-fails.
|
|
724
|
+
_writeStagedFileExclusive(dest, nodeFs.readFileSync(src));
|
|
713
725
|
}
|
|
714
726
|
for (var vd = 0; vd < paths.verbatimDirs.length; vd++) {
|
|
715
727
|
var dent = paths.verbatimDirs[vd];
|
|
@@ -737,9 +749,9 @@ async function rotate(opts) {
|
|
|
737
749
|
var newRootJson = keysJson;
|
|
738
750
|
if (mode === "wrapped") {
|
|
739
751
|
var sealed = await vaultWrap().wrap(keysJson, opts.newPassphrase);
|
|
740
|
-
|
|
752
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.vaultKeySealed), sealed);
|
|
741
753
|
} else {
|
|
742
|
-
|
|
754
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.vaultKeyPlain), keysJson);
|
|
743
755
|
}
|
|
744
756
|
|
|
745
757
|
// 3. re-seal db.key.enc + any operator-supplied additionalSealed files
|
|
@@ -759,14 +771,14 @@ async function rotate(opts) {
|
|
|
759
771
|
var dbKeyB64Aad = vaultAad.unsealRoot(sealedKey, dbKeyAad, oldRootJson);
|
|
760
772
|
dbKey = Buffer.from(dbKeyB64Aad, "base64");
|
|
761
773
|
var resealedAad = vaultAad.sealRoot(dbKeyB64Aad, dbKeyAad, newRootJson);
|
|
762
|
-
|
|
774
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.dbKeySealed), resealedAad);
|
|
763
775
|
} else if (sealedKey.indexOf(C.VAULT_PREFIX) === 0) {
|
|
764
776
|
// Legacy plain-sealed db.key.enc (pre-AAD). Re-key in place; db.init
|
|
765
777
|
// read-migrates plain -> AAD on the next boot.
|
|
766
778
|
var dbKeyB64 = bCrypto.decrypt(sealedKey.substring(VAULT_PREFIX_LEN), oldKeys);
|
|
767
779
|
dbKey = Buffer.from(dbKeyB64, "base64");
|
|
768
780
|
var resealedKey = C.VAULT_PREFIX + bCrypto.encrypt(dbKeyB64, newKeys);
|
|
769
|
-
|
|
781
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.dbKeySealed), resealedKey);
|
|
770
782
|
} else {
|
|
771
783
|
throw new VaultRotateError("vault-rotate/bad-dbkey",
|
|
772
784
|
"rotate: db.key.enc does not start with a vault prefix (vault: or vault.aad:)");
|
|
@@ -789,8 +801,8 @@ async function rotate(opts) {
|
|
|
789
801
|
}
|
|
790
802
|
var asDestDir = nodePath.join(stagingDir, nodePath.dirname(ase.relativePath));
|
|
791
803
|
if (!nodeFs.existsSync(asDestDir)) atomicFile.ensureDir(asDestDir);
|
|
792
|
-
|
|
793
|
-
_reSealValue(current, oldKeys, newKeys)
|
|
804
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, ase.relativePath),
|
|
805
|
+
_reSealValue(current, oldKeys, newKeys));
|
|
794
806
|
}
|
|
795
807
|
|
|
796
808
|
// 3b. Framework-managed crypto-field derived-hash files — always
|
|
@@ -801,14 +813,17 @@ async function rotate(opts) {
|
|
|
801
813
|
// re-seals to the same value since the keypair is unchanged).
|
|
802
814
|
var saltSrc = nodePath.join(dataDir, "vault.derived-hash-salt");
|
|
803
815
|
if (nodeFs.existsSync(saltSrc)) {
|
|
804
|
-
|
|
816
|
+
// Stage via the exclusive-create + fsync helper (not a plain copy) so the
|
|
817
|
+
// salt is durable at write time and no later by-path fsync is needed.
|
|
818
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, "vault.derived-hash-salt"),
|
|
819
|
+
nodeFs.readFileSync(saltSrc));
|
|
805
820
|
}
|
|
806
821
|
var macSrc = nodePath.join(dataDir, "vault.derived-hash-mac.sealed");
|
|
807
822
|
if (nodeFs.existsSync(macSrc)) {
|
|
808
823
|
var macCurrent = nodeFs.readFileSync(macSrc, "utf8").trim();
|
|
809
824
|
if (macCurrent.indexOf(C.VAULT_PREFIX) === 0) {
|
|
810
|
-
|
|
811
|
-
_reSealValue(macCurrent, oldKeys, newKeys)
|
|
825
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, "vault.derived-hash-mac.sealed"),
|
|
826
|
+
_reSealValue(macCurrent, oldKeys, newKeys));
|
|
812
827
|
}
|
|
813
828
|
}
|
|
814
829
|
|
|
@@ -830,7 +845,7 @@ async function rotate(opts) {
|
|
|
830
845
|
try { plainBytes = bCrypto.decryptPacked(packed, dbKey, dbEncAad); }
|
|
831
846
|
catch (_eAad) { plainBytes = bCrypto.decryptPacked(packed, dbKey); }
|
|
832
847
|
var tmpDbPath = nodePath.join(stagingDir, "_blamejs_rotate.tmp.db");
|
|
833
|
-
|
|
848
|
+
_writeStagedFileExclusive(tmpDbPath, plainBytes);
|
|
834
849
|
|
|
835
850
|
var db = new DatabaseSync(tmpDbPath);
|
|
836
851
|
try {
|
|
@@ -893,25 +908,23 @@ async function rotate(opts) {
|
|
|
893
908
|
try { nodeFs.unlinkSync(tmpDbPath + "-shm"); }
|
|
894
909
|
catch (e) { rotateLog.debug("cleanup-failed", { op: "fs.unlinkSync", path: tmpDbPath + "-shm", error: e.message }); }
|
|
895
910
|
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
// inside it. Files are written 0o600 implicitly via the dir's umask
|
|
903
|
-
// and removed before the rotation completes.
|
|
911
|
+
// Every staged path lives inside opts.stagingDir (operator-supplied,
|
|
912
|
+
// ensureDir'd 0o700 owner-only, never under os.tmpdir()) and carries a
|
|
913
|
+
// framework-internal marker name. The staged writes go through
|
|
914
|
+
// _writeStagedFileExclusive — exclusive + no-follow create, owner-only
|
|
915
|
+
// 0o600 — so a same-user pre-plant or symlink swap is a hard failure
|
|
916
|
+
// rather than a followed write, and the bytes never inherit a wider mode.
|
|
904
917
|
var rotatedBytes = nodeFs.readFileSync(tmpDbPath);
|
|
905
918
|
// Re-encrypt under the SAME dataDir AAD so db.init's AAD-first open
|
|
906
919
|
// succeeds after the staged dir is swapped over dataDir in place.
|
|
907
|
-
|
|
920
|
+
_writeStagedFileExclusive(nodePath.join(stagingDir, paths.encryptedDb),
|
|
908
921
|
bCrypto.encryptPacked(rotatedBytes, dbKey, dbEncAad));
|
|
909
922
|
nodeFs.unlinkSync(tmpDbPath);
|
|
910
923
|
|
|
911
924
|
// Round-trip verify on the staged DB
|
|
912
925
|
_emit(progress, { phase: "verify" });
|
|
913
926
|
var verifyTmp = nodePath.join(stagingDir, "_blamejs_verify.tmp.db");
|
|
914
|
-
|
|
927
|
+
_writeStagedFileExclusive(verifyTmp,
|
|
915
928
|
bCrypto.decryptPacked(nodeFs.readFileSync(nodePath.join(stagingDir, paths.encryptedDb)), dbKey, dbEncAad));
|
|
916
929
|
var vdb = new DatabaseSync(verifyTmp);
|
|
917
930
|
try {
|
|
@@ -934,19 +947,26 @@ async function rotate(opts) {
|
|
|
934
947
|
}
|
|
935
948
|
}
|
|
936
949
|
|
|
937
|
-
// 5. fsync staging for durability before caller
|
|
950
|
+
// 5. fsync staging directory entries for durability before the caller swaps.
|
|
951
|
+
// Every staged FILE is already fsync'd at write time by
|
|
952
|
+
// _writeStagedFileExclusive (the re-encrypted db, the resealed vault/db keys,
|
|
953
|
+
// sealed files, the derived-hash salt, and verbatim files), so re-opening
|
|
954
|
+
// each by path here is redundant — and opening a staged file by path is the
|
|
955
|
+
// os-temp-dir open the static analyzer refuses (CWE-377 heuristic). Only the
|
|
956
|
+
// optional verbatimDirs are copied with copyFileSync (no per-file fsync);
|
|
957
|
+
// their directory entries + the rename are made durable by fsyncDir and their
|
|
958
|
+
// source files in dataDir remain intact, so a crash in that narrow window is
|
|
959
|
+
// recoverable.
|
|
938
960
|
_emit(progress, { phase: "fsync" });
|
|
939
|
-
function
|
|
961
|
+
function fsyncDirTree(dir) {
|
|
940
962
|
var entries = nodeFs.readdirSync(dir);
|
|
941
963
|
for (var i = 0; i < entries.length; i++) {
|
|
942
964
|
var p = nodePath.join(dir, entries[i]);
|
|
943
|
-
|
|
944
|
-
if (st.isFile()) _fsyncFileByPath(p);
|
|
945
|
-
else if (st.isDirectory()) fsyncTree(p);
|
|
965
|
+
if (nodeFs.statSync(p).isDirectory()) fsyncDirTree(p);
|
|
946
966
|
}
|
|
947
967
|
atomicFile.fsyncDir(dir);
|
|
948
968
|
}
|
|
949
|
-
|
|
969
|
+
fsyncDirTree(stagingDir);
|
|
950
970
|
|
|
951
971
|
var durationMs = Date.now() - startedAt;
|
|
952
972
|
_emit(progress, {
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:e0214cf9-5d77-475a-af9a-e3cedff9f6d7",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-06T21:14:16.419Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.14.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.14.26",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.14.
|
|
25
|
+
"version": "0.14.26",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.14.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.14.26",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.14.
|
|
57
|
+
"ref": "@blamejs/core@0.14.26",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|