Skip to content

feat(fe): hand apps a session to re-issue their own delegations from - #4247

Closed
sea-snake wants to merge 6 commits into
feat/revoke-sessions-from-settingsfrom
feat/ii-session-delegation-rpc
Closed

feat(fe): hand apps a session to re-issue their own delegations from#4247
sea-snake wants to merge 6 commits into
feat/revoke-sessions-from-settingsfrom
feat/ii-session-delegation-rpc

Conversation

@sea-snake

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

Copy link
Copy Markdown
Contributor

Design: #4224. Overview: #4230.

Requires the canister API from #4243, which the stack order enforces.

Motivation

The canister half of sessions is in place; this is how an app gets one.

Changes

A new JSON-RPC method, ii_session_delegation. Namespaced rather than folded into icrc34_delegation, because an app that asks for a session but has not implemented refresh must not silently start receiving 5-minute delegations. With a session-only response, an app that cannot refresh never calls this, and icrc34_delegation keeps behaving exactly as today.

The chain, and nothing else. The canister signs the session identity to a non-extractable key this origin keeps in IndexedDB, keyed by (identity, account, origin), and the frontend extends that chain to a key the app supplies. The app's hop carries targets restricted to the II canister: a developer guardrail rather than a defence, since a thief holding the chain can refresh, but it makes an app reaching for the session chain where it meant its app delegation fail immediately.

The result carries the chain alone. Nothing is attached to the calls an app then makes — the canister resolves the session from the signature on the call — so an app needs no agent extension of any kind.

The browser key, and its rotation. Sign-in presents a long-lived P-256 key kept in IndexedDB, one per identity so nothing stored links two of the user's identities to the same browser. Each sign-in generates a successor, signs with both keys, and advances to the successor once the canister accepts. So a key proves one sign-in and is then retired, and a copied profile cannot keep signing in unnoticed.

Three rules make that safe on the sign-in path:

  • Advance only on success.
    A response that never arrives leaves both sides on the key the entry still holds.
  • Store the key before the first call.
    The canister may have registered it, so returning with a different key would enrol this browser twice.
  • Serialise with a web lock.
    Two sign-ins at once would leave whichever wrote last holding a key the canister never accepted.

The consent duration is honoured, and an SSO cap still binds it. An SSO organization caps how long its sign-ins stay valid, so a session sends a duration even when the user picked none, matching what the ICRC-34 path already does. Neither clamp has a unit test — the store this path needs is not stubbed in that file.

The consent duration is honoured. valid_for carries the lifetime the user picked at the consent screen, clamped by the canister, so a user choosing one hour gets an hour rather than the default thirty days.

The name. Derived from the user agent in its own module, ordered most-specific-token-first because every later token appears inside the earlier ones' agents. It names the device where a device word exists — Safari on iPhone, Chrome on Mac, Chrome on Chromebook — and uses the platform's device model where there is one, so an Android entry reads Chrome on Pixel 5. The label is coarse by construction, which is why the settings list identifies an entry by its id.

Tests

browser-key.store.test.ts (14): both signatures verify under the domains the canister checks and neither verifies in the other's role; the announced successor is not the key in use; accepting a sign-in rotates to it and not accepting keeps the current key; each identity holds its own; clearing storage yields a key unrelated to either old one; concurrent sign-ins serialise; a browser without the lock API still signs in; the encoding is the 91-byte SPKI and 64-byte signatures the canister parses; and the device id is remembered on acceptance.

describeBrowser.test.ts (27): one per row of both ladders over real user-agent strings, the iPad and Mac pair separated only by maxTouchPoints, and the model path — used when present, ignored when empty or when the platform refuses, absent without the API, and dropped when it would exceed the canister's 128-byte limit.

app-session.store.test.ts (7) and sessionDelegation.test.ts (6) cover storage keyed by identity, account and origin, expiring sessions not served, a held session re-issued without a ceremony, and the chain carrying targets restricted to the II canister.

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

zeropath-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 12b3718.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/frontend/src/lib/stores/app-session.store.ts
      Add in-browser app session store and management (storeAppSession, appSessionFor, discardAppSession, appSessionsForOrigin, purgeAppSessions)
Enhancement ► src/frontend/src/lib/stores/browser-key.store.ts
      Add browser key management for signing in and proof rotation
► src/frontend/src/lib/stores/browser-key.store.test.ts
      Add tests for browser key flow and rotation
Enhancement ► src/frontend/src/lib/stores/channelHandlers/serialize.ts
      Introduce serialized authorization request handling
Enhancement ► src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts
      Add describeBrowser logic to label browser devices
Enhancement ► src/frontend/src/lib/stores/channelHandlers/icrc25.ts
      Extend supported ICRC25 standards with ii_session_delegation
Enhancement ► src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts
      Add ii_session_delegation handling, including session management and delegation results
Enhancement ► src/frontend/src/lib/stores/channelHandlers/delegation.ts
      Replace local delegation serialization with centralized serializeAuthorizationRequest
Enhancement ► src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts
      Add logic to describe and label browsers for tests
Enhancement ► src/frontend/src/lib/stores/channelHandlers/serialize.ts
      Add generic serialization utility for authorization requests
Enhancement ► src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts
      Add tests for ii_session_delegation behavior and session handling
Enhancement ► src/frontend/src/lib/stores/channelHandlers/delegation.ts
      Update to use centralized authorization request serialization
Enhancement ► src/frontend/src/lib/stores/browser-key.store.test.ts
      Add tests for browser key flow and signing behavior

@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 34717f6 to b8a64f9 Compare August 19, 2026 02:57
Copilot AI lite review requested due to automatic review settings August 19, 2026 02:57
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from c9292ba to 33efcb4 Compare August 19, 2026 02:57

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/revoke-sessions-from-settings branch from b8a64f9 to 90f092f Compare August 19, 2026 03:47
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 33efcb4 to 1ab90d6 Compare August 19, 2026 03:47
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 90f092f to 2fba0ef Compare August 19, 2026 06:28
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 1ab90d6 to cf69804 Compare August 19, 2026 06:28
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 2fba0ef to dcb3703 Compare August 19, 2026 07:07
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from cf69804 to 4b28f35 Compare August 19, 2026 07:07
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from dcb3703 to d6d8557 Compare August 19, 2026 08:17
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 4b28f35 to c6019d7 Compare August 19, 2026 08:17
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from d6d8557 to c78ef1a Compare August 19, 2026 08:37
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from c6019d7 to cb54c0b Compare August 19, 2026 08:37
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from c78ef1a to 616f549 Compare August 19, 2026 10:21
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from cb54c0b to 12bbcbd Compare August 19, 2026 10:21
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 616f549 to 1e76b12 Compare August 20, 2026 10:57
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 12bbcbd to 7c25b2d Compare August 20, 2026 10:57
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 1e76b12 to 27cf6f4 Compare August 20, 2026 13:08
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 7c25b2d to e24e8c7 Compare August 20, 2026 13:08
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 27cf6f4 to 1205a12 Compare August 20, 2026 13:52
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from e24e8c7 to 0deb842 Compare August 20, 2026 13:52
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 1205a12 to 1389f4b Compare August 20, 2026 15:24
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 0deb842 to dac7405 Compare August 20, 2026 15:24
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 1389f4b to a55a18f Compare August 20, 2026 16:10
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from dac7405 to d8a2574 Compare August 20, 2026 16:10
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from a55a18f to 451b36d Compare August 20, 2026 17:24
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from d8a2574 to 7a11ac2 Compare August 20, 2026 17:24
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch 2 times, most recently from cff9676 to e98525c Compare August 21, 2026 10:38
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from e7f2393 to 0cce254 Compare August 21, 2026 10:38
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch 2 times, most recently from 8b93de9 to ed92a4a Compare August 21, 2026 17:06
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch 2 times, most recently from 154bb92 to 613299e Compare August 22, 2026 10:49
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch 2 times, most recently from 2609130 to 1350827 Compare August 22, 2026 11:57
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch 2 times, most recently from 5516927 to d3100d3 Compare August 22, 2026 12:52
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 1350827 to c8c11a3 Compare August 22, 2026 12:52
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from d3100d3 to c2bf0ea Compare August 22, 2026 17:24
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from c8c11a3 to eadbdc2 Compare August 22, 2026 17:24
sea-snake and others added 6 commits August 22, 2026 19:41
A session is a record on the account reference plus a canister-signed identity
derived from it. The II frontend gets one from `prepare_account_session` and
witnesses its delegation with `get_account_session`.

The identity is `H(salt, "session", account_seed, created_at, device_id)`, every
field length-prefixed. Building on the account's seed rather than on the numbers
behind it is what makes a session survive anything that leaves the account's
principal unchanged — naming a default account is exactly that.

A ceremony from a browser that already holds a session at this account replaces
it. A copy of the old session's chain therefore stops working at the user's next
sign-in rather than at its expiry.

The cap is 500 stored records per identity, expired ones included, because
nothing observes a session dying. Reaching it reclaims to a watermark of 450
rather than refusing a sign-in: dead sessions go first, then the live ones whose
use earned them the least standing. Reclaiming runs before the new record is
admitted, and admission is granted against what that pass counted rather than
against the anchor's counter, so the stored set never sits above the cap.

Read hardest: the seed derivation, and the reclaim ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An app holds a session chain and asks for a delegation with it. What it gets
lasts five minutes and is not requestable any longer, so revoking the session
ends access within one delegation lifetime.

The call names nothing and attaches nothing. A new index maps the principal a
session's chain is rooted at to the account and browser behind it, so `caller()`
alone identifies the session: a hit is itself the proof, since only the holder
of that session's key can arrive as that principal. The account is named by
principal rather than by locator because materialising a default account changes
the locator and leaves the principal alone.

The index is matched on browser *and* creation time. A browser keeps its id
across sign-ins, so on the browser alone an entry that outlived its session
would authenticate its holder as whatever that browser created next. Every path
that destroys a session drops its entry in the same write.

The `get` re-derives the five-minute ceiling rather than trusting the expiration
it is handed, because longer-lived delegations exist over the same account seed.
An account's own principal is absent from the index, so an app delegation cannot
mint its own replacement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"This browser used this app 3 minutes ago" against "5 weeks ago" is what makes a
session list worth reading, and it is what lets someone spot a session they do
not recognise still being used rather than merely still existing. It is also what
the session cap evicts on, since ordering by creation would drop a months-old
session in daily use in favour of one created an hour ago and never touched.

Every refresh stamps it. An earlier revision of this design coalesced the write
to one an hour on the grounds that finer resolution had no reader; it now has
three. Two cap evictions order on these stamps, and an hour of slack there is
enough to drop the wrong session or the wrong browser. The third is the reading
above: a list that can be an hour stale does not answer the question it exists
to answer.

The write is small next to what the call already does, which inserts a canister
signature and rehashes the certified tree.

The same write carries the reference's `last_used`, which keeps account eviction
accurate for accounts only ever reached through a session, and the device
registry's `last_used`, so a browser that has an app open counts as in use rather
than as idle since its last sign-in. That is an anchor write on a call that
otherwise never touches the anchor; §9.3 traded it away to keep refresh cheap,
and the accounting above is why it is worth paying now.

Implements docs/ongoing/revocable-app-sessions.md §7.3 (S13).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signing out of an app cleared browser state and invalidated nothing. Now it
removes the session record, so the app's access ends within one delegation
lifetime instead of running to the delegation's original expiry.

The method needs no authorization check beyond the match refresh already
performs: a caller cannot produce another session's principal, so it can only
ever remove its own. It returns nothing and always succeeds, so a client that
retries, or that signs out twice, does not have to reason about whether its
session was already gone. Revoking anything else is the II frontend's operation,
not something a dapp can trigger.

Implements docs/ongoing/revocable-app-sessions.md §8.1 (S14, S15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app can sign out only its own session, so "sign this browser out" has to come
from somewhere the anchor is authenticated. These two are authenticated by an
anchor access method rather than by a session chain, and name sessions by locator
rather than by principal, so the principal index stays on the app-facing path.

Signing a browser out is an eager sweep of that anchor's references in one
message, so refresh never has to read the anchor to find out whether its device
was revoked: refresh happens every few minutes per active session, this happens
rarely. The device record survives the sweep, so a browser that has been signed
out is still one the user recognises and signing back in from it reuses the same
id.

Implements docs/ongoing/revocable-app-sessions.md §8.2, §9.3 (S16, S20).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ii_session_delegation` obtains the session an app refreshes against. It is
namespaced rather than folded into `icrc34_delegation` because an app that does
not implement refresh must not silently start receiving 5-minute delegations:
with a session-only response, an app that cannot refresh simply never calls this,
and `icrc34_delegation` keeps behaving exactly as it does today.

The canister signs the session to a non-extractable key this origin holds, and
the app receives that chain extended to a key it supplies, so no private key is
shared and neither side loses non-extractability. The app's hop carries
`targets` restricted to the II canister, which makes an app that reaches for the
session chain where it meant its app delegation fail immediately rather than
appear to work.

Sign-in proves which browser it comes from with a key kept in IndexedDB, one per
identity so nothing stored links two identities to the same browser, and rotates
that key every time: each sign-in announces a freshly generated successor and
advances to it once the canister has accepted. A key therefore proves one
sign-in, so a copy of this browser's profile stops working as soon as the real
browser signs in again.

Advancing only on success is what makes a lost response harmless, and the first
sign-in stores its key before the call for the same reason: the canister may have
registered it, so coming back with a different key would enrol this browser
twice. Sign-ins for one identity are serialised with a web lock, since two at
once would leave us holding a key the canister never accepted.

The key is non-extractable and appears in no chain, no bundle and nothing an app
receives, which is what lets it identify the browser without letting two apps
recognise it. A browser that cannot keep its key registers a fresh one, which the
identity sees as a new browser rather than as a failed sign-in.

Returning to an app, or arriving at a sibling of one, re-issues from the held
session without another ceremony. Extending the chain is offline and is not what
makes anything revocable: the app delegation itself can only come from the
canister, which checks the session record on every mint.

The browser's name comes from the user agent, where several distinct browsers
report the same string, so it is a label rather than an identifier: the settings
list tells two entries apart by the id this browser stores alongside its key.

Implements the frontend half of docs/ongoing/revocable-app-sessions-spec.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from c2bf0ea to 9a4941c Compare August 22, 2026 17:48
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from eadbdc2 to 12b3718 Compare August 22, 2026 17:48
@sea-snake
sea-snake force-pushed the feat/revoke-sessions-from-settings branch from 9a4941c to d2b3363 Compare August 22, 2026 18:08
@sea-snake

Copy link
Copy Markdown
Contributor Author

Superseded by a five-way split of this PR, each layer under 500 lines of implementation:

The combined content is unchanged; the split was verified lossless against this branch's tree.

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.

2 participants