Skip to content

feat(fe): hold a browser key and rotate it on every sign-in - #4271

Merged
sea-snake merged 111 commits into
fe/describe-browserfrom
fe/browser-key-store
Sep 10, 2026
Merged

feat(fe): hold a browser key and rotate it on every sign-in#4271
sea-snake merged 111 commits into
fe/describe-browserfrom
fe/browser-key-store

Conversation

@sea-snake

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

Copy link
Copy Markdown
Contributor

Design: #4224. Overview: #4230. Nothing imports this until the sign-in in #4273.

The canister identifies a browser by a key it proves possession of (#4264), so the frontend has to hold one, and rotate it, so a key captured from one sign-in cannot claim that browser at the next.

A non-extractable P-256 keypair in IndexedDB, per identity, plus a successor generated alongside it. A sign-in signs with the current key and announces the successor; the successor is promoted only once the canister has confirmed the sign-in.

Promoting after confirmation rather than before is what survives a lost response. If the reply never arrives, the browser still holds the key the canister has, so its next attempt presents the same one rather than a successor the canister never saw — which the canister would treat as an unknown browser and enrol as a second entry for the same machine.

Sign-ins are serialised with a web lock, because two at once would leave whichever wrote last holding a key the canister never accepted. Where the Web Locks API is missing the calls run unserialised: not blocking sign-in on it is the deliberate trade, and the window is one concurrent sign-in on the same device.

A browser that no longer matches what it registered as signs in as a new one. The description it reported (#4270) is stored beside the key pair, and compared before anything is sent. A registered entry keeps the description it was created with — the canister ignores what a sign-in reports once an entry is being advanced — so a browser whose brand, system, form factor or model has changed would otherwise keep rotating an entry describing something it no longer is. Where it differs, this presents a fresh key pair, which no entry holds and which therefore registers under its own.

Deciding here rather than being told keeps that a purely local operation: nothing has been sent when the comparison happens, so the entry left behind is untouched and a retry hits the same state and takes the same branch. The description is written with the key pair and never on its own, so the comparison is always against what the canister was actually sent.

The private key never leaves IndexedDB and never appears in a delegation chain.

Tests: browser-key.store.test.ts (21), including the successor promoted only after the call succeeds, a lost response leaving the current key in place, concurrent sign-ins serialised, the store working with the lock API absent, and a changed description signing in on a fresh key pair while an unchanged one keeps rotating.

@zeropath-ai

zeropath-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 80c7e09.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/frontend/src/lib/stores/browser-key.store.ts
▸ Implement browser-key.store with browser key management, signing, rotation, and persistence
► src/frontend/src/lib/stores/browser-key.store.test.ts
▸ Add tests for browser-key.store functionality and behaviors

@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 928d1be to 836aef3 Compare August 22, 2026 18:45
@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 836aef3 to 0938c39 Compare August 22, 2026 19:07
@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 0938c39 to 2e2b753 Compare August 22, 2026 19:25
The canister identifies a browser by a key it proves possession of, so the
frontend has to hold one. A non-extractable P-256 keypair in IndexedDB, per
identity, and a successor generated alongside it: the sign-in signs with the
current key and announces the successor, and the successor is promoted only once
the canister confirms the sign-in.

Promoting after confirmation rather than before is what survives a lost
response: the browser still holds the key the canister has, so its next attempt
presents the same one rather than a successor the canister never saw.

Sign-ins are serialised with a web lock, because two at once would leave
whichever wrote last holding a key the canister never accepted. Where the Web
Locks API is missing the calls run unserialised, which is the accepted cost of
not blocking sign-in on it.

Nothing imports this yet; the sign-in that uses it lands two PRs up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake force-pushed the fe/browser-key-store branch from 2e2b753 to f7b8adf Compare August 22, 2026 19:49
Comment thread src/frontend/src/lib/stores/browser-key.store.ts
Comment thread src/frontend/src/lib/stores/browser-key.store.ts
Comment thread src/frontend/src/lib/stores/browser-key.store.ts Outdated
@sea-snake
sea-snake removed this pull request from stack #4277 September 9, 2026 20:32
@sea-snake
sea-snake added this pull request to stack #4324 September 9, 2026 20:34
@sea-snake
sea-snake removed this pull request from stack #4324 September 9, 2026 20:35
@sea-snake
sea-snake added this pull request to stack #4323 September 9, 2026 20:38
@sea-snake
sea-snake removed this pull request from stack #4323 September 9, 2026 20:42
@sea-snake
sea-snake added this pull request to stack #4326 September 9, 2026 20:43
Comment thread src/frontend/src/lib/stores/browser-key.store.ts Outdated
Comment thread src/frontend/src/lib/stores/browser-key.store.ts Outdated
Comment thread src/frontend/src/lib/stores/browser-key.store.ts
Comment thread src/frontend/src/lib/stores/browser-key.store.ts
sea-snake and others added 5 commits September 10, 2026 12:27
…ld to

`accept` was a callback the caller had to remember to fire, and forgetting it
compiles. The record then keeps the key the canister has just retired, so every
later sign-in pays a stale-key recovery round-trip; `currentBrowserId` stays
absent, which costs the devices page its marker and `forgetIdentity` its
canister call; and a browser that changed what it reports goes unnoticed,
because there is no stored description to compare.

`withBrowserProof` takes `browserIdOf` instead. The caller still supplies the
id — it only exists in the canister's reply — but a required argument is
something `tsc` refuses to let it omit, and the rotation happens here, once,
beside the id it is written with.

The store's own tests split along the line that now matters: `signIn` is a call
the canister answered, `attempt` one it did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
@sea-snake
sea-snake removed this pull request from stack #4326 September 10, 2026 12:55
@sea-snake
sea-snake added this pull request to stack #4328 September 10, 2026 12:55
sea-snake and others added 2 commits September 10, 2026 17:26
`announced` is an adjective with no noun, leaving the doc comment to
supply the word. With the noun in the name the comment says why the
key is retained instead of what it is, and the rotation function's
summary states what it returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
Copilot AI balanced review requested due to automatic review settings September 10, 2026 15:37

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.

🟡 Changes recommended

Lost-response and storage-failure paths can desynchronize local browser records from the canister and prevent correct recovery.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds per-identity browser keys that rotate after confirmed sign-ins.

Changes:

  • Stores and rotates non-extractable P-256 key pairs.
  • Handles stale keys, browser-description changes, and concurrent sign-ins.
  • Adds comprehensive browser-key tests.
File summaries
File Description
browser-key.store.ts Implements browser-key persistence, proofs, rotation, and recovery.
browser-key.store.test.ts Tests signing, rotation, recovery, descriptions, and locking.
Review details

Suppressed comments (2)

src/frontend/src/lib/stores/browser-key.store.ts:160

  • A brand-new identity returns undefined here, and the changed-description branch below also creates fresh without description. Consequently, if that registration is accepted but its response is lost, IndexedDB contains the keys but not the description the canister registered. A description change before recovery then reuses/advances that entry and finally records the newer description locally, while the canister keeps the older one. Create the initial record here and persist description alongside every fresh key pair before calling signIn.
  if (
    stored?.description === undefined ||
    sameDescription(stored.description, description)

src/frontend/src/lib/stores/browser-key.store.ts:289

  • Promoting a real successor drops the existing browserId and registered description. If this retry fails, the incomplete record remains; a later changed browser description is therefore not detected and the old canister entry is advanced instead of registering a new browser. Preserve those fields when announcedSuccessor exists, while keeping the fresh-key fallback free of the old browser ID.
      const promoted: BrowserKeyRecord = {
        keyPair: stored?.announcedSuccessor ?? (await generate()),
      };
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/frontend/src/lib/stores/browser-key.store.ts
@sea-snake sea-snake closed this Sep 10, 2026
@sea-snake sea-snake reopened this Sep 10, 2026
@sea-snake
sea-snake merged commit e5dbee3 into main Sep 10, 2026
84 of 85 checks passed
@sea-snake
sea-snake deleted the fe/browser-key-store branch September 10, 2026 21:20
sea-snake added a commit that referenced this pull request Sep 10, 2026
…4273)

Design: #4224. Overview: #4230. Depends on #4272 for the record store,
#4271 for the browser key, and #4270 for the browser description — this
is what gives all three their production caller.

The canister side is complete after #4265, but nothing hands an app a
session yet. This is the JSON-RPC method that does.

**`ii_session_delegation`** is what an app calls instead of
`icrc34_delegation` when it wants a session rather than a long-lived
delegation. It answers with the session chain, extended to the app's own
key, and the app mints five-minute delegations from it with no further
browser involvement.

The `ii_` prefix is deliberate: an RPC method travels over a transport
shared with other providers and has to say which one it belongs to,
which is why every II-specific RPC method carries it.

**`targets` on the chain is restricted to the II canister.** That is a
developer guardrail, not a defence against a thief — a thief holding the
chain can refresh with it either way. Revocability is the protection
that matters, and saying otherwise in a security table would be
dishonest.

**The consent duration is honoured**, and an SSO organization's cap
still binds it: `valid_for` carries the lifetime the user chose, and an
SSO identity sends a duration even when the user picked none, matching
the ICRC-34 path.

**The request carries only a session public key and an optional
derivation origin.** An app cannot ask for an access level or a
lifetime, because both are the user's to decide at consent.

`icrc25_permissions` now reports `ii_session_delegation` as a granted
scope so an app can discover the method. `icrc34_delegation` itself is
unchanged.

Tests: `sessionDelegation.test.ts` (5) covers a request for another
method being ignored and params carrying no session key being rejected.
The success path has no unit test here, which is worth knowing rather
than glossing: the harness does not stub the authentication store, so
`createSession` never completes in it, and neither this path's SSO clamp
nor the ICRC-34 one is covered. #4248 stubs enough to drive the handler
to an answer and exercises the `targets` restriction and the
held-session path. Review `createSession` by reading it.

---------

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