@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.
@@ -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
  /**
@@ -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 node:crypto.verify.
486
- // KeyObject / PEM shapes can't surface kty so the check happens at
487
- // JWK resolution only.
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) {
@@ -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
- var verified = totp.verify(factor.secret, factor.code);
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
- factorOk = _verifyTotpFactor(opts.factor).ok;
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
- var policy = await policyGet(table);
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
  *