From c3ce7197abcfba75f80cbdb9520ac79d71354fc2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 13:20:33 -0700 Subject: [PATCH 1/3] Stage account-wide devices with revocation, in one scope Issue #331 asked for an account-wide device list. The root constraint is the trust model: the ACL lives only on each Burrow and the Relay never sees a Client static or lists a deliveryId, so no Relay-held directory can name a Burrow's records without reversing those rules. Record the design under remote-security-model.md ## Future as part of remote-trust-followups, built on the Burrow-local revocation entry point: the managed identities stay the passkey (account-wide, Relay-held) and the ACL record (per Burrow), never merged; stage 1 makes Hosted's existing passkey removal reach every Burrow's ACL as a revoke-only answer to the credential ids the Burrow asks about; stage 2 lists and revokes records from Pocket over protocol-v1 on reachable Burrows, behind a fresh proof. Whether stage 1 carries a passkey assertion is left for a decision, with the reasoning in the rationale. Co-Authored-By: Claude Opus 5.5 --- docs/specs/remote-security-model.md | 22 ++++++++++++++++++- docs/specs/remote-security-model.rationale.md | 12 ++++++++++ scripts/spec-word-budgets.json | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 25dd158e3..f0086c729 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -265,7 +265,7 @@ Source of truth: `remote-lib-common/src/security/noise.ts`, `isEnrollment` / `pe ## Future -**Scope: remote-trust-followups** — [Device verification](#device-verification) and [Revocation propagation](#revocation-propagation). +**Scope: remote-trust-followups** — [Device verification](#device-verification), [Revocation propagation](#revocation-propagation), then [Account-wide devices](#account-wide-devices), which builds on it. ### Device verification @@ -274,3 +274,23 @@ Source of truth: `remote-lib-common/src/security/noise.ts`, `isEnrollment` / `pe ### Revocation propagation The Relay pushing revocations to Burrows. Today `BurrowAcl.revokeClient` / `revokePasskey` have no callers and no relay frame carries a revocation, so revoking is hand-editing state ([relay.md](./relay.md), Guardrails) — and `BurrowService` hands the `BurrowRuntime` one ACL snapshot for its whole lifetime, so **restarting the Burrow is the entire lever**: it reloads the ACL *and*, by dropping the relay socket, ends every established session. Editing alone changes nothing that is running. + +### Account-wide devices + +One design with [Revocation propagation](#revocation-propagation), not two: a list nobody can act on and a revocation with no list are halves of one feature. Every stage calls the Burrow's own revocation — one entry point that revokes by record or by passkey credential, ends that record's live sessions, and logs who asked — and none writes the ACL another way. + +**The managed identities are the two the model has; nothing merges them into a "device"** (rationale): + +| Identity | Held by | Removing it cuts | +| --- | --- | --- | +| Passkey | the Relay, per account; synchronized onto many devices ([Passkeys](#passkeys)) | every browser that signs in with it, on every Burrow | +| ACL record | one Burrow, for one Client static ([Client statics](#client-statics)) | that browser on that Burrow | + +One Pocket profile is a record per Burrow, and a phone's tab and its Home Screen install are two profiles. Pocket may mark the records that are its own, from its `KnownBurrowV1`s; nothing else is grouped. + +**The Relay will hold no device directory**: no record's label, `deliveryId`, approval time, or a handle derived from its Client static reaches the Relay or is listed to a session ([Burrow Authorization](#burrow-authorization); rationale). The account view is assembled from what each side already holds, in two stages: + +1. **Passkey removal reaches the ACL.** Hosted's account page already removes a passkey from the Relay, ending its sessions and sign-ins (`docs/specs/hosted.md` -> "Burrow enrollment"), while every Burrow keeps its records active. On its `burrowToken`, at socket open and on a nudge frame after any removal, the Burrow will send the credential ids its active records hold, and the Relay answer which of them its account no longer holds; the Burrow revokes those, an offline one at its next connection. **A Relay-named revocation may only revoke** — never create, restore, or edit a record — so a lying Relay costs re-pairing, which is availability ([Goals](#goals); rationale). +2. **Per-record list and revoke from Pocket**, on each online Burrow it is paired with, over protocol-v1 (`docs/specs/remote-api.md` -> "Future"): each record's mapped label, approval time, last seen, and whether it is the caller's, addressed by an index only that response defines, never a static or `deliveryId`. **Revoking takes a fresh presence proof, never a window ride.** An unreachable Burrow is shown as one, stage 1 being the lever that reaches it. + +Undecided: whether stage 1's request carries a passkey assertion the Burrow verifies against its own ACL, so the Relay could only withhold one (rationale). diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index c6fd62a33..6b62388af 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -162,3 +162,15 @@ Loss stays recoverable by re-pairing, so this changes the advice, not the model. **Why keystroke timing is accepted rather than closed.** Closing it needs batching or cover traffic on the Client→Burrow direction: either holding keystrokes for a fixed quantum — latency a terminal user feels immediately, on the one interaction the product exists for — or sending padding frames forever, which a phone pays for in battery and a relay in bandwidth. The party that observes the timing is the one the user chose to run. A deployment that considers timing part of its threat model needs a different transport, not a different padding policy. **Why a shared push endpoint correlates across Burrows.** One service-worker scope holds exactly one `PushSubscription`, and the Relay stores one row per `(burrowId, deliveryId)` against the endpoint that phone presented, so every `deliveryId` a Pocket profile registers lands on the same endpoint string and the Relay can read off the set of Burrows one phone is paired with — information the ACL deliberately keeps on each Burrow. Per-Burrow endpoints are not available: the browser mints subscriptions per scope, and a scope per Burrow would mean a service worker per Burrow. + +## Account-wide devices + +**Why no Relay-held directory.** It is the one place a list could live for a Burrow that is offline, but it would need a per-record handle the Relay can list to a session: a `deliveryId` is a push bearer capability the Relay never lists ([Burrow Authorization](./remote-security-model.md#burrow-authorization)), and the Client static is what IK keeps from the Relay. Its labels would say little, `PAIRING_DEVICE_LABELS` having two members. The offline case needs a lever rather than a list, and the passkey is already account-wide on the Relay. + +**Why Pocket alone is not enough.** Pocket reaches only the Burrows it is paired with, and only while they are online; "cut this phone off everywhere" is wanted most when a Burrow is asleep. + +**Why nothing is grouped.** Per-Burrow statics are unlinkable by construction, and iOS gives a Safari tab and the Home Screen install separate storage. The one cross-Burrow link, a profile's shared push endpoint, is an accepted leak ([Residual metadata](#residual-metadata)) that covers only profiles with push on — not evidence to build on. + +**Why the Burrow asks about its own ids.** The Relay names removed ids out of the ones the Burrow sends, rather than handing over the account's live set, so a Relay answering nothing revokes nothing, and a self-host `account.json` hand-edit needs no tombstone. + +**Why an unsigned revocation is the recommendation.** A Relay can already refuse every session; making a refusal durable adds only the cost of re-pairing. A signed request would need an assertion for the passkeys' RP ID, the relay origin's hostname, which the account page on `hosted.` cannot make, so the lost-phone case would still need Pocket on another device. Passkey granularity has its own cost: a synchronized passkey lives on the user's other devices too, so removing it cuts them as well, and each pairs every Burrow again under a new passkey; stage 2 is the finer lever wherever a paired Burrow is reachable. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index adf6b82b5..02214caf7 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -22,7 +22,7 @@ "docs/specs/relay.md": 5700, "docs/specs/remote-api.md": 4450, "docs/specs/remote-network.md": 2400, - "docs/specs/remote-security-model.md": 5700, + "docs/specs/remote-security-model.md": 6100, "docs/specs/reopen.md": 1200, "docs/specs/security-audit.md": 5400, "docs/specs/security-ci.md": 3400, From c86c0b27606bf1c23c5379670e180baa16576c30 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 15:54:00 -0700 Subject: [PATCH 2/3] Make a stage-1 revocation unsigned, as a rule Ned decided a Relay-named revocation needs no passkey assertion: revocation fails safe, unlike approving a connection, which fails dangerously. State it as the rule and move the reasoning to the rationale. Co-Authored-By: Claude Opus 5.5 --- docs/specs/remote-security-model.md | 2 +- docs/specs/remote-security-model.rationale.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index f0086c729..24779f8fa 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -293,4 +293,4 @@ One Pocket profile is a record per Burrow, and a phone's tab and its Home Screen 1. **Passkey removal reaches the ACL.** Hosted's account page already removes a passkey from the Relay, ending its sessions and sign-ins (`docs/specs/hosted.md` -> "Burrow enrollment"), while every Burrow keeps its records active. On its `burrowToken`, at socket open and on a nudge frame after any removal, the Burrow will send the credential ids its active records hold, and the Relay answer which of them its account no longer holds; the Burrow revokes those, an offline one at its next connection. **A Relay-named revocation may only revoke** — never create, restore, or edit a record — so a lying Relay costs re-pairing, which is availability ([Goals](#goals); rationale). 2. **Per-record list and revoke from Pocket**, on each online Burrow it is paired with, over protocol-v1 (`docs/specs/remote-api.md` -> "Future"): each record's mapped label, approval time, last seen, and whether it is the caller's, addressed by an index only that response defines, never a static or `deliveryId`. **Revoking takes a fresh presence proof, never a window ride.** An unreachable Burrow is shown as one, stage 1 being the lever that reaches it. -Undecided: whether stage 1's request carries a passkey assertion the Burrow verifies against its own ACL, so the Relay could only withhold one (rationale). +**A stage-1 revocation needs no passkey assertion**: the Burrow acts on the Relay's answer alone (rationale). diff --git a/docs/specs/remote-security-model.rationale.md b/docs/specs/remote-security-model.rationale.md index 6b62388af..b04744629 100644 --- a/docs/specs/remote-security-model.rationale.md +++ b/docs/specs/remote-security-model.rationale.md @@ -173,4 +173,4 @@ Loss stays recoverable by re-pairing, so this changes the advice, not the model. **Why the Burrow asks about its own ids.** The Relay names removed ids out of the ones the Burrow sends, rather than handing over the account's live set, so a Relay answering nothing revokes nothing, and a self-host `account.json` hand-edit needs no tombstone. -**Why an unsigned revocation is the recommendation.** A Relay can already refuse every session; making a refusal durable adds only the cost of re-pairing. A signed request would need an assertion for the passkeys' RP ID, the relay origin's hostname, which the account page on `hosted.` cannot make, so the lost-phone case would still need Pocket on another device. Passkey granularity has its own cost: a synchronized passkey lives on the user's other devices too, so removing it cuts them as well, and each pairs every Burrow again under a new passkey; stage 2 is the finer lever wherever a paired Burrow is reachable. +**Why a revocation needs no signature.** Revocation fails safe, unlike approving a connection, which fails dangerously (Ned's decision, 2026-10): a forged revocation denies access and never grants it. A Relay can already refuse every session; making a refusal durable adds only the cost of re-pairing. A signed request would need an assertion for the passkeys' RP ID, the relay origin's hostname, which the account page on `hosted.` cannot make, so the lost-phone case would still need Pocket on another device. Passkey granularity has its own cost: a synchronized passkey lives on the user's other devices too, so removing it cuts them as well, and each pairs every Burrow again under a new passkey; stage 2 is the finer lever wherever a paired Burrow is reachable. From 4ff0eaa24b343b4664f267fb9cfa27b48165af64 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 22:35:22 -0700 Subject: [PATCH 3/3] Name the shipped revocation entry point in account-wide devices 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 --- docs/specs/remote-security-model.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md index 773f3adfe..dc43c0458 100644 --- a/docs/specs/remote-security-model.md +++ b/docs/specs/remote-security-model.md @@ -305,11 +305,11 @@ Source of truth: `remote-lib-common/src/security/noise.ts`, `isEnrollment` / `pe ### Revocation propagation -The Relay pushing revocations to Burrows. A Burrow revokes only from its own Settings and `dor` ([Revocation](#revocation)); no relay frame or protocol-v1 message carries one yet, and each would call `BurrowService.revoke`. +The Relay pushing revocations to Burrows. A Burrow revokes only from its own Settings and `dor` ([Revocation](#revocation)); no relay frame or protocol-v1 message carries one yet; [Account-wide devices](#account-wide-devices) stages both. ### Account-wide devices -One design with [Revocation propagation](#revocation-propagation), not two: a list nobody can act on and a revocation with no list are halves of one feature. Every stage calls the Burrow's own revocation — one entry point that revokes by record or by passkey credential, ends that record's live sessions, and logs who asked — and none writes the ACL another way. +One design with [Revocation propagation](#revocation-propagation), not two: a list nobody can act on and a revocation with no list are halves of one feature. Each stage is a new caller of `BurrowService.revoke` under a requester of its own ([Revocation](#revocation)). **The managed identities are the two the model has; nothing merges them into a "device"** (rationale): @@ -320,9 +320,9 @@ One design with [Revocation propagation](#revocation-propagation), not two: a li One Pocket profile is a record per Burrow, and a phone's tab and its Home Screen install are two profiles. Pocket may mark the records that are its own, from its `KnownBurrowV1`s; nothing else is grouped. -**The Relay will hold no device directory**: no record's label, `deliveryId`, approval time, or a handle derived from its Client static reaches the Relay or is listed to a session ([Burrow Authorization](#burrow-authorization); rationale). The account view is assembled from what each side already holds, in two stages: +**The Relay will hold no device directory**: no record's label, `deliveryId`, approval time, or `deviceIdFor` id reaches the Relay, which sees only stage 2's ciphertext ([Burrow Authorization](#burrow-authorization); rationale). The account view is assembled from what each side already holds, in two stages: -1. **Passkey removal reaches the ACL.** Hosted's account page already removes a passkey from the Relay, ending its sessions and sign-ins (`docs/specs/hosted.md` -> "Burrow enrollment"), while every Burrow keeps its records active. On its `burrowToken`, at socket open and on a nudge frame after any removal, the Burrow will send the credential ids its active records hold, and the Relay answer which of them its account no longer holds; the Burrow revokes those, an offline one at its next connection. **A Relay-named revocation may only revoke** — never create, restore, or edit a record — so a lying Relay costs re-pairing, which is availability ([Goals](#goals); rationale). -2. **Per-record list and revoke from Pocket**, on each online Burrow it is paired with, over protocol-v1 (`docs/specs/remote-api.md` -> "Future"): each record's mapped label, approval time, last seen, and whether it is the caller's, addressed by an index only that response defines, never a static or `deliveryId`. **Revoking takes a fresh presence proof, never a window ride.** An unreachable Burrow is shown as one, stage 1 being the lever that reaches it. +1. **Passkey removal reaches the ACL.** Hosted's account page already removes a passkey from the Relay, ending its sessions and sign-ins (`docs/specs/hosted.md` -> "Burrow enrollment"), while every Burrow keeps its records active. On its `burrowToken`, at socket open and on a nudge frame after any removal, the Burrow will send the credential ids its active records hold, and the Relay answer which of them its account no longer holds; the Burrow revokes each by its passkey target, an offline one at its next connection. **A Relay-named revocation may only revoke** — never create, restore, or edit a record — so a lying Relay costs re-pairing, which is availability ([Goals](#goals); rationale). +2. **Per-record list and revoke from Pocket**, on each online Burrow it is paired with, over protocol-v1 (`docs/specs/remote-api.md` -> "Future"): each record's mapped label, `deviceIdFor` id, approval time, last seen ([Access log](#access-log)), and whether it is the caller's, revoked by that id as `dor remote revoke` is, never by a static or `deliveryId`. **Revoking takes a fresh presence proof, never a window ride.** An unreachable Burrow is shown as one, stage 1 being the lever that reaches it. **A stage-1 revocation needs no passkey assertion**: the Burrow acts on the Relay's answer alone (rationale).