@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.
@@ -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 every metadata_policy in the chain (top-down) to the leaf's
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
- * The chain is leaf-first; we reverse for top-down application so
343
- * the trust anchor's policy applies first, then each intermediate's,
344
- * then the leaf's claimed metadata is the starting object.
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.claims) continue;
368
- if (stmt.claims.metadata_policy && stmt.claims.metadata_policy[kind]) {
369
- out = _applyOnePolicy(out, stmt.claims.metadata_policy[kind]);
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-signed: verify with its own jwks.
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
- _emitAudit("chain_built", "success", {
468
- leaf: opts.leafEntityId, depth: chain.length, anchor: current,
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 resolves to a trust anchor,
485
- // OR the first that returns a valid subordinate statement. Real
486
- // operators with multiple federations usually have one anchor
487
- // active; we walk in order and pick the first success.
488
- // Track every per-authority failure reason and surface them on
489
- // `no-ascent` rather than masking silently
490
- // swallowing `catch (_e) {}` lets a hostile intermediate that
491
- // serves a malformed-then-valid pair shape-walk the verifier.
492
- // We continue past 404 / fetch errors but refuse on
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
- var authorityCfgJwt = await fetcher(authority.replace(/\/$/, "") + "/.well-known/openid-federation");
506
- var authorityCfgClaims = parseEntityStatement(authorityCfgJwt).claims;
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 verifiable subordinate statement: " +
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
- throw new AuthError("auth-openid-federation/chain-too-deep",
547
- "buildTrustChain: max depth " + maxDepth + " exceeded; refused");
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`, and
1361
- * `requireVacuumAfterErase` — the floors enforced by `b.backup`,
1362
- * `b.audit`, the TLS minimum-version gate, and `b.cryptoField`'s
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,
@@ -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: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
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().emit({
391
+ audit().safeEmit({
379
392
  action: auditAction,
380
393
  outcome: info.status >= 500 ? "failure" : "denied",
381
394
  actor: requestHelpers.extractActorContext(req, {