@blamejs/core 0.14.11 → 0.14.13
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 +4 -0
- package/README.md +2 -2
- package/lib/agent-idempotency.js +113 -0
- package/lib/agent-orchestrator.js +108 -0
- package/lib/agent-snapshot.js +137 -0
- package/lib/agent-tenant.js +193 -17
- package/lib/archive-wrap.js +234 -1
- package/lib/archive.js +1 -0
- package/lib/auth/oid4vp.js +47 -28
- package/lib/cluster.js +186 -14
- package/lib/crypto-field.js +5 -0
- package/lib/db.js +15 -0
- package/lib/mail-srs.js +122 -19
- package/lib/safe-archive.js +196 -136
- package/lib/validate-opts.js +24 -0
- package/lib/vault/rotate.js +175 -15
- package/lib/vault-aad.js +84 -33
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/auth/oid4vp.js
CHANGED
|
@@ -148,40 +148,52 @@ function _validateDcql(dcql) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
* Walk
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
151
|
+
* Walk a DCQL claims path pointer (OpenID4VP 1.0 §7.1.1) against the
|
|
152
|
+
* resolved-claim object the SD-JWT VC verifier produced, applying
|
|
153
|
+
* `leafPredicate` at the terminal node and returning a boolean:
|
|
154
|
+
* string → object property (["address", "country"])
|
|
155
|
+
* integer → array index (["array", 0])
|
|
156
|
+
* null → all elements of the array at this depth (recurse; the
|
|
157
|
+
* match is an existence check over the candidate leaves)
|
|
158
|
+
* A `null` segment on a non-array node — like an integer index into a
|
|
159
|
+
* non-array or a string key into an array — is a NON-MATCH, not an
|
|
160
|
+
* error: this walks holder credential data, not operator config, so a
|
|
161
|
+
* structural mismatch fails the match cleanly (rule §5 defensive
|
|
162
|
+
* request-shape reader tier) rather than throwing and crashing the
|
|
163
|
+
* verify request.
|
|
158
164
|
*/
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (node === undefined || node === null) return { found: false, value: undefined };
|
|
171
|
-
node = node[seg];
|
|
165
|
+
function _walkPath(node, path, idx, leafPredicate) {
|
|
166
|
+
if (idx === path.length) return leafPredicate(node);
|
|
167
|
+
if (node === undefined || node === null) return false;
|
|
168
|
+
var seg = path[idx];
|
|
169
|
+
if (seg === null) {
|
|
170
|
+
if (!Array.isArray(node)) return false;
|
|
171
|
+
return node.some(function (el) { return _walkPath(el, path, idx + 1, leafPredicate); });
|
|
172
|
+
}
|
|
173
|
+
if (typeof seg === "number") {
|
|
174
|
+
if (!Array.isArray(node)) return false;
|
|
175
|
+
return _walkPath(node[seg], path, idx + 1, leafPredicate);
|
|
172
176
|
}
|
|
173
|
-
|
|
177
|
+
// string segment selects an object property; a string key into an
|
|
178
|
+
// array is a non-match (use a null wildcard or integer index instead).
|
|
179
|
+
if (Array.isArray(node)) return false;
|
|
180
|
+
return _walkPath(node[seg], path, idx + 1, leafPredicate);
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
function _matchClaim(claims, claimQuery) {
|
|
177
|
-
var
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
var values = claimQuery.values;
|
|
185
|
+
var leafPredicate;
|
|
186
|
+
if (values && values.length > 0) {
|
|
187
|
+
leafPredicate = function (leaf) {
|
|
188
|
+
if (leaf === undefined) return false;
|
|
189
|
+
return values.some(function (v) {
|
|
190
|
+
return v === leaf || JSON.stringify(v) === JSON.stringify(leaf);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
} else {
|
|
194
|
+
leafPredicate = function (leaf) { return leaf !== undefined; };
|
|
183
195
|
}
|
|
184
|
-
return
|
|
196
|
+
return _walkPath(claims, claimQuery.path, 0, leafPredicate);
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
function _matchCredentialQuery(presentation, query) {
|
|
@@ -219,6 +231,13 @@ function _matchCredentialQuery(presentation, query) {
|
|
|
219
231
|
* implement their own verifier transport call this directly after
|
|
220
232
|
* SD-JWT VC verification.
|
|
221
233
|
*
|
|
234
|
+
* Claim path pointers follow OpenID4VP 1.0 §7.1.1: a string segment
|
|
235
|
+
* selects an object property, a non-negative integer indexes an array,
|
|
236
|
+
* and a `null` segment matches any element of the array at that depth
|
|
237
|
+
* (e.g. `["degrees", null, "type"]` matches the `type` claim of any
|
|
238
|
+
* element in the `degrees` array). A `null` segment applied to a
|
|
239
|
+
* non-array node is a non-match.
|
|
240
|
+
*
|
|
222
241
|
* @example
|
|
223
242
|
* var match = b.auth.oid4vp.matchDcql([
|
|
224
243
|
* { id: "id-card", format: "vc+sd-jwt", claims: { vct: "...", given_name: "Alice" } }
|
package/lib/cluster.js
CHANGED
|
@@ -52,6 +52,7 @@ var safeAsync = require("./safe-async");
|
|
|
52
52
|
var safeJson = require("./safe-json");
|
|
53
53
|
var safeSql = require("./safe-sql");
|
|
54
54
|
var safeUrl = require("./safe-url");
|
|
55
|
+
var validateOpts = require("./validate-opts");
|
|
55
56
|
var { FrameworkError, ClusterError } = require("./framework-error");
|
|
56
57
|
|
|
57
58
|
// Lazy: vault → db → cluster forms a load-time chain, and external-db is
|
|
@@ -99,6 +100,15 @@ var configuredDialect = null;
|
|
|
99
100
|
// (or external observer) can resolve "where is the current leader?"
|
|
100
101
|
// via cluster.currentLeader() / cluster.discoveryHandler().
|
|
101
102
|
var configuredEndpoint = null;
|
|
103
|
+
// Operator declaration that this node's vault keypair legitimately
|
|
104
|
+
// changed via a key rotation (b.vault.rotate). When set, a fingerprint
|
|
105
|
+
// that differs from the canonical cluster-state row is ADOPTED (the row
|
|
106
|
+
// advances to the new fingerprint + bumps the rotation epoch) instead
|
|
107
|
+
// of FATAL-refusing boot. Unset (the default) keeps the strict
|
|
108
|
+
// drift-refusal posture for the UNexpected mismatch. See
|
|
109
|
+
// _checkVaultKeyConsistency for the consistency model.
|
|
110
|
+
var configuredAcceptRotation = false;
|
|
111
|
+
var configuredExpectedVaultKeyFp = null;
|
|
102
112
|
|
|
103
113
|
var log = boot("cluster");
|
|
104
114
|
|
|
@@ -142,6 +152,16 @@ function _emitTransition(kind, detail) {
|
|
|
142
152
|
* outside `leader` / `follower`, and on a chain or vault-key mismatch
|
|
143
153
|
* that would let this node corrupt cluster state.
|
|
144
154
|
*
|
|
155
|
+
* After a vault-key rotation (`b.vault.rotate`) the public-key
|
|
156
|
+
* fingerprint changes, so the canonical cluster-state row no longer
|
|
157
|
+
* matches and every node would otherwise refuse boot with
|
|
158
|
+
* `VAULT_KEY_DRIFT`. Pass `acceptVaultKeyRotation: true` to declare the
|
|
159
|
+
* change legitimate: the node advances the canonical fingerprint and
|
|
160
|
+
* bumps a rotation epoch instead of refusing. `expectedVaultKeyFp`
|
|
161
|
+
* narrows the acceptance to a single blessed fingerprint so a typo'd /
|
|
162
|
+
* stale key file is still caught. The strict cross-node drift refusal
|
|
163
|
+
* stays in force whenever the rotation is NOT declared.
|
|
164
|
+
*
|
|
145
165
|
* @opts
|
|
146
166
|
* nodeId: string, // required; stable identity
|
|
147
167
|
* role: "leader"|"follower",
|
|
@@ -152,6 +172,11 @@ function _emitTransition(kind, detail) {
|
|
|
152
172
|
* provider: object, // custom election provider
|
|
153
173
|
* externalDbBackend: object, // required when no custom provider
|
|
154
174
|
* dialect: "postgres"|"sqlite"|"mysql",
|
|
175
|
+
* acceptVaultKeyRotation: boolean, // adopt a rotated vault-key
|
|
176
|
+
* // fingerprint instead of
|
|
177
|
+
* // refusing boot on mismatch
|
|
178
|
+
* expectedVaultKeyFp: string, // optional; bless ONLY this
|
|
179
|
+
* // post-rotation fingerprint
|
|
155
180
|
* onTransition: function (event),
|
|
156
181
|
*
|
|
157
182
|
* @example
|
|
@@ -227,6 +252,31 @@ async function init(opts) {
|
|
|
227
252
|
configuredEndpoint = null;
|
|
228
253
|
}
|
|
229
254
|
|
|
255
|
+
// Vault-key rotation acceptance (config-time tier: THROW on bad
|
|
256
|
+
// input). acceptVaultKeyRotation is a boolean declaration; an
|
|
257
|
+
// expectedVaultKeyFp without it is a misconfiguration (the operator
|
|
258
|
+
// blessed a fingerprint but never enabled adoption).
|
|
259
|
+
validateOpts.optionalBoolean(opts.acceptVaultKeyRotation,
|
|
260
|
+
"cluster.init({ acceptVaultKeyRotation })", ClusterError, "INVALID_CONFIG");
|
|
261
|
+
configuredAcceptRotation = opts.acceptVaultKeyRotation === true;
|
|
262
|
+
if (opts.expectedVaultKeyFp !== undefined) {
|
|
263
|
+
if (typeof opts.expectedVaultKeyFp !== "string" ||
|
|
264
|
+
!/^[0-9a-f]{128}$/.test(opts.expectedVaultKeyFp)) {
|
|
265
|
+
throw _err("INVALID_CONFIG",
|
|
266
|
+
"cluster.init({ expectedVaultKeyFp }) must be a 128-char " +
|
|
267
|
+
"lowercase-hex SHA3-512 fingerprint (b.vault rotation output)", true);
|
|
268
|
+
}
|
|
269
|
+
if (!configuredAcceptRotation) {
|
|
270
|
+
throw _err("INVALID_CONFIG",
|
|
271
|
+
"cluster.init({ expectedVaultKeyFp }) requires " +
|
|
272
|
+
"acceptVaultKeyRotation: true — blessing a fingerprint without " +
|
|
273
|
+
"enabling adoption has no effect", true);
|
|
274
|
+
}
|
|
275
|
+
configuredExpectedVaultKeyFp = opts.expectedVaultKeyFp;
|
|
276
|
+
} else {
|
|
277
|
+
configuredExpectedVaultKeyFp = null;
|
|
278
|
+
}
|
|
279
|
+
|
|
230
280
|
if (typeof opts.onTransition === "function") {
|
|
231
281
|
transitionHandlers.push(opts.onTransition);
|
|
232
282
|
}
|
|
@@ -433,6 +483,49 @@ function _vaultKeyFingerprint() {
|
|
|
433
483
|
keys.ecPublicKey);
|
|
434
484
|
}
|
|
435
485
|
|
|
486
|
+
// Idempotent migration for the rotationEpoch column. cluster-provider-db
|
|
487
|
+
// ensureSchema creates _blamejs_cluster_state without it (the column was
|
|
488
|
+
// added when rotation-epoch acceptance landed); ADD COLUMN here keeps the
|
|
489
|
+
// path version-agnostic the same way the provider migrates the leader
|
|
490
|
+
// row's `endpoint` column. The only expected failure is "column already
|
|
491
|
+
// exists," which is swallowed. SQLite / MySQL don't take a DEFAULT on a
|
|
492
|
+
// non-constant, so the column is nullable and treated as epoch 0 when
|
|
493
|
+
// absent on legacy rows.
|
|
494
|
+
async function _ensureRotationEpochColumn() {
|
|
495
|
+
try {
|
|
496
|
+
await externalDb().query(
|
|
497
|
+
"ALTER TABLE _blamejs_cluster_state ADD COLUMN rotationEpoch BIGINT",
|
|
498
|
+
[],
|
|
499
|
+
{ backend: configuredExternalDbBackend }
|
|
500
|
+
);
|
|
501
|
+
} catch (_e) { /* column already exists (or table absent — caught upstream) */ }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Consistency model (CWE-345 binding-integrity for sealed columns):
|
|
505
|
+
//
|
|
506
|
+
// Every node fingerprints its vault PUBLIC keys (SHA3-512, one-way) and
|
|
507
|
+
// the cluster agrees on ONE canonical fingerprint stored in
|
|
508
|
+
// _blamejs_cluster_state. A node holding a different key seals new
|
|
509
|
+
// writes the rest of the cluster can't unseal — silent corruption — so
|
|
510
|
+
// an UNDECLARED mismatch fails closed (FATAL: VAULT_KEY_DRIFT).
|
|
511
|
+
//
|
|
512
|
+
// A vault-key rotation (b.vault.rotate, lib/vault/rotate.js) legitimately
|
|
513
|
+
// changes the public-key fingerprint on EVERY node. The rotation only
|
|
514
|
+
// re-seals the local dataDir; it does not touch the external coordination
|
|
515
|
+
// row, so the canonical fingerprint goes stale and every node would
|
|
516
|
+
// refuse boot. acceptVaultKeyRotation: true is the operator's signed-off
|
|
517
|
+
// declaration "this change is a rotation, not drift": the booting node
|
|
518
|
+
// ADVANCES the canonical row to its own fingerprint and bumps a
|
|
519
|
+
// monotonic rotationEpoch. expectedVaultKeyFp narrows the adoption to a
|
|
520
|
+
// single blessed fingerprint so a stale / wrong key file is still
|
|
521
|
+
// refused. The strict refusal is unchanged when no rotation is declared,
|
|
522
|
+
// which is exactly the cross-node drift case the check defends.
|
|
523
|
+
//
|
|
524
|
+
// The epoch is observability + a future-replay guard, not an auth
|
|
525
|
+
// boundary — the auth boundary is the operator's deliberate opt
|
|
526
|
+
// (acceptVaultKeyRotation) plus the optional fingerprint allowlist. A
|
|
527
|
+
// forged row can only ever cost a single declared boot, and a genuinely
|
|
528
|
+
// different key would still fail every sealed read.
|
|
436
529
|
async function _checkVaultKeyConsistency() {
|
|
437
530
|
var localFp = _vaultKeyFingerprint();
|
|
438
531
|
if (localFp === null) {
|
|
@@ -469,10 +562,15 @@ async function _checkVaultKeyConsistency() {
|
|
|
469
562
|
throw e;
|
|
470
563
|
}
|
|
471
564
|
|
|
565
|
+
// Bring the rotationEpoch column into existence (idempotent). The INSERT
|
|
566
|
+
// above already proved the table is present, so a real ALTER failure
|
|
567
|
+
// here is "column exists" and is swallowed.
|
|
568
|
+
await _ensureRotationEpochColumn();
|
|
569
|
+
|
|
472
570
|
// Read whatever fingerprint is canonical (ours if first boot,
|
|
473
571
|
// someone else's if we lost the race or are joining an existing cluster).
|
|
474
572
|
var rows = await externalDb().query(
|
|
475
|
-
"SELECT vaultKeyFp, recordedByNode, recordedAt FROM _blamejs_cluster_state " +
|
|
573
|
+
"SELECT vaultKeyFp, recordedByNode, recordedAt, rotationEpoch FROM _blamejs_cluster_state " +
|
|
476
574
|
"WHERE scope = 'state'",
|
|
477
575
|
[],
|
|
478
576
|
{ backend: configuredExternalDbBackend }
|
|
@@ -486,22 +584,92 @@ async function _checkVaultKeyConsistency() {
|
|
|
486
584
|
true);
|
|
487
585
|
}
|
|
488
586
|
var canonical = rows.rows[0];
|
|
587
|
+
var fpPrefix = C.BYTES.bytes(16);
|
|
489
588
|
if (canonical.vaultKeyFp !== localFp) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
589
|
+
// Mismatch. Two readings: a legitimate vault-key rotation the
|
|
590
|
+
// operator has declared, or genuine cross-node drift. Without the
|
|
591
|
+
// declaration, always fail closed — sealed-column corruption is the
|
|
592
|
+
// worse outcome.
|
|
593
|
+
if (!configuredAcceptRotation) {
|
|
594
|
+
throw _err("VAULT_KEY_DRIFT",
|
|
595
|
+
"FATAL: vault-key drift detected. " +
|
|
596
|
+
"local node: " + nodeId +
|
|
597
|
+
"; local fingerprint: " + localFp.slice(0, fpPrefix) + "…" +
|
|
598
|
+
"; canonical recorded by: " + canonical.recordedByNode +
|
|
599
|
+
"; canonical fingerprint: " + canonical.vaultKeyFp.slice(0, fpPrefix) + "…" +
|
|
600
|
+
". This node holds a DIFFERENT vault key than the rest of the " +
|
|
601
|
+
"cluster. Sealed-column writes from this node would be unreadable " +
|
|
602
|
+
"by the others (and vice versa). If the key changed via " +
|
|
603
|
+
"b.vault.rotate, re-init with acceptVaultKeyRotation: true to " +
|
|
604
|
+
"advance the cluster's recorded fingerprint; otherwise restore the " +
|
|
605
|
+
"same vault key file before booting this node into the cluster.",
|
|
606
|
+
true);
|
|
607
|
+
}
|
|
608
|
+
// Rotation declared. If the operator blessed a specific fingerprint,
|
|
609
|
+
// the LOCAL key must match it — this rejects a stale / wrong key file
|
|
610
|
+
// that happens to differ from canonical for the wrong reason.
|
|
611
|
+
if (configuredExpectedVaultKeyFp && configuredExpectedVaultKeyFp !== localFp) {
|
|
612
|
+
throw _err("VAULT_KEY_ROTATION_MISMATCH",
|
|
613
|
+
"FATAL: acceptVaultKeyRotation is set but this node's vault-key " +
|
|
614
|
+
"fingerprint does not match the blessed expectedVaultKeyFp. " +
|
|
615
|
+
"local node: " + nodeId +
|
|
616
|
+
"; local fingerprint: " + localFp.slice(0, fpPrefix) + "…" +
|
|
617
|
+
"; expected fingerprint: " + configuredExpectedVaultKeyFp.slice(0, fpPrefix) + "…" +
|
|
618
|
+
". This node is NOT holding the rotated key the operator approved. " +
|
|
619
|
+
"Restore the post-rotation vault key file (or correct " +
|
|
620
|
+
"expectedVaultKeyFp) before booting this node into the cluster.",
|
|
621
|
+
true);
|
|
622
|
+
}
|
|
623
|
+
// Adopt: advance the canonical row to the new fingerprint and bump
|
|
624
|
+
// the monotonic rotation epoch. The UPDATE is gated on the OLD
|
|
625
|
+
// fingerprint so two nodes adopting concurrently converge on a single
|
|
626
|
+
// advance (the loser's WHERE matches nothing and it re-reads the
|
|
627
|
+
// already-advanced row below).
|
|
628
|
+
var priorEpoch = (canonical.rotationEpoch != null) ? Number(canonical.rotationEpoch) : 0;
|
|
629
|
+
if (!isFinite(priorEpoch) || priorEpoch < 0) priorEpoch = 0;
|
|
630
|
+
var nextEpoch = priorEpoch + 1;
|
|
631
|
+
await externalDb().query(
|
|
632
|
+
"UPDATE _blamejs_cluster_state SET " +
|
|
633
|
+
" vaultKeyFp = " + (ph ? "$1" : "?") + ", " +
|
|
634
|
+
" recordedAt = " + (ph ? "$2" : "?") + ", " +
|
|
635
|
+
" recordedByNode = " + (ph ? "$3" : "?") + ", " +
|
|
636
|
+
" rotationEpoch = " + (ph ? "$4" : "?") + " " +
|
|
637
|
+
"WHERE scope = 'state' AND vaultKeyFp = " + (ph ? "$5" : "?"),
|
|
638
|
+
[localFp, nowMs, nodeId, nextEpoch, canonical.vaultKeyFp],
|
|
639
|
+
{ backend: configuredExternalDbBackend }
|
|
640
|
+
);
|
|
641
|
+
// Re-read so the post-adopt state reflects whoever actually won the
|
|
642
|
+
// advance (this node, or a peer that adopted the SAME rotated key a
|
|
643
|
+
// beat earlier). A surviving mismatch here means the row now carries a
|
|
644
|
+
// fingerprint that is neither the old one nor ours — a real drift that
|
|
645
|
+
// the rotation declaration does not cover, so fail closed.
|
|
646
|
+
var after = await externalDb().query(
|
|
647
|
+
"SELECT vaultKeyFp, recordedByNode, rotationEpoch FROM _blamejs_cluster_state " +
|
|
648
|
+
"WHERE scope = 'state'",
|
|
649
|
+
[],
|
|
650
|
+
{ backend: configuredExternalDbBackend }
|
|
651
|
+
);
|
|
652
|
+
var post = (after.rows && after.rows[0]) || canonical;
|
|
653
|
+
if (post.vaultKeyFp !== localFp) {
|
|
654
|
+
throw _err("VAULT_KEY_DRIFT",
|
|
655
|
+
"FATAL: vault-key drift detected after rotation-accept. " +
|
|
656
|
+
"local node: " + nodeId +
|
|
657
|
+
"; local fingerprint: " + localFp.slice(0, fpPrefix) + "…" +
|
|
658
|
+
"; canonical fingerprint: " + post.vaultKeyFp.slice(0, fpPrefix) + "…" +
|
|
659
|
+
". A concurrent node advanced the cluster to a DIFFERENT key than " +
|
|
660
|
+
"this node holds — the declared rotation does not cover this " +
|
|
661
|
+
"fingerprint. Restore the agreed post-rotation vault key file.",
|
|
662
|
+
true);
|
|
663
|
+
}
|
|
664
|
+
log("cluster vault-key rotation accepted (fingerprint " +
|
|
665
|
+
localFp.slice(0, fpPrefix) + "… epoch " +
|
|
666
|
+
(post.rotationEpoch != null ? Number(post.rotationEpoch) : nextEpoch) +
|
|
667
|
+
", recorded by " + post.recordedByNode + ")");
|
|
668
|
+
return;
|
|
502
669
|
}
|
|
503
670
|
log("cluster vault-key consistency ok (fingerprint " +
|
|
504
|
-
localFp.slice(0,
|
|
671
|
+
localFp.slice(0, fpPrefix) + "… recorded by " + canonical.recordedByNode +
|
|
672
|
+
(canonical.rotationEpoch != null ? ", epoch " + Number(canonical.rotationEpoch) : "") + ")");
|
|
505
673
|
}
|
|
506
674
|
|
|
507
675
|
async function _tryAcquire() {
|
|
@@ -967,6 +1135,8 @@ async function shutdown() {
|
|
|
967
1135
|
configuredExternalDbBackend = null;
|
|
968
1136
|
configuredDialect = null;
|
|
969
1137
|
configuredEndpoint = null;
|
|
1138
|
+
configuredAcceptRotation = false;
|
|
1139
|
+
configuredExpectedVaultKeyFp = null;
|
|
970
1140
|
transitionHandlers = [];
|
|
971
1141
|
// nodeId is preserved post-shutdown so audit metadata still reflects
|
|
972
1142
|
// who this process was; cleared only by _resetForTest.
|
|
@@ -988,6 +1158,8 @@ function _resetForTest() {
|
|
|
988
1158
|
configuredExternalDbBackend = null;
|
|
989
1159
|
configuredDialect = null;
|
|
990
1160
|
configuredEndpoint = null;
|
|
1161
|
+
configuredAcceptRotation = false;
|
|
1162
|
+
configuredExpectedVaultKeyFp = null;
|
|
991
1163
|
transitionHandlers = [];
|
|
992
1164
|
}
|
|
993
1165
|
|
package/lib/crypto-field.js
CHANGED
|
@@ -1105,6 +1105,11 @@ module.exports = {
|
|
|
1105
1105
|
getSealedFields: getSealedFields,
|
|
1106
1106
|
sealRow: sealRow,
|
|
1107
1107
|
unsealRow: unsealRow,
|
|
1108
|
+
// _aadParts — the column-AAD builder the seal/unseal path uses. Exported
|
|
1109
|
+
// (internal) so the vault-key rotation pipeline reconstructs the IDENTICAL
|
|
1110
|
+
// AAD tuple a cell was sealed under — one source of truth, no drift
|
|
1111
|
+
// between the seal side and the rotate side.
|
|
1112
|
+
_aadParts: _aadParts,
|
|
1108
1113
|
// Doc-shaped aliases — operators / tests preparing a JS document
|
|
1109
1114
|
// object (vs. a SQL row) reach for sealDoc / unsealDoc naming. Same
|
|
1110
1115
|
// function, identical shape, returns a new object (input untouched).
|
package/lib/db.js
CHANGED
|
@@ -1318,6 +1318,15 @@ async function init(opts) {
|
|
|
1318
1318
|
derivedHashes: t.derivedHashes,
|
|
1319
1319
|
hashNamespaces: t.hashNamespaces,
|
|
1320
1320
|
derivedHashMode: t.derivedHashMode,
|
|
1321
|
+
// AAD-binding metadata MUST pass through — without it a schema that
|
|
1322
|
+
// declares { aad: true } registers as a plain table, so its cells
|
|
1323
|
+
// seal under vault: (not vault.aad:) and the vault-key rotation
|
|
1324
|
+
// pipeline cannot reconstruct their AAD. registerTable defaults these
|
|
1325
|
+
// (aad:false / rowIdField:"id" / schemaVersion:"1") so non-AAD tables
|
|
1326
|
+
// are unaffected.
|
|
1327
|
+
aad: t.aad,
|
|
1328
|
+
rowIdField: t.rowIdField,
|
|
1329
|
+
schemaVersion: t.schemaVersion,
|
|
1321
1330
|
});
|
|
1322
1331
|
tableMetadata[t.name] = {
|
|
1323
1332
|
primaryKey: _normalizePk(t),
|
|
@@ -3161,6 +3170,12 @@ module.exports = {
|
|
|
3161
3170
|
// (plain mode) or when the plaintext DB doesn't exist.
|
|
3162
3171
|
flushToDisk: encryptToDisk,
|
|
3163
3172
|
snapshot: snapshot,
|
|
3173
|
+
// Internal AAD constructors, exported so the vault-key rotation
|
|
3174
|
+
// pipeline (lib/vault/rotate.js) re-seals db.enc / db.key.enc under the
|
|
3175
|
+
// SAME deployment-bound AAD this module writes them with — single source
|
|
3176
|
+
// of truth for the wire-format literals (no duplicated constants).
|
|
3177
|
+
_dbEncAad: _dbEncAad,
|
|
3178
|
+
_dbKeyAad: _dbKeyAad,
|
|
3164
3179
|
// integrityCheck — runs PRAGMA integrity_check against the live db
|
|
3165
3180
|
// and returns "ok" on success, an array of corruption lines
|
|
3166
3181
|
// otherwise. Operators wire this into a periodic monitor or a
|
package/lib/mail-srs.js
CHANGED
|
@@ -28,15 +28,24 @@
|
|
|
28
28
|
* - `local` is the original sender's local-part
|
|
29
29
|
* - `forwarder.example` is the rewriting forwarder's domain
|
|
30
30
|
*
|
|
31
|
-
* SRS1
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* Wire format (SRS1 — the multi-hop chain case):
|
|
32
|
+
*
|
|
33
|
+
* SRS1=HHH=priorForwarder==<SRS0-body>@thisForwarder
|
|
34
|
+
*
|
|
35
|
+
* When an already-SRS0 (or SRS1) address is forwarded again,
|
|
36
|
+
* `srs1Rewrite(srsAddress)` wraps it: it keeps the original SRS0
|
|
37
|
+
* body verbatim, prepends the preceding forwarder's domain, and
|
|
38
|
+
* binds the pair with this forwarder's own HMAC tag — no new
|
|
39
|
+
* timestamp, no repeated original local-part. `reverse()` detects
|
|
40
|
+
* SRS1, verifies this hop's tag, and unwraps exactly one hop back to
|
|
41
|
+
* the prior forwarder's SRS0 address so the bounce re-routes to it.
|
|
35
42
|
*
|
|
36
43
|
* `b.mail.srs.create({ secret, forwarderDomain })` returns
|
|
37
|
-
* `{ rewrite, reverse }`. `rewrite(originalSender)`
|
|
38
|
-
*
|
|
39
|
-
*
|
|
44
|
+
* `{ rewrite, srs1Rewrite, reverse }`. `rewrite(originalSender)`
|
|
45
|
+
* produces the SRS0 address; `srs1Rewrite(srsAddress)` chains a
|
|
46
|
+
* further hop as SRS1; `reverse(srsAddress)` decodes an SRS0 back to
|
|
47
|
+
* the original sender (verifying HMAC + expiry) or unwraps an SRS1
|
|
48
|
+
* one hop back to the prior forwarder.
|
|
40
49
|
*
|
|
41
50
|
* @card
|
|
42
51
|
* SRS Sender Rewriting Scheme — forwarder envelope-from rewriting with HMAC-bound day-rotated tags so the next-hop SPF check passes and bounces route correctly back to the original sender.
|
|
@@ -94,6 +103,34 @@ function _dayDiff(stamp, nowMs) {
|
|
|
94
103
|
return diff;
|
|
95
104
|
}
|
|
96
105
|
|
|
106
|
+
// Parse an SRS1 local-part "SRS1=<tag>=<priorForwarder>==<srs0Body>"
|
|
107
|
+
// into its three fields. The 4-char base32 tag and the prior-forwarder
|
|
108
|
+
// domain both carry no "=", so the FIRST "=" ends the tag and the FIRST
|
|
109
|
+
// "==" (which can only fall immediately after the "="-free prior-forwarder
|
|
110
|
+
// domain) ends the prior forwarder — even when the inner SRS0 body carries
|
|
111
|
+
// its own single "=" separators.
|
|
112
|
+
function _parseSrs1(localPart) {
|
|
113
|
+
var rest = localPart.slice(5); // strip "SRS1="
|
|
114
|
+
var firstEq = rest.indexOf("=");
|
|
115
|
+
if (firstEq <= 0) {
|
|
116
|
+
throw new SrsError("srs/malformed",
|
|
117
|
+
"srs.reverse: SRS1 must be SRS1=tag=priorForwarder==<srs0body>");
|
|
118
|
+
}
|
|
119
|
+
var tag = rest.slice(0, firstEq);
|
|
120
|
+
var afterTag = rest.slice(firstEq + 1);
|
|
121
|
+
var sep = afterTag.indexOf("==");
|
|
122
|
+
if (sep <= 0) {
|
|
123
|
+
throw new SrsError("srs/malformed",
|
|
124
|
+
"srs.reverse: SRS1 missing the '==' prior-forwarder separator");
|
|
125
|
+
}
|
|
126
|
+
var srs0Body = afterTag.slice(sep + 2);
|
|
127
|
+
if (!srs0Body) {
|
|
128
|
+
throw new SrsError("srs/malformed",
|
|
129
|
+
"srs.reverse: SRS1 carries an empty inner SRS0 body");
|
|
130
|
+
}
|
|
131
|
+
return { tag: tag, priorForwarder: afterTag.slice(0, sep), srs0Body: srs0Body };
|
|
132
|
+
}
|
|
133
|
+
|
|
97
134
|
/**
|
|
98
135
|
* @primitive b.mail.srs.create
|
|
99
136
|
* @signature b.mail.srs.create(opts)
|
|
@@ -101,7 +138,11 @@ function _dayDiff(stamp, nowMs) {
|
|
|
101
138
|
* @status stable
|
|
102
139
|
*
|
|
103
140
|
* Build an SRS rewriter bound to the operator's forwarder domain +
|
|
104
|
-
* HMAC signing secret. Returns `{ rewrite, reverse }
|
|
141
|
+
* HMAC signing secret. Returns `{ rewrite, srs1Rewrite, reverse }` —
|
|
142
|
+
* `rewrite` produces an SRS0 origin address, `srs1Rewrite` chains an
|
|
143
|
+
* already-SRS0/SRS1 address as SRS1 for a further forwarding hop, and
|
|
144
|
+
* `reverse` decodes either form (SRS0 → original sender with HMAC +
|
|
145
|
+
* expiry checks; SRS1 → the prior forwarder's address, one hop back).
|
|
105
146
|
*
|
|
106
147
|
* @opts
|
|
107
148
|
* secret: string, // operator's HMAC-SHA-256 signing secret (>=32 bytes recommended)
|
|
@@ -121,6 +162,11 @@ function _dayDiff(stamp, nowMs) {
|
|
|
121
162
|
* // Bounce arrives back at SRS0=...; decode to deliver
|
|
122
163
|
* var original = srs.reverse(rewritten);
|
|
123
164
|
* // → "alice@bob.com"
|
|
165
|
+
*
|
|
166
|
+
* // A further forwarding hop chains the already-SRS0 address as SRS1
|
|
167
|
+
* var hop2 = srs.srs1Rewrite(rewritten);
|
|
168
|
+
* // → "SRS1=HHHH=forwarder.example==HHHH=TT=bob.com=alice@forwarder.example"
|
|
169
|
+
* srs.reverse(hop2); // → the prior-hop SRS0 address, re-routed one hop back
|
|
124
170
|
*/
|
|
125
171
|
function create(opts) {
|
|
126
172
|
if (!opts || typeof opts !== "object") {
|
|
@@ -158,13 +204,12 @@ function create(opts) {
|
|
|
158
204
|
throw new SrsError("srs/bad-address",
|
|
159
205
|
"srs.rewrite: localPart / domain exceeds RFC 5321 length cap");
|
|
160
206
|
}
|
|
161
|
-
// Refuse SRS double-encoding from this primitive —
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// deployments and adds substantial spec surface).
|
|
207
|
+
// Refuse SRS double-encoding from this primitive — already-SRS0 (or
|
|
208
|
+
// SRS1) inputs chain through srs1Rewrite(), which keeps the original
|
|
209
|
+
// SRS0 body verbatim rather than re-stamping it as a fresh origin.
|
|
165
210
|
if (/^SRS[01]=/i.test(localPart)) {
|
|
166
211
|
throw new SrsError("srs/already-rewritten",
|
|
167
|
-
"srs.rewrite: address already SRS-encoded;
|
|
212
|
+
"srs.rewrite: address already SRS-encoded; use srs1Rewrite() to chain a further forwarding hop");
|
|
168
213
|
}
|
|
169
214
|
var now = typeof nowMs === "number" ? nowMs : Date.now();
|
|
170
215
|
var ts = _dayStamp(now);
|
|
@@ -173,6 +218,49 @@ function create(opts) {
|
|
|
173
218
|
return "SRS0=" + tag + "=" + ts + "=" + domain + "=" + localPart + "@" + forwarderDomain;
|
|
174
219
|
}
|
|
175
220
|
|
|
221
|
+
function srs1Rewrite(srsAddress) {
|
|
222
|
+
validateOpts.requireNonEmptyString(
|
|
223
|
+
srsAddress, "srs.srs1Rewrite.address", SrsError, "srs/bad-address");
|
|
224
|
+
var at = srsAddress.lastIndexOf("@");
|
|
225
|
+
if (at <= 0 || at === srsAddress.length - 1) {
|
|
226
|
+
throw new SrsError("srs/bad-address",
|
|
227
|
+
"srs.srs1Rewrite: address must be in localPart@domain form");
|
|
228
|
+
}
|
|
229
|
+
var localPart = srsAddress.slice(0, at);
|
|
230
|
+
// The SRS0 body is kept verbatim across the whole chain (the SRS1
|
|
231
|
+
// optimization: no new timestamp, no repeated original local-part).
|
|
232
|
+
// `priorForwarder` is the domain the bounce must ultimately reach to
|
|
233
|
+
// recover the original sender — i.e. the forwarder that MINTED the
|
|
234
|
+
// inner SRS0. From an SRS0 input that is its own @domain; from an
|
|
235
|
+
// SRS1 input (a third or later hop) it is the originator already
|
|
236
|
+
// recorded in the SRS1, NOT the immediately-preceding forwarder, so
|
|
237
|
+
// every hop's bounce routes straight back to the SRS0 originator.
|
|
238
|
+
var priorForwarder, srs0Body;
|
|
239
|
+
if (/^SRS0=/i.test(localPart)) {
|
|
240
|
+
priorForwarder = srsAddress.slice(at + 1);
|
|
241
|
+
srs0Body = localPart.slice(5);
|
|
242
|
+
} else if (/^SRS1=/i.test(localPart)) {
|
|
243
|
+
var inner = _parseSrs1(localPart);
|
|
244
|
+
priorForwarder = inner.priorForwarder;
|
|
245
|
+
srs0Body = inner.srs0Body;
|
|
246
|
+
} else {
|
|
247
|
+
throw new SrsError("srs/not-srs0",
|
|
248
|
+
"srs.srs1Rewrite: input must be an SRS0 or SRS1 address (use rewrite() for a plain address)");
|
|
249
|
+
}
|
|
250
|
+
if (!priorForwarder || priorForwarder.indexOf("=") !== -1) {
|
|
251
|
+
throw new SrsError("srs/bad-address",
|
|
252
|
+
"srs.srs1Rewrite: prior forwarder domain must be a non-empty domain without '=' (would corrupt SRS1 field parsing)");
|
|
253
|
+
}
|
|
254
|
+
var opaque = priorForwarder + "==" + srs0Body;
|
|
255
|
+
var tag = _hashTag(secret, opaque);
|
|
256
|
+
var result = "SRS1=" + tag + "=" + priorForwarder + "==" + srs0Body + "@" + forwarderDomain;
|
|
257
|
+
if (result.length > 256) { // RFC 5321 §4.5.3.1.3 path-length cap
|
|
258
|
+
throw new SrsError("srs/too-long",
|
|
259
|
+
"srs.srs1Rewrite: rewritten address exceeds the RFC 5321 256-octet path limit (forwarding chain too deep)");
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
176
264
|
function reverse(srsAddress, nowMs) {
|
|
177
265
|
validateOpts.requireNonEmptyString(
|
|
178
266
|
srsAddress, "srs.reverse.address", SrsError, "srs/bad-address");
|
|
@@ -183,13 +271,15 @@ function create(opts) {
|
|
|
183
271
|
}
|
|
184
272
|
var localPart = srsAddress.slice(0, at);
|
|
185
273
|
var rcptDomain = srsAddress.slice(at + 1);
|
|
186
|
-
// Allow case-insensitive SRS0
|
|
187
|
-
// FIRST so an obviously-non-
|
|
274
|
+
// Allow case-insensitive SRS0 / SRS1 prefixes per the spec. Check
|
|
275
|
+
// this FIRST so an obviously-non-SRS input (`plain@example.com`)
|
|
188
276
|
// gets the specific not-srs0 verdict instead of the more general
|
|
189
277
|
// wrong-forwarder verdict.
|
|
190
|
-
|
|
278
|
+
var isSrs0 = /^SRS0=/i.test(localPart);
|
|
279
|
+
var isSrs1 = /^SRS1=/i.test(localPart);
|
|
280
|
+
if (!isSrs0 && !isSrs1) {
|
|
191
281
|
throw new SrsError("srs/not-srs0",
|
|
192
|
-
"srs.reverse: address local-part does not start with SRS0=");
|
|
282
|
+
"srs.reverse: address local-part does not start with SRS0= or SRS1=");
|
|
193
283
|
}
|
|
194
284
|
// Domain binding — the rewriter is scoped to a specific forwarder
|
|
195
285
|
// domain, and reverse() must verify the bounce arrived at THAT
|
|
@@ -203,6 +293,18 @@ function create(opts) {
|
|
|
203
293
|
"srs.reverse: bounce addressed to '" + rcptDomain + "' but rewriter " +
|
|
204
294
|
"is bound to forwarderDomain '" + forwarderDomain + "'");
|
|
205
295
|
}
|
|
296
|
+
if (isSrs1) {
|
|
297
|
+
var s1 = _parseSrs1(localPart);
|
|
298
|
+
if (!_timingSafeStringEqual(s1.tag, _hashTag(secret, s1.priorForwarder + "==" + s1.srs0Body))) {
|
|
299
|
+
throw new SrsError("srs/bad-tag",
|
|
300
|
+
"srs.reverse: SRS1 HMAC tag does not verify (wrong secret or tampered envelope-from)");
|
|
301
|
+
}
|
|
302
|
+
// Unwrap exactly one hop: re-address the bounce to the prior
|
|
303
|
+
// forwarder's SRS0. That forwarder owns the inner SRS0's tag +
|
|
304
|
+
// expiry, so we do NOT re-check them here — per the SRS spec each
|
|
305
|
+
// hop verifies only its OWN hash.
|
|
306
|
+
return "SRS0=" + s1.srs0Body + "@" + s1.priorForwarder;
|
|
307
|
+
}
|
|
206
308
|
var rest = localPart.slice(5);
|
|
207
309
|
var parts = rest.split("=");
|
|
208
310
|
if (parts.length < 4) {
|
|
@@ -231,8 +333,9 @@ function create(opts) {
|
|
|
231
333
|
}
|
|
232
334
|
|
|
233
335
|
return Object.freeze({
|
|
234
|
-
rewrite:
|
|
235
|
-
|
|
336
|
+
rewrite: rewrite,
|
|
337
|
+
srs1Rewrite: srs1Rewrite,
|
|
338
|
+
reverse: reverse,
|
|
236
339
|
forwarderDomain: forwarderDomain,
|
|
237
340
|
});
|
|
238
341
|
}
|