feat(dashboard): scope a phone's dashboard session to the gateway process - #5763
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, well-argued rebinding of a phone session's lifetime, but two fail-toward-permissive choices deserve human eyes before the default ships. Watch
Suggestions
[DESIGN-REVIEWED] 800f44b |
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: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered. Composing the review. First-Principles-Verdict: CONCERNS The boot-bound default fixes the reported defect at cause level, but the What this change shipsIntent: stop a phone's dashboard session from expiring mid-use on a clock the operator cannot see — a FIX of a reported defect, delivered as a new session shape.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 800f44b |
|
Two findings this round. One is correct and I am not disputing it; the other is on code this PR does not touch.
The The refresh-cookie finding is CORRECT, and my previous round's fix does not close it. Worth stating plainly rather than arguing: Last round I made rotation preserve the address pin. That stops a stolen access cookie from working elsewhere, but it does nothing about a stolen refresh cookie, because Two things are true at once and both belong in the record:
The suggested fix — revert boot-bound refresh issuance — resolves it by removing the capability, so it is not a fix I can apply unilaterally; the point of the change is that a phone should not be signed out for having been idle. I am taking the choice between peer-binding the refresh credential and reverting to the repository owner rather than picking one on their behalf, and will not push code to make this lane green in the meantime. Holding the lane BLOCKING is the right state until that decision lands. |
…cess A phone that scans the Overview "Phone access" QR code was signed out on a clock it could not see. The session was minted with `no_refresh`, so no refresh chain was issued and `session_exp` (1h by default) was a hard ceiling: the phone lapsed mid-use and the operator re-scanned, over and over. The clock was never the property anyone wanted. "Is my phone still signed in" has an answer the operator already knows — is the gateway still running — so that is what the session is now bound to. A scanned phone stays signed in for as long as this gateway PROCESS lives, is not signed out for ordinary idling, and is signed out by a restart. How --- New `dashboard/boot_id.py`: a per-process random id, generated lazily, never persisted. It is the deliberate mirror image of `revocation_gen`, whose docstring explains that persisting the counter is precisely what lets sessions survive a restart WITHOUT logging anyone out. Both exist because "how long may this session live" has two right answers depending on where the credential went: a browser on the operator's own machine should not be logged out by a restart, while a credential handed to a device the dashboard cannot identify is better bounded by something the operator can see and act on. Minted as a `boot` claim, then checked in three places, because a fresh token is derived from an old one at each and dropping the claim silently converts a restart-scoped session into a 20h/30d one that keeps working: * `validate_token` rejects a mismatch on the LINK path and the COOKIE path alike — a boot-bound URL left in someone's history must not be redeemable after a restart either. * The token->session exchange re-mints, so `boot` is copied forward explicitly next to the existing `embed_parent_port` claim. * `api_auth_refresh` carries it onto both rotated cookies, and `validate_refresh_token` applies the same check. The refresh chain is the one credential that outlives an access cookie, so without this a restart-orphaned refresh cookie would mint a brand-new session on the phone's next visit. Both claims are CLAIM-GATED: a token without one is not checked against either mechanism, so no existing session is affected and no other code path changes. Rotation also had to keep the peer pin -------------------------------------- Enabling the refresh chain for a phone session would otherwise have dropped its peer pin. `/api/auth/refresh` bypasses the auth middleware, and `_rebind_rotated_token_to_peer` only re-pinned when tailnet identity trust was ON — off by default. That gap could not bite before, because a `no_refresh` session never rotated and the `ip:` pin set at the exchange held for its whole life. So the rotation path now falls through to `bind_token_ip` with the refresh request's own address, using the same key shape the middleware uses. Scoped to boot-bound sessions: pinning EVERY rotation would change roaming for ordinary browser sessions, which today survive an address change precisely because their rotated token is unbound. A test pins that scope. What is NOT changed ------------------- `MAX_SESSION_TTL_SECS` is untouched. The 20-hour cap is what limits how often a silent rotation happens, not how long the phone stays signed in — rotation is what extends a boot-bound session, so the ceiling never needed to move and no security constant does. That also keeps this change off every other session type: the `?token=` URL, chat-platform dashboard links and ordinary browser sessions behave exactly as before. Honest about the trade ---------------------- This is a DIFFERENT bound, not a strictly tighter one. A gateway with long uptime grants a correspondingly long session, which a 20-hour clock would have cut. It is defensible because the bound is legible and actionable — `uptime` answers the question, and a restart is a hard revoke that needs no state recorded anywhere — and because everything else still applies: the peer pin, the persisted revocation counter, the per-session nonce denylist, and `kirocrew logout`. One limit is stated rather than glossed: the refresh credential lives 30 days and is renewed on each rotation, so a phone left untouched for 30 days does re-scan. "Not signed out by being idle" would have been false, and the config description, the configuration table and the token-auth spec all say the real thing instead. `dashboard.qr_session_until_restart` (default true) turns it off for an operator who wants the credential bounded by a clock regardless of process lifetime. An unreadable config resolves to the DEFAULT rather than to the other shape: "we could not read your override, so use the default" is the honest reading, and guessing the timed shape would present as a phone that signs itself out for no reason the operator can see. Tests ----- `test/test_boot_bound_session.py` (13) covers the id itself (stable in-process, a new process differs, and — asserted behaviourally by watching an isolated config dir stay empty rather than by grepping for a path constant — nothing is written to disk), the access-token check on both paths, the refresh-chain check, and the exchange carrying the claim. `test_auth_refresh_handlers_cov80.py` gains the rotation cases, asserted on the Set-Cookie values because the response body deliberately carries no tokens. `test_tailnet_mobile.py` pins the default shape, the opt-out shape, and the unreadable-config fallback (only the handler's own config read fails, so a fallback that guessed cannot pass by accident). Two test-authoring traps hit while writing these, recorded because both produce tests that pass on the bug they exist to catch: `check_token_ip` returns True for an UNBOUND token, so "the right peer passes" is vacuous — the pin test asserts the binding EXISTS first; and the first draft used `203.0.113.9` as the "other" peer, which is the request helper's default remote, so it compared the pinned address against itself. Mutation-probed, each edit confirmed to land before running, all via `PYTHONPATH=<repo>/src python -m pytest <file>` so pytest imports the source tree under test: * Rotation dropping the boot claim -> exactly the rotation test red. * Flipping the QR default -> exactly the two default-shape tests red. * Removing the access-token check -> exactly 4 tests red across both files. * Disabling the boot-bound pin branch -> exactly the pin test red, failing with "rotated token was left unbound"; the unbound-rotation test stays green, which is what proves the scope holds. One probe was written and then DELETED rather than kept: "rotation re-derives the id instead of carrying it" is not reachable as a defect, because `validate_refresh_token` has already rejected a stale binding, so in-process the carried and re-derived values are identical. A test that "caught" it would only have been testing its own mock. The call-site comment states the real reason to carry it anyway — the rotated pair should be a function of the credential presented, not of the process. Verified: 63205 passed. Locally green on all three blocking gates from ci.yml (isort, flake8, mypy "no issues found in 1102 source files") plus the black baseline gate — two files graduated out of that shrinking baseline and were pruned with `--update-baseline`. `config-baseline.json` regenerated. 97 failures in this environment are pre-existing and environmental (long worktree path -> `AF_UNIX path too long`, userns EPERM), confirmed by running the same 13 failing files against a stashed pristine tree in the same environment: pristine 97, this branch 96.
cf912eb to
800f44b
Compare
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsTwo advisory findings on the new boot-bound QR rotation path; nothing blocks the merge. FINDING — src/kiro_crew/dashboard/handlers/auth_refresh.py:562 — with tailnet FINDING — src/kiro_crew/dashboard/handlers/auth_refresh.py:566 — [OPUS-REVIEWED] 800f44b Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
…owlist When trust_identity is enabled, allowed_logins is non-empty, and a request arrives through tailscale serve from a verified peer on the allowlist with NO credential (no query token, no access or refresh cookie), the gateway now issues a boot-bound session cookie directly, without requiring a prior token login. The session is boot-bound (expires when the gateway restarts) and pinned to the verified peer identity via the require_peer claim, matching the security model of QR sessions (PR kirodotdev#5763). A refresh chain is minted so the session survives across the access token 20h window. The new code path runs before the token-extraction block in the auth middleware, so requests that already carry a credential follow the existing token+pin path unchanged. Closes kirodotdev#6132
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
A phone that scans the Overview "Phone access" QR code was signed out on a
clock it could not see. The session was minted with
no_refresh, so no refreshchain was issued and
session_exp(1h by default) was a hard ceiling: the phonelapsed mid-use and the operator re-scanned, over and over.
The clock was never the property anyone wanted. "Is my phone still signed in"
has an answer the operator already knows — is the gateway still running — so
that is what the session is now bound to. A scanned phone stays signed in for
as long as this gateway PROCESS lives, is not signed out by being idle, and is
signed out by a restart.
How
New
dashboard/boot_id.py: a per-process random id, generated lazily, neverpersisted. It is the deliberate mirror image of
revocation_gen, whosedocstring explains that persisting the counter is precisely what lets sessions
survive a restart WITHOUT logging anyone out. Both exist because "how long may
this session live" has two right answers depending on where the credential went:
a browser on the operator's own machine should not be logged out by a restart,
while a credential handed to a device the dashboard cannot identify is better
bounded by something the operator can see and act on.
Minted as a
bootclaim, then checked in three places, because a fresh token isderived from an old one at each and dropping the claim silently converts a
restart-scoped session into a 20h/30d one that keeps working:
validate_tokenrejects a mismatch on the LINK path and the COOKIE pathalike — a boot-bound URL left in someone's history must not be redeemable
after a restart either.
bootis copied forward explicitlynext to the existing
embed_parent_portclaim.api_auth_refreshcarries it onto both rotated cookies, andvalidate_refresh_tokenapplies the same check. The refresh chain is the onecredential that outlives an access cookie, so without this a restart-orphaned
refresh cookie would mint a brand-new session on the phone's next visit.
Both claims are CLAIM-GATED: a token without one is not checked against either
mechanism, so no existing session is affected and no other code path changes.
What is NOT changed
MAX_SESSION_TTL_SECSis untouched. The 20-hour cap is what limits how often asilent rotation happens, not how long the phone stays signed in — rotation is
what extends a boot-bound session, so the ceiling never needed to move and no
security constant does. That also keeps this change off every other session
type: the
?token=URL, chat-platform dashboard links and ordinary browsersessions behave exactly as before.
Honest about the trade
This is a DIFFERENT bound, not a strictly tighter one. A gateway with long
uptime grants a correspondingly long session, which a 20-hour clock would have
cut. It is defensible because the bound is legible and actionable —
uptimeanswers the question, and a restart is a hard revoke that needs no state
recorded anywhere — and because everything else still applies unchanged: the
peer pin, the persisted revocation counter, the per-session nonce denylist, and
kirocrew logout, which ends the session immediately.dashboard.qr_session_until_restart(default true) turns it off for an operatorwho wants the credential bounded by a clock regardless of process lifetime. An
unreadable config resolves to the DEFAULT rather than to the other shape:
"we could not read your override, so use the default" is the honest reading, and
guessing the timed shape would present as a phone that signs itself out for no
reason the operator can see.
Tests
test/test_boot_bound_session.py(13) covers the id itself (stable in-process,a new process differs, and — asserted behaviourally by watching an isolated
config dir stay empty rather than by grepping for a path constant — nothing is
written to disk), the access-token check on both paths, the refresh-chain check,
and the exchange carrying the claim.
test_auth_refresh_handlers_cov80.pygains the rotation cases, asserted on theSet-Cookie values because the response body deliberately carries no tokens.
test_tailnet_mobile.pypins the default shape, the opt-out shape, and theunreadable-config fallback (only the handler's own config read fails, so a
fallback that guessed cannot pass by accident).
Mutation-probed, each edit confirmed to land before running, all via
PYTHONPATH=<repo>/src python -m pytest <file>so pytest imports the sourcetree under test:
One probe was written and then DELETED rather than kept: "rotation re-derives
the id instead of carrying it" is not reachable as a defect, because
validate_refresh_tokenhas already rejected a stale binding, so in-process thecarried and re-derived values are identical. A test that "caught" it would only
have been testing its own mock. The call-site comment states the real reason to
carry it anyway — the rotated pair should be a function of the credential
presented, not of the process, and that property does not depend on a check in
another module having run first.
Verified: 63205 passed. 97 failures in this environment are pre-existing and
environmental (long worktree path ->
AF_UNIX path too long, userns EPERM) —confirmed by running the same 13 failing files against a stashed pristine tree
in the same environment, which fails 97 to this branch's 96.
config-baseline.jsonregenerated; black clean.