Skip to content

feat(be): mint short-lived app delegations from a revocable session - #4243

Closed
sea-snake wants to merge 2 commits into
feat/session-devices-registryfrom
feat/session-create-and-cap
Closed

feat(be): mint short-lived app delegations from a revocable session#4243
sea-snake wants to merge 2 commits into
feat/session-devices-registryfrom
feat/session-create-and-cap

Conversation

@sea-snake

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

Copy link
Copy Markdown
Contributor

Design: #4224. Overview: #4230.

This is the PR the feature turns on: after it, a delegation an app holds is short-lived and its session is revocable.

Read hardest: the seed derivation, and the reclaim ordering in reclaim_order / reclaim_sessions. The seed decides what survives an account being renamed; the ordering decides which of a user's sessions dies when they hit the cap.

Motivation

DEFAULT_EXPIRATION_PERIOD_NS is 30 minutes and MAX_EXPIRATION_PERIOD_NS is 30 days, with the app choosing. Nothing II can do reaches an artifact it has already handed out; rotating the salt changes what future derivations produce without touching an already-signed delegation.

The minting half already ships, scoped to MCP: a grant in mcp_grant_memory, delegations capped at 5 minutes, prepare_account_delegation(max_expiration) enforcing the absolute cap. What MCP does not need, and apps do, is many sessions per identity and somewhere to put them.

Changes

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

Finding a session from a call. A new index maps the principal a session's chain is rooted at to {account_principal, device_id}. An app-facing call therefore names nothing and attaches nothing: caller() is looked up, the account principal resolves through the principal index, and device_id picks the record. A hit is itself the proof that the caller is that session.

The account is named by principal rather than by locator deliberately: materialising a default changes its locator and leaves its principal alone, so naming an account touches one index entry instead of every session on it.

Nothing signed travels with the call, so there is no artifact to issue per session, none to decode, none to witness on the get, and nothing an app's agent has to attach.

The browser proof. prepare_account_session takes device_key, the next_device_key it rotates to, and two signatures: the current key over the session key and the successor, and the successor over the session key and the current key, under a different domain so neither can be replayed in the other's role. Proving possession of the successor is what stops a key read off the wire — ingress messages are public — being announced by someone who does not hold it and claimed when its browser next presents one.

Replacement, not reuse. A ceremony from a browser that already holds a session at that account deletes it and mints a new one. A copy of the old session's chain therefore stops working at the user's next sign-in rather than at its expiry, and the consent special case disappears: a change of consent and a repeat of the same consent take one path.

The session cap is per identity. 500 stored records — expired ones included, since nothing observes a session dying — reclaimed to a watermark of 450 rather than blocking. A session_count on the anchor decides whether to act; the pass it triggers walks every row the identity has, takes dead sessions before live ones, and orders the live on last_used + (last_used − created_at), so an app in weekly use outranks one opened once and abandoned yesterday. Reclaiming runs before the new record is admitted, and admission is granted against what that pass counted rather than against the counter, so the stored set never sits above the cap. There is no per-reference cap: one browser holds one session per account, so the browser registry bounds a reference at 20 by construction.

Note what this cap does not do: it puts no bound on a flood of sign-ins. Standing rises with use, so anyone who can provoke sign-ins and keep refreshing them can outrank idle sessions. What stands in the way is the ceremony — creating a session needs an access method — and a stronger bound is deliberately left for whoever finds this one too weak.

The app-facing pair. app_prepare_delegation / app_get_delegation, capped at 5 minutes and not requestable by the app. The get re-derives the ceiling rather than trusting the expiration it is handed, since longer-lived delegations over the same account seed exist. An account principal is absent from the session index, so an app delegation cannot mint its own replacement.

Tests

Unit (46). session_creation_tests (14): the browser's session is replaced rather than reused, a different browser gets its own, expired records are pruned on write, one reference holds one session per browser, the cap reclaims to the watermark taking expired records first and then the stalest, an account the identity does not hold is refused, and the seed binds every immutable field. session_consent_change_tests (4). device_key::tests (10): a browser holding both keys is accepted; a successor the caller cannot prove, a successor signature replayed as the current one, a signature over another session key, one paired with a substituted successor, another browser's signature, a signature over the bare session key, a key that is not P-256, a wrong length and an empty signature are each refused.

PocketIC (29). Creation and witnessing, browser reuse, refusal for another identity, minting through the session index, the minted principal equalling the account's own and the account principal minting nothing itself, an expired session refused, a delegation longer than the TTL refused, replacement invalidating the previous chain, a consent change not reused, a browser dropped by the registry cap losing its sessions, the registration archived with the name redacted, and for the proof: a successor the caller cannot prove, a successor another browser holds even when proven, a retired key coming back as a new browser, a replayed announcement refused, the current key accepted after a lost response, and a rotation keeping the browser entry.

@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 d6bc4e9.

Security Overview
Detected Code Changes

The diff is too large to display a summary of code changes.

@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
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch from 659b1fd to 6f92189 Compare 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-create-and-cap branch 2 times, most recently from df89321 to c2a6a06 Compare August 19, 2026 03:46
@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-create-and-cap branch from c2a6a06 to ac3197e 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-create-and-cap branch from ac3197e to bcbf093 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-create-and-cap branch from bcbf093 to 25f36b4 Compare August 19, 2026 08:17
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 3955e8e to 01d618a Compare August 19, 2026 08:37
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch from 25f36b4 to 3acce11 Compare August 19, 2026 08:37
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 01d618a to d6d824c Compare August 19, 2026 10:21
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch from 3acce11 to acddddb 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-create-and-cap branch from acddddb to f708e87 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-create-and-cap branch 4 times, most recently from 786f787 to b1ba451 Compare August 20, 2026 16:10
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 506a7b7 to 45b4a7a Compare August 20, 2026 17:24
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch from b1ba451 to 0ed4eee Compare August 20, 2026 17:24
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 45b4a7a to d28dcbf Compare August 21, 2026 08:54
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch from 0ed4eee to 0524bb5 Compare August 21, 2026 08:54
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from d28dcbf to cb1da49 Compare August 21, 2026 10:38
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch 3 times, most recently from 286cfaf to 77fe485 Compare August 22, 2026 11:57
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from cb1da49 to 126da3c Compare August 22, 2026 11:57
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch 2 times, most recently from ab4ad14 to f9da942 Compare August 22, 2026 17:24
@sea-snake
sea-snake force-pushed the feat/session-devices-registry branch from 126da3c to 73ab606 Compare August 22, 2026 17:24
sea-snake and others added 2 commits August 22, 2026 19:32
A sign-in that names a browser has to prove the browser holds the key it
names, and the successor it announces for its next sign-in.

Two signatures, under two domains: the current key over the session key and
the successor, and the successor over the session key and the current key.
Separate domains are what stop either signature being replayed in the other's
role. Ingress messages are public, so a key read off the wire could otherwise
be announced by someone who does not hold it, and claimed when its browser
next presents one.

P-256 only, and no fallback: a key that does not parse, a signature of the
wrong length and an empty signature are each refused rather than skipped.

The module is annotated `allow(dead_code)` because its caller is the sign-in
ceremony, which lands two PRs up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
@sea-snake
sea-snake force-pushed the feat/session-create-and-cap branch from f9da942 to d6bc4e9 Compare August 22, 2026 17:48
@sea-snake

Copy link
Copy Markdown
Contributor Author

Superseded by a four-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