@blamejs/core 0.14.26 → 0.14.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/agent-envelope-mac.js +104 -0
- package/lib/agent-event-bus.js +105 -4
- package/lib/agent-posture-chain.js +8 -42
- package/lib/atomic-file.js +33 -3
- package/lib/audit.js +31 -23
- package/lib/auth/openid-federation.js +108 -47
- package/lib/compliance.js +147 -4
- package/lib/crypto-field.js +87 -1
- package/lib/error-page.js +14 -1
- package/lib/file-upload.js +52 -7
- package/lib/framework-error.js +3 -1
- package/lib/gate-contract.js +53 -0
- package/lib/http-client.js +23 -9
- package/lib/mail-server-jmap.js +117 -12
- package/lib/middleware/body-parser.js +71 -25
- package/lib/middleware/csrf-protect.js +19 -8
- package/lib/object-store/azure-blob.js +28 -2
- package/lib/observability.js +87 -0
- package/lib/otel-export.js +25 -1
- package/lib/parsers/safe-xml.js +47 -7
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/router.js +212 -5
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +132 -27
- package/lib/websocket.js +19 -5
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -334,14 +334,23 @@ function _applyOnePolicy(metadata, policy) {
|
|
|
334
334
|
* @signature b.auth.openidFederation.applyMetadataPolicy(metadata, chain, kind)
|
|
335
335
|
* @since 0.8.62
|
|
336
336
|
*
|
|
337
|
-
* Apply
|
|
337
|
+
* Apply the federation's metadata_policy (top-down) to the leaf's
|
|
338
338
|
* declared metadata for the given entity-kind ("openid_relying_party"
|
|
339
339
|
* / "openid_provider" / "federation_entity" / etc.) and return the
|
|
340
340
|
* effective metadata. Throws on any policy violation.
|
|
341
341
|
*
|
|
342
|
-
*
|
|
343
|
-
* the
|
|
344
|
-
*
|
|
342
|
+
* Per OpenID Federation 1.0 §6.2, an entity's metadata_policy comes
|
|
343
|
+
* from the SUPERIOR-SIGNED subordinate statement about that entity
|
|
344
|
+
* (`chain[i].subordinate.metadata_policy`), NOT from the entity's own
|
|
345
|
+
* self-published configuration. An entity cannot self-declare the
|
|
346
|
+
* policy that constrains it — that would let a leaf widen or drop the
|
|
347
|
+
* trust anchor's value / subset_of / essential constraints. The leaf's
|
|
348
|
+
* own self-config metadata_policy is therefore ignored.
|
|
349
|
+
*
|
|
350
|
+
* The chain is leaf-first; each `chain[i].subordinate` is the statement
|
|
351
|
+
* signed by the superior directly above entity `i`, so walking high
|
|
352
|
+
* index → low index applies the anchor's policy first, then each
|
|
353
|
+
* intermediate's, narrowing down to the leaf (§6.2 narrow-only merge).
|
|
345
354
|
*
|
|
346
355
|
* @example
|
|
347
356
|
* var effective = b.auth.openidFederation.applyMetadataPolicy(
|
|
@@ -361,12 +370,17 @@ function applyMetadataPolicy(metadata, chain, kind) {
|
|
|
361
370
|
"applyMetadataPolicy: chain must be an array");
|
|
362
371
|
}
|
|
363
372
|
var out = Object.assign({}, metadata);
|
|
364
|
-
// Walk top-down (anchor last in leaf-first array).
|
|
373
|
+
// Walk top-down (anchor last in leaf-first array). Read the policy
|
|
374
|
+
// from each node's SUPERIOR-SIGNED subordinate statement — never from
|
|
375
|
+
// the entity's own self-config — so the anchor/intermediate
|
|
376
|
+
// constraints can't be dropped by a self-declared policy. The anchor
|
|
377
|
+
// node carries no `.subordinate` (it terminates the chain) and is
|
|
378
|
+
// skipped; the leaf's self-config policy is never read.
|
|
365
379
|
for (var i = chain.length - 1; i >= 0; i--) {
|
|
366
380
|
var stmt = chain[i];
|
|
367
|
-
if (!stmt || !stmt.
|
|
368
|
-
if (stmt.
|
|
369
|
-
out = _applyOnePolicy(out, stmt.
|
|
381
|
+
if (!stmt || !stmt.subordinate) continue;
|
|
382
|
+
if (stmt.subordinate.metadata_policy && stmt.subordinate.metadata_policy[kind]) {
|
|
383
|
+
out = _applyOnePolicy(out, stmt.subordinate.metadata_policy[kind]);
|
|
370
384
|
}
|
|
371
385
|
}
|
|
372
386
|
return out;
|
|
@@ -433,6 +447,18 @@ async function buildTrustChain(opts) {
|
|
|
433
447
|
};
|
|
434
448
|
var maxDepth = opts.maxDepth || MAX_CHAIN_DEPTH;
|
|
435
449
|
|
|
450
|
+
// ---- Phase 1: collect the chain bottom-up (leaf → anchor) ----------
|
|
451
|
+
// Fetch each entity's self-config + the superior-signed subordinate
|
|
452
|
+
// statement about it, but DEFER cryptographic chain verification to
|
|
453
|
+
// Phase 2. The signature on a subordinate statement must be checked
|
|
454
|
+
// against the keys ATTESTED for the signing authority by ITS superior
|
|
455
|
+
// — flowing down from the operator-pinned anchor — not against the
|
|
456
|
+
// authority's own self-published config jwks. Verifying eagerly here
|
|
457
|
+
// (against self-published keys) is a fetch-time TOCTOU: an attacker
|
|
458
|
+
// controlling the authority's endpoint can serve attacker jwks to the
|
|
459
|
+
// statement-verify fetch while serving genuine config elsewhere, and
|
|
460
|
+
// the only operator-pinned trust (the anchor key) never gates the
|
|
461
|
+
// subordinate links.
|
|
436
462
|
var chain = [];
|
|
437
463
|
var current = opts.leafEntityId;
|
|
438
464
|
var depth = 0;
|
|
@@ -446,6 +472,7 @@ async function buildTrustChain(opts) {
|
|
|
446
472
|
// hostile-federation probes immediately.
|
|
447
473
|
var visited = Object.create(null);
|
|
448
474
|
visited[current] = true;
|
|
475
|
+
var reachedAnchor = false;
|
|
449
476
|
while (depth < maxDepth) {
|
|
450
477
|
var entityConfigUrl = current.replace(/\/$/, "") + "/.well-known/openid-federation";
|
|
451
478
|
var entityConfigJwt = await fetcher(entityConfigUrl);
|
|
@@ -454,21 +481,22 @@ async function buildTrustChain(opts) {
|
|
|
454
481
|
throw new AuthError("auth-openid-federation/bad-self-statement",
|
|
455
482
|
"entity configuration for \"" + current + "\" must have iss==sub==entity_id");
|
|
456
483
|
}
|
|
457
|
-
// Self-
|
|
484
|
+
// Self-statement integrity: a well-formed entity config is self-signed
|
|
485
|
+
// over its own jwks. This proves the document isn't truncated/garbled
|
|
486
|
+
// — it is NOT the trust decision. Trust flows from the anchor through
|
|
487
|
+
// the subordinate statements verified top-down in Phase 2.
|
|
458
488
|
verifyEntityStatement(entityConfigJwt, parsedEC.claims.jwks || {});
|
|
459
489
|
|
|
460
490
|
// Is this entity a trust anchor?
|
|
461
491
|
if (Object.prototype.hasOwnProperty.call(opts.trustAnchors, current)) {
|
|
462
492
|
// Verify the anchor's self-statement using the operator-pinned
|
|
463
493
|
// JWKS — defends against a compromised anchor key by trusting
|
|
464
|
-
// the configured one over what the anchor publishes today.
|
|
494
|
+
// the configured one over what the anchor publishes today. The
|
|
495
|
+
// pinned keys become the root of the top-down verification.
|
|
465
496
|
verifyEntityStatement(entityConfigJwt, opts.trustAnchors[current]);
|
|
466
497
|
chain.push({ jwt: entityConfigJwt, claims: parsedEC.claims, role: "trust_anchor" });
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
});
|
|
470
|
-
_emitMetric("chain-built");
|
|
471
|
-
return chain;
|
|
498
|
+
reachedAnchor = true;
|
|
499
|
+
break;
|
|
472
500
|
}
|
|
473
501
|
// Not the anchor — add to chain, ascend via authority_hints.
|
|
474
502
|
chain.push({
|
|
@@ -481,16 +509,15 @@ async function buildTrustChain(opts) {
|
|
|
481
509
|
throw new AuthError("auth-openid-federation/no-authority-hints",
|
|
482
510
|
"entity \"" + current + "\" has no authority_hints; cannot ascend to a trust anchor");
|
|
483
511
|
}
|
|
484
|
-
// Pick the FIRST authority_hint that
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
// signature-verify failure (cryptographic refusal is a hard stop).
|
|
512
|
+
// Pick the FIRST authority_hint that yields a fetchable subordinate
|
|
513
|
+
// statement with matching iss/sub. We continue past 404 / fetch /
|
|
514
|
+
// parse errors (acceptable "try the next hint") and surface every
|
|
515
|
+
// failure reason on `no-ascent` rather than masking it — silently
|
|
516
|
+
// swallowing `catch (_e) {}` lets a hostile intermediate that serves
|
|
517
|
+
// a malformed-then-valid pair shape-walk the verifier. Cryptographic
|
|
518
|
+
// verification is NOT done here; the selected statement is verified
|
|
519
|
+
// against the superior-attested keys in Phase 2, so a forged
|
|
520
|
+
// signature fails the whole chain regardless of which hint picked it.
|
|
494
521
|
var ascended = false;
|
|
495
522
|
var ascentErrors = [];
|
|
496
523
|
for (var ai = 0; ai < parsedEC.claims.authority_hints.length; ai++) {
|
|
@@ -502,49 +529,83 @@ async function buildTrustChain(opts) {
|
|
|
502
529
|
ascentErrors.push({ authority: authority, code: "iss-sub-mismatch" });
|
|
503
530
|
continue;
|
|
504
531
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
// Cryptographic verification — any throw here is a hard
|
|
508
|
-
// refusal, NOT a "try next authority" signal. A malformed-
|
|
509
|
-
// signature subordinate from an authority listed by the
|
|
510
|
-
// entity means that authority is hostile or compromised;
|
|
511
|
-
// moving on lets a chain-shaping attacker bypass the gate.
|
|
512
|
-
verifyEntityStatement(subordinateJwt, authorityCfgClaims.jwks || {});
|
|
513
|
-
chain[chain.length - 1].claims.jwks = parsedSub.claims.jwks || chain[chain.length - 1].claims.jwks;
|
|
514
|
-
chain[chain.length - 1].subordinateJwt = subordinateJwt;
|
|
515
|
-
chain[chain.length - 1].subordinate = parsedSub.claims;
|
|
516
|
-
// Refuse revisit. A trust anchor terminates the loop
|
|
517
|
-
// before re-entry, so a revisit here ALWAYS means a cyclic
|
|
532
|
+
// Refuse revisit. A trust anchor terminates the loop before
|
|
533
|
+
// re-entry, so a revisit here ALWAYS means a cyclic
|
|
518
534
|
// authority_hints graph.
|
|
519
535
|
if (visited[authority]) {
|
|
520
536
|
throw new AuthError("auth-openid-federation/chain-cycle",
|
|
521
537
|
"buildTrustChain: authority \"" + authority + "\" already visited — " +
|
|
522
538
|
"cyclic authority_hints graph refused");
|
|
523
539
|
}
|
|
540
|
+
// Stash the superior-signed subordinate statement on the entity
|
|
541
|
+
// it is ABOUT. Phase 2 verifies its signature against the
|
|
542
|
+
// attested keys for `authority` and applies its metadata_policy.
|
|
543
|
+
chain[chain.length - 1].subordinateJwt = subordinateJwt;
|
|
544
|
+
chain[chain.length - 1].subordinate = parsedSub.claims;
|
|
524
545
|
visited[authority] = true;
|
|
525
546
|
current = authority;
|
|
526
547
|
ascended = true;
|
|
527
548
|
break;
|
|
528
549
|
} catch (err) {
|
|
550
|
+
// A cycle refusal is a hard stop, not a "try next hint" signal.
|
|
551
|
+
if (err && err.code === "auth-openid-federation/chain-cycle") throw err;
|
|
529
552
|
var errCode = (err && err.code) || "unknown";
|
|
530
|
-
// Network / 404 / parse errors at the AUTHORITY-fetch step
|
|
531
|
-
// are acceptable "try the next hint" signals. Verify-side
|
|
532
|
-
// failures (crypto) are NOT — surface them and abort.
|
|
533
|
-
if (/^auth-openid-federation\/(?:bad-jwk|alg-kty-mismatch|bad-signature|signature-failed)$/.test(errCode)) {
|
|
534
|
-
throw err;
|
|
535
|
-
}
|
|
536
553
|
ascentErrors.push({ authority: authority, code: errCode, message: (err && err.message) || String(err) });
|
|
537
554
|
}
|
|
538
555
|
}
|
|
539
556
|
if (!ascended) {
|
|
540
557
|
throw new AuthError("auth-openid-federation/no-ascent",
|
|
541
|
-
"entity \"" + current + "\" has authority_hints but none yielded a
|
|
558
|
+
"entity \"" + current + "\" has authority_hints but none yielded a fetchable subordinate statement: " +
|
|
542
559
|
JSON.stringify(ascentErrors));
|
|
543
560
|
}
|
|
544
561
|
depth += 1;
|
|
545
562
|
}
|
|
546
|
-
|
|
547
|
-
|
|
563
|
+
if (!reachedAnchor) {
|
|
564
|
+
throw new AuthError("auth-openid-federation/chain-too-deep",
|
|
565
|
+
"buildTrustChain: max depth " + maxDepth + " exceeded; refused");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---- Phase 2: verify top-down against attested keys ----------------
|
|
569
|
+
// Trust flows from the operator-pinned anchor downward. Each
|
|
570
|
+
// subordinate statement is signed by the superior directly above the
|
|
571
|
+
// entity it describes, and pins that entity's jwks. We start with the
|
|
572
|
+
// anchor's pinned keys and, for each step down, verify the subordinate
|
|
573
|
+
// statement with the keys attested for its SIGNER (never the signer's
|
|
574
|
+
// self-published config), then adopt the statement's attested jwks as
|
|
575
|
+
// the trusted keys for the next step. This closes the fetch-time TOCTOU
|
|
576
|
+
// and makes the anchor key gate every link, not just the anchor's own
|
|
577
|
+
// self-config.
|
|
578
|
+
var anchorEntityId = chain[chain.length - 1].claims.iss;
|
|
579
|
+
var attestedJwks = opts.trustAnchors[anchorEntityId];
|
|
580
|
+
for (var ci = chain.length - 2; ci >= 0; ci--) {
|
|
581
|
+
var node = chain[ci];
|
|
582
|
+
if (!node.subordinate || !node.subordinateJwt) {
|
|
583
|
+
// Every non-anchor node must carry the superior-signed statement
|
|
584
|
+
// collected in Phase 1; its absence is an internal invariant break.
|
|
585
|
+
throw new AuthError("auth-openid-federation/no-subordinate",
|
|
586
|
+
"buildTrustChain: entity \"" + node.claims.iss + "\" has no superior-signed subordinate statement");
|
|
587
|
+
}
|
|
588
|
+
// Verify against the SIGNER's attested keys (flowed down), not the
|
|
589
|
+
// signer's self-published config jwks.
|
|
590
|
+
verifyEntityStatement(node.subordinateJwt, attestedJwks || {});
|
|
591
|
+
// The subordinate statement pins this entity's jwks — adopt the
|
|
592
|
+
// attested keys for the next link down, and reflect them on the node.
|
|
593
|
+
if (node.subordinate.jwks && Array.isArray(node.subordinate.jwks.keys)) {
|
|
594
|
+
node.claims.jwks = node.subordinate.jwks;
|
|
595
|
+
attestedJwks = node.subordinate.jwks;
|
|
596
|
+
} else {
|
|
597
|
+
// A subordinate statement that pins no keys cannot attest the next
|
|
598
|
+
// link — refuse rather than fall back to self-published keys.
|
|
599
|
+
throw new AuthError("auth-openid-federation/no-attested-jwks",
|
|
600
|
+
"subordinate statement for \"" + node.claims.iss + "\" pins no jwks; cannot attest the chain downward");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
_emitAudit("chain_built", "success", {
|
|
605
|
+
leaf: opts.leafEntityId, depth: chain.length, anchor: anchorEntityId,
|
|
606
|
+
});
|
|
607
|
+
_emitMetric("chain-built");
|
|
608
|
+
return chain;
|
|
548
609
|
}
|
|
549
610
|
|
|
550
611
|
/**
|
package/lib/compliance.js
CHANGED
|
@@ -50,6 +50,18 @@ var audit = lazyRequire(function () { return require("./audit"); });
|
|
|
50
50
|
var retentionMod = lazyRequire(function () { return require("./retention"); });
|
|
51
51
|
var db = lazyRequire(function () { return require("./db"); });
|
|
52
52
|
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
53
|
+
var redact = lazyRequire(function () { return require("./redact"); });
|
|
54
|
+
|
|
55
|
+
// Postures whose floor implies an outbound-DLP gate (b.redact's
|
|
56
|
+
// classifier presets cover exactly these regimes). Pinning one of these
|
|
57
|
+
// does NOT auto-install outbound DLP — the compliance coordinator holds
|
|
58
|
+
// no httpClient / mail / webhook handles — so set() emits a one-time
|
|
59
|
+
// `compliance.posture.outbound_dlp_unwired` warning when none is wired,
|
|
60
|
+
// so the gap is grep-able in the audit chain instead of a silent paper-
|
|
61
|
+
// compliance hole (CWE-200 / CWE-201 outbound data exposure).
|
|
62
|
+
var OUTBOUND_DLP_FLOOR_POSTURES = Object.freeze([
|
|
63
|
+
"hipaa", "pci-dss", "gdpr", "soc2", "fapi-2.0", "fapi-2.0-message-signing",
|
|
64
|
+
]);
|
|
53
65
|
|
|
54
66
|
// Recognised posture names. Aligns with the compliance-posture
|
|
55
67
|
// vocabulary every guard / retention floor / etc. accepts. Operators
|
|
@@ -445,6 +457,24 @@ function set(posture) {
|
|
|
445
457
|
"warning");
|
|
446
458
|
}
|
|
447
459
|
}
|
|
460
|
+
|
|
461
|
+
// Outbound-DLP wiring signal. A posture whose floor implies an
|
|
462
|
+
// outbound-DLP gate is being pinned, but set() cannot install the
|
|
463
|
+
// interceptors itself (no httpClient / mail / webhook handles). Warn
|
|
464
|
+
// once when nothing is wired so the gap is visible in the audit chain
|
|
465
|
+
// rather than a silent paper-compliance hole. Fires at most once per
|
|
466
|
+
// pin (set() is idempotent for the same posture).
|
|
467
|
+
if (OUTBOUND_DLP_FLOOR_POSTURES.indexOf(posture) !== -1) {
|
|
468
|
+
var dlpInstalled = false;
|
|
469
|
+
try { dlpInstalled = redact().isOutboundDlpInstalled() === true; }
|
|
470
|
+
catch (_e) { dlpInstalled = false; }
|
|
471
|
+
if (!dlpInstalled) {
|
|
472
|
+
_emitAudit("compliance.posture.outbound_dlp_unwired",
|
|
473
|
+
{ posture: posture,
|
|
474
|
+
recommendation: "compliance.set does not auto-install outbound DLP — it holds no httpClient / mail / webhook handles. Call b.redact.installForPosture('" + posture + "', { httpClient, mail, webhook }) with your primitive instances so outbound payloads are classified (CWE-200 / CWE-201)." },
|
|
475
|
+
"warning");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
448
478
|
}
|
|
449
479
|
|
|
450
480
|
// _applyPostureCascade — walks every primitive that
|
|
@@ -948,6 +978,25 @@ function describe(posture) {
|
|
|
948
978
|
// + DPDP §12 + LGPD-BR Art. 18 + PIPL-CN
|
|
949
979
|
// Art. 47 all require effective erasure;
|
|
950
980
|
// leftover index residue defeats it.
|
|
981
|
+
// sealEnvelopeFloor — minimum field-level seal envelope a
|
|
982
|
+
// sealed-column table may declare under
|
|
983
|
+
// this posture: "plain" (vault.seal, no
|
|
984
|
+
// AAD), "aad" (AEAD-bound to table/row/
|
|
985
|
+
// column via b.vault.aad), or "per-row-key"
|
|
986
|
+
// (K_row crypto-shred). cryptoField.
|
|
987
|
+
// registerTable refuses a table whose
|
|
988
|
+
// declared envelope is below the floor when
|
|
989
|
+
// this posture is the globally-pinned one.
|
|
990
|
+
// PCI-DSS Req. 3.5/3.6 (PAN render
|
|
991
|
+
// unreadable, key-management binding) and
|
|
992
|
+
// HIPAA 45 CFR 164.312(a)(2)(iv) +
|
|
993
|
+
// 164.312(e)(2)(ii) (encryption that
|
|
994
|
+
// resists ciphertext relocation, CWE-311 /
|
|
995
|
+
// CWE-326) need an AAD-bound envelope at
|
|
996
|
+
// minimum so a DB-write attacker cannot
|
|
997
|
+
// copy a sealed cell between rows. Absent
|
|
998
|
+
// on a posture → no floor (back-compat;
|
|
999
|
+
// plain envelopes keep registering).
|
|
951
1000
|
//
|
|
952
1001
|
// This table is the single source-of-truth — duplicating values into
|
|
953
1002
|
// per-primitive defaults would drift the moment a regulator updates.
|
|
@@ -957,12 +1006,22 @@ var POSTURE_DEFAULTS = Object.freeze({
|
|
|
957
1006
|
auditChainSignedRequired: true,
|
|
958
1007
|
tlsMinVersion: "TLSv1.3",
|
|
959
1008
|
requireVacuumAfterErase: true,
|
|
1009
|
+
// 45 CFR 164.312(a)(2)(iv) + (e)(2)(ii) — ePHI encryption must
|
|
1010
|
+
// resist ciphertext relocation; a plain vault.seal cell can be
|
|
1011
|
+
// copied between rows undetected (CWE-311 / CWE-326). AAD-bound
|
|
1012
|
+
// envelope is the floor.
|
|
1013
|
+
sealEnvelopeFloor: "aad",
|
|
960
1014
|
}),
|
|
961
1015
|
"pci-dss": Object.freeze({
|
|
962
1016
|
backupEncryptionRequired: true,
|
|
963
1017
|
auditChainSignedRequired: true,
|
|
964
1018
|
tlsMinVersion: "TLSv1.3",
|
|
965
1019
|
requireVacuumAfterErase: false,
|
|
1020
|
+
// PCI-DSS v4 Req. 3.5 (PAN unreadable) + Req. 3.6 (key-management
|
|
1021
|
+
// binding) — the seal must bind cardholder data to its storage
|
|
1022
|
+
// location so a relocated ciphertext fails to verify. AAD-bound
|
|
1023
|
+
// envelope is the floor.
|
|
1024
|
+
sealEnvelopeFloor: "aad",
|
|
966
1025
|
}),
|
|
967
1026
|
"gdpr": Object.freeze({
|
|
968
1027
|
backupEncryptionRequired: false, // GDPR Art. 32 says "appropriate" — not mandatory floor
|
|
@@ -1357,10 +1416,13 @@ var POSTURE_DEFAULTS = Object.freeze({
|
|
|
1357
1416
|
* where `set()` would over-pin the process.
|
|
1358
1417
|
*
|
|
1359
1418
|
* Recognised keys per posture include `backupEncryptionRequired`,
|
|
1360
|
-
* `auditChainSignedRequired`, `tlsMinVersion`,
|
|
1361
|
-
* `requireVacuumAfterErase` — the floors
|
|
1362
|
-
* `b.audit`, the TLS minimum-version gate,
|
|
1363
|
-
* residual-erasure pass.
|
|
1419
|
+
* `auditChainSignedRequired`, `tlsMinVersion`,
|
|
1420
|
+
* `requireVacuumAfterErase`, and `sealEnvelopeFloor` — the floors
|
|
1421
|
+
* enforced by `b.backup`, `b.audit`, the TLS minimum-version gate,
|
|
1422
|
+
* `b.cryptoField`'s residual-erasure pass, and `b.cryptoField`'s
|
|
1423
|
+
* field-level seal-envelope gate. Keys not declared for a posture
|
|
1424
|
+
* return `null` (no floor), so reading `sealEnvelopeFloor` for a
|
|
1425
|
+
* posture that doesn't pin one is the back-compat no-op signal.
|
|
1364
1426
|
*
|
|
1365
1427
|
* @example
|
|
1366
1428
|
* b.compliance.postureDefault("hipaa", "tlsMinVersion");
|
|
@@ -1615,10 +1677,91 @@ function isCrossBorderRegulated(posture) {
|
|
|
1615
1677
|
return CROSS_BORDER_REGULATED_POSTURES.indexOf(posture) !== -1;
|
|
1616
1678
|
}
|
|
1617
1679
|
|
|
1680
|
+
// Region-tag wildcards. Both spellings mean "no residency constraint"
|
|
1681
|
+
// across the framework — the external-db gate uses "unrestricted" as
|
|
1682
|
+
// its default + wildcard, while the local db-query / external-db row
|
|
1683
|
+
// gates also accept "global" as the region-neutral row tag. Normalizing
|
|
1684
|
+
// folds both to "unrestricted" so callers reason about one wildcard.
|
|
1685
|
+
var _REGION_WILDCARDS = Object.freeze(["global", "unrestricted", "any", "*"]);
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* @primitive b.compliance.normalizeRegionTag
|
|
1689
|
+
* @signature b.compliance.normalizeRegionTag(tag)
|
|
1690
|
+
* @since 0.14.27
|
|
1691
|
+
* @compliance gdpr
|
|
1692
|
+
* @related b.compliance.isRegionCompatible, b.compliance.isCrossBorderRegulated
|
|
1693
|
+
*
|
|
1694
|
+
* Canonicalize an operator-supplied residency region tag so the same
|
|
1695
|
+
* region declared as `"EU"`, `"eu"`, or `" Eu "` compares equal. Lower-
|
|
1696
|
+
* cases and trims the tag; folds the no-constraint wildcards
|
|
1697
|
+
* (`"global"` / `"unrestricted"` / `"any"` / `"*"`) to `"unrestricted"`.
|
|
1698
|
+
* Returns `null` for non-string / empty input.
|
|
1699
|
+
*
|
|
1700
|
+
* This is an ADDITIVE helper composed OVER the residency write gates
|
|
1701
|
+
* (`b.db.from` local, `b.externalDb.query` backend/replica) — it does
|
|
1702
|
+
* not change the gate internals. Callers normalize their tags with it
|
|
1703
|
+
* BEFORE handing them to the gate so case / wildcard drift (`"EU"` vs
|
|
1704
|
+
* `"eu"` vs `"global"`) doesn't read as a region mismatch.
|
|
1705
|
+
*
|
|
1706
|
+
* @example
|
|
1707
|
+
* b.compliance.normalizeRegionTag("EU"); // → "eu"
|
|
1708
|
+
* b.compliance.normalizeRegionTag(" eu "); // → "eu"
|
|
1709
|
+
* b.compliance.normalizeRegionTag("global"); // → "unrestricted"
|
|
1710
|
+
* b.compliance.normalizeRegionTag("unrestricted"); // → "unrestricted"
|
|
1711
|
+
* b.compliance.normalizeRegionTag(null); // → null
|
|
1712
|
+
*/
|
|
1713
|
+
function normalizeRegionTag(tag) {
|
|
1714
|
+
if (typeof tag !== "string") return null;
|
|
1715
|
+
var t = tag.trim().toLowerCase();
|
|
1716
|
+
if (t.length === 0) return null;
|
|
1717
|
+
if (_REGION_WILDCARDS.indexOf(t) !== -1) return "unrestricted";
|
|
1718
|
+
return t;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* @primitive b.compliance.isRegionCompatible
|
|
1723
|
+
* @signature b.compliance.isRegionCompatible(a, b)
|
|
1724
|
+
* @since 0.14.27
|
|
1725
|
+
* @compliance gdpr
|
|
1726
|
+
* @related b.compliance.normalizeRegionTag, b.compliance.isCrossBorderRegulated
|
|
1727
|
+
*
|
|
1728
|
+
* Returns `true` when two residency region tags are compatible for a
|
|
1729
|
+
* same-region write/replication after normalization: identical
|
|
1730
|
+
* normalized regions are compatible, and a wildcard (`"global"` /
|
|
1731
|
+
* `"unrestricted"`) on EITHER side is compatible. Different concrete
|
|
1732
|
+
* regions (`"eu"` vs `"us"`) are NOT compatible — a cross-border
|
|
1733
|
+
* transfer the operator must opt into explicitly at the gate.
|
|
1734
|
+
*
|
|
1735
|
+
* Mirrors the residency gate's compatibility rule (identical-or-
|
|
1736
|
+
* wildcard) but over NORMALIZED tags, so it is case- and wildcard-drift
|
|
1737
|
+
* insensitive. ADDITIVE helper composed over the gate — it does not
|
|
1738
|
+
* change `_residencyCompatible` in db-query.js / external-db.js.
|
|
1739
|
+
* Missing/non-string tags on either side normalize to `null`, treated
|
|
1740
|
+
* as "no constraint" → compatible (matches the gate's
|
|
1741
|
+
* `!primaryTag || !replicaTag` short-circuit).
|
|
1742
|
+
*
|
|
1743
|
+
* @example
|
|
1744
|
+
* b.compliance.isRegionCompatible("EU", "eu"); // → true
|
|
1745
|
+
* b.compliance.isRegionCompatible("eu", "global"); // → true
|
|
1746
|
+
* b.compliance.isRegionCompatible("unrestricted", "us"); // → true
|
|
1747
|
+
* b.compliance.isRegionCompatible("eu", "us"); // → false
|
|
1748
|
+
* b.compliance.isRegionCompatible("EU", null); // → true
|
|
1749
|
+
*/
|
|
1750
|
+
function isRegionCompatible(a, b) {
|
|
1751
|
+
var na = normalizeRegionTag(a);
|
|
1752
|
+
var nb = normalizeRegionTag(b);
|
|
1753
|
+
if (na === null || nb === null) return true; // no constraint either side
|
|
1754
|
+
if (na === nb) return true; // identical region (post-normalize)
|
|
1755
|
+
if (na === "unrestricted" || nb === "unrestricted") return true; // wildcard either side
|
|
1756
|
+
return false;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1618
1759
|
module.exports = {
|
|
1619
1760
|
set: set,
|
|
1620
1761
|
current: current,
|
|
1621
1762
|
isCrossBorderRegulated: isCrossBorderRegulated,
|
|
1763
|
+
normalizeRegionTag: normalizeRegionTag,
|
|
1764
|
+
isRegionCompatible: isRegionCompatible,
|
|
1622
1765
|
CROSS_BORDER_REGULATED_POSTURES: CROSS_BORDER_REGULATED_POSTURES,
|
|
1623
1766
|
assert: assert,
|
|
1624
1767
|
clear: clear,
|
package/lib/crypto-field.js
CHANGED
|
@@ -155,6 +155,25 @@ var perRowResidency = Object.create(null);
|
|
|
155
155
|
// { tableName: { keySize, info } }
|
|
156
156
|
var perRowKeyTables = Object.create(null);
|
|
157
157
|
|
|
158
|
+
// Seal-envelope strength ranking. A regulated posture can declare a
|
|
159
|
+
// sealEnvelopeFloor in b.compliance POSTURE_DEFAULTS; registerTable
|
|
160
|
+
// refuses a table that seals columns under a weaker envelope than the
|
|
161
|
+
// floor when that posture is the globally-pinned one. Higher rank =
|
|
162
|
+
// stronger binding:
|
|
163
|
+
// plain — vault.seal: XChaCha20-Poly1305 under the vault root,
|
|
164
|
+
// no AAD; a DB-write attacker can copy a cell to another
|
|
165
|
+
// row undetected (CWE-311 / CWE-326).
|
|
166
|
+
// aad — vault.aad.seal: AEAD-bound to (table,row,column,
|
|
167
|
+
// schemaVersion); a relocated cell fails Poly1305.
|
|
168
|
+
// per-row-key — K_row crypto-shred: aad binding PLUS a per-row key,
|
|
169
|
+
// so destroying the row-secret renders residue
|
|
170
|
+
// mathematically undecryptable.
|
|
171
|
+
var SEAL_ENVELOPE_RANK = Object.freeze({
|
|
172
|
+
"plain": 0,
|
|
173
|
+
"aad": 1,
|
|
174
|
+
"per-row-key": 2,
|
|
175
|
+
});
|
|
176
|
+
|
|
158
177
|
// The framework registry table that holds each row's AAD-sealed
|
|
159
178
|
// row-secret. Named once so the seal-side AAD (materializePerRowKey),
|
|
160
179
|
// the read-side AAD (unsealRow's K_row fetch), and rotate's reseal all
|
|
@@ -232,6 +251,14 @@ function isRowSealed(value) {
|
|
|
232
251
|
* hash namespaces. Subsequent `sealRow` / `unsealRow` / `eraseRow`
|
|
233
252
|
* calls dispatch through this registry.
|
|
234
253
|
*
|
|
254
|
+
* Seal-envelope floor: when a compliance posture that declares a
|
|
255
|
+
* `sealEnvelopeFloor` is globally pinned (`b.compliance.set` — today
|
|
256
|
+
* `hipaa` / `pci-dss` require at least an AAD-bound envelope), a table
|
|
257
|
+
* that seals columns under a weaker envelope throws
|
|
258
|
+
* `crypto-field/seal-envelope-below-floor` here at registration so the
|
|
259
|
+
* operator catches the under-protected schema at boot. Unpinned and
|
|
260
|
+
* non-regulated deployments register unchanged.
|
|
261
|
+
*
|
|
235
262
|
* @opts
|
|
236
263
|
* sealedFields: string[], // column names sealed via vault.seal
|
|
237
264
|
* derivedHashes: { [hashCol]: { from: string, normalize?: fn } },
|
|
@@ -289,8 +316,25 @@ function registerTable(name, opts) {
|
|
|
289
316
|
"'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
|
|
290
317
|
}
|
|
291
318
|
}
|
|
319
|
+
var sealedFields = Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [];
|
|
320
|
+
// Seal-envelope floor gate. Only fires when ALL hold:
|
|
321
|
+
// (1) a posture is globally pinned (b.compliance.set) — read via
|
|
322
|
+
// compliance().current(), the same source the residency write
|
|
323
|
+
// gates read; an UNPINNED deployment is untouched (back-compat),
|
|
324
|
+
// (2) that posture declares a sealEnvelopeFloor in POSTURE_DEFAULTS
|
|
325
|
+
// (only regulated regimes do — hipaa / pci-dss), and
|
|
326
|
+
// (3) the table actually seals columns under an envelope WEAKER than
|
|
327
|
+
// the floor.
|
|
328
|
+
// A non-sealing table, an unpinned deployment, or a posture without a
|
|
329
|
+
// floor all pass through exactly as before. Config-time / entry-point
|
|
330
|
+
// tier: THROW so the operator catches the under-protected schema at
|
|
331
|
+
// boot rather than shipping PHI/PCI under a relocatable plain seal
|
|
332
|
+
// (CWE-311 / CWE-326).
|
|
333
|
+
if (sealedFields.length > 0) {
|
|
334
|
+
_assertSealEnvelopeFloor(name, aadOn);
|
|
335
|
+
}
|
|
292
336
|
schemas[name] = {
|
|
293
|
-
sealedFields:
|
|
337
|
+
sealedFields: sealedFields,
|
|
294
338
|
derivedHashes: derivedHashes,
|
|
295
339
|
hashNamespaces: Object.assign({}, opts.hashNamespaces || {}),
|
|
296
340
|
aad: aadOn,
|
|
@@ -300,6 +344,48 @@ function registerTable(name, opts) {
|
|
|
300
344
|
};
|
|
301
345
|
}
|
|
302
346
|
|
|
347
|
+
// _assertSealEnvelopeFloor — config-time guard for registerTable. Reads
|
|
348
|
+
// the globally-pinned posture (compliance().current()) and its declared
|
|
349
|
+
// sealEnvelopeFloor; throws when `table` seals columns under a weaker
|
|
350
|
+
// envelope. No-op when no posture is pinned, the posture declares no
|
|
351
|
+
// floor, or compliance isn't loaded — so unpinned/non-regulated
|
|
352
|
+
// deployments register exactly as before.
|
|
353
|
+
function _assertSealEnvelopeFloor(table, aadOn) {
|
|
354
|
+
var posture;
|
|
355
|
+
var floor;
|
|
356
|
+
try {
|
|
357
|
+
var c = compliance();
|
|
358
|
+
posture = c.current();
|
|
359
|
+
if (typeof posture !== "string" || posture.length === 0) return;
|
|
360
|
+
floor = c.postureDefault(posture, "sealEnvelopeFloor");
|
|
361
|
+
} catch (_e) {
|
|
362
|
+
// compliance not loaded / unavailable — record nothing, gate nothing.
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (typeof floor !== "string" || !Object.prototype.hasOwnProperty.call(SEAL_ENVELOPE_RANK, floor)) {
|
|
366
|
+
return; // posture pins no recognised floor → back-compat pass-through
|
|
367
|
+
}
|
|
368
|
+
// Declared envelope for this table: per-row-key beats aad beats plain.
|
|
369
|
+
// declarePerRowKey may run before or after registerTable; honour it
|
|
370
|
+
// when it ran first.
|
|
371
|
+
var declared = perRowKeyTables[table] ? "per-row-key" : (aadOn ? "aad" : "plain");
|
|
372
|
+
if (SEAL_ENVELOPE_RANK[declared] < SEAL_ENVELOPE_RANK[floor]) {
|
|
373
|
+
throw new CryptoFieldError("crypto-field/seal-envelope-below-floor",
|
|
374
|
+
"registerTable: table '" + table + "' seals columns under the '" +
|
|
375
|
+
declared + "' envelope, but the pinned compliance posture '" +
|
|
376
|
+
posture + "' requires at least '" + floor + "'. " +
|
|
377
|
+
(floor === "aad"
|
|
378
|
+
? "Pass registerTable({ aad: true, rowIdField: <pk> }) so each " +
|
|
379
|
+
"cell is AEAD-bound to (table, row, column) and cannot be " +
|
|
380
|
+
"relocated between rows"
|
|
381
|
+
: "Call b.cryptoField.declarePerRowKey('" + table + "', ...) " +
|
|
382
|
+
"before registerTable so each row gets a crypto-shred K_row") +
|
|
383
|
+
" (CWE-311 / CWE-326). Unpinned or non-regulated deployments are " +
|
|
384
|
+
"unaffected; this gate fires only under a posture that declares a " +
|
|
385
|
+
"sealEnvelopeFloor.");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
303
389
|
// Derived-hash digest width for the keyed (hmac-shake256) mode: 32
|
|
304
390
|
// bytes -> 64 hex chars.
|
|
305
391
|
var DERIVED_HASH_BYTES = 32;
|
package/lib/error-page.js
CHANGED
|
@@ -373,9 +373,22 @@ function create(opts) {
|
|
|
373
373
|
// Audit every error. Best-effort — never let an audit-write failure
|
|
374
374
|
// mask the original error. Outcome differentiates 5xx (failure) vs
|
|
375
375
|
// 4xx (denied) so consumers can filter without re-classifying status.
|
|
376
|
+
//
|
|
377
|
+
// Use safeEmit, not emit: the metadata.stack and reason fields carry
|
|
378
|
+
// the original exception's stack + message, which routinely embed
|
|
379
|
+
// secrets (a database connection string, an API key, a bearer token
|
|
380
|
+
// surfaced inside a thrown error). emit() writes straight to the
|
|
381
|
+
// tamper-evident, durable audit chain WITHOUT redaction, so those
|
|
382
|
+
// secrets would persist in plaintext in the signed log
|
|
383
|
+
// (CWE-532: insertion of sensitive information into log file).
|
|
384
|
+
// safeEmit runs b.redact.redact() over actor / reason / metadata —
|
|
385
|
+
// including nested keys like metadata.stack — before the record
|
|
386
|
+
// reaches the chain, scrubbing connection strings, JWTs, PEM blocks,
|
|
387
|
+
// and AWS keys. safeEmit is also drop-silent on malformed input,
|
|
388
|
+
// matching this hot-path "audit best-effort" posture.
|
|
376
389
|
if (auditOn) {
|
|
377
390
|
try {
|
|
378
|
-
audit().
|
|
391
|
+
audit().safeEmit({
|
|
379
392
|
action: auditAction,
|
|
380
393
|
outcome: info.status >= 500 ? "failure" : "denied",
|
|
381
394
|
actor: requestHelpers.extractActorContext(req, {
|