fix(auth): bind refresh chains to the tailnet peer that opened them - #8617
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound extension of an existing mechanism, but the shipped corrupt-state posture contradicts the PR body, and two flagged tradeoffs genuinely need the maintainer call. Watch
Suggestions
[DESIGN-REVIEWED] 2031ba8 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. Composing the review. First-Principles-Verdict: CONCERNS The fix earns its place; the server-side What this change shipsIntent: stop a stolen refresh cookie from renewing a session on a different allowed device — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 2031ba8 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate list contained no candidates, and my independent trace of the security-critical path confirms the logic is sound: the degraded store fails closed in No findings. [OPUS-REVIEWED] 2031ba8 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
b570091 to
7c66e0f
Compare
7c66e0f to
0fe61d6
Compare
|
BLOCKING — Malformed chain-peer state crashes refresh authentication (
Legitimate, and the consequence is worse than a crash in isolation: the raise happens inside the Fixed at a shared chokepoint (
Guarding only the reported key would have left the identical crash on its two siblings three lines away, and left the whole-document case unguarded ahead of all three. All seven now resolve to "no records" — the same direction Coverage: |
0fe61d6 to
2dd5b8f
Compare
2dd5b8f to
45cf3e3
Compare
|
BLOCKING — exchanges leak process-global authentication state (
Correct on both halves, and measured rather than reasoned about. I removed the proposed fixture and ran this file followed by a canary asserting the globals are clean: The second line is the sharper half and is exactly what you described: Root cause is deliberate and stays: Fixed with the fixture Canary green after the fix, and it also contains the one test that deliberately clears |
efe1ef2 to
bbfb364
Compare
What I got wrong. I argued this could not be fixed without reversing The invariant now implemented, which satisfies every round on this span at once rather than trading one off against another:
Two consequences worth calling out because neither is obvious. First, Red-before, on the exact harm you described — revoke a chain, then corrupt the list that recorded it: Green after. Coverage added: the revival case above; the endpoint answering 401 rather than 500 (round 1) or 200 (round 3); the degraded store refusing to overwrite the file; absence not counting as corruption; and 887 passed / 3 skipped / 0 failed across the auth, refresh, tailnet, token_auth, config-baseline, peer-auth, mobile-link and diagnostics families, |
## Problem Phase 3 pins ACCESS sessions to a daemon-verified tailnet peer, and PR #2411 made rotation carry that pin forward onto the replacement access token. What it did not do was bind the refresh CHAIN. The peer-bound rotation mechanism (`require_peer` / `peer_key`) existed and worked, but exactly one producer armed it: the persistent QR phone session. Every ordinary Phase-3 session opened an UNBOUND chain and took the `else` branch into `_rebind_rotated_token_to_peer`, which re-binds the rotated token to whoever presents it next. So a refresh cookie stolen from allowed node A and replayed from allowed node B rotated cleanly and came back pinned to B. The chain was the laundering path around the access token's own pin. Proven before the fix, on origin/main: an ordinary `?token=` exchange with a verified laptop peer mints a chain with `require_peer` absent; replaying that cookie with the phone resolving returns 200 and `check_token_peer(new_access, PHONE_KEY)` is True. ## What changed The existing mechanism is extended to ordinary Phase-3 sessions rather than a second one being invented. - `token_auth`: an exchange that resolves a daemon-verified, allowlisted peer now opens its chain with `require_peer` + that peer's `peer_key`, and records the binding server-side. Gated on a RESOLVED peer, not on the pin key, since that key is `ip:<addr>` when nothing resolved and binding a chain to the tunnel's shared loopback would read as a pin while excluding nobody. - `refresh_tokens`: `refresh_chains.json` gains a `chain_peers` record (`chain_id` -> `peer_key`, `exp`), evicted with its chain, dropped on revocation, and re-stamped inside the same locked write as the consumption so one rotation still costs one state write. - `auth_refresh`: the signed claim and the server-side record are two independent authorities, and the request must satisfy every key either names. A rotation path that drops the claim is caught by the record it cannot influence. It does NOT cover a mint path that omits both -- such a chain is indistinguishable from a legitimate pre-upgrade one. - `dashboard.tailscale.bind_refresh_chains` (default `true`) is the operator's opt-out. Narrowing-only like the other two tailscale load rules: a non-boolean resolves to `true`, so a typo can only leave the binding on. ## The roaming tradeoff (#2417 comment) `pin_scope` already owns it, and the binding respects it. At `login` scope the pin key is `ts:login:<login>`, so a person's other device rotates normally. At `node` scope the ACCESS token was already device-pinned, so an unbound chain was the only thing making cross-device use appear to work -- by laundering a fresh pin, which is the defect. ## Migration Absence. A chain with neither the signed claim nor a `chain_peers` record is unbound -- every chain outstanding at upgrade, and every session opened with no verified peer -- so the change logs nobody out by itself. ## Unreadable persisted state fails closed, and does not crash Three review rounds landed on this one function from opposite directions, and the invariant that satisfies all of them is: absence is fine; corruption of a PRESENT security record is not. - A malformed container used to RAISE inside the `RefreshStateManager` constructor, which runs from `_get_state()`, so one bad byte-range 500'd every `/api/auth/refresh` until the file was hand-repaired. `.get(key, [])` does not cover it: the key exists, so the default is never consulted. - Reading it as EMPTY is the opposite defect, and the one the Opus adjudication upheld as non-self-healing: `revoked_chains` read as empty makes `is_chain_revoked` answer False for a chain logout already killed, so a stolen cookie rotates into fresh credentials. Reuse detection does not save you -- it only re-fires on a jti replay the attacker need not cause. So `_record_list` now returns `None` for a present-but-unreadable list, `_load` marks the store degraded, and `validate_refresh_token` refuses with `"refresh state unavailable"` -- the same posture the function already applies to an unreadable revocation counter twelve lines down. `_persist` refuses while degraded, because writing our empty state over the operator's file would destroy the records AND let the next start resume rotating cleanly, turning the refusal into the bypass it prevents. Deliberately NOT touched: an unparseable FILE still starts empty. `atomic_write` makes a torn write impossible, so a file that will not parse at all is better read as "not our state" than as "our records, lost", and that behaviour is pinned by `test_tr_i_17_corrupted_state_file_starts_empty` (settled in an earlier review round). A regression test now asserts that boundary so a future round of this argument cannot quietly widen into it. ## Residual tradeoff flagged for maintainer review The claim is carried onto BOTH halves of the rotated pair, because binding only the chain would leave the access cookie with the same laundering shape one credential over (an empty pin map after a restart re-pins it to whichever allowed node arrives first). An access cookie carrying `require_peer` fails closed when no peer resolves, so a session bound this way needs one re-mint via the `kirocrew token` URL after an operator switches identity trust OFF. ## Tests `test/test_refresh_peer_binding.py`, 27 tests, red before / green after: - the exploit end to end (steal from laptop, replay from phone) and that the refusal neither consumes the jti nor revokes the chain; - the mint side, the persisted record, and a fresh manager reading it back; - roaming at `login` scope, and the config opt-out; - migration (claimless + unrecorded chain still rotates); - the record alone refusing a different peer with no claim on the token; - fail-closed on an unresolvable peer, without revoking; - the rotated ACCESS token's signed device, and no first-arrival takeover after a restart while the original device still authenticates; - a revoked chain that must NOT come back to life through a corrupt file; - the degraded store refusing rotation at the endpoint with 401, not 500 or 200; - the degraded store refusing to overwrite the unreadable file; - absence not counting as corruption, and the unparseable-file boundary above; - process-global auth state isolated per test, since these drive the real middleware (measured: 10 leaked peer bindings and a store singleton pinned to a prior test's tmp home before the fixture). Suite: 887 passed / 3 skipped / 0 failed across the auth, refresh, tailnet, token_auth, config-baseline, peer-auth, mobile-link and diagnostics families. black / isort / flake8 / mypy (1294 files) / docs-lint and the repo's twelve `check_*` gates clean; `config-baseline.json` regenerated for the new field. Closes #2417
bbfb364 to
2031ba8
Compare
Entirely my error, and the worst kind on a security PR: the body was written for round 1's fix and I did not rewrite it when round 3 inverted the direction. A reviewer approving that text would have been approving fail-open while the diff shipped fail-closed. The section is now titled "Unreadable persisted state fails closed, and does not crash" and leads with the invariant the code implements — absence is fine; corruption of a PRESENT security record is not — followed by a five-row table giving the actual reading for each file state, both rounds' findings as the reasons, and the availability cost named outright ("all rotation stops until the file is repaired or removed"). Your suggestion to rename it was right: the old title asserted the inverse of the code. |
The asymmetry is real and you have described it exactly. Where I disagree is that it matters: the adversarial case is already lost before this file is reached. What the guard is genuinely for is accidental corruption, which is the shape that actually occurs: a hand-edit, a foreign or legacy format, a partial restore. Those produce a file that still parses as JSON but whose records cannot be read — and that is precisely the branch that now refuses. I have re-phrased the body away from adversarial language, since you are right that it invited exactly this objection. On not shipping the seam silently: the body now carries it as a named paragraph — that the unparseable and deleted cases still start empty, that this is scoped to accidental corruption, why the adversarial framing does not apply, and what closing it properly would take (a positive "I have no records" marker — a schema version or sentinel — so absence stops being ambiguous with fresh install). That is a wider format change than this PR should make, and it is the same reason the unparseable branch is left exactly as One correction to the note: the GPT-r3 decision is no longer queued — I fixed it in |
Agreed on all three points, including that it reaches every Phase-3 session rather than only the exploit path — that is the part a reader is most likely to miss, and it is why I have not treated the body note as an acceptance. The decision, unchanged from the earlier round: keep both halves (as shipped) also closes the post-restart first-arrival re-pin on the access cookie, at the cost you name; chain-only is two lines at the My recommendation is still to keep both, because the sibling hole is the same bug and shipping with it open invites the next round. But it is a user-visible availability change and the call is the module owner's. I will make the chain-only change on request; it does not need another review cycle to describe. |
I checked each step rather than taking it on trust, and it holds. The claim is inside the HMAC-signed payload, so an attacker cannot strip it and present a claimless token for a chain the record covers — the signature check rejects that before That leaves exactly the case you name — a future mint path that writes the record and omits the claim — and the producer/reader counts (1 and 1) are what make that hypothetical rather than latent. Your framing of the cost is also the part I had underweighted: not the lines, but a persisted schema field whose absence-means-unbound semantics have to stay true forever, in a file whose other keys now fail closed when unreadable. I am not defending it beyond that. Where it goes is the subtraction, answered in its own comment. |
I think you are right, and the split you drew is the right one — Why I am asking rather than doing it. The field is not incidental to the issue, it is written into its scope note: "Persisted-format change to Worth noting what dropping it also buys: it removes one of the open decisions outright. The record is why Ready to do it on a yes: delete both |
The second clause is the part I had not accounted for. Even granting the opt-out its intended beneficiary, two devices sharing one chain do not coexist quietly: the grace window is chain-head-only and same-IP, so the second device's rotation presents a jti the first has already consumed from a different address, which is reuse detection doing its job and revoking the chain. So Unchanged from the earlier round is why I am not deleting it myself: the flag exists as the answer to @NicholasRBowers' routing comment on #2417 — "it also stops legitimate roaming between allowed nodes, which is user-visible behavior that the remote-and-mobile guide currently documents as working". Removing the escape hatch that comment asked for is the same kind of call as adding it was, and it is now the second subtraction on this PR that collides with the issue text. My recommendation, updated by your argument: drop it, and let |
Problem / Motivation
A dashboard refresh cookie stolen from allowed tailnet node A and replayed from allowed node B rotates successfully, and the replacement access token comes back pinned to B.
Phase 3 pins ACCESS sessions to a daemon-verified tailnet peer, and #2411 made rotation carry that pin forward. The peer-bound rotation mechanism (
require_peer/peer_key) exists and works —/api/auth/refreshverifies the carried key against the daemon-verified peer and refuses withpeer_mismatch/peer_unverified/peer_binding_missingbefore any mint. But it was armed for exactly one producer: the persistent QR phone session intailnet_mobile.py. Every ordinary Phase-3 session opened an unbound chain and took theelsebranch into_rebind_rotated_token_to_peer, which re-binds the rotated token to whoever presents it next.Proven on
origin/mainbefore writing the fix: an ordinary?token=exchange with a verified laptop peer mints a chain withrequire_peerabsent; replaying that cookie with the phone resolving returns200andcheck_token_peer(new_access, PHONE_KEY)isTrue.Why it matters
The refresh cookie is a 30-day bearer credential, and the chain was the laundering path around the access token's own device pin. Anyone who obtains one — a backup, a shared browser profile, a second machine the user also owns and someone else can reach — converts it into a fresh, fully-pinned session on their own device, and the audit trail attributes it to them as a legitimate peer.
kirocrew logoutis the only thing that ends it, which the remote-and-mobile guide already documents as a known gap.What changed (motivation → approach → change)
The existing mechanism is extended to ordinary Phase-3 sessions; no second mechanism is introduced.
token_auth— an exchange that resolves a daemon-verified, allowlisted peer now opens its chain withrequire_peer+ that peer'speer_key. Gated on a resolved peer, not on the pin key: that key isip:<addr>when nothing resolved, and binding a chain to the tunnel's shared loopback address would read as a pin while excluding nobody.refresh_tokens—refresh_chains.jsongains achain_peersrecord (chain_id→peer_key,exp): evicted with its chain, dropped on revocation, and re-stamped inside the same locked write as the consumption, so one rotation still costs one state write and can never record a spent jti while losing the binding that decides who may spend its replacement. Malformed records are skipped rather than fatal, mirroring the existingexpguard.auth_refresh— the signed claim and the server-side record are two independent authorities, and the request must satisfy every key either of them names. The record earns its place because this gap was one mint path carrying the claim while the others did not: a rotation path that drops the claim is caught by the record it cannot influence. It does not cover a future mint path that omits both — such a chain has no claim and no record, which is indistinguishable from a legitimate pre-upgrade chain and so rotates unbound. Making that detectable would need a positive marker on every unbound chain (a schema version, or an explicitunboundrecord) so absence stops meaning "legacy"; that is a wider design move than this PR, and moot while the legacy-chain question below is still open. Credit to Design Review for catching the overclaim.dashboard.tailscale.bind_refresh_chains(defaulttrue) is the operator's opt-out. Narrowing-only like the two existing tailscale load rules — a non-boolean resolves totrue— so a typo can only ever leave the binding on.Security property now enforced: a refresh chain opened by a daemon-verified tailnet peer can only be rotated for that same peer key. A different node (node scope) or a different login is refused with
peer_identity_mismatchand audited. The refusal does not revoke the chain: identity resolution fails transiently as often as maliciously, and burning a 30-day credential over a daemon blip would turn a recoverable hiccup into a re-mint.The roaming tradeoff @NicholasRBowers raised
pin_scopealready owns roaming, and the binding respects it rather than overriding it:loginscope the pin key ists:login:<login>, so the same person's other device rotates normally. Roaming works with the binding on. (Test:test_login_scope_still_roams_between_the_users_own_devices.)nodescope (the default) the ACCESS token was already device-pinned in-memory. So an unbound chain was the only thing making cross-device use appear to work — by laundering a fresh pin on rotation, which is the defect itself, not a feature the guide promised.bind_refresh_chains: falsecovers the operator who genuinely needs cross-device roaming at node scope and accepts that a stolen refresh cookie then renews from any allowed node. Documented as that tradeoff in both the guide and the spec.Migration
Absence, per the issue's scope note. A chain with neither the signed claim nor a
chain_peersrecord is unbound — that is every chain outstanding at upgrade, and every session opened with no verified peer — so the change logs nobody out by itself.Unreadable persisted state fails closed, and does not crash
Three review rounds landed on this one function from opposite directions. The
invariant now shipped satisfies all of them:
refresh_chains.jsonatomic_writemakes a torn write impossible, so a file that will not parse is "not our state", not "our records, lost"Round 1 (GPT, BLOCKING) was that a malformed container raised inside the
RefreshStateManagerconstructor — which runs from_get_state()— so one badbyte-range 500'd every
/api/auth/refreshuntil the file was hand-repaired.A
.get(key, [])default does not cover it: the key exists, so the default isnever consulted.
Round 3 (GPT, upheld by the Opus adjudication) was that reading it as empty
is the opposite defect, and the more serious one:
revoked_chainsread as emptymakes
is_chain_revokedanswerFalsefor a chainPOST /api/auth/logoutalready killed, so a stolen cookie rotates into fresh credentials. Nothing
self-corrects it — reuse detection only re-fires on a jti replay the attacker
need not cause.
So
_record_listreturnsNonefor a present-but-unreadable list,_loadrecords which keys were unreadable, and
validate_refresh_tokenrefuses with"refresh state unavailable"— placed beforeis_chain_revoked, since thatis the check being bypassed. It is the same posture, and the same wording shape,
the function already applies twelve lines down when the revocation counter cannot
be read.
_persistalso refuses while degraded: writing our empty in-memorystate over the operator's file would destroy the records and let the next start
load a clean store and resume rotating, converting the refusal into the bypass it
exists to prevent.
Availability cost, stated plainly: all rotation stops until the file is
repaired or removed. The ~20h access cookie masks it,
revocation_genlives ina separate file so
kirocrew logoutstill works, and the condition is loud(
logger.errornaming the path and the affected keys).The seam this leaves, named rather than shipped quietly. An unparseable or
deleted file still starts empty, so
{"revoked_chains": null}refuses allrotation while
rm refresh_chains.jsonsilently drops the same revocations. Thatasymmetry is deliberate and is scoped to accidental corruption — a hand-edit,
a foreign or legacy format, a bad restore. It is not a defence against an
adversarial write, and cannot be: this file lives beside
token_signing.keyinthe same
0700data home, so anyone who can write one can write the other andmint arbitrary tokens outright. Closing the seam properly would need a positive
"I have no records" marker (a schema version, or a sentinel) so that absence
stops being ambiguous with fresh install — a wider format change than this PR
should carry, and the reason the unparseable-file branch is left exactly as
test_tr_i_17_corrupted_state_file_starts_emptypins it. A regression test nowasserts that boundary so a future round of this argument cannot quietly widen
into it.
The
require_peerclaim is carried onto both halves of the rotated pair. Binding only the chain would leave the access cookie with the same laundering shape one credential over: after a restart the in-memory pin map is empty, so a stolen access cookie is re-pinned to whichever allowed node presents it first. Binding both closes that sibling hole for free and keeps the two credentials consistent.The cost: an access cookie carrying
require_peerfails closed when no peer resolves, so a session bound this way needs one re-mint via thekirocrew tokenURL after an operator switches identity trust off. That is the same fail-closed posture the QRrequire_peershape already has, but it is new for ordinary sessions, so it is a maintainer decision rather than mine. The narrower alternative — bind the chain only, leave the rotated access token as today — is a two-line change if you prefer it; I did not take it because it leaves the post-restart takeover hole open on the access cookie.Tests
test/test_refresh_peer_binding.py— 23 tests, red before / green after. The exploit was reproduced against unmodifiedmainfirst (see Manual verification).test_stolen_refresh_cookie_cannot_rotate_from_another_allowed_nodepeer_identity_mismatch, noSet-Cookie, jti not consumed, chain not revokedtest_the_original_node_still_rotates_its_own_chaintest_ordinary_phase3_exchange_binds_the_chain_it_openstest_the_chain_binding_is_persisted_for_the_next_gatewaychain_peersshape, and a fresh manager reading it back (the restart case)test_a_non_tailnet_exchange_still_opens_an_unbound_chaintest_the_rotated_access_token_carries_the_signed_devicetest_a_rotated_access_token_is_refused_for_another_node_after_restarttest_login_scope_still_roams_between_the_users_own_devicestest_the_opt_out_restores_the_unbound_chainbind_refresh_chains: falsetest_a_pre_upgrade_chain_keeps_todays_unbound_semanticstest_the_persisted_record_alone_refuses_a_different_peertest_a_bound_chain_cannot_rotate_while_identity_is_unverifiabletest_revoking_a_chain_drops_its_peer_recordtest_an_expired_chain_binding_is_evictedtest_a_malformed_chain_binding_does_not_brick_the_storetest_a_wrong_shaped_state_file_does_not_500_every_refreshtest_a_wrong_shaped_state_file_still_serves_a_rotation/api/auth/refreshanswers200, not500Regression sweep — this is exactly the code where a narrow fix breaks a legitimate flow, so the sweep is wider than the new file: 875 passed, 3 skipped, 0 failed across the auth / refresh / tailnet / token_auth / config-loader / config-baseline / peer-auth / mobile-link selection, including all 50 existing
test_auth_refresh_handlers_cov80.pytests and the fulltest_refresh_tokens.pyandtest_token_auth.pyfiles. An earlier sweep on the pre-rebase tree ran 4444 tests over a wider selection with the same result.Gates, all green on the rebased tree:
black(baselined),isort,flake8,mypy(1288 files, no issues),docs-lint(261 files), plus the repo'scheck_*gates for subprocess encoding, agent-SDK boundary, sync-IO-in-async, lockdown-before-publish, loop-bound locks, builtin-skill scope, testpaths coverage, brand name, harness parity, feature map, changelog history, and focus cue.config-baseline.jsonwas regenerated for the new config field.The diff-scoped runner escalates to the full backend suite for this surface; that full run is deferred to CI's sharded
Backend Testslanes, which is where it completes in reasonable time.Manual verification
The exploit was reproduced as an executable probe against unmodified
origin/mainbefore any source change — the ordinary?token=link exchange mintedrequire_peer-absent, the phone's replay returned200, andcheck_token_peer(new_access, PHONE_KEY)wasTrue. That probe is not committed; its assertions live on astest_stolen_refresh_cookie_cannot_rotate_from_another_allowed_nodewith the outcome inverted.One
nosemgrepadded, for a proven false positive.python-logger-credential-disclosurefired on the two new
logger.warningcalls inrefresh_tokens.py, and the"potential hardcoded secret" it names is the format string itself — the
literal begins
"refresh_tokens: ...", which contains "token". Neither call logsa credential: one logs a JSON field name (
"chain_peers"/"consumed_jtis"/"revoked_chains") plus a type name, the other a file path plus a type name.Suppressed in the form this repo already uses for this exact rule
(
sel.py:1388,slack/scope_probe.py:65,providers/acp.py:1108,mcp_gateway/credwatch.py:206): a comment naming what is logged, then the ruleid inline. The module's four pre-existing
"refresh_tokens: ..."log calls carrythe same false positive and are simply baselined, since the gate scans diff-only.
Rewording was the alternative, but it would break the six-call prefix convention
this module relies on for log grepping.
Not exercised against a live tailnet: every test fakes
resolve_forwarded_peer, which is how the existing peer tests in this area work too, so the daemon-facing half (tailscale whoisbehindtailscale serve) is unchanged and uncovered here as before.Screenshots / video
Why no screenshot: backend-only change — auth handlers, the refresh-token module, config schema, and docs. No frontend file is touched and no rendered surface changes (CI's
Frontend Lint & Type CheckandBundle Size Gateboth skip on this diff).Related Issues
Follow-up from #2411 (issue #1762, RFC
rfc-tailnet-dashboard-accessPhase 3).Closes #2417
Pattern harvest
Rule candidate: review-prompt
Pattern: a security mechanism whose enforcement is complete but whose arming has exactly one producer.
The verify side here was correct and well-tested from day one; the defect was that only one of several mint paths set the claim it verifies, so the check was unreachable for every other session shape. Worth asking on review of any new signed claim: which mint paths can reach this check, and which silently bypass it by omitting the claim? The same question applies to
boot,no_refresh, andembed_parent_portin this file, each of which carries its own "carried, never re-derived" comment for the same reason.Design Review sharpened this into the form worth keeping: a second authority protects the paths that carry part of the state, but absence is not detectable — a producer that omits every marker is indistinguishable from a legitimate legacy record. So the durable guard for the next producer is the review question above, not the record.
Also generalizable as a design rule, applied in this PR: when a signed claim gates a security decision, keep a server-side record the credential cannot influence as a second authority, so the next mint path that forgets the claim fails closed instead of silently unbound.
Review dispositions
Every raised concern is answered in its own comment on this PR. Summary:
chain_peerscrashes every refreshtest_token_auth.pyalready uses; leak measured (10 bindings + a pinned store)chain_peer()wrapper has 0 consumersFour decisions are with the maintainer; nothing else is outstanding.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)