@blamejs/core 0.14.25 → 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 +4 -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/oauth.js +25 -5
- package/lib/auth/openid-federation.js +108 -47
- package/lib/auth/sd-jwt-vc.js +16 -3
- package/lib/break-glass.js +153 -3
- package/lib/compliance.js +147 -4
- package/lib/crypto-field.js +87 -1
- package/lib/dsr.js +378 -52
- 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/queue-local.js +23 -1
- package/lib/queue.js +7 -0
- package/lib/redact.js +68 -11
- package/lib/redis-client.js +160 -31
- package/lib/request-helpers.js +7 -0
- package/lib/router.js +212 -5
- package/lib/ssrf-guard.js +51 -4
- package/lib/static.js +132 -27
- package/lib/vault/rotate.js +64 -44
- 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/auth/sd-jwt-vc.js
CHANGED
|
@@ -482,9 +482,13 @@ async function verify(presentation, opts) {
|
|
|
482
482
|
"verify: issuerKeyResolver returned no key");
|
|
483
483
|
}
|
|
484
484
|
// CVE-2026-22817 — when issuerKeyResolver returns a JWK object,
|
|
485
|
-
// cross-check alg/kty BEFORE handing it to
|
|
486
|
-
// KeyObject / PEM shapes can't surface kty so
|
|
487
|
-
// JWK
|
|
485
|
+
// cross-check the issuer JWS alg/kty BEFORE handing it to
|
|
486
|
+
// node:crypto.verify. KeyObject / PEM shapes can't surface kty, so this
|
|
487
|
+
// guard only fires when the resolver hands back a JWK (the common path).
|
|
488
|
+
// The holder KB-JWT path applies its OWN _assertAlgKtyMatch against the
|
|
489
|
+
// cnf.jwk below — note that the holder key is issuer-ATTESTED (it comes
|
|
490
|
+
// from the cryptographically-verified issuer payload's cnf claim), not
|
|
491
|
+
// header-resolved, so the two cross-checks defend different trust edges.
|
|
488
492
|
if (typeof issuerKey === "object" &&
|
|
489
493
|
!(issuerKey instanceof nodeCrypto.KeyObject) &&
|
|
490
494
|
!Buffer.isBuffer(issuerKey) &&
|
|
@@ -610,6 +614,15 @@ async function verify(presentation, opts) {
|
|
|
610
614
|
throw new AuthError("auth-sd-jwt-vc/unsupported-alg",
|
|
611
615
|
"verify: KB-JWT alg unsupported");
|
|
612
616
|
}
|
|
617
|
+
// CVE-2026-22817 — cross-check the KB-JWT header alg against the holder
|
|
618
|
+
// key type BEFORE importing the key / verifying. The issuer path does
|
|
619
|
+
// this for issuerKey (above); the holder KB-JWT path must too. The
|
|
620
|
+
// KB-JWT header alg is attacker-controllable (the holder mints the
|
|
621
|
+
// KB-JWT), and holderKey is a cnf.jwk with a kty, so an alg/kty
|
|
622
|
+
// mismatch (e.g. a header claiming EdDSA against an EC cnf key) is
|
|
623
|
+
// refused with the precise alg-mismatch error rather than handed to
|
|
624
|
+
// node:crypto.verify.
|
|
625
|
+
jwtExternal._assertAlgKtyMatch(kbAlg, holderKey);
|
|
613
626
|
var holderKeyObj = nodeCrypto.createPublicKey({ key: holderKey, format: "jwk" });
|
|
614
627
|
var kbParsed = _verifyJwt(maybeKbJwt, holderKeyObj, kbAlg);
|
|
615
628
|
if (opts.audience && kbParsed.payload.aud !== opts.audience) {
|
package/lib/break-glass.js
CHANGED
|
@@ -75,6 +75,10 @@ var DEK_BYTES = C.BYTES.bytes(32);
|
|
|
75
75
|
var GRANT_ID_BYTES = C.BYTES.bytes(16);
|
|
76
76
|
|
|
77
77
|
var DEFAULT_GRANT_TTL_MS = C.TIME.minutes(15);
|
|
78
|
+
// Replay-step retention. A TOTP code is only valid inside the verifier's
|
|
79
|
+
// drift window (minutes); retaining the highest-accepted step for an hour
|
|
80
|
+
// guarantees any in-window replay attempt arrives after the floor is set.
|
|
81
|
+
var REPLAY_STEP_TTL_MS = C.TIME.hours(1);
|
|
78
82
|
var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
|
|
79
83
|
var DEFAULT_REASON_MIN_LEN = 12;
|
|
80
84
|
var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
|
|
@@ -817,10 +821,58 @@ function _verifyTotpFactor(factor) {
|
|
|
817
821
|
if (!factor || typeof factor !== "object") return { ok: false };
|
|
818
822
|
if (typeof factor.secret !== "string" || factor.secret.length === 0) return { ok: false };
|
|
819
823
|
if (typeof factor.code !== "string" || factor.code.length === 0) return { ok: false };
|
|
820
|
-
|
|
824
|
+
// factor.now threads a deterministic test clock into totp.verify. The
|
|
825
|
+
// replay floor is NOT applied here: acceptance reserves the matched step
|
|
826
|
+
// atomically in _reserveTotpStep, so two concurrent grants presenting the
|
|
827
|
+
// same in-window code cannot both pass (a read-then-commit floor races —
|
|
828
|
+
// both reads observe the old floor before either commits). totp.verify
|
|
829
|
+
// returns the step the code matches (a fixed value for a given code within
|
|
830
|
+
// the drift window) or false; the reserve then floors replays of that step.
|
|
831
|
+
var vopts = {};
|
|
832
|
+
if (typeof factor.now === "number") vopts.now = factor.now;
|
|
833
|
+
var verified = totp.verify(factor.secret, factor.code, vopts);
|
|
821
834
|
return { ok: verified !== false, step: verified };
|
|
822
835
|
}
|
|
823
836
|
|
|
837
|
+
// Replay-step cache key. Keyed by BOTH the actorId AND a non-reversible
|
|
838
|
+
// fingerprint of the TOTP secret. Keying on actorId alone would falsely
|
|
839
|
+
// reject a legitimate second grant when two distinct credentials accept a
|
|
840
|
+
// code at the same TOTP step (the step number is a wall-clock counter, not
|
|
841
|
+
// per-credential) — the secret fingerprint disambiguates them. The secret
|
|
842
|
+
// never reaches the cache in any reversible form.
|
|
843
|
+
function _replayStepKey(actorId, secret) {
|
|
844
|
+
return "totp-step:" + actorId + ":" + sha3Hash(secret);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Atomically reserve the accepted TOTP step for (actorId, secret): advance
|
|
848
|
+
// the stored replay floor to `step` only when `step` is strictly above the
|
|
849
|
+
// current floor, and report whether THIS caller won the reservation. The
|
|
850
|
+
// compare-and-advance is one atomic cache update, so two concurrent grant()
|
|
851
|
+
// calls presenting the same in-window code cannot both pass — the first wins
|
|
852
|
+
// and raises the floor to `step`, the second observes step <= floor and is
|
|
853
|
+
// refused. (A separate read-then-commit sequence let both reads see the old
|
|
854
|
+
// floor before either committed, so both verified — the replay this closes.)
|
|
855
|
+
// The TTL outlives the verify drift window many times over so a replayed code
|
|
856
|
+
// stays floored until it expires.
|
|
857
|
+
//
|
|
858
|
+
// Fails CLOSED (returns false) on a cache fault: a grant cannot proceed
|
|
859
|
+
// without a working factor cache regardless — the lockout check at the top of
|
|
860
|
+
// grant() already gates on the same cache — so refusing here can only reject,
|
|
861
|
+
// never loosen replay protection.
|
|
862
|
+
async function _reserveTotpStep(actorId, secret, step) {
|
|
863
|
+
_ensureFactorLockout();
|
|
864
|
+
if (typeof step !== "number") return false;
|
|
865
|
+
var won = false;
|
|
866
|
+
try {
|
|
867
|
+
await _factorLockoutCache.update(_replayStepKey(actorId, secret), function (prior) {
|
|
868
|
+
if (typeof prior === "number" && step <= prior) { won = false; return { value: prior }; }
|
|
869
|
+
won = true;
|
|
870
|
+
return { value: step };
|
|
871
|
+
}, { ttlMs: REPLAY_STEP_TTL_MS });
|
|
872
|
+
} catch (_e) { return false; }
|
|
873
|
+
return won;
|
|
874
|
+
}
|
|
875
|
+
|
|
824
876
|
// Passkey factor — operator presents a WebAuthn assertion plus the
|
|
825
877
|
// challenge/origin/RPID + the previously-enrolled credential record.
|
|
826
878
|
// Phishing-resistant; the private key lives on the YubiKey, not in
|
|
@@ -990,8 +1042,20 @@ async function grant(opts) {
|
|
|
990
1042
|
}
|
|
991
1043
|
|
|
992
1044
|
var factorOk = false;
|
|
1045
|
+
var totpSecret = null;
|
|
993
1046
|
if (factorType === "totp") {
|
|
994
|
-
|
|
1047
|
+
totpSecret = opts.factor && opts.factor.secret;
|
|
1048
|
+
// Verify the code, then atomically reserve the step it matched as the act
|
|
1049
|
+
// of acceptance. The reserve advances the per-(actor,secret) replay floor
|
|
1050
|
+
// in one compare-and-set, so a code already redeemed inside the drift
|
|
1051
|
+
// window — including by a concurrent grant for the same credential — is
|
|
1052
|
+
// refused. (A read-then-commit floor raced: both grants read the old
|
|
1053
|
+
// floor before either committed, so both passed.)
|
|
1054
|
+
var totpResult = _verifyTotpFactor(opts.factor);
|
|
1055
|
+
if (totpResult.ok && typeof totpResult.step === "number" &&
|
|
1056
|
+
typeof totpSecret === "string" && totpSecret.length > 0) {
|
|
1057
|
+
factorOk = await _reserveTotpStep(actorId, totpSecret, totpResult.step);
|
|
1058
|
+
}
|
|
995
1059
|
} else if (factorType === "passkey") {
|
|
996
1060
|
factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
|
|
997
1061
|
}
|
|
@@ -1086,6 +1150,78 @@ function _reasonForAudit(reason, mode) {
|
|
|
1086
1150
|
return out;
|
|
1087
1151
|
}
|
|
1088
1152
|
|
|
1153
|
+
// Enforce the grant's IP / session bindings at redemption. policy.set
|
|
1154
|
+
// documents pinIp / sessionPin as default-ON, and grant() captures
|
|
1155
|
+
// grantRow.ip / grantRow.sessionId at mint time — but without this gate
|
|
1156
|
+
// the bindings are stored-and-never-enforced (a grant minted from IP-A
|
|
1157
|
+
// would redeem from IP-B). Called BEFORE the SELECT-then-increment so a
|
|
1158
|
+
// mismatch does not consume a grant.
|
|
1159
|
+
//
|
|
1160
|
+
// FAIL-CLOSED: when a pin is requested but the binding was captured null
|
|
1161
|
+
// (e.g. an Express-shaped req whose IP requestHelpers.clientIp couldn't
|
|
1162
|
+
// read at mint time), the redemption is REFUSED rather than silently
|
|
1163
|
+
// skipped — a `grantRow.ip != null` short-circuit would defeat the pin
|
|
1164
|
+
// for exactly the requests whose binding capture failed.
|
|
1165
|
+
function _enforceGrantPins(policy, grantRow, redeemReq, actorFor) {
|
|
1166
|
+
if (!policy) return;
|
|
1167
|
+
if (policy.pinIp) {
|
|
1168
|
+
if (grantRow.ip == null) {
|
|
1169
|
+
audit.safeEmit({
|
|
1170
|
+
action: "breakglass.unsealrow",
|
|
1171
|
+
outcome: "denied",
|
|
1172
|
+
actor: actorFor(grantRow),
|
|
1173
|
+
reason: "grant-ip-binding-missing",
|
|
1174
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1175
|
+
});
|
|
1176
|
+
throw new BreakGlassError("breakglass/grant-ip-mismatch",
|
|
1177
|
+
"unsealRow: grant " + grantRow._id + " has pinIp on but no IP was " +
|
|
1178
|
+
"captured at mint (fail-closed) — re-mint from a request whose client " +
|
|
1179
|
+
"IP the framework can resolve", true);
|
|
1180
|
+
}
|
|
1181
|
+
var redeemIp = requestHelpers.clientIp(redeemReq, { trustProxy: _trustProxy });
|
|
1182
|
+
if (redeemIp !== grantRow.ip) {
|
|
1183
|
+
audit.safeEmit({
|
|
1184
|
+
action: "breakglass.unsealrow",
|
|
1185
|
+
outcome: "denied",
|
|
1186
|
+
actor: actorFor(grantRow),
|
|
1187
|
+
reason: "grant-ip-mismatch",
|
|
1188
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1189
|
+
});
|
|
1190
|
+
throw new BreakGlassError("breakglass/grant-ip-mismatch",
|
|
1191
|
+
"unsealRow: grant " + grantRow._id + " is pinned to its issuing IP " +
|
|
1192
|
+
"and this redemption arrived from a different address", true);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (policy.sessionPin) {
|
|
1196
|
+
if (grantRow.sessionId == null) {
|
|
1197
|
+
audit.safeEmit({
|
|
1198
|
+
action: "breakglass.unsealrow",
|
|
1199
|
+
outcome: "denied",
|
|
1200
|
+
actor: actorFor(grantRow),
|
|
1201
|
+
reason: "grant-session-binding-missing",
|
|
1202
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1203
|
+
});
|
|
1204
|
+
throw new BreakGlassError("breakglass/grant-session-mismatch",
|
|
1205
|
+
"unsealRow: grant " + grantRow._id + " has sessionPin on but no " +
|
|
1206
|
+
"session id was captured at mint (fail-closed) — re-mint from a " +
|
|
1207
|
+
"request carrying req.session.id", true);
|
|
1208
|
+
}
|
|
1209
|
+
var redeemSession = (redeemReq && redeemReq.session && redeemReq.session.id) || null;
|
|
1210
|
+
if (redeemSession !== grantRow.sessionId) {
|
|
1211
|
+
audit.safeEmit({
|
|
1212
|
+
action: "breakglass.unsealrow",
|
|
1213
|
+
outcome: "denied",
|
|
1214
|
+
actor: actorFor(grantRow),
|
|
1215
|
+
reason: "grant-session-mismatch",
|
|
1216
|
+
metadata: { grantId: grantRow._id, table: grantRow.scopeTable },
|
|
1217
|
+
});
|
|
1218
|
+
throw new BreakGlassError("breakglass/grant-session-mismatch",
|
|
1219
|
+
"unsealRow: grant " + grantRow._id + " is pinned to its issuing " +
|
|
1220
|
+
"session and this redemption arrived from a different session", true);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1089
1225
|
// ---- Use a grant ----
|
|
1090
1226
|
|
|
1091
1227
|
/**
|
|
@@ -1195,6 +1331,13 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
1195
1331
|
grantRow.maxRowsPerGrant + " allowed rows", true);
|
|
1196
1332
|
}
|
|
1197
1333
|
|
|
1334
|
+
// IP / session pin enforcement — BEFORE the SELECT-then-increment so a
|
|
1335
|
+
// pin mismatch does not consume the grant. Fail-closed when a requested
|
|
1336
|
+
// pin's binding was captured null (see _enforceGrantPins). The policy is
|
|
1337
|
+
// fetched once here and reused for the Model-A/B unseal dispatch below.
|
|
1338
|
+
var policy = await policyGet(table);
|
|
1339
|
+
_enforceGrantPins(policy, grantRow, opts.req, _actorFor);
|
|
1340
|
+
|
|
1198
1341
|
// SELECT-before-increment — fetch the target row FIRST. If the row
|
|
1199
1342
|
// doesn't exist (operator typo, race with row-deletion, etc.), the
|
|
1200
1343
|
// grant should not be consumed. Without this ordering, a single
|
|
@@ -1237,7 +1380,8 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
1237
1380
|
"unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
|
|
1238
1381
|
}
|
|
1239
1382
|
void updateRes;
|
|
1240
|
-
|
|
1383
|
+
// policy was fetched above for the pin enforcement; reuse it for the
|
|
1384
|
+
// Model-A vs Model-B (cryptographic) unseal dispatch.
|
|
1241
1385
|
var unsealedRow;
|
|
1242
1386
|
if (policy && policy.cryptographic) {
|
|
1243
1387
|
// Snapshot the raw glass-locked column ciphertexts BEFORE
|
|
@@ -1427,6 +1571,12 @@ async function listActive(opts) {
|
|
|
1427
1571
|
* distinct `breakglass.grant.bypass` audit row so post-incident review
|
|
1428
1572
|
* separates operator-initiated reads from scheduled-job reads.
|
|
1429
1573
|
*
|
|
1574
|
+
* This path is service-to-service: it consumes NO grant row, so the
|
|
1575
|
+
* `pinIp` / `sessionPin` grant bindings enforced by `unsealRow` do not
|
|
1576
|
+
* apply here. A grant that was minted with those pins is not redeemable
|
|
1577
|
+
* through this surface — the bypass is gated solely by the
|
|
1578
|
+
* `serviceAccountBypass` allowlist + required-role check.
|
|
1579
|
+
*
|
|
1430
1580
|
* @opts
|
|
1431
1581
|
* reason: string, // operator-supplied reason recorded into the audit row
|
|
1432
1582
|
*
|