feat(be): register the browser a session was created from - #4242
Merged
sea-snake merged 62 commits intoSep 9, 2026
Conversation
|
✅ No security or compliance issues detected. Reviewed everything up to a727d29. Security Overview
Detected Code Changes
|
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 19, 2026 01:47
9192b92 to
1c9d6e9
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 19, 2026 02:57
1c9d6e9 to
95afa5f
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 19, 2026 06:28
95afa5f to
e4b6ba5
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 19, 2026 07:07
e4b6ba5 to
28bc1a6
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 19, 2026 08:17
28bc1a6 to
3955e8e
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
2 times, most recently
from
August 19, 2026 10:21
01d618a to
d6d824c
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 20, 2026 10:57
d6d824c to
2ec6d25
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 20, 2026 13:08
2ec6d25 to
506a7b7
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
3 times, most recently
from
August 21, 2026 10:38
d28dcbf to
cb1da49
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
2 times, most recently
from
August 22, 2026 17:24
126da3c to
73ab606
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ # Conflicts: # src/internet_identity/src/storage/storable.rs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…name The settings list needs to name a browser the user will recognise, and the client is the only party that can see what it is running on. It used to send a composed label, which froze the wording at registration: a record written before "Chrome OS" became "ChromeOS" would read the old name for as long as it existed, and nothing could re-derive it. So the client sends what it resolved rather than what it wrote — a brand, an operating system, a form factor, and the hardware model where it can name one — and the wording lives in the frontend, where changing it reaches every stored record at once. `Other` carries a token this list does not name, because an unrecognised browser is worth seeing rather than worth hiding behind a generic label. The description is taken only where a sign-in registers a browser. An entry that is advanced keeps what it was registered with, so what a browser reports is a fact about a registration rather than about the last sign-in. `BrowserBrand` rather than `Browser` because that is what the client hints call it, and because `Browser` names the registry entry it describes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
This was referenced Sep 8, 2026
A variant is what earns an icon on the devices list, and an icon nobody
recognises is worse than the name written out — so the named set is the six
that hold 97.5% of the web between them, measured worldwide in August 2026:
Chrome, Safari, Edge, Firefox, Samsung Internet, Opera.
Brave and Vivaldi go with that bar. Brave sits at 0.57% and its agent says
Chrome, which is now what it reads as. Vivaldi does not reach StatCounter's
top nineteen at all and was only ever here because the agent table listed it;
it self-identifies, so it arrives as `Other("Vivaldi")` — named, no icon.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
MRmarioruci
reviewed
Sep 9, 2026
MRmarioruci
reviewed
Sep 9, 2026
MRmarioruci
reviewed
Sep 9, 2026
MRmarioruci
reviewed
Sep 9, 2026
MRmarioruci
reviewed
Sep 9, 2026
MRmarioruci
approved these changes
Sep 9, 2026
`main.rs` was hand-mapping `Browser` into `BrowserInfo`, so the decision about which fields stay inside storage sat next to the endpoint rather than next to the type that knows the keys are secret. `Anchor::browsers_info()` makes that choice once, and returns the `Option` the interface carries so the emptiness check goes with it. `StorableBrowser` loses its `Storable` impl: it is only ever encoded as a field of `StorableAnchor`, never as a stable-structure value, so nothing consulted its `BOUND` — `StorableBrowserDescription` beside it has no impl either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
MRmarioruci
approved these changes
Sep 9, 2026
sea-snake
added a commit
that referenced
this pull request
Sep 10, 2026
Design: #4224. Overview: #4230. This is the caller #4266 and #4264 were waiting for, so it removes their `#[allow(dead_code)]` annotations. **`prepare_account_session` / `get_account_session`.** The first creates a session and signs its delegation to the II frontend's key; the second witnesses it. A separate pair from `prepare_account_delegation`, not an option on it: both mint, but one proves a live session and identifies the account by its principal while the other proves an access method and names the anchor outright. Merging them would mean one method with two authorizers and two argument shapes, and would drag the frontend's internal surface into the public API. **Everything that can refuse does so before anything is written**, cheapest first. The account check leads — an account the identity does not hold is the one failure a caller can provoke, and returning it after the writes would leave a browser registered for a sign-in that never happened — so a request that was never going to succeed does not pay for two P-256 verifications on the way to being told so. Nothing is revealed by that order: `check_authz_and_record_activity` above is the auth guard. The browser proof follows, using the verifier from #4264, and `create_session` takes the `VerifiedBrowserKeys` it produces rather than two byte strings. **The session credential is scoped to Internet Identity.** It exists to mint app delegations, which is an update call on this canister and nothing else, so it is signed with `targets = [id()]` and can be presented nowhere else. Leaving that to the II frontend would have left it to the party holding the session key, who can decline to add it. `permissions` stays absent for the same reason it is set on app delegations: minting is an update call, so a read-only session that could not make one could not sign in to an app at all — read-only travels on the app delegation instead. **`get_account_session` tells three failures apart.** No stored session for that id is `NoSuchSession`; a session that is present with no signature for the asked-for key and expiration is `NoSuchDelegation`, because the remedy is to ask with the parameters that were signed rather than to sign in again; a seed that will not derive is the salt being unset, which is `InternalCanisterError`. An over-long origin is that too, rather than a rejected message the caller cannot read as a response. **The request carries what the browser is, not a label for it.** `browser_description` holds the tokens the registry stores (#4242), and each token a client writes for itself — an unrecognised brand, an unrecognised system, and the hardware model — is bounded at 64 bytes. The named variants carry no text, so a description of nothing but those is within the limit whatever it says. Refused rather than truncated: a cut-off token would put a value in the record that no parser ever produced. **Every later failure traps rather than returning.** On the IC, returning an error commits state and only a trap rolls the message back, so once the browser registration is written a failure has to trap or a caller could be told "no" and still have a browser enrolled. **`valid_for`** is the lifetime the user chose at consent, clamped by the canister to between 10 minutes and 30 days. Every ceremony creates, so it always applies: the replacement's expiry is measured from the ceremony that made it, and no session is renewed in place. Tests: `integration/sessions.rs` (18) drives the real ceremony — creating and verifying a session, a request for another identity refused, the registry cap dropping the least recently used and ending its sessions, the key proof's rejections at the endpoint, rotation keeping the entry, a retired key returning as a new browser, and two browsers each keeping the description it registered with. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake
added a commit
that referenced
this pull request
Sep 10, 2026
Design: #4224. Overview: #4230. "This browser used this app 3 minutes ago" against "5 weeks ago" is what makes a session list worth reading, and what lets someone spot a session they do not recognise **still being used** rather than merely still existing. **Every refresh stamps**, and three consumers make coarsening it unattractive: the session cap orders live sessions on this field, so inside a coarsening interval every session would look equally idle; the browser registry cap orders on the same signal, where an hour is long enough to drop a browser in use; and a user-facing list that can be an hour stale does not answer the question it exists to answer. **What the stamps cost, stated separately, because they are not the same write.** `last_refreshed_ns` and the reference's `last_used` ride free: the list blob is rewritten either way, so they change bytes already in flight. The browser registry's `last_used` does not — `stamp_browser_use` is a `read` plus a `write` of the whole anchor, which serialises every passkey, OpenID credential, recovery key and verified email, pre-checks the email-recovery binding against its reverse index, and re-syncs five reverse indices by diffing vectors, all to move one `u64`. So this PR adds **one anchor read-modify-write per five minutes of app use, per live session**, where before there was one per sign-in. That is accepted here rather than argued away: the registry cap orders on this field, and a browser in use dropping out of the list is worse than the write. The coarsening argument above is about `last_refreshed_ns` and the user-facing list, and does not apply to the device field. The same write carries three stamps: | Field | Lives on | Drives | | ----- | -------- | ------ | | `last_used` | the account reference | account eviction (#4235) | | `last_refreshed` | the session record | the session cap (#4267) and the user-facing list | | `last_used` | the device record | the registry cap (#4242) and the settings list | Stamping the device is the only reason refresh touches the anchor: it authenticates by session chain and never runs `check_authorization`. What that write buys is a use signal a sign-in stamp cannot give the browser list. Stable writes scale with `1/T` alongside the calls, so lowering the app-delegation TTL multiplies both. Tests: `session_refresh_stamp_tests` (8), including a stamp for a session that is gone being refused rather than reporting success, and a refresh advancing the device's `last_used` while leaving its enrolment timestamp alone. PocketIC drives two refreshes a minute apart and reads the device back off `identity_info`. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake
added a commit
that referenced
this pull request
Sep 10, 2026
Design: #4224. Overview: #4230. Nothing imports this until the sign-in in #4273. Settings shows which browsers hold a session so the user can end one they do not recognise, and each entry needs to be something they *will* recognise. The client is the only party that can see what it is running on, so it is the one that has to say. `describeBrowser` resolves the four things the record holds (#4242) — a brand, an operating system, a form factor, and the hardware model where one is reported — rather than composing a label out of them. **Resolving rather than naming is the point.** A composed label fixes the wording at the moment of sign-in: a browser that registered before "Chrome OS" became "ChromeOS" would read the old name for as long as its record existed, and the agent it was parsed from is not kept, so nothing could re-derive it. With tokens the wording lives where the list is read (#4249), so renaming a product — or teaching this table to recognise a browser it used to miss — reaches every stored record instead of only the browsers that sign in afterwards. **Two sources, because neither covers the field alone.** The user agent carries the brand and the system on every engine, and its table stays ordered most specific first, because every later token appears inside the earlier ones' agents: matching `Chrome` first would claim every Chromium-based browser, and every Chromium agent carries `Safari/` as well. Client hints are Chromium-only — Safari and Firefox expose nothing at all — so they supply only what no agent can: the hardware model, which a reduced Android agent reports as the literal string `K`, and a form factor the browser states rather than one inferred. Only the plural `formFactors` is asked for; the singular shape Chromium shipped first is not, because only current browsers are a concern. A stated `Watch`, `XR`, `Automotive` or `EInk` resolves to `Unknown`: the canister names desktops, mobiles and tablets, and calling a watch a phone is worse than naming it as none of them. **A browser that hides reads as the one it imitates.** Brave ships a plain Chrome agent on purpose and strips the hints that would give it away, so it reads as Chrome — which is what the agent says, and the honest answer rather than a gap. Asking `navigator.brave.isBrave()` would name it, at the cost of a check per browser this frontend has heard of, which is the hardcoded list this table exists to avoid. A browser that does name itself in its agent, Vivaldi or DuckDuckGo, travels as the token it gave rather than resolving to Chrome, without needing a row of its own. An unrecognised browser or system is carried as the token that arrived, capped to what the canister accepts, because that row is the one this list exists to surface. `maxTouchPoints` is consumed here rather than stored: an iPad in desktop mode sends a Mac agent and exposes no hints, so it is the only thing that tells it from a Mac. Tests: `describeBrowser.test.ts` (37) — the 20 real user-agent strings, one per rung of both ladders, including the iPad and Mac pair separated only by `maxTouchPoints`; the model used when present and absent when empty or refused; a stated form factor, the four that resolve to `Unknown`, and a tablet stated beside one of them; a browser that hides itself reading as the one it imitates; and an unrecognised token capped to 64 bytes. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake
added a commit
that referenced
this pull request
Sep 10, 2026
…om (#4249) Design: #4224. Overview: #4230. Renders what #4246 revokes; #4248 stacks on top of this. The canister knows which browsers an identity has signed in from and can end all of one browser's sessions at once. This is the screen for it: **Devices**, its own page in the manage nav, between Recovery and Settings. - **Fed by `identity_info.browsers`**, so no extra call: the field rides on the same certified update the page's other data already comes from. - **Browsers are grouped by the machine they run on** — one card, a heading per platform, most recently used group first and most recently used browser first within it, which is the order someone deciding what to sign out wants to read. The heading counts browsers and hedges the machines ("2 browsers on Mac device(s)"), because two identical laptops report the same thing and nothing sent can tell them apart. - **The browser reading the page is marked in place**, by a pill on its own row rather than by a section of its own, so it keeps its position among that machine's other browsers. The frontend recognises itself by the browser id it stored at sign-in, because `identity_info` is signed by an access method and the canister cannot tell which browser is asking. - **A row says what the machine is, not only what it is called.** The form factor picks the glyph on the group heading, and the brand picks the mark on the row — the six browsers that hold essentially all of the market (Chrome, Safari, Edge, Firefox, Samsung Internet, Opera), each from the vendor's own artwork. A browser outside the six keeps its name and shows no mark: an icon nobody recognises says less than the name written out. - **The name is derived here, not read.** The record holds tokens — brand, system, form factor, and the hardware model where the client could name one (#4242) — and this page turns them into the words a user reads. So renaming a product, or teaching the client to recognise one, renames every row that already exists rather than only the browsers that sign in afterwards. The platform word is deliberately not the system's own name: nobody says they are on "Chrome OS" or "iPadOS", and the hardware wins where it is known, because "Chrome on Pixel 9" is what its owner recognises. - **Signed out has two causes.** A browser holding no sessions reads as signed out rather than disappearing, so the row stays where the user last saw it — and so does one idle beyond thirty days, whatever its session count says. `MAX_SESSION_TTL_NS` is thirty days and `last_used` advances on every refresh, so every session such a browser held is past its absolute bound; the count includes expired records until some write prunes them. - **Beyond ninety days a browser is not rendered at all.** Nothing can be signed out there, and keeping it buries the rows that can. - **The page can never be empty**, because the browser reading it is always one of the rows — including when the canister holds no record for it, a browser signed in to Internet Identity but to no app yet. It describes itself and reads as signed out, last used never, first seen now. - **Signing out asks first**, and says what it costs: every app opened from that browser asks for sign-in again, while this browser stays signed in to Internet Identity itself. The call is `revoke_browser_sessions`; the entry stays, so signing back in reuses it. Failures surface in a toast rather than silently. Below `sm` the row stacks the meta under the name — the two columns and the button leave a 375px screen no room for a name beside them. Tests: `browsers.test.ts` (33), including ordering on use rather than registration, the current browser marked among two entries sharing a name, nothing marked for an id this identity does not hold, the form factor each glyph stands for, grouping by platform with the most recently used group first, a browser past ninety days left out and one a day short of it kept, both causes of a signed-out row, `revoke_browser_sessions` errors mapped to messages, and every brand and platform word this page derives. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design: #4224. Overview: #4230. The registry only — #4264 proves and rotates a browser's key, #4249 renders the list.
Sessions live per account, so a user who wants to sign one browser out has nothing to name it by. A browser registry groups a browser's sessions across apps so they can be revoked together.
StorableAnchorgainsbrowsers(field 7) andnext_browser_id(field 8), bothOptionso existing anchors decode cleanly. Each entry is{id, description, created_at, last_used, current_browser_key, next_browser_key}, capped at 20 because the anchor blob is read on nearly every authenticated path.current_browser_keyis the key the browser last proved with andnext_browser_keythe successor it announced then. Either resolves the entry, and presenting the successor promotes it, retiring the key it replaced. That is what makes a browser profile copied off disk stop working: the copy dies as soon as the real browser signs in again.SuccessorAlreadyInUse), so one public key belongs to at most one entry and resolving a presented key never depends on list order. feat(be): verify a browser's key and its announced successor #4264 adds the stronger rule, proof of possession of the successor.last_used, notcreated_at. Clearing browser storage loses the key, so every wipe enrols a fresh entry and the wiping browser always holds the newestcreated_at— under enrolment order it would never be its own victim, and twenty wipes would evict twenty browsers the user actually signs in from.descriptionis what the browser reported, in tokens rather than a name to show.BrowserBrand,OperatingSystemandFormFactorare variants, plus the hardware model where a client can name one; each variant carries anOther : textfor something this list does not name, because an unrecognised browser is worth seeing rather than worth hiding behind a generic label. Tokens because products get renamed — "Chrome OS" became "ChromeOS", "Mac OS X" became "macOS" — so the wording a user reads lives in the frontend (feat(fe): a Devices page for the browsers an identity is signed in from #4249), where changing it reaches every stored record at once. The canister stores these and never interprets them.identity_infoalongsidemcp_config, via a newBrowserInfocandid type in anoptfield, backwards compatible in both directions.Tests:
browser_tests(19) covering registration and reuse, rotation including a lost response and ten rotations keeping one id, the one-key-one-entry invariant, and the cap dropping the least recently used — including twenty storage wipes interleaved with use of one browser leaving that browser listed.browser_descriptionround-trips every token through storage, including the unrecognised ones, so adding a variant on one side without the other fails there rather than mapping to something else. PocketIC asserts an anchor from the previous release decodes with the new field absent.