Skip to content

feat(dashboard): scope a phone's dashboard session to the gateway process - #5763

Merged
bolichen97 merged 1 commit into
mainfrom
feat/configurable-phone-qr-session
Aug 25, 2026
Merged

feat(dashboard): scope a phone's dashboard session to the gateway process#5763
bolichen97 merged 1 commit into
mainfrom
feat/configurable-phone-qr-session

Conversation

@bolichen97

Copy link
Copy Markdown
Collaborator

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 by being idle, 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.

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 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 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).

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 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.

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, 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.json
regenerated; black clean.

@bolichen97
bolichen97 requested a review from a team as a code owner August 25, 2026 03:53
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 800f44b5c5570e034c972737fd5b012c5bb36e20 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • The unreadable-config fallback loosens an explicit tightening: an operator who set qr_session_until_restart: false (the clock-bound shape) silently gets the until-restart session when KiroCrewConfig.load throws ("An unreadable config resolves to the DEFAULT"). Elsewhere this repo's posture is tightest-wins; failing toward no_refresh costs only a re-scan, while failing toward boot overrides a deliberate security narrowing. Consider falling back to the stricter shape, or at least warn-level logging instead of logger.debug.
  • The default flip is a posture change for existing deployments at the network boundary: a leaked-then-redeemed QR session's blast radius goes from a 1-hour ceiling to process lifetime (months on a long-lived gateway), on by default with no per-upgrade notice mechanism until the release changelog. The mitigations (peer pin, single-use nonce, kirocrew logout, restart-as-revoke) are real; just make sure this lands in the release's "Before you upgrade" section.

Suggestions

  • _rebind_rotated_token_to_peer now keys two orthogonal properties (boot scoping, rotation address-pinning) on one boot_bound flag; a one-line rename or comment naming the pin decision as "sessions that were address-pinned at mint" would keep a future non-boot pinned session type from missing it.

[DESIGN-REVIEWED] 800f44b

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 800f44b5c5570e034c972737fd5b012c5bb36e20 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 800f44b

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 800f44b5c5570e034c972737fd5b012c5bb36e20: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 800f44b5c5570e034c972737fd5b012c5bb36e20 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence gathered. Composing the review.

First-Principles-Verdict: CONCERNS

The boot-bound default fixes the reported defect at cause level, but the qr_session_until_restart opt-out knob has one consumer and no named person who wants it.

What this change ships

Intent: 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.

  1. A scanned phone now stays signed in while the gateway runs (changed default) — justified
  2. A gateway restart now signs the phone out — link, cookie, and refresh chain alike — justified
  3. New config key dashboard.qr_session_until_restart restores the timed shape — one consumer, inherited
  4. An unreadable config mints the default shape, not the timed one — justified
  5. A rotated boot-bound token now keeps its address pin — justified; mechanism undeclared in description
  6. Ordinary browser/link sessions behave exactly as before (claim-gated) — justified
  7. New boot_id module + refresh_token_boot helper — justified; singular forms, cycle-seam matches revocation_gen/token_secret
  8. Spec, user-docs, and config-baseline updates — mandated by AGENTS.md same-commit rule
  9. Pure black reformat hunks in auth_refresh.py/refresh_tokens.py + baseline prune — rides along

Watch

  • The knob is read at exactly 1 site (grepped qr_session_until_restart: 1 non-test src consumer, tailnet_mobile.py:663), and its justification is "an operator who wants the credential bounded by a clock" — a want with no reported case, i.e. inherited flexibility. Its zero option costs nobody observable anything today; the boot default alone removes the defect.
  • Item 5 is real (in-memory pins die at restart, so an unpinned rotation fails open) but the description's "the peer pin… still apply unchanged" quietly required new code (bind_token_ip at auth_refresh.py:566) to be true.

Subtractions

  • Drop dashboard.qr_session_until_restart (loader field, _safe_bool read, baseline entry, docs row) and the no_refresh else-branch in api_tailnet_mobile_qr — boot becomes the only QR shape; the no_refresh machinery in token_auth.py:2442–2651 then has zero mint sites and can follow in a later deletion.
  • Move the pure reformat hunks and the two black-baseline prunes to their own commit, as AGENTS.md itself prescribes.

[FIRST-PRINCIPLES-REVIEWED] 800f44b

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 25, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Two findings this round. One is correct and I am not disputing it; the other is on code this PR does not touch.

