Skip to content

Revoke a paired phone live, and keep an owner-local access log - #1459

Merged
nedtwigg merged 6 commits into
mainfrom
issue-1166-revoke-access-log
Oct 31, 2026
Merged

nedtwigg merged 6 commits into
mainfrom
issue-1166-revoke-access-log

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Revoke a paired phone live, and keep an owner-local access log

Nothing in the product could revoke a paired phone: BurrowAcl.revokeClient and revokePasskey had no callers, so a lost phone stayed paired until someone hand-edited the Burrow's state and restarted it. Nothing recorded who connected either; the only trace was console warnings.

The root cause was that the ACL had one writer, the pairing approval, and the runtime exposed no way to narrow it or to end what a record authorized. This adds one revocation entry point, by Client static or passkey credential: BurrowRuntime.revoke takes effect before its save (live ACL, established sessions ended with the goodbye, pending connections refused, presence windows closed), queued with approvals; BurrowService.revoke dispatches to the running Burrow or, for one the network policy holds stopped, the store, on the lifecycle chain so a start cannot load the ACL before it. The revokeDevice command resolves a short device id (64 bits of the Client static, hex) and refuses an id that names more than one static.

The access log is bounded (500), append-only, persisted beside the ACL (access-log.json in standalone's private state dir, globalState in VS Code), and records pairings, connections, attaches, ends (path and duration), denials with the specific miss the wire never carries, revocations with who asked, and one-time sessions. It holds no terminal content and e2e-lint now forbids any Relay, Hosted, or Client module naming it.

Spec revocation and the access log; audit them as S292 and S293

remote-security-model.md gains "Revocation" (one Burrow-side entry point, effective before its save, by Client static or passkey, device ids that authorize nothing) and "Access log" (owner-local, bounded, no terminal content) above the fold; "Revocation propagation" stays under Future for the Relay- and Pocket-initiated half (#331), reserved to call the same entry point. security-remote.md gains the two guarantees, the S292/S293 checks, and the access log's row under "Credentials at rest"; e2e-lint holds the revocation callers, the four ACL save sites, and keeps the log out of every Relay, Hosted, and phone module. security.md drops both Known gaps and keeps the narrower one that is still true: removing a passkey on the Hosted account page ends the phone's Relay sessions but not its Burrow pairings. Pocket's forget-this-phone copy now points at Revoke instead of Disconnect.

List and revoke paired phones in Settings and with dor remote

Settings -> Remote control replaces the paired count with the paired-phone list (label, device id, paired, connected now or last seen), each row with a Revoke that asks once, and a collapsed access log beside it (also shown on an un-enrolled machine once one-time sessions have logged). Both re-read on the service's access event and after every revoke.

dor remote list|revoke|log reaches the same Burrow commands through the webview's control router, revoking as requester dor; a host with no Burrow link (the website playground) refuses, and an older Dormouse is named as predating the verbs. dor-cli.md, security-local.md (what the control socket carries), and the pairing story follow.

Squash on merge: the first commit fails e2e-lint on its own. Merge before the #331 design branch, since both touch scripts/security-rule-ids.json and the word budgets.

Fixes #1166

🤖 Generated with Claude Code

nedtwigg and others added 3 commits October 30, 2026 13:30
Nothing in the product could revoke a paired phone: `BurrowAcl.revokeClient`
and `revokePasskey` had no callers, so a lost phone stayed paired until
someone hand-edited the Burrow's state and restarted it. Nothing recorded who
connected either; the only trace was console warnings.

The root cause was that the ACL had one writer, the pairing approval, and the
runtime exposed no way to narrow it or to end what a record authorized. This
adds one revocation entry point, by Client static or passkey credential:
`BurrowRuntime.revoke` takes effect before its save (live ACL, established
sessions ended with the goodbye, pending connections refused, presence
windows closed), queued with approvals; `BurrowService.revoke` dispatches to
the running Burrow or, for one the network policy holds stopped, the store,
on the lifecycle chain so a start cannot load the ACL before it. The
`revokeDevice` command resolves a short device id (64 bits of the Client
static, hex) and refuses an id that names more than one static.

The access log is bounded (500), append-only, persisted beside the ACL
(access-log.json in standalone's private state dir, globalState in VS Code),
and records pairings, connections, attaches, ends (path and duration),
denials with the specific miss the wire never carries, revocations with who
asked, and one-time sessions. It holds no terminal content and e2e-lint now
forbids any Relay, Hosted, or Client module naming it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
remote-security-model.md gains "Revocation" (one Burrow-side entry point,
effective before its save, by Client static or passkey, device ids that
authorize nothing) and "Access log" (owner-local, bounded, no terminal
content) above the fold; "Revocation propagation" stays under Future for
the Relay- and Pocket-initiated half (#331), reserved to call the same entry
point. security-remote.md gains the two guarantees, the S292/S293 checks,
and the access log's row under "Credentials at rest"; e2e-lint holds the
revocation callers, the four ACL save sites, and keeps the log out of every
Relay, Hosted, and phone module. security.md drops both Known gaps and keeps
the narrower one that is still true: removing a passkey on the Hosted
account page ends the phone's Relay sessions but not its Burrow pairings.
Pocket's forget-this-phone copy now points at Revoke instead of Disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings -> Remote control replaces the paired count with the paired-phone
list (label, device id, paired, connected now or last seen), each row with a
Revoke that asks once, and a collapsed access log beside it (also shown on an
un-enrolled machine once one-time sessions have logged). Both re-read on the
service's `access` event and after every revoke.

`dor remote list|revoke|log` reaches the same Burrow commands through the
webview's control router, revoking as requester `dor`; a host with no Burrow
link (the website playground) refuses, and an older Dormouse is named as
predating the verbs. dor-cli.md, security-local.md (what the control socket
carries), and the pairing story follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3d29eea
Status: ✅  Deploy successful!
Preview URL: https://b20cde2b.mouseterm.pages.dev
Branch Preview URL: https://issue-1166-revoke-access-log.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a revocation's save fails, the error says the phone stays refused "until Dormouse restarts". A network-policy change gets it back sooner, with no restart. #setNetworkPolicy calls #stopBurrow() and then #start() whenever the level leaves the running set or the paths change, and #startBurrow rebuilds the runtime from this.#store.loadAcl(...). That load returns the pre-revocation records, because the save that would have carried the revocation is the one that failed. So someone who hits "disk full" and then switches Local networks only ↔ Anywhere has re-admitted the phone they just cut off, after being told it was refused. The spec's "a restart before one undoes it" has the same gap. Two ways out: name the Burrow restart in the message and the spec (suggestion inline), or have the service hold on to the unsaved revocation and apply it at the next #startBurrow. The second keeps the guarantee as written in security-remote.md ("even if the revocation cannot be saved").

The passkey half of RevocationTarget has no reachable trigger. #revokeDevice only ever builds { clientStaticPublicKey }, and the public BurrowService.revoke has no caller outside tests: Settings and dor both go through revokeDevice → #revoke. That leaves the passkey branch of revokeTarget, BurrowRuntime.revoke's by-passkey session matching, and the second revokePasskey count in e2e-lint shipping for #331, which the spec lists under Future. Since "Revocation" already has a Reserved: line for that caller, one option is to ship the static-only target now and add the passkey variant together with the Relay frame that names a credential. The other is to keep it and make the spec say that today only tests reach it.

Comment thread lib/src/host/remote/service.ts Resolved
…arts

A running Burrow's revocation takes effect before its save, but the service
rebuilds a runtime from the store on a network-policy change, and the store
still held the phone as paired, so switching levels after a failed save
re-admitted a phone the person had just cut off. The service now keeps each
such revocation (the exact records, by both identities and approval time, so
a later pairing is untouched) and applies it to every ACL it reads for that
Burrow until an ACL save lands; the error and spec name quitting Dormouse as
what undoes it. The spec also says the passkey target is reached only by
tests until #331's callers land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg

Copy link
Copy Markdown
Member Author

Round 1, on the review body's findings:

  1. A failed revocation save is undone by a network-policy restart. Fixed in 5c7a8bb, keeping the guarantee as written. Until an ACL save lands, the service applies each unsaved revocation to every ACL it reads for that Burrow. The error and remote-security-model.md -> "Revocation" now name quitting Dormouse as the only thing that undoes it. The details are in the inline thread.
  2. The passkey half of RevocationTarget has no reachable trigger. Kept on purpose. remote: no account-wide device list — pairing, revocation, and push targets are all per-Host #331 is being designed in parallel, and its two stages need it as they are planned: the Relay naming a removed credential id, and Pocket revoking over protocol-v1. Both are to call this one entry point by passkey credential. The spec now says it outright: "Revocation" notes that the passkey target is named only by tests today. Its Reserved: line points at the callers under remote-trust-followups. burrow-runtime.test.ts covers the by-passkey session ending, so the branch is not untested.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hold can miss a record the failed revocation retired. named is read from burrow.activeRecords before burrow.revoke joins the runtime's #saveApproval queue, so an approval queued ahead of it is not in the snapshot. Re-pairing an already-paired browser is a supported path: #approvePairing replaces "a window for an older record of this static". Say that approval saves, and then the revocation's save fails. The runtime revokes both records for the static, but only the old one is held. After the next network-policy restart, #loadAcl returns the new record active, and the phone is back. The trigger is narrow: a re-pair landing while the person revokes, followed by a failed save. Even so, the error tells the person the phone is refused, so a gap in the hold is the case this commit set out to close.

A smaller form avoids the race and the per-record snapshot. Hold { target, at } with at = this.#now() taken in the catch. Then in #loadAcl, revoke every active record that the target names and whose approvedAt <= at. Anything the runtime revoked was approved by then, and a pairing made after the failure is still untouched. That removes the named pre-read and the three-field record match.

Comment thread lib/src/host/remote/service.ts Resolved
The hold snapshotted the target's active records before the revocation
joined the runtime's approval queue, so a re-pair of the same static that
committed first was revoked by the runtime but never held, and came back on
the next network-policy restart. The service now holds `{ target, at }`, taken
after the failure, and applies it to every stored record the target names
that was approved by then: everything the runtime revoked, and nothing paired
since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg

Copy link
Copy Markdown
Member Author

Round 2: the review body's one finding is the hold missing a re-pair that committed ahead of the revocation. Fixed in 9c8a72b using the suggested { target, at } form; the details are in the inline thread.

Resolve scripts/spec-word-budgets.json: both sides added words to
docs/specs/dor-cli.md, so ratchet its budget to the merged size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nedtwigg
nedtwigg marked this pull request as ready for review October 31, 2026 05:26
@nedtwigg
nedtwigg merged commit df0d0b0 into main Oct 31, 2026
19 checks passed
@nedtwigg
nedtwigg deleted the issue-1166-revoke-access-log branch October 31, 2026 05:26
nedtwigg added a commit that referenced this pull request Oct 31, 2026
Resolve scripts/security-rule-ids.json to next 295 with only 265 retired, since #1459 now uses S292 and S293 and this branch uses S294. Resolve scripts/spec-word-budgets.json by taking the larger budget per spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nedtwigg added a commit that referenced this pull request Oct 31, 2026
Take #1459's Revocation propagation text, since BurrowService.revoke now exists, and keep this branch's Account-wide devices design after it. Resolve scripts/spec-word-budgets.json by taking the larger budget per spec, then ratchet docs/specs/remote-security-model.md, since both sides added words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nedtwigg added a commit that referenced this pull request Oct 31, 2026
Revocation shipped (#1459) as BurrowService.revoke with a Client-static
or passkey target, a REVOCATION_REQUESTERS member, and a revoked log
entry. Point the design at it instead of describing a hypothetical one:
stage 1 is the passkey target's first non-test caller, stage 2 revokes by
the shipped deviceIdFor id as dor remote revoke does (dropping the
invented per-response index), and last seen is the access log's. Revocation
propagation now points at the stages rather than restating the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Revoke a paired phone, and log who connected

2 participants