feat(be): mint short-lived app delegations from a revocable session - #4243
Closed
sea-snake wants to merge 2 commits into
Closed
feat(be): mint short-lived app delegations from a revocable session#4243sea-snake wants to merge 2 commits into
sea-snake wants to merge 2 commits into
Conversation
|
✅ No security or compliance issues detected. Reviewed everything up to d6bc4e9. Security Overview
Detected Code ChangesThe diff is too large to display a summary of 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-create-and-cap
branch
from
August 19, 2026 01:47
659b1fd to
6f92189
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-create-and-cap
branch
2 times, most recently
from
August 19, 2026 03:46
df89321 to
c2a6a06
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-create-and-cap
branch
from
August 19, 2026 06:28
c2a6a06 to
ac3197e
Compare
This was referenced Aug 19, 2026
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-create-and-cap
branch
from
August 19, 2026 07:07
ac3197e to
bcbf093
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-create-and-cap
branch
from
August 19, 2026 08:17
bcbf093 to
25f36b4
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 19, 2026 08:37
3955e8e to
01d618a
Compare
sea-snake
force-pushed
the
feat/session-create-and-cap
branch
from
August 19, 2026 08:37
25f36b4 to
3acce11
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 19, 2026 10:21
01d618a to
d6d824c
Compare
sea-snake
force-pushed
the
feat/session-create-and-cap
branch
from
August 19, 2026 10:21
3acce11 to
acddddb
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-create-and-cap
branch
from
August 20, 2026 10:57
acddddb to
f708e87
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-create-and-cap
branch
4 times, most recently
from
August 20, 2026 16:10
786f787 to
b1ba451
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 20, 2026 17:24
506a7b7 to
45b4a7a
Compare
sea-snake
force-pushed
the
feat/session-create-and-cap
branch
from
August 20, 2026 17:24
b1ba451 to
0ed4eee
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 21, 2026 08:54
45b4a7a to
d28dcbf
Compare
sea-snake
force-pushed
the
feat/session-create-and-cap
branch
from
August 21, 2026 08:54
0ed4eee to
0524bb5
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 21, 2026 10:38
d28dcbf to
cb1da49
Compare
sea-snake
force-pushed
the
feat/session-create-and-cap
branch
3 times, most recently
from
August 22, 2026 11:57
286cfaf to
77fe485
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 22, 2026 11:57
cb1da49 to
126da3c
Compare
sea-snake
force-pushed
the
feat/session-create-and-cap
branch
2 times, most recently
from
August 22, 2026 17:24
ab4ad14 to
f9da942
Compare
sea-snake
force-pushed
the
feat/session-devices-registry
branch
from
August 22, 2026 17:24
126da3c to
73ab606
Compare
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
force-pushed
the
feat/session-create-and-cap
branch
from
August 22, 2026 17:48
f9da942 to
d6bc4e9
Compare
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. |
This was referenced Aug 22, 2026
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.
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_NSis 30 minutes andMAX_EXPIRATION_PERIOD_NSis 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, anddevice_idpicks 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_sessiontakesdevice_key, thenext_device_keyit 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_counton 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 onlast_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. Thegetre-derives the ceiling rather than trusting theexpirationit 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.