taskrunner.py:342 — out of scope. This PR's surface is 13 files and src/kiro_crew/taskrunner.py is not among them:

$ git diff origin/main...HEAD --name-only
.github/black-baseline.txt
config-baseline.json
docs/system-specs/features/dashboard-token-auth.md
src/kiro_crew/config/loader.py
src/kiro_crew/dashboard/boot_id.py
src/kiro_crew/dashboard/handlers/auth_refresh.py
src/kiro_crew/dashboard/handlers/tailnet_mobile.py
src/kiro_crew/dashboard/refresh_tokens.py
src/kiro_crew/dashboard/token_auth.py
src/kiro_crew/docs/configuration.md
test/test_auth_refresh_handlers_cov80.py
test/test_boot_bound_session.py
test/test_tailnet_mobile.py

The except FileExistsError: pass at that line is unchanged from main. Whether it is a real defect is a separate question worth its own issue — it is simply not something this branch introduced or can be judged against.

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 api_auth_refresh performs no peer check on the credential presented to it — the only bind_token_peer call there is on the way out. So a thief presenting the refresh cookie from another reachable peer gets a fresh access token pinned to themselves. Pinning the output re-pins to whoever asked.

Two things are true at once and both belong in the record:

  1. This exposure is not new to the codebase. Every ordinary browser session already holds an unpinned refresh cookie with exactly this property. What this PR does is extend that pre-existing shape to a phone session, which previously avoided it by being minted no_refresh and therefore never having a refresh credential at all.
  2. That means the honest fix is not another patch on the output side. Either the refresh credential itself becomes peer-bound for these sessions (embed the peer key as a claim and require it to match at refresh), or the feature keeps no_refresh and the idle-expiry it implies.

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.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 25, 2026
…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.
@bolichen97
bolichen97 force-pushed the feat/configurable-phone-qr-session branch from cf912eb to 800f44b Compare August 25, 2026 04:45
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 800f44b5c5570e034c972737fd5b012c5bb36e20 — this comment is updated in place on each push.

Review details

Two 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 trust_identity on and a boot-bound QR session, a transient peer is None during rotation does NOT return but falls through to bind_token_ip(access_token, client_ip), pinning the rotated token to the ts:ip: proxy address; the next request resolves the peer again, _check_pin compares the ts: key against the stored ip: pin, mismatches, and the first-use re-pin is skipped (ok is False) — a hard in-session lockout where the pre-PR unbound-on-peer-None path self-healed → Fix: when the tailnet-identity branch applies but the peer failed to resolve, return unbound instead of falling through to the boot_bound ip-pin (gate the if boot_bound: on not (isinstance(trust, TailnetTrust) and trust.trust_identity and trust.allowed_logins)).

FINDING — src/kiro_crew/dashboard/handlers/auth_refresh.py:566 — bind_token_ip(access_token, client_ip, session_exp) omits proxied, so it defaults False; a phone QR session behind tailscale serve (trust off) was bound proxied=True at the exchange, but once the original binding expires only rotated proxied=False bindings remain and proxied_pin_observed() returns False, so the Security Posture "IP pinning" row reports a shared same-host-proxy pin as "Per-client" → Fix: carry the observation on rotation, bind_token_ip(access_token, client_ip, session_exp, proxied=is_proxied_request(request)), mirroring the exchange-site binding.

[OPUS-REVIEWED] 800f44b

Verdict parsed from the review's SHA-scoped output markers for commit 800f44b5c5570e034c972737fd5b012c5bb36e20.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 800f44b5c5570e034c972737fd5b012c5bb36e20: <one-sentence reason>

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 25, 2026
@bolichen97
bolichen97 merged commit 65dbff2 into main Aug 25, 2026
66 checks passed
@bolichen97
bolichen97 deleted the feat/configurable-phone-qr-session branch August 25, 2026 06:09
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 25, 2026
nodomain added a commit to nodomain/KiroCrew that referenced this pull request Aug 29, 2026
…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
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Open PR relationship audit

This 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

  • PR #6652 is PARTIALLY_COVERED relative to this PR. Coverage is explicitly incomplete; this finding is not a completion or closure claim. Recommended action for PR #6652: CONTINUE_DEVELOPMENT. Main already carries the session model the PR mints into, so only the credential-less admission step is genuinely new. That step is what the review blocks on. Files: src/kiro_crew/dashboard/token_auth.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants