Skip to content

feat(be): register the browser a session was created from - #4242

Merged
sea-snake merged 62 commits into
feat/session-record-storagefrom
feat/session-devices-registry
Sep 9, 2026
Merged

feat(be): register the browser a session was created from#4242
sea-snake merged 62 commits into
feat/session-record-storagefrom
feat/session-devices-registry

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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.

  • StorableAnchor gains browsers (field 7) and next_browser_id (field 8), both Option so 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_key is the key the browser last proved with and next_browser_key the 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.
  • Accepting both is what makes a lost response harmless. The browser advances only once a sign-in succeeded, so an unanswered call leaves it proving with the key the entry still holds rather than looking like a new machine.
  • A successor another browser of the same anchor already holds is refused (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.
  • The id never changes across rotations, so rotating costs a session nothing: sessions record the id, not the key. Ids come from a monotonic per-anchor counter and are never reused, so a flood of registrations leaves a permanent gap in the sequence rather than a list that looks untouched.
  • At the cap the least recently used entry is dropped rather than the registration failing, and its id is returned so the caller can end that browser's sessions too.
  • Eviction orders on last_used, not created_at. Clearing browser storage loses the key, so every wipe enrols a fresh entry and the wiping browser always holds the newest created_at — under enrolment order it would never be its own victim, and twenty wipes would evict twenty browsers the user actually signs in from.
  • description is what the browser reported, in tokens rather than a name to show. BrowserBrand, OperatingSystem and FormFactor are variants, plus the hardware model where a client can name one; each variant carries an Other : text for 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.
  • A description is fixed at registration. An entry that is advanced keeps what it was created with, so what a browser reports is a fact about a registration rather than about the last sign-in. A browser reporting something else is one this anchor has not seen, and the client is the party that decides so, by presenting a key pair no entry holds (feat(fe): hold a browser key and rotate it on every sign-in #4271) — which is the path that already registers.
  • Entries ride on identity_info alongside mcp_config, via a new BrowserInfo candid type in an opt field, 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_description round-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.

@sea-snake
sea-snake requested a review from a team as a code owner August 19, 2026 01:13
@zeropath-ai

zeropath-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to a727d29.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/frontend/src/lib/generated/internet_identity_idl.js
    Add browser-related types (OperatingSystem, FormFactor, BrowserBrand, BrowserDescription, BrowserInfo) and include browsers in IdentityInfo
► src/frontend/src/lib/generated/internet_identity_types.d.ts
    Add Browser-related types (OperatingSystem, FormFactor, BrowserBrand, BrowserDescription, BrowserInfo) and integrate into IdentityInfo
Enhancement ► src/internet_identity/internet_identity.did
    Add BrowserBrand, OperatingSystem, FormFactor, BrowserDescription, and BrowserInfo type definitions to IDL for browser info on anchors
Enhancement ► src/internet_identity/src/main.rs\n+ let browsers = state::anchor(identity_number).browsers_info();
    Include browsers information in IdentityInfo
Enhancement ► src/internet_identity/src/storage.rs
    Acknowledge browsers and next_browser_id fields in anchor storage (browsers and next_browser_id added)
Enhancement ► src/internet_identity/src/storage/anchor.rs
    Add Browser struct, MAX_BROWSERS constant, BrowserError enum, and BrowserInfo/BrowserDescription integrations
    Implement Browser conversion to/from StorableBrowser
    Add methods browsers and browsers_info, and resolve_browser logic for browser management
    Extend Anchor to include browsers and next_browser_id and initialize defaults <- updates to multiple places
Enhancement ► src/internet_identity/src/storage/storable.rs
    Expose new browser-related modules (browser, browser_description) in storable exports
Enhancement ► src/internet_identity/src/storage/storable/anchor.rs
    Add fields browsers: Option<Vec> and next_browser_id: Option with corresponding CBOR mapping notes
Enhancement ► src/internet_identity/src/storage/storable/browser.rs
    Add new StorableBrowser type with id, description, created_at, last_used, current_browser_key, next_browser_key
Enhancement ► src/internet_identity/src/storage/storable/browser_description.rs
    Add new StorableBrowserDescription type to store browser description specifics

@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 9192b92 to 1c9d6e9 Compare August 19, 2026 01:47
Copilot AI lite review requested due to automatic review settings August 19, 2026 01:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 1c9d6e9 to 95afa5f Compare August 19, 2026 02:57
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 95afa5f to e4b6ba5 Compare August 19, 2026 06:28
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from e4b6ba5 to 28bc1a6 Compare August 19, 2026 07:07
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 28bc1a6 to 3955e8e Compare August 19, 2026 08:17
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch 2 times, most recently from 01d618a to d6d824c Compare August 19, 2026 10:21
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from d6d824c to 2ec6d25 Compare August 20, 2026 10:57
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 2ec6d25 to 506a7b7 Compare August 20, 2026 13:08
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch 3 times, most recently from d28dcbf to cb1da49 Compare August 21, 2026 10:38
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch 2 times, most recently from 126da3c to 73ab606 Compare August 22, 2026 17:24
sea-snake and others added 9 commits September 8, 2026 01:29
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
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
Comment thread src/internet_identity/src/storage/anchor.rs
Comment thread src/internet_identity/src/main.rs Outdated
Comment thread src/internet_identity/src/main.rs Outdated
Comment thread src/internet_identity/internet_identity.did
Comment thread src/internet_identity/src/storage/storable/browser.rs Outdated
sea-snake and others added 4 commits September 9, 2026 13:12
`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
@sea-snake
sea-snake merged commit 3e6208a into main Sep 9, 2026
84 checks passed
@sea-snake
sea-snake deleted the feat/session-devices-registry branch September 9, 2026 20:20
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:revocable-app-sessions Design: revocable app sessions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants