From d6bc4e9ff5f0c1a352ccbfdbe9aaef30736b802d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 19:41:27 +0200 Subject: [PATCH 1/6] feat(be): create a revocable session, capped per identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/archive/archive.did | 5 + src/canister_tests/src/api/archive.rs | 3 +- .../src/api/internet_identity/api_v2.rs | 27 + src/canister_tests/src/framework.rs | 64 ++ .../lib/generated/internet_identity_idl.js | 57 ++ .../generated/internet_identity_types.d.ts | 93 +++ src/internet_identity/internet_identity.did | 65 ++ src/internet_identity/src/delegation.rs | 29 + .../src/email_recovery/remove.rs | 1 + src/internet_identity/src/main.rs | 14 + src/internet_identity/src/sessions.rs | 302 ++++++- src/internet_identity/src/state.rs | 2 + src/internet_identity/src/storage.rs | 539 ++++++++++++- src/internet_identity/src/storage/anchor.rs | 8 + .../src/storage/anchor/tests.rs | 3 + src/internet_identity/src/storage/storable.rs | 1 + .../src/storage/storable/anchor.rs | 5 + .../src/storage/storable/session_handle.rs | 40 + src/internet_identity/src/storage/tests.rs | 762 +++++++++++++++++- .../src/verified_emails/remove.rs | 1 + .../tests/integration/main.rs | 1 + .../tests/integration/sessions.rs | 588 ++++++++++++++ .../src/archive/types.rs | 6 + .../src/internet_identity/types.rs | 90 +++ 24 files changed, 2677 insertions(+), 29 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/session_handle.rs create mode 100644 src/internet_identity/tests/integration/sessions.rs diff --git a/src/archive/archive.did b/src/archive/archive.did index 01b3d98f42a..ea7e6320520 100644 --- a/src/archive/archive.did +++ b/src/archive/archive.did @@ -71,6 +71,11 @@ type Operation = variant { add_name; update_name; remove_name; + // Registering the browser a session was created from. Once per browser per + // anchor; the self-reported name is redacted like an account name. + register_session_device : record { + name : Private; + }; create_account : record { name : Private; }; diff --git a/src/canister_tests/src/api/archive.rs b/src/canister_tests/src/api/archive.rs index 5d53bf64f43..7aa1a693d81 100644 --- a/src/canister_tests/src/api/archive.rs +++ b/src/canister_tests/src/api/archive.rs @@ -134,7 +134,8 @@ pub mod compat { | Operation::AddEmailRecovery | Operation::RemoveEmailRecovery | Operation::AddVerifiedEmail - | Operation::RemoveVerifiedEmail => { + | Operation::RemoveVerifiedEmail + | Operation::RegisterSessionDevice { .. } => { panic!("not available in compat type") } Operation::CreateAccount { name } => CompatOperation::CreateAccount { name }, diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index c45aa2af998..0651dc579de 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -762,3 +762,30 @@ pub fn account_principal_index_backfill_status( (), ) } + +pub fn prepare_account_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: PrepareAccountSessionRequest, +) -> Result, RejectResponse> { + call_candid_as( + env, + canister_id, + RawEffectivePrincipal::None, + sender, + "prepare_account_session", + (request,), + ) + .map(|(x,)| x) +} + +pub fn get_account_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: GetAccountSessionRequest, +) -> Result, RejectResponse> { + query_candid_as(env, canister_id, sender, "get_account_session", (request,)).map(|(x,)| x) +} + diff --git a/src/canister_tests/src/framework.rs b/src/canister_tests/src/framework.rs index efd7ffa149d..2d1f5a415ab 100644 --- a/src/canister_tests/src/framework.rs +++ b/src/canister_tests/src/framework.rs @@ -361,6 +361,70 @@ pub fn restore_compressed_stable_memory(env: &PocketIc, canister_id: CanisterId, env.set_stable_memory(canister_id, buffer, BlobCompression::Gzip); } +/// A browser key of the kind `prepare_account_session` demands a proof from. +/// +/// The DER encoding and the domain prefix have to match what the canister verifies, +/// so both are spelled out here rather than derived. +pub struct BrowserKey { + signing_key: p256::ecdsa::SigningKey, +} + +/// The SPKI header WebCrypto emits for an `ECDSA` P-256 public key, ahead of the 65-byte +/// uncompressed point. +const P256_SPKI_HEADER: [u8; 26] = [ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, + 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, +]; + +const DEVICE_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-device-key"; +const SUCCESSOR_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-device-successor"; + +impl BrowserKey { + pub fn new(seed: u8) -> Self { + Self { + signing_key: p256::ecdsa::SigningKey::from_bytes(&[seed; 32].into()) + .expect("failed to build a browser key"), + } + } + + /// The key a browser rotates to after `self`, so a test can walk the chain. + pub fn successor(&self) -> Self { + let mut seed = [0u8; 32]; + seed.copy_from_slice(&self.signing_key.to_bytes()); + seed[0] = seed[0].wrapping_add(1); + Self { + signing_key: p256::ecdsa::SigningKey::from_bytes(&seed.into()) + .expect("failed to build a browser key"), + } + } + + pub fn public_key(&self) -> PublicKey { + let point = p256::ecdsa::VerifyingKey::from(&self.signing_key).to_encoded_point(false); + let mut der = P256_SPKI_HEADER.to_vec(); + der.extend_from_slice(point.as_bytes()); + ByteBuf::from(der) + } + + pub fn sign(&self, session_key: &SessionKey, next_device_key: &PublicKey) -> ByteBuf { + self.sign_with(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_device_key) + } + + /// The successor's own signature, proving the browser holds the key it announces. + pub fn sign_as_successor(&self, session_key: &SessionKey, device_key: &PublicKey) -> ByteBuf { + self.sign_with(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, device_key) + } + + fn sign_with(&self, domain: &[u8], session_key: &SessionKey, other: &PublicKey) -> ByteBuf { + use p256::ecdsa::signature::Signer; + + let mut message = domain.to_vec(); + message.extend_from_slice(session_key); + message.extend_from_slice(other); + let signature: p256::ecdsa::Signature = self.signing_key.sign(&message); + ByteBuf::from(signature.to_bytes().to_vec()) + } +} + pub const PUBKEY_1: &str = "test"; pub const PUBKEY_2: &str = "some other key"; pub const RECOVERY_PUBKEY_1: &str = "recovery 1"; diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 55988f7be09..b27a3de3e1c 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -386,6 +386,23 @@ export const idlFactory = ({ IDL }) => { 'InternalCanisterError' : IDL.Text, 'Unauthorized' : IDL.Principal, }); + const GetAccountSessionRequest = IDL.Record({ + 'session_key' : SessionKey, + 'origin' : FrontendHostname, + 'account_number' : IDL.Opt(AccountNumber), + 'expiration' : Timestamp, + 'identity_number' : UserNumber, + }); + const GetAccountSessionResponse = IDL.Record({ + 'signed_delegation' : SignedDelegation, + }); + const AccountSessionError = IDL.Variant({ + 'InternalCanisterError' : IDL.Text, + 'Unauthorized' : IDL.Principal, + 'NoSuchSession' : IDL.Null, + 'NoSuchAccount' : IDL.Null, + 'InvalidDeviceKey' : IDL.Null, + }); const GetAccountsError = IDL.Variant({ 'InternalCanisterError' : IDL.Text, 'Unauthorized' : IDL.Principal, @@ -686,6 +703,26 @@ export const idlFactory = ({ IDL }) => { 'user_key' : UserKey, 'expiration' : Timestamp, }); + const PrepareAccountSessionRequest = IDL.Record({ + 'permissions' : IDL.Opt(Permissions), + 'session_key' : SessionKey, + 'valid_for' : IDL.Opt(IDL.Nat64), + 'origin' : FrontendHostname, + 'device_name' : IDL.Text, + 'account_number' : IDL.Opt(AccountNumber), + 'device_key_signature' : IDL.Vec(IDL.Nat8), + 'device_key' : PublicKey, + 'identity_number' : UserNumber, + 'next_device_key' : PublicKey, + 'next_device_key_signature' : IDL.Vec(IDL.Nat8), + }); + const PrepareAccountSessionResponse = IDL.Record({ + 'user_key' : PublicKey, + 'device_id' : IDL.Nat32, + 'created_at' : Timestamp, + 'expiration' : Timestamp, + 'account_principal' : IDL.Principal, + }); const PrepareAttributeRequest = IDL.Record({ 'origin' : FrontendHostname, 'attribute_keys' : IDL.Vec(IDL.Text), @@ -1039,6 +1076,16 @@ export const idlFactory = ({ IDL }) => { ], ['query'], ), + 'get_account_session' : IDL.Func( + [GetAccountSessionRequest], + [ + IDL.Variant({ + 'Ok' : GetAccountSessionResponse, + 'Err' : AccountSessionError, + }), + ], + ['query'], + ), 'get_accounts' : IDL.Func( [UserNumber, FrontendHostname], [ @@ -1295,6 +1342,16 @@ export const idlFactory = ({ IDL }) => { ], [], ), + 'prepare_account_session' : IDL.Func( + [PrepareAccountSessionRequest], + [ + IDL.Variant({ + 'Ok' : PrepareAccountSessionResponse, + 'Err' : AccountSessionError, + }), + ], + [], + ), 'prepare_attributes' : IDL.Func( [PrepareAttributeRequest], [ diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index 5aacaa52dae..db772b6ea7d 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -19,6 +19,16 @@ export interface AccountInfo { 'last_used' : [] | [Timestamp], } export type AccountNumber = bigint; +export type AccountSessionError = { 'InternalCanisterError' : string } | + { 'Unauthorized' : Principal } | + { 'NoSuchSession' : null } | + { 'NoSuchAccount' : null } | + { + /** + * The browser's key is unusable, or its signature does not verify against it. + */ + 'InvalidDeviceKey' : null + }; export interface AccountUpdate { 'name' : [] | [string] } export type AddTentativeDeviceResponse = { /** @@ -688,6 +698,16 @@ export type GetAccountError = { 'anchor_number' : UserNumber, } }; +export interface GetAccountSessionRequest { + 'session_key' : SessionKey, + 'origin' : FrontendHostname, + 'account_number' : [] | [AccountNumber], + 'expiration' : Timestamp, + 'identity_number' : UserNumber, +} +export interface GetAccountSessionResponse { + 'signed_delegation' : SignedDelegation, +} export type GetAccountsError = { 'InternalCanisterError' : string } | { 'Unauthorized' : Principal }; export type GetAttributesError = { 'AuthorizationError' : Principal } | @@ -1318,6 +1338,63 @@ export interface PrepareAccountDelegation { 'user_key' : UserKey, 'expiration' : Timestamp, } +export interface PrepareAccountSessionRequest { + /** + * The consented access level, fixed for the session's life. + */ + 'permissions' : [] | [Permissions], + /** + * The II frontend's own key. The app never sees this chain's private key. + */ + 'session_key' : SessionKey, + /** + * Clamped to the session maximum. + */ + 'valid_for' : [] | [bigint], + 'origin' : FrontendHostname, + /** + * Labels the browser in the user's session list, e.g. "Chrome on MacBook". + */ + 'device_name' : string, + 'account_number' : [] | [AccountNumber], + /** + * Signature over session_key and next_device_key, verified with device_key. + */ + 'device_key_signature' : Uint8Array | number[], + /** + * The browser's own public key, DER-encoded, as the registry currently holds it. A + * key this anchor has not seen registers a browser under it. + */ + 'device_key' : PublicKey, + 'identity_number' : UserNumber, + /** + * What the browser rotates to once this sign-in succeeds. + */ + 'next_device_key' : PublicKey, + /** + * Signature by next_device_key over session_key and device_key, proving the browser + * holds the key it is announcing. + */ + 'next_device_key_signature' : Uint8Array | number[], +} +export interface PrepareAccountSessionResponse { + 'user_key' : PublicKey, + /** + * Which browser this sign-in was attributed to, so the settings list can mark the one + * the user is looking at. Not a credential: a caller never presents it. + */ + 'device_id' : number, + 'created_at' : Timestamp, + /** + * The session's valid_till. + */ + 'expiration' : Timestamp, + /** + * The principal apps see for this account, so the frontend can tell its own + * sessions apart without minting a delegation to learn it. + */ + 'account_principal' : Principal, +} export type PrepareAttributeError = { 'AuthorizationError' : Principal } | { 'ValidationError' : { 'problems' : Array } } | { 'GetAccountError' : GetAccountError }; @@ -2049,6 +2126,11 @@ export interface _SERVICE { { 'Ok' : SignedDelegation } | { 'Err' : AccountDelegationError } >, + 'get_account_session' : ActorMethod< + [GetAccountSessionRequest], + { 'Ok' : GetAccountSessionResponse } | + { 'Err' : AccountSessionError } + >, /** * Multiple accounts */ @@ -2341,6 +2423,17 @@ export interface _SERVICE { { 'Ok' : PrepareAccountDelegation } | { 'Err' : AccountDelegationError } >, + /** + * Creates or reuses a revocable session at one account and signs its identity to + * the II frontend's own key. Called only by the II frontend, which ships with the + * canister; requires an anchor access method, so a session can neither spawn nor + * extend itself. + */ + 'prepare_account_session' : ActorMethod< + [PrepareAccountSessionRequest], + { 'Ok' : PrepareAccountSessionResponse } | + { 'Err' : AccountSessionError } + >, /** * Attribute sharing protocol * ========================== diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 7c97b7f5221..62d3766a607 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1017,6 +1017,64 @@ type SessionDeviceInfo = record { last_used : Timestamp; }; +type PrepareAccountSessionRequest = record { + identity_number : UserNumber; + origin : FrontendHostname; + account_number : opt AccountNumber; + // The II frontend's own key. The app never sees this chain's private key. + session_key : SessionKey; + // Labels the browser in the user's session list, e.g. "Chrome on MacBook". + device_name : text; + // The browser's own public key, DER-encoded, as the registry currently holds it. A + // key this anchor has not seen registers a browser under it. + device_key : PublicKey; + // What the browser rotates to once this sign-in succeeds. + next_device_key : PublicKey; + // Signature over session_key and next_device_key, verified with device_key. + device_key_signature : blob; + // Signature by next_device_key over session_key and device_key, proving the browser + // holds the key it is announcing. + next_device_key_signature : blob; + // The consented access level, fixed for the session's life. + permissions : opt Permissions; + // Clamped to the session maximum. + valid_for : opt nat64; +}; + +type PrepareAccountSessionResponse = record { + user_key : PublicKey; + // The session's valid_till. + expiration : Timestamp; + created_at : Timestamp; + // Which browser this sign-in was attributed to, so the settings list can mark the one + // the user is looking at. Not a credential: a caller never presents it. + device_id : nat32; + // The principal apps see for this account, so the frontend can tell its own + // sessions apart without minting a delegation to learn it. + account_principal : principal; +}; + +type GetAccountSessionRequest = record { + identity_number : UserNumber; + origin : FrontendHostname; + account_number : opt AccountNumber; + session_key : SessionKey; + expiration : Timestamp; +}; + +type GetAccountSessionResponse = record { + signed_delegation : SignedDelegation; +}; + +type AccountSessionError = variant { + Unauthorized : principal; + NoSuchAccount; + NoSuchSession; + // The browser's key is unusable, or its signature does not verify against it. + InvalidDeviceKey; + InternalCanisterError : text; +}; + type IdentityInfo = record { authn_methods : vec AuthnMethodData; authn_method_registration : opt AuthnMethodRegistrationInfo; @@ -1871,6 +1929,13 @@ service : (opt InternetIdentityInit) -> { update : AccountUpdate ) -> (variant { Ok : AccountInfo; Err: UpdateAccountError }); + // Creates or reuses a revocable session at one account and signs its identity to + // the II frontend's own key. Called only by the II frontend, which ships with the + // canister; requires an anchor access method, so a session can neither spawn nor + // extend itself. + prepare_account_session : (PrepareAccountSessionRequest) -> (variant { Ok : PrepareAccountSessionResponse; Err : AccountSessionError }); + get_account_session : (GetAccountSessionRequest) -> (variant { Ok : GetAccountSessionResponse; Err : AccountSessionError }) query; + prepare_account_delegation : ( anchor_number : UserNumber, origin : FrontendHostname, diff --git a/src/internet_identity/src/delegation.rs b/src/internet_identity/src/delegation.rs index 50ea7769dd4..7aebe803cec 100644 --- a/src/internet_identity/src/delegation.rs +++ b/src/internet_identity/src/delegation.rs @@ -124,6 +124,35 @@ pub fn calculate_account_seed_with_salt( hash_bytes(blob) } +const SESSION_SEED_PREFIX: &str = "session"; + +/// The seed of a session's canister-signed identity. +/// +/// Built on the account's own seed, so a session survives anything that leaves the +/// account's principal unchanged, including naming a default account. `device_id` and +/// `created_at` are inputs, so a session's attribution cannot be rewritten in storage +/// without invalidating it. Unguessability comes from the salt. +pub fn calculate_session_seed_with_salt( + salt: &[u8; 32], + account_seed: &Hash, + created_at: Timestamp, + device_id: SessionDeviceId, +) -> Hash { + fn push_field(blob: &mut Vec, data: &[u8]) { + blob.extend_from_slice(&(data.len() as u64).to_be_bytes()); + blob.extend_from_slice(data); + } + + let mut blob: Vec = vec![]; + push_field(&mut blob, salt); + push_field(&mut blob, SESSION_SEED_PREFIX.as_bytes()); + push_field(&mut blob, account_seed); + push_field(&mut blob, &created_at.to_be_bytes()); + push_field(&mut blob, &device_id.to_be_bytes()); + + hash_bytes(blob) +} + fn hash_bytes(value: impl AsRef<[u8]>) -> Hash { let mut hasher = Sha256::new(); hasher.update(value.as_ref()); diff --git a/src/internet_identity/src/email_recovery/remove.rs b/src/internet_identity/src/email_recovery/remove.rs index e4cde763649..669d5b32e02 100644 --- a/src/internet_identity/src/email_recovery/remove.rs +++ b/src/internet_identity/src/email_recovery/remove.rs @@ -77,6 +77,7 @@ mod tests { let mut a = Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 1, devices: vec![], openid_credentials: vec![], diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index e7ca809157c..d3101510e36 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -490,6 +490,20 @@ async fn set_default_account( Ok(result) } +#[update] +async fn prepare_account_session( + request: PrepareAccountSessionRequest, +) -> Result { + sessions::prepare_account_session(request).await +} + +#[query] +fn get_account_session( + request: GetAccountSessionRequest, +) -> Result { + sessions::get_account_session(request) +} + #[update] async fn prepare_account_delegation( anchor_number: AnchorNumber, diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 11fef341fa1..b1168f358dc 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -1,5 +1,299 @@ -// The sign-in ceremony that creates a session is added on top of this; for now the module -// holds only the verifier its request will be checked against. -#![allow(dead_code)] - pub mod device_key; + +use crate::anchor_management::post_operation_bookkeeping; +use crate::authz_utils::{ + check_authorization, check_authz_and_record_activity, AuthorizationError, IdentityUpdateError, +}; +use crate::delegation::{ + add_delegation_signature, calculate_session_seed_with_salt, canister_sig_principal, + check_frontend_length, der_encode_canister_sig_key, DelegationAccess, +}; +use crate::sessions::device_key::verify_device_keys; +use crate::state::{self, storage_borrow, storage_borrow_mut}; +use crate::storage::account::{ReadAccountParams, SessionRecord}; +use crate::storage::{CreateSessionParams, StorageError}; +use crate::{update_root_hash, DAY_NS, MINUTE_NS}; +use candid::Principal; +use ic_canister_sig_creation::signature_map::CanisterSigInputs; +use ic_canister_sig_creation::DELEGATION_SIG_DOMAIN; +use ic_cdk::api::time; +use ic_certification::Hash; +use internet_identity_interface::archive::types::{Operation, Private}; +use internet_identity_interface::internet_identity::types::{ + AccountNumber, AccountSessionError, AnchorNumber, ApplicationNumber, + Delegation, FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, + PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp, +}; +use serde_bytes::ByteBuf; + +pub const DEFAULT_SESSION_TTL_NS: u64 = 30 * DAY_NS; +pub const MAX_SESSION_TTL_NS: u64 = 30 * DAY_NS; +const MIN_SESSION_TTL_NS: u64 = 10 * MINUTE_NS; + +/// The device name is a label the user reads, never anything the canister acts on. +const MAX_DEVICE_NAME_BYTES: usize = 128; + +impl From for AccountSessionError { + fn from(err: AuthorizationError) -> Self { + AccountSessionError::Unauthorized(err.principal) + } +} + +impl From for AccountSessionError { + fn from(err: IdentityUpdateError) -> Self { + match err { + IdentityUpdateError::Unauthorized(principal) => { + AccountSessionError::Unauthorized(principal) + } + IdentityUpdateError::StorageError(_, storage_error) => storage_error.into(), + } + } +} + +impl From for AccountSessionError { + fn from(err: StorageError) -> Self { + match err { + StorageError::MissingAccount { .. } | StorageError::ApplicationNotFound { .. } => { + AccountSessionError::NoSuchAccount + } + other => AccountSessionError::InternalCanisterError(other.to_string()), + } + } +} + +pub async fn prepare_account_session( + request: PrepareAccountSessionRequest, +) -> Result { + let PrepareAccountSessionRequest { + identity_number, + origin, + account_number, + session_key, + device_name, + device_key, + next_device_key, + device_key_signature, + next_device_key_signature, + permissions, + valid_for, + } = request; + + check_authz_and_record_activity(identity_number)?; + check_frontend_length(&origin); + if device_name.len() > MAX_DEVICE_NAME_BYTES { + return Err(AccountSessionError::InternalCanisterError( + "device name exceeds the limit".to_string(), + )); + } + if !verify_device_keys( + &device_key, + &device_key_signature, + &next_device_key, + &next_device_key_signature, + &session_key, + ) { + return Err(AccountSessionError::InvalidDeviceKey); + } + state::ensure_salt_set().await; + + let now = time(); + let valid_till = now.saturating_add( + valid_for + .unwrap_or(DEFAULT_SESSION_TTL_NS) + .clamp(MIN_SESSION_TTL_NS, MAX_SESSION_TTL_NS), + ); + let access = DelegationAccess::from(permissions); + let read_only = access == DelegationAccess::ReadOnly; + + // Checked before anything is written. An account this identity does not hold is the + // one failure a caller can provoke, and returning it after the writes below would + // leave a browser registered for a sign-in that never happened. + if storage_borrow(|storage| { + storage.read_account(ReadAccountParams { + account_number, + anchor_number: identity_number, + origin: &origin, + known_app_num: None, + }) + }) + .is_none() + { + return Err(AccountSessionError::NoSuchAccount); + } + + let mut anchor = state::anchor(identity_number); + // A rotating browser presents the successor it announced, so both values are known. + let known_device = anchor + .session_devices() + .iter() + .any(|device| device.key == device_key || device.pending == device_key); + let (device_id, dropped_devices) = anchor + .resolve_session_device(device_key, next_device_key, device_name, now) + .map_err(|_| AccountSessionError::InvalidDeviceKey)?; + storage_borrow_mut(|storage| storage.write(anchor)) + .expect("failed to write the anchor while registering a browser"); + + if !known_device { + post_operation_bookkeeping( + identity_number, + Operation::RegisterSessionDevice { + name: Private::Redacted, + }, + ); + } + + for dropped in dropped_devices { + storage_borrow_mut(|storage| storage.revoke_device_sessions(identity_number, dropped)) + .expect("failed to end the sessions of a browser the registry dropped"); + } + + // The account was checked above, so anything left is a broken storage invariant + // rather than a request this caller could have got wrong. Trapping rolls the whole + // message back, including the browser registration. + let session = storage_borrow_mut(|storage| { + storage.create_session(CreateSessionParams { + anchor_number: identity_number, + origin: origin.clone(), + account_number, + device_id, + valid_till, + read_only, + now, + }) + }) + .expect("failed to create a session for an account that was just read"); + + let (seed, application_number) = + session_identity(identity_number, &origin, account_number, &session) + .expect("failed to derive the identity of a session that was just created"); + let account_principal = account_principal(identity_number, application_number, account_number) + .expect("failed to derive the principal of an account that was just read"); + + state::signature_map_mut(|sigs| { + add_delegation_signature(sigs, session_key, seed.as_ref(), session.valid_till, None); + }); + update_root_hash(); + + Ok(PrepareAccountSessionResponse { + user_key: ByteBuf::from(der_encode_canister_sig_key(seed.to_vec())), + expiration: session.valid_till, + created_at: session.created_at, + device_id, + account_principal, + }) +} + +pub fn get_account_session( + request: GetAccountSessionRequest, +) -> Result { + let GetAccountSessionRequest { + identity_number, + origin, + account_number, + session_key, + expiration, + } = request; + + check_authorization(identity_number)?; + check_frontend_length(&origin); + + let sessions = storage_borrow(|storage| { + storage.account_sessions(identity_number, &origin, account_number) + }) + .ok_or(AccountSessionError::NoSuchAccount)?; + + sessions + .into_iter() + .filter(|session| session.valid_till == expiration) + .find_map(|session| { + let (seed, _) = + session_identity(identity_number, &origin, account_number, &session).ok()?; + let signed_delegation = witness_session_delegation(&seed, &session_key, expiration)?; + Some(GetAccountSessionResponse { signed_delegation }) + }) + .ok_or(AccountSessionError::NoSuchSession) +} + +fn witness_session_delegation( + seed: &Hash, + session_key: &[u8], + expiration: Timestamp, +) -> Option { + state::assets_and_signatures(|certified_assets, sigs| { + let inputs = CanisterSigInputs { + domain: DELEGATION_SIG_DOMAIN, + seed, + message: &crate::delegation::delegation_signature_msg_with_permissions( + session_key, + expiration, + None, + None, + ), + }; + sigs.get_signature_as_cbor(&inputs, Some(certified_assets.root_hash())) + .ok() + }) + .map(|signature| SignedDelegation { + delegation: Delegation { + pubkey: ByteBuf::from(session_key.to_vec()), + expiration, + targets: None, + permissions: None, + }, + signature: ByteBuf::from(signature), + }) +} + +fn session_identity( + anchor_number: AnchorNumber, + origin: &FrontendHostname, + account_number: Option, + session: &SessionRecord, +) -> Result<(Hash, ApplicationNumber), AccountSessionError> { + let (salt, application_number) = storage_borrow(|storage| { + ( + storage.salt().copied(), + storage.lookup_application_number_with_origin(origin), + ) + }); + let salt = salt.ok_or_else(|| { + AccountSessionError::InternalCanisterError(StorageError::SaltNotSet.to_string()) + })?; + let application_number: ApplicationNumber = + application_number.ok_or(AccountSessionError::NoSuchAccount)?; + + let (account, _) = storage_borrow(|storage| { + storage.account_with_sessions(anchor_number, application_number, account_number) + }) + .ok_or(AccountSessionError::NoSuchAccount)?; + let seed = calculate_session_seed_with_salt( + &salt, + &account.calculate_seed_with_salt(&salt), + session.created_at, + session.device_id, + ); + Ok((seed, application_number)) +} + +/// The principal an app sees for this account. A session handle names the account by this +/// and never by the numbers behind it, which are II's alone. +/// +/// The account is read rather than reconstructed: a materialized default derives from +/// `seed_from_anchor`, which only the stored row carries. +fn account_principal( + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, +) -> Result { + let salt = storage_borrow(|storage| storage.salt().copied()).ok_or_else(|| { + AccountSessionError::InternalCanisterError(StorageError::SaltNotSet.to_string()) + })?; + let (account, _) = storage_borrow(|storage| { + storage.account_with_sessions(anchor_number, application_number, account_number) + }) + .ok_or(AccountSessionError::NoSuchAccount)?; + Ok(canister_sig_principal( + ic_cdk::id(), + account.calculate_seed_with_salt(&salt).to_vec(), + )) +} diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index 86cbd61f213..270e78d98d3 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -290,6 +290,7 @@ pub fn init_new() { memory, ); storage_replace(storage); + storage_borrow_mut(|storage| storage.set_canister_id(ic_cdk::id())); } pub fn init_from_stable_memory() { @@ -298,6 +299,7 @@ pub fn init_from_stable_memory() { }); let storage = Storage::from_memory(DefaultMemoryImpl::default()); storage_replace(storage); + storage_borrow_mut(|storage| storage.set_canister_id(ic_cdk::id())); } pub fn save_persistent_state() { diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5c1b0cbefc0..3f453f8191d 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -105,11 +105,12 @@ use identity_jose::jwk::Jwk; use internet_identity_interface::archive::types::BufferedEntry; use crate::delegation::{self, check_frontend_length}; +use crate::delegation::{calculate_session_seed_with_salt, canister_sig_principal}; use crate::openid::OpenIdCredentialKey; use crate::state::PersistentState; use crate::stats::event_stats::AggregationKey; use crate::stats::event_stats::{EventData, EventKey}; -use crate::storage::account::AccountReference; +use crate::storage::account::{AccountReference, SessionRecord}; use crate::storage::anchor::Anchor; use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; @@ -123,6 +124,7 @@ use crate::storage::storable::application::StorableOriginSha256; use crate::storage::storable::application_number::StorableApplicationNumber; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; +use crate::storage::storable::session_handle::StorableSessionHandle; use internet_identity_interface::internet_identity::types::*; use storable::anchor::StorableAnchor; use storable::anchor_number::StorableAnchorNumber; @@ -212,6 +214,7 @@ const MCP_REGISTRATION_MEMORY_INDEX: u8 = 31u8; const SSO_STABLE_ID_INDEX_MEMORY_INDEX: u8 = 32u8; const NEXT_APPLICATION_NUMBER_MEMORY_INDEX: u8 = 33u8; const LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_INDEX: u8 = 34u8; +const LOOKUP_SESSION_WITH_PRINCIPAL_MEMORY_INDEX: u8 = 35u8; const ANCHOR_MEMORY_ID: MemoryId = MemoryId::new(ANCHOR_MEMORY_INDEX); const ARCHIVE_BUFFER_MEMORY_ID: MemoryId = MemoryId::new(ARCHIVE_BUFFER_MEMORY_INDEX); @@ -297,6 +300,8 @@ const NEXT_APPLICATION_NUMBER_MEMORY_ID: MemoryId = /// Reverse index from the principal a dapp sees to the account that produced it: /// `self_authenticating(der_encode_canister_sig_key(seed)) -> (anchor, application, account)`. +const LOOKUP_SESSION_WITH_PRINCIPAL_MEMORY_ID: MemoryId = + MemoryId::new(LOOKUP_SESSION_WITH_PRINCIPAL_MEMORY_INDEX); const LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_ID: MemoryId = MemoryId::new(LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_INDEX); @@ -318,6 +323,21 @@ const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS /// Bounds the victim scan, which runs on the sign-in path. const MAX_ROWS_SCANNED_FOR_EVICTION: usize = 2 * MAX_EVICTABLE_DEFAULT_ACCOUNTS as usize; +/// Session records one identity may hold, counted as stored rather than as live. +/// +/// Counting what is stored is what makes the cap enforceable: a session expires with no +/// write anywhere, so nothing observes it becoming dead, and a count of live sessions could +/// only ever be a guess. An expired record instead holds its slot until something reclaims +/// it — and since it is the first thing reclaimed, it is spent before any session a user +/// still relies on, so holding a slot costs its owner nothing. +/// +/// A bound on concurrent activity, not on history: every session expires within 30 days, so +/// the set is the apps used in the last month times the browsers they were used from. +pub const MAX_SESSIONS_PER_ANCHOR: u32 = 500; +/// Reclaiming goes down to here rather than to the cap, so the pass that walks an identity's +/// rows runs once and then not again for the next fifty sign-ins. +pub const SESSIONS_WATERMARK_PER_ANCHOR: u32 = 450; + /// Bounds one message's eviction work. const MAX_EVICTIONS_PER_CALL: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; @@ -406,6 +426,15 @@ pub struct Storage { lookup_account_with_principal_memory_wrapper: MemoryWrapper>, lookup_account_with_principal_memory: StableBTreeMap>, + /// This canister's own id, which session and account principals derive from. Set once + /// at init; a fixed default keeps derivations consistent in unit tests, which run + /// outside a canister. + canister_id: Principal, + /// Where a session lives, keyed by the principal its chain is rooted at. An app-facing + /// call carries nothing but that principal, so this is what turns `caller()` into a + /// session. + lookup_session_with_principal_memory: + StableBTreeMap>, /// Counter that counts how often there was a discrepancy between the anchor accounts counter and the actual number of accounts stable_account_counter_discrepancy_counter_memory: StableCell>, @@ -544,6 +573,8 @@ impl Storage { let next_application_number_memory = memory_manager.get(NEXT_APPLICATION_NUMBER_MEMORY_ID); let lookup_account_with_principal_memory = memory_manager.get(LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_ID); + let lookup_session_with_principal_memory = + memory_manager.get(LOOKUP_SESSION_WITH_PRINCIPAL_MEMORY_ID); let stable_account_counter_discrepancy_counter_memory = memory_manager.get(STABLE_ACCOUNT_COUNTER_DISCREPANCY_COUNTER_MEMORY_ID); let lookup_anchor_with_openid_credential_memory = @@ -631,6 +662,10 @@ impl Storage { lookup_account_with_principal_memory_wrapper: MemoryWrapper::new( lookup_account_with_principal_memory.clone(), ), + canister_id: Principal::management_canister(), + lookup_session_with_principal_memory: StableBTreeMap::init( + lookup_session_with_principal_memory, + ), lookup_account_with_principal_memory: StableBTreeMap::init( lookup_account_with_principal_memory, ), @@ -890,6 +925,7 @@ impl Storage { verified_emails: _, session_devices: _, next_session_device_id: _, + session_count: _, }) = previous_anchor_maybe { ( @@ -1766,6 +1802,470 @@ impl Storage { Ok(Some(())) } + /// Signs one browser out of everything, in a single message. + pub fn revoke_device_sessions( + &mut self, + anchor_number: AnchorNumber, + device_id: SessionDeviceId, + ) -> Result { + let affected: Vec<(ApplicationNumber, Vec)> = self + .stable_account_reference_list_memory + .range( + (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), + ) + .filter_map(|((_, application_number), list)| { + let references: Vec = list.into(); + references + .iter() + .any(|reference| { + reference + .sessions + .iter() + .any(|session| session.device_id == device_id) + }) + .then_some((application_number, references)) + }) + .collect(); + + let mut removed = 0u64; + for (application_number, mut references) in affected { + let mut dropped: Vec<(Option, SessionRecord)> = vec![]; + for reference in &mut references { + let account_number = reference.account_number; + reference.sessions.retain(|session| { + if session.device_id == device_id { + dropped.push((account_number, session.clone())); + return false; + } + true + }); + } + removed += dropped.len() as u64; + self.write_reference_list(anchor_number, application_number, references)?; + for (account_number, session) in &dropped { + self.unindex_sessions( + anchor_number, + application_number, + *account_number, + std::slice::from_ref(session), + ); + } + } + if removed > 0 { + self.change_session_count(anchor_number, removed as usize, 0)?; + } + + Ok(removed) + } + + /// Tells storage which canister it is, so the principals it derives match the ones + /// callers arrive as. + pub fn set_canister_id(&mut self, canister_id: Principal) { + self.canister_id = canister_id; + } + + /// The principal a session's chain is rooted at, which is what an app-facing call + /// arrives as. `None` only when the salt is unset or the account is gone, both of + /// which make the session unusable anyway. + fn session_principal( + &self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + session: &SessionRecord, + ) -> Option { + let salt = self.salt().copied()?; + let account = self.read_account(ReadAccountParams { + account_number, + anchor_number, + origin: &self + .stable_application_memory + .get(&application_number)? + .origin, + known_app_num: Some(application_number), + })?; + let seed = calculate_session_seed_with_salt( + &salt, + &account.calculate_seed_with_salt(&salt), + session.created_at, + session.device_id, + ); + Some(canister_sig_principal(self.canister_id, seed.to_vec())) + } + + /// Frees a slot for one more session, and reports whether the anchor has one. + /// + /// The stored count is a trigger, never the thing the cap is enforced against: a + /// session can expire with no write anywhere, so the count drifts upwards. Once it + /// reaches the cap this recounts what the rows hold and reclaims against that, so an + /// admission is only ever granted against a number that was just counted. + fn ensure_session_slot( + &mut self, + anchor_number: AnchorNumber, + now: Timestamp, + ) -> Result { + if self.read(anchor_number)?.session_count < MAX_SESSIONS_PER_ANCHOR { + return Ok(true); + } + Ok(self.reclaim_sessions(anchor_number, now)? < MAX_SESSIONS_PER_ANCHOR) + } + + /// Replaces the count with a number that was counted rather than accumulated. + fn set_session_count( + &mut self, + anchor_number: AnchorNumber, + count: u32, + ) -> Result<(), StorageError> { + let mut anchor = self.read(anchor_number)?; + if anchor.session_count == count { + return Ok(()); + } + anchor.session_count = count; + self.write(anchor) + } + + /// Moves the count without considering the cap, for the paths that only remove. + fn change_session_count( + &mut self, + anchor_number: AnchorNumber, + removed: usize, + added: usize, + ) -> Result { + let mut anchor = self.read(anchor_number)?; + anchor.session_count = anchor + .session_count + .saturating_sub(removed as u32) + .saturating_add(added as u32); + let count = anchor.session_count; + self.write(anchor)?; + Ok(count) + } + + /// Walks the anchor's rows once and reclaims down to the watermark, taking sessions in + /// [`SessionRecord::reclaim_order`]: dead ones first, then the least recently used. + /// + /// Returns what the rows actually hold once it is done, which is the number the cap is + /// enforced against. The stored counter is only ever a trigger for running this pass — + /// it can drift, this cannot, because it counts the sessions themselves. + /// + /// One pass per fifty sign-ins, because it reclaims to the watermark rather than to the + /// cap, and bounded by the same row limit account eviction uses. + fn reclaim_sessions( + &mut self, + anchor_number: AnchorNumber, + now: Timestamp, + ) -> Result { + struct Candidate { + order: (bool, Timestamp, SessionDeviceId), + row: usize, + account_number: Option, + device_id: SessionDeviceId, + } + + // Every row, not a bounded prefix of them: the number this returns is what the cap is + // enforced against, and a truncated scan would undercount, lower the counter to the + // undercount, and let the stored set climb past the cap from there. An identity's rows + // are already bounded — the row cap holds the evictable ones and the account cap holds + // the rest — and a sequential scan of them costs a fraction of the writes it saves. + let mut rows: Vec<(ApplicationNumber, Vec)> = self + .stable_account_reference_list_memory + .range( + (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), + ) + .map(|((_, application_number), list)| (application_number, list.into())) + .collect(); + + let mut candidates: Vec = vec![]; + for (row, (_, references)) in rows.iter().enumerate() { + for reference in references { + for session in &reference.sessions { + candidates.push(Candidate { + order: session.reclaim_order(now), + row, + account_number: reference.account_number, + device_id: session.device_id, + }); + } + } + } + let stored = candidates.len() as u32; + candidates.sort_by_key(|candidate| candidate.order); + + let surplus = stored.saturating_sub(SESSIONS_WATERMARK_PER_ANCHOR) as usize; + let victims = &candidates[..surplus.min(candidates.len())]; + if victims.is_empty() { + self.set_session_count(anchor_number, stored)?; + return Ok(stored); + } + + // One write per row rather than one per victim: the row is a single blob, so + // dropping several of its sessions one at a time would rewrite it several times. + let mut touched: Vec = victims.iter().map(|victim| victim.row).collect(); + touched.sort_unstable(); + touched.dedup(); + + let mut dropped_total = 0usize; + for row in touched { + let (application_number, references) = &mut rows[row]; + let application_number = *application_number; + let mut removed: Vec<(Option, SessionRecord)> = vec![]; + for reference in references.iter_mut() { + let account_number = reference.account_number; + reference.sessions.retain(|session| { + // The row has to be part of the match: one browser holds one session per + // account, but the same browser and the same account number appear in + // every row, so matching on that pair alone reaches across applications. + let doomed = victims.iter().any(|victim| { + victim.row == row + && victim.account_number == account_number + && victim.device_id == session.device_id + }); + if doomed { + removed.push((account_number, session.clone())); + } + !doomed + }); + } + if removed.is_empty() { + continue; + } + self.write_reference_list(anchor_number, application_number, references.clone())?; + for (account_number, session) in &removed { + self.unindex_sessions( + anchor_number, + application_number, + *account_number, + std::slice::from_ref(session), + ); + } + dropped_total += removed.len(); + } + + let remaining = stored.saturating_sub(dropped_total as u32); + self.set_session_count(anchor_number, remaining)?; + Ok(remaining) + } + + /// Drops the index entries of sessions that have just been removed from a row. + fn unindex_sessions( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + removed: &[SessionRecord], + ) { + for session in removed { + if let Some(principal) = + self.session_principal(anchor_number, application_number, account_number, session) + { + self.lookup_session_with_principal_memory.remove(&principal); + } + } + } + + /// The account a session handle names, together with its sessions. + pub fn account_with_sessions( + &self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + ) -> Option<(Account, Vec)> { + let origin = self + .stable_application_memory + .get(&application_number) + .map(|application| application.origin)?; + let references: Vec = self + .lookup_account_references(anchor_number, application_number)? + .into_iter() + .map(Into::into) + .collect(); + let reference = references + .into_iter() + .find(|reference| reference.account_number == account_number)?; + let account = self.read_account(ReadAccountParams { + account_number, + anchor_number, + origin: &origin, + known_app_num: Some(application_number), + })?; + Some((account, reference.sessions)) + } + + pub fn account_sessions( + &self, + anchor_number: AnchorNumber, + origin: &FrontendHostname, + account_number: Option, + ) -> Option> { + let application_number = self.lookup_application_number_with_origin(origin)?; + let references: Vec = self + .lookup_account_references(anchor_number, application_number)? + .into_iter() + .map(Into::into) + .collect(); + references + .into_iter() + .find(|reference| reference.account_number == account_number) + .map(|reference| reference.sessions) + } + + /// Creates the session `prepare_account_session` mints an identity from, replacing + /// whatever this browser already held at this account. + pub fn create_session( + &mut self, + params: CreateSessionParams, + ) -> Result { + let CreateSessionParams { + anchor_number, + origin, + account_number, + device_id, + valid_till, + read_only, + now, + } = params; + + // The row this session lands in has to exist first, but an existing one must not be + // written here: the single write at the end of this function carries `last_used`. + let application_number = match self.lookup_application_number_with_origin(&origin) { + Some(application_number) + if self + .lookup_account_references(anchor_number, application_number) + .is_some() => + { + application_number + } + _ => { + if account_number.is_some() { + return Err(StorageError::MissingAccount { + anchor_number, + name: origin, + }); + } + let application_number = + self.lookup_or_insert_application_number_with_origin(&origin); + self.write_reference_list( + anchor_number, + application_number, + vec![AccountReference::new(None, Some(now))], + )?; + self.evict_idle_tracked_defaults(anchor_number, application_number)?; + application_number + } + }; + + // Reclaiming before the session is admitted rather than after it: the stored set + // never sits above the cap, not even for the rest of this message. + if !self.ensure_session_slot(anchor_number, now)? { + return Err(StorageError::SessionCapNotReclaimed { anchor_number }); + } + + let mut references: Vec = self + .lookup_account_references(anchor_number, application_number) + .ok_or(StorageError::MissingAccount { + anchor_number, + name: origin, + })? + .into_iter() + .map(Into::into) + .collect(); + + let reference = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + .ok_or(StorageError::MissingAccount { + anchor_number, + name: String::new(), + })?; + reference.last_used = Some(now); + + // A ceremony replaces whatever this browser held here, rather than reusing it: the + // copy of an old session's chain stops working at the user's next sign-in instead of + // at its expiry. + let mut dropped: Vec<(Option, SessionRecord)> = vec![]; + reference.sessions.retain(|session| { + if session.device_id == device_id { + dropped.push((account_number, session.clone())); + return false; + } + true + }); + + let session = SessionRecord { + created_at: now, + valid_till, + last_refreshed: None, + device_id, + read_only, + }; + reference.sessions.push(session.clone()); + + // The whole row, not just the reference being written: this row is about to be + // rewritten anyway, and a dead session on a sibling reference has nothing else + // coming for it. + for reference in references.iter_mut() { + let account_number = reference.account_number; + reference.sessions.retain(|session| { + if session.is_expired(now) { + dropped.push((account_number, session.clone())); + return false; + } + true + }); + } + + self.write_reference_list(anchor_number, application_number, references)?; + for (account_number, session) in &dropped { + self.unindex_sessions( + anchor_number, + application_number, + *account_number, + std::slice::from_ref(session), + ); + } + if let Some(principal) = + self.session_principal(anchor_number, application_number, account_number, &session) + { + self.lookup_session_with_principal_memory.insert( + principal, + StorableSessionHandle { + account_principal: self + .account_principal_of(anchor_number, application_number, account_number) + .map(|p| p.as_slice().to_vec()) + .unwrap_or_default(), + device_id, + created_at: session.created_at, + }, + ); + } + self.change_session_count(anchor_number, dropped.len(), 1)?; + + Ok(session) + } + + /// The principal an app sees for an account, which is what a session handle names. + fn account_principal_of( + &self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + ) -> Option { + let salt = self.salt().copied()?; + let account = self.read_account(ReadAccountParams { + account_number, + anchor_number, + origin: &self + .stable_application_memory + .get(&application_number)? + .origin, + known_app_num: Some(application_number), + })?; + Some(canister_sig_principal( + self.canister_id, + account.calculate_seed_with_salt(&salt).to_vec(), + )) + } + /// Writes the reference-list row an `AnchorApplicationConfig` row implies, leaving /// `last_used` unset. pub fn ensure_account_reference_list( @@ -1809,6 +2309,23 @@ impl Storage { self.sync_account_principal_index(anchor_number, application_number, &previous, &[])?; + // The row's sessions go with it, so their index entries have to go too. A browser + // keeps its id, and evicting a row leaves the account's principal untouched, so an + // entry left behind here would be waiting for the next sign-in at this origin. + let mut dropped = 0usize; + for reference in &previous { + self.unindex_sessions( + anchor_number, + application_number, + reference.account_number, + &reference.sessions, + ); + dropped += reference.sessions.len(); + } + if dropped > 0 { + self.change_session_count(anchor_number, dropped, 0)?; + } + self.stable_account_reference_list_memory.remove(&key); self.stable_anchor_application_config_memory.remove(&key); @@ -2874,6 +3391,16 @@ impl Storage { } } +pub struct CreateSessionParams { + pub anchor_number: AnchorNumber, + pub origin: FrontendHostname, + pub account_number: Option, + pub device_id: SessionDeviceId, + pub valid_till: Timestamp, + pub read_only: bool, + pub now: Timestamp, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option<(AnchorNumber, ApplicationNumber)>, @@ -2966,6 +3493,12 @@ pub enum StorageError { anchor_number: AnchorNumber, application_number: ApplicationNumber, }, + /// Reclaiming ran and the identity is still at the session cap. Unreachable unless + /// reclaiming stopped honouring its contract, which is why it is an error rather than a + /// refused sign-in: the sign-in is the thing this cap must never fail. + SessionCapNotReclaimed { + anchor_number: AnchorNumber, + }, /// Tried to bind a recovery email that's already on a different /// anchor. The "one anchor per address" invariant from design /// §8.2 is enforced at the storage layer; the caller surfaces @@ -3043,6 +3576,10 @@ impl fmt::Display for StorageError { f, "recovery email is already bound to a different anchor ({existing_anchor})", ), + Self::SessionCapNotReclaimed { anchor_number } => write!( + f, + "anchor {anchor_number} is at the session cap and reclaiming freed nothing" + ), } } } diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 9197acd7e72..08fe17055e6 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -42,6 +42,7 @@ pub struct Anchor { /// Capped by `MAX_SESSION_DEVICES`. pub(crate) session_devices: Vec, pub(crate) next_session_device_id: SessionDeviceId, + pub(crate) session_count: u32, pub(crate) metadata: Option>, pub(crate) name: Option, pub(crate) created_at: Option, @@ -233,6 +234,7 @@ impl From for (StorableFixedAnchor, StorableAnchor) { verified_emails, session_devices, next_session_device_id, + session_count, metadata, name, created_at, @@ -500,6 +502,7 @@ impl From for (StorableFixedAnchor, StorableAnchor) { verified_emails, session_devices, next_session_device_id, + session_count: Some(session_count), }, ) } @@ -517,6 +520,7 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { verified_emails, session_devices, next_session_device_id, + session_count, } = storable_anchor; let name = name.clone(); @@ -637,6 +641,7 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { verified_emails, session_devices, next_session_device_id, + session_count: session_count.unwrap_or_default(), devices, metadata, } @@ -660,6 +665,7 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho let Some(storable_anchor) = storable_anchor else { return Anchor { name: None, + session_count: 0, openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], @@ -706,6 +712,7 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho verified_emails, session_devices, next_session_device_id: storable_anchor.next_session_device_id.unwrap_or_default(), + session_count: storable_anchor.session_count.unwrap_or_default(), metadata, name, created_at, @@ -791,6 +798,7 @@ impl Anchor { Self { anchor_number, created_at: Some(created_at), + session_count: 0, devices: vec![], openid_credentials: vec![], email_recovery: vec![], diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index c49292b8a49..4f53d47eaff 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -225,6 +225,7 @@ fn should_prevent_mutation_when_invariants_are_violated() { let mut anchor = Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ device1.clone(), @@ -249,6 +250,7 @@ fn should_prevent_addition_when_invariants_are_violated() { let mut anchor = Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ recovery_phrase(1, DeviceProtection::Unprotected), @@ -273,6 +275,7 @@ fn should_allow_removal_when_invariants_are_violated() { let mut anchor = Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ device1.clone(), diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 9a73d75a029..a00dda2e1ae 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -26,6 +26,7 @@ pub mod passkey_credential; pub mod recovery_key; pub mod session_device; pub mod session_device_id; +pub mod session_handle; pub mod session_record; pub mod special_device_migration; pub mod sso_stable_id_key; diff --git a/src/internet_identity/src/storage/storable/anchor.rs b/src/internet_identity/src/storage/storable/anchor.rs index af496b3ab02..0f01cd6b320 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -40,6 +40,11 @@ pub struct StorableAnchor { /// Monotonic per-anchor allocator for `session_devices`. Ids are never reused. #[n(8)] pub next_session_device_id: Option, + /// Live sessions this anchor holds, as a trigger for the session cap rather than a + /// source of truth: expiry removes a session with no write to observe, so this can + /// over-count until a reclaim pass prunes and corrects it. + #[n(9)] + pub session_count: Option, } impl Storable for StorableAnchor { diff --git a/src/internet_identity/src/storage/storable/session_handle.rs b/src/internet_identity/src/storage/storable/session_handle.rs new file mode 100644 index 00000000000..3f1770520a8 --- /dev/null +++ b/src/internet_identity/src/storage/storable/session_handle.rs @@ -0,0 +1,40 @@ +use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use ic_stable_structures::storable::Bound; +use ic_stable_structures::Storable; +use minicbor::{Decode, Encode}; +use std::borrow::Cow; + +/// Where the session a caller authenticates as is stored. +/// +/// The account is named by its principal rather than by its locator because materialising a +/// default account changes the locator and leaves the principal alone, so a rename touches +/// one entry in the principal index instead of every session of that account. +/// +/// A browser keeps its id across sign-ins, so the browser alone does not name a session: +/// the creation time is what distinguishes the record this entry was written for from +/// whatever that browser creates later. Both are inputs to the session seed, so an entry +/// can only ever resolve to the one session whose principal is its own key. +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq)] +#[cbor(map)] +pub struct StorableSessionHandle { + #[cbor(n(0), with = "minicbor::bytes")] + pub account_principal: Vec, + #[n(1)] + pub device_id: StorableSessionDeviceId, + #[n(2)] + pub created_at: u64, +} + +impl Storable for StorableSessionHandle { + fn to_bytes(&self) -> Cow<'_, [u8]> { + let mut buffer = Vec::new(); + minicbor::encode(self, &mut buffer).expect("failed to encode StorableSessionHandle"); + Cow::Owned(buffer) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + minicbor::decode(&bytes).expect("failed to decode StorableSessionHandle") + } + + const BOUND: Bound = Bound::Unbounded; +} diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f17e15a13eb..02ce9b1c012 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1318,6 +1318,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 0, devices: vec![], openid_credentials: vec![], @@ -1352,6 +1353,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 1, devices: vec![Device { pubkey: ByteBuf::from("recovery_key_pubkey"), @@ -1397,6 +1399,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 2, devices: vec![Device { pubkey: ByteBuf::from("passkey_pubkey"), @@ -1442,6 +1445,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 3, devices: vec![Device { pubkey: ByteBuf::from("passkey_no_origin"), @@ -1487,6 +1491,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 4, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey"), @@ -1532,6 +1537,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 5, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey_no_origin"), @@ -1577,6 +1583,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 6, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_auth"), @@ -1622,6 +1629,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 7, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_recovery"), @@ -1681,6 +1689,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 8, devices: vec![ Device { @@ -1727,6 +1736,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 9, devices: vec![], openid_credentials: vec![openid_credential(1)], @@ -1748,6 +1758,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 10, devices: vec![], openid_credentials: vec![], @@ -1782,6 +1793,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 11, devices: vec![Device { pubkey: ByteBuf::from("unknown_keytype_passkey"), @@ -1834,6 +1846,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 12, devices: vec![Device { pubkey: ByteBuf::from("device_with_metadata"), @@ -1873,6 +1886,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 13, devices: vec![], openid_credentials: vec![], @@ -1907,6 +1921,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 14, devices: vec![Device { pubkey: ByteBuf::from("protected_recovery_key"), @@ -1955,6 +1970,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 15, devices: vec![Device { pubkey: ByteBuf::from("protected_passkey"), @@ -2002,6 +2018,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 16, devices: vec![Device { pubkey: ByteBuf::from("unusual_device"), @@ -2047,6 +2064,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 17, devices: vec![Device { pubkey: ByteBuf::from("recovery_phrase_custom_alias"), @@ -2092,6 +2110,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 18, devices: vec![Device { pubkey: ByteBuf::from("platform_passkey"), @@ -2137,6 +2156,7 @@ fn test_anchor_storage_migration_round_trip() { Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 19, devices: vec![Device { pubkey: ByteBuf::from("unknown_keytype_passkey_2"), @@ -3551,6 +3571,8 @@ mod account_principal_index_tests { other_anchor_number ); } + + } mod account_principal_index_backfill_tests { @@ -3683,6 +3705,7 @@ mod account_principal_index_backfill_tests { mod session_record_tests { use crate::storage::account::{AccountReference, SessionRecord}; use crate::storage::storable::account_reference::StorableAccountReference; + use crate::storage::MAX_EVICTABLE_DEFAULT_ACCOUNTS; use crate::{Storage, DAY_NS, MINUTE_NS}; use ic_stable_structures::{Storable, VectorMemory}; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -3751,8 +3774,8 @@ mod session_record_tests { #[test] fn a_row_holding_a_session_is_evictable_like_any_other() { let (mut storage, anchor_number) = storage_with_anchor(); - let origin = "https://has-a-session.com".to_string(); - let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + let application_number = storage + .lookup_or_insert_application_number_with_origin(&"https://example.com".to_string()); storage .write_reference_list( anchor_number, @@ -3768,6 +3791,51 @@ mod session_record_tests { assert_eq!(storage.evictable_default_rows(anchor_number).len(), 1); } + /// Eviction orders on the row's `last_used`, which every refresh stamps, so a session + /// in use keeps its row at the newest end and survives the cap on its own. + #[test] + fn a_refreshed_session_keeps_its_row_and_a_stale_one_does_not() { + let (mut storage, anchor_number) = storage_with_anchor(); + let stale = "https://never-came-back.com".to_string(); + let refreshed = "https://still-in-use.com".to_string(); + let stale_application = storage.lookup_or_insert_application_number_with_origin(&stale); + let refreshed_application = + storage.lookup_or_insert_application_number_with_origin(&refreshed); + + for (application, last_used) in [(stale_application, 1), (refreshed_application, u64::MAX)] + { + storage + .write_reference_list( + anchor_number, + application, + vec![AccountReference { + account_number: None, + last_used: Some(last_used), + sessions: vec![session(1, u64::MAX)], + }], + ) + .unwrap(); + } + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + storage + .set_account_last_used( + anchor_number, + format!("https://app-{index}.com"), + None, + index + 2, + ) + .unwrap(); + } + + assert!(storage + .lookup_account_references(anchor_number, stale_application) + .is_none()); + assert!(storage + .lookup_account_references(anchor_number, refreshed_application) + .is_some()); + } + #[test] fn reclaim_order_ranks_dead_sessions_first() { let now = 1_000; @@ -3782,27 +3850,6 @@ mod session_record_tests { assert!(expired.reclaim_order(now) < live_untouched.reclaim_order(now)); } - #[test] - fn a_flood_of_unused_sessions_cannot_displace_a_used_one() { - let now = 100 * DAY_NS; - let held = SessionRecord { - last_refreshed: Some(now - DAY_NS), - ..session(now - 20 * DAY_NS, now + DAY_NS) - }; - // Created after the session it would have to outrank, which under a plain recency - // order would protect it. - let flood: Vec = (0..500) - .map(|index| SessionRecord { - device_id: index, - ..session(now - 1, now + DAY_NS) - }) - .collect(); - - assert!(flood - .iter() - .all(|session| session.reclaim_order(now) < held.reclaim_order(now))); - } - #[test] fn an_app_in_weekly_use_outranks_one_opened_once_yesterday() { let now = 100 * DAY_NS; @@ -3823,3 +3870,672 @@ mod session_record_tests { ); } } + +mod session_creation_tests { + use crate::delegation::calculate_session_seed_with_salt; + use crate::storage::account::{AccountReference, CreateAccountParams, SessionRecord}; + use crate::storage::{ + CreateSessionParams, MAX_SESSIONS_PER_ANCHOR, SESSIONS_WATERMARK_PER_ANCHOR, + }; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + const SALT: [u8; 32] = [17u8; 32]; + const ORIGIN: &str = "https://example.com"; + + fn storage_with_anchor() -> (Storage, AnchorNumber) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt(SALT); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + fn params(anchor_number: AnchorNumber, device_id: u32, now: u64) -> CreateSessionParams { + CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id, + valid_till: now + 10_000, + read_only: false, + now, + } + } + + fn sessions_of( + storage: &Storage, + anchor_number: AnchorNumber, + ) -> Vec { + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + let references: Vec = storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .into_iter() + .map(Into::into) + .collect(); + references + .into_iter() + .find(|reference| reference.account_number.is_none()) + .unwrap() + .sessions + } + + #[test] + fn creating_a_session_tracks_the_account_and_stores_the_record() { + let (mut storage, anchor_number) = storage_with_anchor(); + + let session = storage + .create_session(params(anchor_number, 1, 1_000)) + .unwrap(); + + assert_eq!(session.created_at, 1_000); + assert_eq!(session.valid_till, 11_000); + assert_eq!(session.last_refreshed, None); + assert_eq!(session.device_id, 1); + assert_eq!(sessions_of(&storage, anchor_number), vec![session]); + } + + /// A ceremony replaces the browser's session rather than reusing it, so a copy of the + /// old one stops working at the user's next sign-in instead of at its expiry. + #[test] + fn the_same_device_replaces_its_session() { + let (mut storage, anchor_number) = storage_with_anchor(); + let first = storage + .create_session(params(anchor_number, 1, 1_000)) + .unwrap(); + + let again = storage + .create_session(params(anchor_number, 1, 5_000)) + .unwrap(); + + assert_ne!(again.created_at, first.created_at); + assert_eq!(sessions_of(&storage, anchor_number).len(), 1); + } + + #[test] + fn a_different_device_gets_its_own_session() { + let (mut storage, anchor_number) = storage_with_anchor(); + storage + .create_session(params(anchor_number, 1, 1_000)) + .unwrap(); + + storage + .create_session(params(anchor_number, 2, 1_000)) + .unwrap(); + + assert_eq!(sessions_of(&storage, anchor_number).len(), 2); + } + + #[test] + fn expired_sessions_are_pruned_when_the_list_is_written() { + let (mut storage, anchor_number) = storage_with_anchor(); + for device_id in 0..3 { + storage + .create_session(params(anchor_number, device_id, 1_000)) + .unwrap(); + } + + storage + .create_session(params(anchor_number, 9, 20_000)) + .unwrap(); + + let sessions = sessions_of(&storage, anchor_number); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].device_id, 9); + } + + /// There is no per-reference cap: one browser holds one session per account, so the + /// reference is bounded by the browser registry rather than by a number of its own. + #[test] + fn one_reference_holds_one_session_per_browser() { + let (mut storage, anchor_number) = storage_with_anchor(); + for device_id in 0..12u32 { + let mut p = params(anchor_number, device_id, 1_000); + p.valid_till = 1_000_000; + storage.create_session(p).unwrap(); + } + + let sessions = sessions_of(&storage, anchor_number); + assert_eq!(sessions.len(), 12); + assert!(sessions.iter().any(|s| s.device_id == 0)); + } + + /// The per-identity cap reclaims to a watermark rather than blocking, taking expired + /// records first and then the least recently used. + #[test] + fn the_session_cap_reclaims_to_the_watermark() { + let (mut storage, anchor_number) = storage_with_anchor(); + let application_number = + storage.lookup_or_insert_application_number_with_origin(&ORIGIN.to_string()); + + let sessions: Vec = (0..MAX_SESSIONS_PER_ANCHOR) + .map(|device_id| SessionRecord { + created_at: 1_000, + valid_till: 1_000_000, + // Device 0 is the stalest live one; device 1 has already expired. + last_refreshed: Some(500_000 + device_id as u64), + device_id, + read_only: false, + }) + .map(|mut session| { + if session.device_id == 1 { + session.valid_till = 2_000; + } + session + }) + .collect(); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(1), + sessions, + }], + ) + .unwrap(); + let mut anchor = storage.read(anchor_number).unwrap(); + anchor.session_count = MAX_SESSIONS_PER_ANCHOR; + storage.write(anchor).unwrap(); + + let mut params = params(anchor_number, 9_999, 600_000); + params.valid_till = 1_000_000; + storage.create_session(params).unwrap(); + + let remaining = sessions_of(&storage, anchor_number); + assert_eq!( + remaining.len(), + SESSIONS_WATERMARK_PER_ANCHOR as usize + 1, + "reclaims to the watermark and then admits the session it made room for" + ); + // The expired one and the stalest live one are gone; the freshest are not. + assert!(!remaining.iter().any(|s| s.device_id == 1)); + assert!(!remaining.iter().any(|s| s.device_id == 0)); + assert!(remaining + .iter() + .any(|s| s.device_id == MAX_SESSIONS_PER_ANCHOR - 1)); + assert!(remaining.iter().any(|s| s.device_id == 9_999)); + } + + #[test] + fn the_cap_is_never_exceeded_however_many_sign_ins_arrive() { + let (mut storage, anchor_number) = storage_with_anchor(); + + for device_id in 0..(MAX_SESSIONS_PER_ANCHOR + 120) { + let mut params = params(anchor_number, device_id, 600_000 + device_id as u64); + params.valid_till = 100_000_000; + storage.create_session(params).unwrap(); + + let stored = sessions_of(&storage, anchor_number).len(); + assert!( + stored <= MAX_SESSIONS_PER_ANCHOR as usize, + "{stored} stored after {device_id} sign-ins" + ); + assert_eq!( + storage.read(anchor_number).unwrap().session_count as usize, + stored, + "the counter parted ways with the rows after {device_id} sign-ins" + ); + } + } + + #[test] + fn an_over_counting_anchor_is_corrected_rather_than_denied() { + let (mut storage, anchor_number) = storage_with_anchor(); + storage.create_session(params(anchor_number, 1, 1_000)).unwrap(); + + // Nothing observes a session expiring, so the count drifts up. The cap must be + // enforced against what the rows hold, not against the drift. + let mut anchor = storage.read(anchor_number).unwrap(); + anchor.session_count = MAX_SESSIONS_PER_ANCHOR; + storage.write(anchor).unwrap(); + + storage.create_session(params(anchor_number, 2, 2_000)).unwrap(); + + assert_eq!(sessions_of(&storage, anchor_number).len(), 2); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); + } + + /// Two rows, both holding a default account, and both holding sessions for the same + /// browser ids. Reclaiming must take only the sessions it selected. + #[test] + fn reclaiming_takes_only_the_sessions_it_selected() { + const OTHER_ORIGIN: &str = "https://other.example"; + let (mut storage, anchor_number) = storage_with_anchor(); + + // Two rows of this size put the identity two over the watermark, so the pass selects + // exactly two victims — one in each row. + const PER_ROW: u32 = SESSIONS_WATERMARK_PER_ANCHOR / 2 + 1; + let row = |expired_device: u32| -> Vec { + let sessions = (0..PER_ROW) + .map(|device_id| { + if device_id == expired_device { + SessionRecord { + created_at: 1, + valid_till: 2, + last_refreshed: None, + device_id, + read_only: false, + } + } else { + SessionRecord { + created_at: 1_000, + valid_till: 100_000_000, + last_refreshed: Some(500_000), + device_id, + read_only: false, + } + } + }) + .collect(); + vec![AccountReference { + account_number: None, + last_used: Some(1), + sessions, + }] + }; + + let first = storage.lookup_or_insert_application_number_with_origin(&ORIGIN.to_string()); + let second = + storage.lookup_or_insert_application_number_with_origin(&OTHER_ORIGIN.to_string()); + storage + .write_reference_list(anchor_number, first, row(0)) + .unwrap(); + storage + .write_reference_list(anchor_number, second, row(1)) + .unwrap(); + + let mut anchor = storage.read(anchor_number).unwrap(); + anchor.session_count = MAX_SESSIONS_PER_ANCHOR; + storage.write(anchor).unwrap(); + + let mut params = params(anchor_number, 9_999, 600_000); + params.valid_till = 100_000_000; + storage.create_session(params).unwrap(); + + let devices = |application_number| -> Vec { + let references: Vec = storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .into_iter() + .map(Into::into) + .collect(); + let mut ids: Vec = references + .into_iter() + .flat_map(|reference| reference.sessions) + .map(|session| session.device_id) + .collect(); + ids.sort_unstable(); + ids + }; + + let first_devices = devices(first); + let second_devices = devices(second); + + assert!( + !first_devices.contains(&0), + "the expired session selected in the first row should be gone" + ); + assert!( + !second_devices.contains(&1), + "the expired session selected in the second row should be gone" + ); + assert!( + first_devices.contains(&1), + "the first row's live session for browser 1 was not selected and must survive" + ); + assert!( + second_devices.contains(&0), + "the second row's live session for browser 0 was not selected and must survive" + ); + } + + + + /// The flood bound, exercised through the cap rather than through the order alone: a + /// session the user has actually kept alive survives a row full of sign-ins nobody + /// came back to, even though every one of them is newer than it. + #[test] + fn a_flood_of_unused_sessions_cannot_displace_a_used_one() { + let (mut storage, anchor_number) = storage_with_anchor(); + let application_number = + storage.lookup_or_insert_application_number_with_origin(&ORIGIN.to_string()); + + let mut sessions = vec![SessionRecord { + created_at: 1_000, + valid_till: 100_000_000, + last_refreshed: Some(400_000), + device_id: 1, + read_only: false, + }]; + sessions.extend((2..=MAX_SESSIONS_PER_ANCHOR).map(|device_id| SessionRecord { + created_at: 500_000, + valid_till: 100_000_000, + last_refreshed: None, + device_id, + read_only: false, + })); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(1), + sessions, + }], + ) + .unwrap(); + let mut anchor = storage.read(anchor_number).unwrap(); + anchor.session_count = MAX_SESSIONS_PER_ANCHOR; + storage.write(anchor).unwrap(); + + let mut params = params(anchor_number, 9_999, 600_000); + params.valid_till = 100_000_000; + storage.create_session(params).unwrap(); + + let remaining = sessions_of(&storage, anchor_number); + assert!( + remaining.iter().any(|session| session.device_id == 1), + "the session that was kept alive was reclaimed" + ); + assert!( + remaining.len() < MAX_SESSIONS_PER_ANCHOR as usize, + "nothing was reclaimed, so the test proves nothing" + ); + } + + #[test] + fn a_named_account_can_hold_its_own_sessions() { + let (mut storage, anchor_number) = storage_with_anchor(); + let named = storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: ORIGIN.to_string(), + }) + .unwrap(); + let mut p = params(anchor_number, 1, 1_000); + p.account_number = named.account_number; + + storage.create_session(p).unwrap(); + + assert_eq!(sessions_of(&storage, anchor_number).len(), 0); + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + let references: Vec = storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .into_iter() + .map(Into::into) + .collect(); + let named_reference = references + .iter() + .find(|r| r.account_number == named.account_number) + .unwrap(); + assert_eq!(named_reference.sessions.len(), 1); + } + + #[test] + fn a_session_for_an_account_the_anchor_does_not_hold_is_refused() { + let (mut storage, anchor_number) = storage_with_anchor(); + let mut p = params(anchor_number, 1, 1_000); + p.account_number = Some(4_242); + + let result = storage.create_session(p); + + assert!(result.is_err()); + } + + #[test] + fn an_expired_same_round_record_is_pruned_rather_than_colliding() { + let (mut storage, anchor_number) = storage_with_anchor(); + let application_number = + storage.lookup_or_insert_application_number_with_origin(&ORIGIN.to_string()); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(1), + sessions: vec![SessionRecord { + created_at: 1_000, + // Already expired at `now`, so it is not reused, but it is still + // present when the seed for the new record is derived. + valid_till: 1_000, + last_refreshed: None, + device_id: 1, + read_only: false, + }], + }], + ) + .unwrap(); + + // Pruning removes the expired record, so the guard does not fire here; the + // reachable shape is a live record the reuse step declined, which cannot happen. + let created = storage + .create_session(params(anchor_number, 1, 1_000)) + .unwrap(); + assert_eq!(created.created_at, 1_000); + } + + /// Creating twice from one browser at one account replaces, so there is never a second + /// record to collide with in the same round. + #[test] + fn creating_twice_in_one_round_from_one_browser_yields_one_session() { + let (mut storage, anchor_number) = storage_with_anchor(); + let params = |read_only| CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: 1, + valid_till: u64::MAX, + read_only, + now: 1_000, + }; + + let first = storage.create_session(params(false)).unwrap(); + storage.create_session(params(false)).unwrap(); + assert_eq!(sessions_of(&storage, anchor_number).len(), 1); + + let replaced = storage.create_session(params(true)).unwrap(); + assert_ne!(replaced.read_only, first.read_only); + assert_eq!(sessions_of(&storage, anchor_number).len(), 1); + } + + #[test] + fn the_session_seed_binds_the_account_and_every_immutable_field() { + use crate::storage::account::Account; + + let account = Account::new(10_000, ORIGIN.to_string(), None, None); + let account_seed = account.calculate_seed_with_salt(&SALT); + let other_account = Account::new(10_001, ORIGIN.to_string(), None, None); + let other_seed = other_account.calculate_seed_with_salt(&SALT); + + let base = calculate_session_seed_with_salt(&SALT, &account_seed, 1_000, 1); + + assert_ne!( + base, + calculate_session_seed_with_salt(&SALT, &other_seed, 1_000, 1) + ); + assert_ne!( + base, + calculate_session_seed_with_salt(&SALT, &account_seed, 1_001, 1) + ); + assert_ne!( + base, + calculate_session_seed_with_salt(&SALT, &account_seed, 1_000, 2) + ); + assert_ne!( + base, + calculate_session_seed_with_salt(&[18u8; 32], &account_seed, 1_000, 1) + ); + assert_eq!( + base, + calculate_session_seed_with_salt(&SALT, &account_seed, 1_000, 1) + ); + } + + #[test] + fn a_session_seed_is_distinct_from_the_account_seed_it_belongs_to() { + use crate::storage::account::Account; + + let account = Account::new(10_000, ORIGIN.to_string(), None, None); + let account_seed = account.calculate_seed_with_salt(&SALT); + let session_seed = calculate_session_seed_with_salt(&SALT, &account_seed, 1_000, 1); + + assert_ne!(account_seed, session_seed); + } + + /// Naming a default account keeps its principal, so it must keep its sessions too. + #[test] + fn naming_a_default_account_leaves_its_session_identity_unchanged() { + use crate::storage::account::Account; + + let default = Account::new(10_000, ORIGIN.to_string(), None, None); + let before = calculate_session_seed_with_salt( + &SALT, + &default.calculate_seed_with_salt(&SALT), + 1_000, + 1, + ); + + let named = Account::new_full( + 10_000, + ORIGIN.to_string(), + Some("work".to_string()), + Some(7), + None, + Some(10_000), + ); + let after = calculate_session_seed_with_salt( + &SALT, + &named.calculate_seed_with_salt(&SALT), + 1_000, + 1, + ); + + assert_eq!(before, after); + } +} + +mod session_consent_change_tests { + use crate::storage::account::AccountReference; + use crate::storage::CreateSessionParams; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + const ORIGIN: &str = "https://example.com"; + + fn storage_with_anchor() -> (Storage, AnchorNumber) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + fn create( + storage: &mut Storage, + anchor_number: AnchorNumber, + read_only: bool, + now: u64, + ) -> u64 { + storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: 1, + valid_till: u64::MAX, + read_only, + now, + }) + .unwrap() + .created_at + } + + fn sessions(storage: &Storage, anchor_number: AnchorNumber) -> Vec { + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .into_iter() + .map(AccountReference::from) + .find(|reference| reference.account_number.is_none()) + .unwrap() + .sessions + .into_iter() + .map(|session| session.read_only) + .collect() + } + + #[test] + fn the_same_consent_still_replaces_the_session() { + let (mut storage, anchor_number) = storage_with_anchor(); + let first = create(&mut storage, anchor_number, false, 1_000); + + let again = create(&mut storage, anchor_number, false, 2_000); + + assert_ne!(again, first); + assert_eq!(sessions(&storage, anchor_number), vec![false]); + } + + #[test] + fn a_downgraded_consent_replaces_the_session() { + let (mut storage, anchor_number) = storage_with_anchor(); + let full_access = create(&mut storage, anchor_number, false, 1_000); + + let read_only = create(&mut storage, anchor_number, true, 2_000); + + assert_ne!(read_only, full_access); + assert_eq!(sessions(&storage, anchor_number), vec![true]); + } + + #[test] + fn an_upgraded_consent_replaces_the_session() { + let (mut storage, anchor_number) = storage_with_anchor(); + create(&mut storage, anchor_number, true, 1_000); + + create(&mut storage, anchor_number, false, 2_000); + + assert_eq!(sessions(&storage, anchor_number), vec![false]); + } + + #[test] + fn a_consent_change_leaves_another_browser_alone() { + let (mut storage, anchor_number) = storage_with_anchor(); + storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: 2, + valid_till: u64::MAX, + read_only: false, + now: 1_000, + }) + .unwrap(); + create(&mut storage, anchor_number, false, 1_000); + + create(&mut storage, anchor_number, true, 2_000); + + let mut held = sessions(&storage, anchor_number); + held.sort_unstable(); + assert_eq!(held, vec![false, true]); + } +} diff --git a/src/internet_identity/src/verified_emails/remove.rs b/src/internet_identity/src/verified_emails/remove.rs index 6f39a75f942..845958f4b5f 100644 --- a/src/internet_identity/src/verified_emails/remove.rs +++ b/src/internet_identity/src/verified_emails/remove.rs @@ -33,6 +33,7 @@ mod tests { let mut a = Anchor { session_devices: vec![], next_session_device_id: 0, + session_count: 0, anchor_number: 1, devices: vec![], openid_credentials: vec![], diff --git a/src/internet_identity/tests/integration/main.rs b/src/internet_identity/tests/integration/main.rs index 88238b8ed24..3e8383145bf 100644 --- a/src/internet_identity/tests/integration/main.rs +++ b/src/internet_identity/tests/integration/main.rs @@ -19,6 +19,7 @@ mod mcp; mod openid; mod rollback; mod session_delegation; +mod sessions; mod stable_memory; mod upgrade; mod v2_api; diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs new file mode 100644 index 00000000000..78ec32169b7 --- /dev/null +++ b/src/internet_identity/tests/integration/sessions.rs @@ -0,0 +1,588 @@ +//! Tests for revocable app sessions: creating one, and minting app delegations from it. + +use candid::Principal; +use canister_tests::api::internet_identity::api_v2::{ + get_account_session, prepare_account_session, +}; +use canister_tests::flows; +use canister_tests::framework::{ + env, install_ii_with_archive, principal_1, time, verify_delegation, BrowserKey, +}; +use internet_identity_interface::internet_identity::types::{ + AccountSessionError, GetAccountSessionRequest, PrepareAccountSessionRequest, + PrepareAccountSessionResponse, +}; +use pocket_ic::{PocketIc, RejectResponse}; +use pretty_assertions::assert_eq; +use serde_bytes::ByteBuf; +use std::time::Duration; + +const ORIGIN: &str = "https://some-dapp.com"; + +fn session_request(identity_number: u64) -> PrepareAccountSessionRequest { + session_request_from(identity_number, &BrowserKey::new(1)) +} + +/// The same browser presenting the same key again is what makes a sign-in a reuse rather +/// than a registration, so every test that wants a second browser passes a second key. +fn session_request_from( + identity_number: u64, + browser: &BrowserKey, +) -> PrepareAccountSessionRequest { + let session_key = ByteBuf::from(vec![1; 32]); + let next_device_key = browser.successor().public_key(); + PrepareAccountSessionRequest { + identity_number, + origin: ORIGIN.to_string(), + account_number: None, + device_name: "Chrome on MacBook".to_string(), + device_key: browser.public_key(), + device_key_signature: browser.sign(&session_key, &next_device_key), + next_device_key_signature: browser + .successor() + .sign_as_successor(&session_key, &browser.public_key()), + next_device_key, + session_key, + permissions: None, + valid_for: None, + } +} + +/// Creates a session and returns it together with the principal its chain roots at. +fn create_session( + env: &PocketIc, + canister_id: Principal, + identity_number: u64, +) -> (PrepareAccountSessionResponse, Principal) { + let prepared = prepare_account_session( + env, + canister_id, + principal_1(), + session_request(identity_number), + ) + .unwrap() + .unwrap(); + let session_principal = Principal::self_authenticating(&prepared.user_key); + (prepared, session_principal) +} + +#[test] +fn should_create_a_session_and_witness_its_delegation() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let (prepared, _) = create_session(&env, canister_id, identity_number); + assert!(prepared.expiration > time(&env)); + + let fetched = get_account_session( + &env, + canister_id, + principal_1(), + GetAccountSessionRequest { + identity_number, + origin: ORIGIN.to_string(), + account_number: None, + session_key: ByteBuf::from(vec![1; 32]), + expiration: prepared.expiration, + }, + )? + .unwrap(); + + verify_delegation( + &env, + prepared.user_key.clone(), + &fetched.signed_delegation, + &env.root_key().unwrap(), + ); + Ok(()) +} + + +#[test] +fn should_refuse_a_session_for_another_anchor() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let result = prepare_account_session( + &env, + canister_id, + Principal::anonymous(), + session_request(identity_number), + )?; + + assert!(matches!(result, Err(AccountSessionError::Unauthorized(_)))); + + Ok(()) +} + + + + + + + + + +/// Registering a browser happens once per browser per anchor, so it is rare enough to +/// archive, unlike the per-sign-in events the account design keeps out of the archive. +/// The self-reported name is redacted. +#[test] +fn should_archive_a_browser_registration_with_the_name_redacted() -> Result<(), RejectResponse> { + use canister_tests::api::archive as archive_api; + use canister_tests::api::internet_identity as ii_api; + use canister_tests::framework::{ + arg_with_wasm_hash, install_ii_canister_with_arg, ARCHIVE_WASM, II_WASM, + }; + use internet_identity_interface::archive::types::{Operation, Private}; + use internet_identity_interface::internet_identity::types::DeployArchiveResult; + + let env = env(); + let ii_canister = install_ii_canister_with_arg( + &env, + II_WASM.clone(), + arg_with_wasm_hash(ARCHIVE_WASM.clone()), + ); + let DeployArchiveResult::Success(archive_canister) = + ii_api::deploy_archive(&env, ii_canister, &ARCHIVE_WASM) + .expect("archive deployment failed") + else { + panic!("archive deployment did not succeed"); + }; + let identity_number = flows::register_anchor(&env, ii_canister); + + prepare_account_session( + &env, + ii_canister, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + + // The same browser signing in again is not a registration. + let mut again = session_request(identity_number); + again.origin = "https://another-dapp.com".to_string(); + prepare_account_session(&env, ii_canister, principal_1(), again)?.unwrap(); + + env.advance_time(Duration::from_secs(2)); + env.tick(); + + let entries = archive_api::get_entries(&env, archive_canister, None, None)?; + let registrations = entries + .entries + .into_iter() + .flatten() + .filter(|entry| { + matches!( + entry.operation, + Operation::RegisterSessionDevice { + name: Private::Redacted + } + ) + }) + .count(); + assert_eq!(registrations, 1); + + Ok(()) +} + + +/// A request naming an account the identity does not hold is the one failure a caller can +/// provoke here, so it must be refused before anything is written. Otherwise a rejected +/// sign-in would still leave a browser in the user's list. +#[test] +fn should_refuse_an_unknown_account_without_registering_a_browser() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.account_number = Some(9_999); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::NoSuchAccount)); + assert_eq!( + identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices, + None + ); + + Ok(()) +} + +/// A browser is named by a key it proves possession of. Without the proof an attacker +/// holding an access method could attribute a session to a browser the user recognises. +#[test] +fn should_refuse_a_signature_from_another_key() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.device_key_signature = + BrowserKey::new(9).sign(&request.session_key, &request.next_device_key); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); + + Ok(()) +} + +/// The proof takes its freshness from the session key, so a signature captured from one +/// request cannot be replayed to attach a second session to that browser. +#[test] +fn should_refuse_a_signature_over_another_session_key() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let mut request = session_request_from(identity_number, &browser); + request.session_key = ByteBuf::from(vec![2; 32]); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); + + Ok(()) +} + +#[test] +fn should_refuse_a_key_that_is_not_a_public_key() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.device_key = ByteBuf::from(vec![0; 91]); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); + + Ok(()) +} + +/// Verification runs before anything is written, so a rejected proof leaves no browser +/// in the user's list. +#[test] +fn should_register_no_browser_when_the_proof_fails() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.device_key_signature = ByteBuf::from(vec![0; 64]); + prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap_err(); + + assert_eq!( + identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices, + None + ); + + Ok(()) +} + +/// A key the identity has not seen registers a browser of its own, which is the signal a +/// sign-in from somewhere the user does not recognise gives them. +#[test] +fn should_register_a_second_browser_for_a_key_it_has_not_seen() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + + let mut second = session_request_from(identity_number, &BrowserKey::new(2)); + second.device_name = "Firefox on Linux".to_string(); + prepare_account_session(&env, canister_id, principal_1(), second)?.unwrap(); + + let devices = identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices + .expect("the identity should hold browsers"); + + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].name, "Chrome on MacBook"); + assert_eq!(devices[1].name, "Firefox on Linux"); + assert_ne!(devices[0].id, devices[1].id); + + Ok(()) +} + +/// A browser that lost its key is a new browser, which is the cost of the design and +/// what the registry cap is sized for. +#[test] +fn should_register_a_fresh_browser_after_a_storage_wipe() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &BrowserKey::new(3)), + )? + .unwrap(); + + let devices = identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices + .expect("the identity should hold browsers"); + + assert_eq!(devices.len(), 2); + + Ok(()) +} + +/// The browser rotates its key at every sign-in, so the successor it announced last time is +/// what it presents next. +#[test] +fn should_accept_the_successor_a_browser_announced() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + let rotated = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + assert_eq!(rotated.device_id, first.device_id); + assert_eq!( + identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices + .unwrap() + .len(), + 1 + ); + + Ok(()) +} + +/// Which is what stops a copied browser profile signing in alongside the original without +/// showing up: the key it copied is retired the next time the real browser signs in, so the +/// copy can only come back as a browser of its own. +#[test] +fn should_treat_a_retired_key_as_a_new_browser() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + // A browser generates a fresh successor for every attempt, a copy of one included, and + // has to prove it holds it. + let fresh = BrowserKey::new(7); + let mut request = session_request_from(identity_number, &browser); + request.next_device_key = fresh.public_key(); + request.device_key_signature = browser.sign(&request.session_key, &request.next_device_key); + request.next_device_key_signature = + fresh.sign_as_successor(&request.session_key, &browser.public_key()); + let copy = prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); + + assert_ne!(copy.device_id, first.device_id); + + Ok(()) +} + +/// A retired key announcing the successor that replaced it is a replay of a request the real +/// browser already made, and the successor is in use, so it is refused outright. +#[test] +fn should_refuse_a_replayed_announcement() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + let replayed = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )?; + + assert_eq!(replayed, Err(AccountSessionError::InvalidDeviceKey)); + + Ok(()) +} + +/// A response the browser never received leaves it proving with the key the entry still +/// holds, which must not cost it its identity. +#[test] +fn should_accept_the_current_key_when_a_response_was_lost() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + let retried = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + assert_eq!(retried.device_id, first.device_id); + + Ok(()) +} + +/// Presented keys are visible on the wire, so announcing a key another browser is about to +/// present would otherwise take over its entry when it does. +#[test] +fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let victim = BrowserKey::new(1); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &victim), + )? + .unwrap(); + + let attacker = BrowserKey::new(2); + let mut request = session_request_from(identity_number, &attacker); + request.next_device_key = victim.successor().public_key(); + request.device_key_signature = attacker.sign(&request.session_key, &request.next_device_key); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); + + Ok(()) +} + + +/// A key nobody holds cannot be announced: without the successor's own signature, a key read +/// off the wire could be planted as another browser's successor and claimed later. +#[test] +fn should_refuse_a_successor_the_caller_cannot_prove() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let mut request = session_request_from(identity_number, &browser); + // Everything the wire carries, but the successor's signature made by the wrong key. + request.next_device_key_signature = + browser.sign_as_successor(&request.session_key, &browser.public_key()); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); + + Ok(()) +} + +/// Announcing a key another browser of this identity holds keeps two entries from answering +/// to one key, which is what makes resolving a presented key unambiguous. +#[test] +fn should_refuse_a_successor_another_browser_holds_even_when_proven() -> Result<(), RejectResponse> +{ + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let victim = BrowserKey::new(1); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &victim), + )? + .unwrap(); + + // The attacker proves possession of the victim's key, as a profile copy could. + let attacker = BrowserKey::new(2); + let mut request = session_request_from(identity_number, &attacker); + request.next_device_key = victim.public_key(); + request.device_key_signature = attacker.sign(&request.session_key, &request.next_device_key); + request.next_device_key_signature = + victim.sign_as_successor(&request.session_key, &attacker.public_key()); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); + + Ok(()) +} + diff --git a/src/internet_identity_interface/src/archive/types.rs b/src/internet_identity_interface/src/archive/types.rs index a60ae1a2405..9064eb2cb87 100644 --- a/src/internet_identity_interface/src/archive/types.rs +++ b/src/internet_identity_interface/src/archive/types.rs @@ -77,6 +77,12 @@ pub enum Operation { #[serde(rename = "set_default_account")] SetDefaultAccount, + + // Once per browser per anchor, so rare enough to archive, unlike the per-sign-in + // events the account design keeps out of it. The name is self-reported by the + // client, so it is redacted like an account name. + #[serde(rename = "register_session_device")] + RegisterSessionDevice { name: Private }, } #[derive(Eq, PartialEq, Clone, Debug, CandidType, Deserialize)] diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 4dcf0b549a0..654d7de6c27 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -755,3 +755,93 @@ pub enum SetDefaultAccountError { origin: FrontendHostname, }, } + +/// Creates or reuses a revocable session at one account and signs its identity to the +/// II frontend's own key. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct PrepareAccountSessionRequest { + pub identity_number: IdentityNumber, + pub origin: FrontendHostname, + pub account_number: Option, + pub session_key: SessionKey, + pub device_name: String, + /// The browser's own public key, DER-encoded, as the registry currently holds it. A + /// key this anchor has not seen registers a browser under it. + pub device_key: PublicKey, + /// What the browser rotates to once this sign-in succeeds. + pub next_device_key: PublicKey, + /// Signature over `session_key` and `next_device_key`, verified with `device_key`. + /// A second signature by `next_device_key` proves the browser holds it. + pub device_key_signature: ByteBuf, + pub next_device_key_signature: ByteBuf, + /// The consented access level, fixed for the session's life. + pub permissions: Option, + /// Clamped to the session maximum. + pub valid_for: Option, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct PrepareAccountSessionResponse { + pub user_key: UserKey, + pub expiration: Timestamp, + pub created_at: Timestamp, + /// Which browser this sign-in was attributed to, so the settings list can mark the one + /// the user is looking at. Not a credential: a caller never presents it. + pub device_id: SessionDeviceId, + /// The principal apps see for this account. The caller is the anchor that owns it + /// and can mint a delegation for it at any time, so this reveals nothing new; it + /// saves the II frontend from having to mint one just to learn it. + pub account_principal: Principal, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct GetAccountSessionRequest { + pub identity_number: IdentityNumber, + pub origin: FrontendHostname, + pub account_number: Option, + pub session_key: SessionKey, + pub expiration: Timestamp, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct GetAccountSessionResponse { + pub signed_delegation: SignedDelegation, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum AccountSessionError { + Unauthorized(Principal), + NoSuchAccount, + NoSuchSession, + /// The browser's key is unusable, or its signature does not verify against it. + InvalidDeviceKey, + InternalCanisterError(String), +} + +/// Mints an app delegation from a live session. The session is proven by the caller's +/// own chain, so nothing about the account is named in the request. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct AppPrepareDelegationRequest { + pub session_key: SessionKey, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct AppPrepareDelegationResponse { + pub user_key: UserKey, + pub expiration: Timestamp, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct AppGetDelegationRequest { + pub session_key: SessionKey, + pub expiration: Timestamp, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum AppSessionError { + /// No usable session behind this caller: revoked, expired, pruned, or never one at + /// all. One outcome, because which of those it is depends on whether a prune has run + /// yet, and because an app can act on none of them differently. + NoMatchingSession, + InternalCanisterError(String), +} From 9e8df39c40288fd594248f13d734e12a76b0b6a1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 19:43:01 +0200 Subject: [PATCH 2/6] feat(be): mint short-lived app delegations from a session 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) --- .../src/api/internet_identity/api_v2.rs | 29 ++ .../lib/generated/internet_identity_idl.js | 52 +- .../generated/internet_identity_types.d.ts | 42 ++ src/internet_identity/internet_identity.did | 31 ++ src/internet_identity/src/main.rs | 14 + src/internet_identity/src/sessions.rs | 123 ++++- src/internet_identity/src/storage.rs | 16 + .../src/storage/storable/session_handle.rs | 7 + src/internet_identity/src/storage/tests.rs | 72 +++ .../tests/integration/sessions.rs | 461 +++++++++++++++++- 10 files changed, 832 insertions(+), 15 deletions(-) diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index 0651dc579de..fc01f4dd450 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -789,3 +789,32 @@ pub fn get_account_session( query_candid_as(env, canister_id, sender, "get_account_session", (request,)).map(|(x,)| x) } +/// Like the other app-facing helpers, but attaches the session locator bundle as +/// `sender_info`. PocketIC does not verify the `sender_info` canister signature, so the +/// signer, the bundle expiry and the seed match against the caller are what this +/// exercises. +pub fn app_prepare_delegation( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: AppPrepareDelegationRequest, +) -> Result, RejectResponse> { + call_candid_as( + env, + canister_id, + RawEffectivePrincipal::None, + sender, + "app_prepare_delegation", + (request,), + ) + .map(|(x,)| x) +} + +pub fn app_get_delegation( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: AppGetDelegationRequest, +) -> Result, RejectResponse> { + query_candid_as(env, canister_id, sender, "app_get_delegation", (request,)).map(|(x,)| x) +} diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index b27a3de3e1c..741f668c387 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -138,6 +138,32 @@ export const idlFactory = ({ IDL }) => { 'device_registration_timeout' : Timestamp, }), }); + const SessionKey = PublicKey; + const AppGetDelegationRequest = IDL.Record({ + 'session_key' : SessionKey, + 'expiration' : Timestamp, + }); + const Delegation = IDL.Record({ + 'permissions' : IDL.Opt(IDL.Text), + 'pubkey' : PublicKey, + 'targets' : IDL.Opt(IDL.Vec(IDL.Principal)), + 'expiration' : Timestamp, + }); + const SignedDelegation = IDL.Record({ + 'signature' : IDL.Vec(IDL.Nat8), + 'delegation' : Delegation, + }); + const AppSessionError = IDL.Variant({ + 'NoMatchingSession' : IDL.Null, + 'InternalCanisterError' : IDL.Text, + }); + const AppPrepareDelegationRequest = IDL.Record({ + 'session_key' : SessionKey, + }); + const AppPrepareDelegationResponse = IDL.Record({ + 'user_key' : PublicKey, + 'expiration' : Timestamp, + }); const IdentityNumber = IDL.Nat64; const AuthnMethodProtection = IDL.Variant({ 'Protected' : IDL.Null, @@ -358,22 +384,11 @@ export const idlFactory = ({ IDL }) => { 'nonce' : IDL.Text, 'expires_at' : Timestamp, }); - const SessionKey = PublicKey; const EmailRecoveryGetDelegationArgs = IDL.Record({ 'session_key' : SessionKey, 'expiration' : Timestamp, 'nonce' : IDL.Text, }); - const Delegation = IDL.Record({ - 'permissions' : IDL.Opt(IDL.Text), - 'pubkey' : PublicKey, - 'targets' : IDL.Opt(IDL.Vec(IDL.Principal)), - 'expiration' : Timestamp, - }); - const SignedDelegation = IDL.Record({ - 'signature' : IDL.Vec(IDL.Nat8), - 'delegation' : Delegation, - }); const BufferedArchiveEntry = IDL.Record({ 'sequence_number' : IDL.Nat64, 'entry' : IDL.Vec(IDL.Nat8), @@ -887,6 +902,21 @@ export const idlFactory = ({ IDL }) => { [AddTentativeDeviceResponse], [], ), + 'app_get_delegation' : IDL.Func( + [AppGetDelegationRequest], + [IDL.Variant({ 'Ok' : SignedDelegation, 'Err' : AppSessionError })], + ['query'], + ), + 'app_prepare_delegation' : IDL.Func( + [AppPrepareDelegationRequest], + [ + IDL.Variant({ + 'Ok' : AppPrepareDelegationResponse, + 'Err' : AppSessionError, + }), + ], + [], + ), 'authn_method_add' : IDL.Func( [IdentityNumber, AuthnMethodData], [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : AuthnMethodAddError })], diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index db772b6ea7d..ee29ae5eccc 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -73,6 +73,33 @@ export interface AnchorCredentials { 'credentials' : Array, 'recovery_credentials' : Array, } +export interface AppGetDelegationRequest { + 'session_key' : SessionKey, + /** + * Must match the prepared value. + */ + 'expiration' : Timestamp, +} +export interface AppPrepareDelegationRequest { + /** + * The key the app delegation delegates to. Nothing about the account is named: + * the caller's own session chain is what identifies it. + */ + 'session_key' : SessionKey, +} +export interface AppPrepareDelegationResponse { + 'user_key' : PublicKey, + 'expiration' : Timestamp, +} +export type AppSessionError = { + /** + * No usable session behind this caller: revoked, expired, pruned, or never one at + * all. One outcome, because which of those it is depends on whether a prune has run + * yet, and because an app can act on none of them differently. + */ + 'NoMatchingSession' : null + } | + { 'InternalCanisterError' : string }; /** * Configuration parameters related to the archive. */ @@ -1870,6 +1897,21 @@ export interface _SERVICE { [UserNumber, DeviceData], AddTentativeDeviceResponse >, + 'app_get_delegation' : ActorMethod< + [AppGetDelegationRequest], + { 'Ok' : SignedDelegation } | + { 'Err' : AppSessionError } + >, + /** + * Mints a short-lived app delegation from a live session. Called by app frontends + * with the session chain, so revoking the session ends access within one delegation + * lifetime. + */ + 'app_prepare_delegation' : ActorMethod< + [AppPrepareDelegationRequest], + { 'Ok' : AppPrepareDelegationResponse } | + { 'Err' : AppSessionError } + >, /** * Adds a new authentication method to the identity. * Requires authentication. diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 62d3766a607..86335d290ef 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1075,6 +1075,31 @@ type AccountSessionError = variant { InternalCanisterError : text; }; +type AppPrepareDelegationRequest = record { + // The key the app delegation delegates to. Nothing about the account is named: + // the caller's own session chain is what identifies it. + session_key : SessionKey; +}; + +type AppPrepareDelegationResponse = record { + user_key : PublicKey; + expiration : Timestamp; +}; + +type AppGetDelegationRequest = record { + session_key : SessionKey; + // Must match the prepared value. + expiration : Timestamp; +}; + +type AppSessionError = variant { + // No usable session behind this caller: revoked, expired, pruned, or never one at + // all. One outcome, because which of those it is depends on whether a prune has run + // yet, and because an app can act on none of them differently. + NoMatchingSession; + InternalCanisterError : text; +}; + type IdentityInfo = record { authn_methods : vec AuthnMethodData; authn_method_registration : opt AuthnMethodRegistrationInfo; @@ -1936,6 +1961,12 @@ service : (opt InternetIdentityInit) -> { prepare_account_session : (PrepareAccountSessionRequest) -> (variant { Ok : PrepareAccountSessionResponse; Err : AccountSessionError }); get_account_session : (GetAccountSessionRequest) -> (variant { Ok : GetAccountSessionResponse; Err : AccountSessionError }) query; + // Mints a short-lived app delegation from a live session. Called by app frontends + // with the session chain, so revoking the session ends access within one delegation + // lifetime. + app_prepare_delegation : (AppPrepareDelegationRequest) -> (variant { Ok : AppPrepareDelegationResponse; Err : AppSessionError }); + app_get_delegation : (AppGetDelegationRequest) -> (variant { Ok : SignedDelegation; Err : AppSessionError }) query; + prepare_account_delegation : ( anchor_number : UserNumber, origin : FrontendHostname, diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index d3101510e36..5cee41653c8 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -504,6 +504,20 @@ fn get_account_session( sessions::get_account_session(request) } +#[update] +fn app_prepare_delegation( + request: AppPrepareDelegationRequest, +) -> Result { + sessions::app_prepare_delegation(request) +} + +#[query] +fn app_get_delegation( + request: AppGetDelegationRequest, +) -> Result { + sessions::app_get_delegation(request) +} + #[update] async fn prepare_account_delegation( anchor_number: AnchorNumber, diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index b1168f358dc..7bd7f711355 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -10,17 +10,19 @@ use crate::delegation::{ }; use crate::sessions::device_key::verify_device_keys; use crate::state::{self, storage_borrow, storage_borrow_mut}; -use crate::storage::account::{ReadAccountParams, SessionRecord}; +use crate::storage::account::{Account, ReadAccountParams, SessionRecord}; use crate::storage::{CreateSessionParams, StorageError}; use crate::{update_root_hash, DAY_NS, MINUTE_NS}; use candid::Principal; use ic_canister_sig_creation::signature_map::CanisterSigInputs; use ic_canister_sig_creation::DELEGATION_SIG_DOMAIN; use ic_cdk::api::time; +use ic_cdk::caller; use ic_certification::Hash; use internet_identity_interface::archive::types::{Operation, Private}; use internet_identity_interface::internet_identity::types::{ - AccountNumber, AccountSessionError, AnchorNumber, ApplicationNumber, + AccountNumber, AccountSessionError, AnchorNumber, AppGetDelegationRequest, + AppPrepareDelegationRequest, AppPrepareDelegationResponse, AppSessionError, ApplicationNumber, Delegation, FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp, }; @@ -297,3 +299,120 @@ fn account_principal( account.calculate_seed_with_salt(&salt).to_vec(), )) } + +/// The one window revocation cannot reach. Matches what MCP mints, and is not +/// requestable by the app. +pub const APP_DELEGATION_TTL_NS: u64 = 5 * MINUTE_NS; + +pub fn app_prepare_delegation( + request: AppPrepareDelegationRequest, +) -> Result { + let now = time(); + let (account, session) = authorize_session(now)?; + + let expiration = u64::min( + now.saturating_add(APP_DELEGATION_TTL_NS), + session.valid_till, + ); + let seed = account_seed(&account)?; + let access = DelegationAccess::from_read_only(session.read_only); + + state::signature_map_mut(|sigs| { + add_delegation_signature( + sigs, + request.session_key, + seed.as_ref(), + expiration, + access.permissions(), + ); + }); + update_root_hash(); + + Ok(AppPrepareDelegationResponse { + user_key: ByteBuf::from(der_encode_canister_sig_key(seed.to_vec())), + expiration, + }) +} + +pub fn app_get_delegation( + request: AppGetDelegationRequest, +) -> Result { + let now = time(); + let (account, session) = authorize_session(now)?; + + if request.expiration > now.saturating_add(APP_DELEGATION_TTL_NS) + || request.expiration > session.valid_till + { + return Err(AppSessionError::NoMatchingSession); + } + + let seed = account_seed(&account)?; + let access = DelegationAccess::from_read_only(session.read_only); + let permissions = access.permissions(); + + state::assets_and_signatures(|certified_assets, sigs| { + let inputs = CanisterSigInputs { + domain: DELEGATION_SIG_DOMAIN, + seed: &seed, + message: &crate::delegation::delegation_signature_msg_with_permissions( + &request.session_key, + request.expiration, + None, + permissions, + ), + }; + sigs.get_signature_as_cbor(&inputs, Some(certified_assets.root_hash())) + }) + .map(|signature| SignedDelegation { + delegation: Delegation { + pubkey: request.session_key, + expiration: request.expiration, + targets: None, + permissions: permissions.map(str::to_string), + }, + signature: ByteBuf::from(signature), + }) + .map_err(|_| AppSessionError::NoMatchingSession) +} + +/// Authenticates a refresh from `caller()` alone. +/// +/// The session index is keyed by the principal a session's chain is rooted at, so a hit is +/// itself the proof that the caller is that session: nothing is named in the request and +/// nothing is attached to it. +fn authorize_session(now: Timestamp) -> Result<(Account, SessionRecord), AppSessionError> { + let handle = storage_borrow(|storage| storage.lookup_session_with_principal(caller())) + .ok_or(AppSessionError::NoMatchingSession)?; + let locator = storage_borrow(|storage| storage.lookup_account_with_principal(handle.account())) + .ok_or(AppSessionError::NoMatchingSession)?; + + let (account, sessions) = storage_borrow(|storage| { + storage.account_with_sessions( + locator.anchor_number, + locator.application_number, + locator.account_number, + ) + }) + .ok_or(AppSessionError::NoMatchingSession)?; + + // The browser and the creation time together, because a browser keeps its id across + // sign-ins: on the browser alone, an index entry that outlived its session would + // authenticate its holder as whatever that browser created next. + let session = sessions + .into_iter() + .find(|session| { + session.device_id == handle.device_id && session.created_at == handle.created_at + }) + .ok_or(AppSessionError::NoMatchingSession)?; + if session.is_expired(now) { + return Err(AppSessionError::NoMatchingSession); + } + Ok((account, session)) +} + +fn account_seed(account: &Account) -> Result { + let salt = storage_borrow(|storage| storage.salt().copied()).ok_or_else(|| { + AppSessionError::InternalCanisterError(StorageError::SaltNotSet.to_string()) + })?; + Ok(account.calculate_seed_with_salt(&salt)) +} diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 3f453f8191d..09e530223f7 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1858,12 +1858,28 @@ impl Storage { Ok(removed) } + /// The account a principal a dapp sees was derived for. + pub fn lookup_account_with_principal( + &self, + principal: Principal, + ) -> Option { + self.lookup_account_with_principal_memory.get(&principal) + } + /// Tells storage which canister it is, so the principals it derives match the ones /// callers arrive as. pub fn set_canister_id(&mut self, canister_id: Principal) { self.canister_id = canister_id; } + /// Where the session a caller authenticates as is stored. + pub fn lookup_session_with_principal( + &self, + principal: Principal, + ) -> Option { + self.lookup_session_with_principal_memory.get(&principal) + } + /// The principal a session's chain is rooted at, which is what an app-facing call /// arrives as. `None` only when the salt is unset or the account is gone, both of /// which make the session unusable anyway. diff --git a/src/internet_identity/src/storage/storable/session_handle.rs b/src/internet_identity/src/storage/storable/session_handle.rs index 3f1770520a8..7828c7aa0db 100644 --- a/src/internet_identity/src/storage/storable/session_handle.rs +++ b/src/internet_identity/src/storage/storable/session_handle.rs @@ -1,4 +1,5 @@ use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use candid::Principal; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use minicbor::{Decode, Encode}; @@ -25,6 +26,12 @@ pub struct StorableSessionHandle { pub created_at: u64, } +impl StorableSessionHandle { + pub fn account(&self) -> Principal { + Principal::from_slice(&self.account_principal) + } +} + impl Storable for StorableSessionHandle { fn to_bytes(&self) -> Cow<'_, [u8]> { let mut buffer = Vec::new(); diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 02ce9b1c012..0f23d5ce4ba 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3572,7 +3572,34 @@ mod account_principal_index_tests { ); } + #[test] + fn a_principal_resolves_to_the_account_it_was_derived_for() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + storage + .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let principal = default_account_principal(anchor_number, &origin); + + let locator = storage.lookup_account_with_principal(principal).unwrap(); + assert_eq!(locator.anchor_number, anchor_number); + assert_eq!(locator.application_number, application_number); + assert_eq!(locator.account_number, None); + } + + #[test] + fn a_principal_that_was_never_derived_resolves_to_nothing() { + let (storage, _) = storage_with_anchor(); + + assert_eq!( + storage.lookup_account_with_principal(Principal::anonymous()), + None + ); + } } mod account_principal_index_backfill_tests { @@ -4197,7 +4224,52 @@ mod session_creation_tests { ); } + /// A browser keeps its id across sign-ins, so an index entry left behind by a removal + /// would be waiting for whatever that browser creates next. + #[test] + fn signing_a_browser_out_removes_its_index_entries() { + let (mut storage, anchor_number) = storage_with_anchor(); + let session = storage.create_session(params(anchor_number, 7, 1_000)).unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + let principal = storage + .session_principal(anchor_number, application_number, None, &session) + .unwrap(); + assert!(storage.lookup_session_with_principal(principal).is_some()); + + storage.revoke_device_sessions(anchor_number, 7).unwrap(); + + assert!( + storage.lookup_session_with_principal(principal).is_none(), + "the revoked session's entry outlived it" + ); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 0); + } + + /// Row eviction leaves the account's principal untouched, so the same origin comes back + /// at the same account. Its sessions must not. + #[test] + fn evicting_a_row_removes_its_sessions_index_entries() { + let (mut storage, anchor_number) = storage_with_anchor(); + let session = storage.create_session(params(anchor_number, 7, 1_000)).unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + let principal = storage + .session_principal(anchor_number, application_number, None, &session) + .unwrap(); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + assert!( + storage.lookup_session_with_principal(principal).is_none(), + "an evicted row left its sessions resolvable" + ); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 0); + } /// The flood bound, exercised through the cap rather than through the order alone: a /// session the user has actually kept alive survives a row full of sign-ins nobody diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 78ec32169b7..898b68dd6a6 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -2,14 +2,15 @@ use candid::Principal; use canister_tests::api::internet_identity::api_v2::{ - get_account_session, prepare_account_session, + app_get_delegation, app_prepare_delegation, get_account_session, prepare_account_session, }; use canister_tests::flows; use canister_tests::framework::{ env, install_ii_with_archive, principal_1, time, verify_delegation, BrowserKey, }; use internet_identity_interface::internet_identity::types::{ - AccountSessionError, GetAccountSessionRequest, PrepareAccountSessionRequest, + AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, + GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, PrepareAccountSessionResponse, }; use pocket_ic::{PocketIc, RejectResponse}; @@ -18,6 +19,7 @@ use serde_bytes::ByteBuf; use std::time::Duration; const ORIGIN: &str = "https://some-dapp.com"; +const APP_DELEGATION_TTL_NS: u64 = 5 * 60 * 1_000_000_000; fn session_request(identity_number: u64) -> PrepareAccountSessionRequest { session_request_from(identity_number, &BrowserKey::new(1)) @@ -98,6 +100,42 @@ fn should_create_a_session_and_witness_its_delegation() -> Result<(), RejectResp Ok(()) } +#[test] +fn should_replace_the_session_of_a_browser_signing_in_again() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let (first, first_principal) = create_session(&env, canister_id, identity_number); + env.advance_time(Duration::from_secs(60)); + + let second = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + + assert_ne!(second.created_at, first.created_at); + assert_ne!(second.user_key, first.user_key); + + // The chain the first ceremony handed out stops working, which is what bounds a copy of + // it to the user's next sign-in rather than to its expiry. + assert_eq!( + app_prepare_delegation( + &env, + canister_id, + first_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?, + Err(AppSessionError::NoMatchingSession) + ); + + Ok(()) +} #[test] fn should_refuse_a_session_for_another_anchor() -> Result<(), RejectResponse> { @@ -117,13 +155,308 @@ fn should_refuse_a_session_for_another_anchor() -> Result<(), RejectResponse> { Ok(()) } +#[test] +fn should_mint_an_app_delegation_from_a_session() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + let app_key = ByteBuf::from(vec![7; 32]); + let minted = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: app_key.clone(), + }, + )? + .unwrap(); + assert!(minted.expiration <= time(&env) + APP_DELEGATION_TTL_NS); + assert!(minted.expiration > time(&env)); + let signed = app_get_delegation( + &env, + canister_id, + session_principal, + AppGetDelegationRequest { + session_key: app_key, + expiration: minted.expiration, + }, + )? + .unwrap(); + verify_delegation(&env, minted.user_key, &signed, &env.root_key().unwrap()); + Ok(()) +} + +/// The minted delegation is for the account, not for the session, so it is the principal +/// the dapp already knows, and the one the session response names. +#[test] +fn should_mint_the_accounts_own_principal() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::{ + prepare_account_delegation, AccountDelegationParams, + }; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let params = AccountDelegationParams::new( + &env, + canister_id, + principal_1(), + identity_number, + ORIGIN.to_string(), + None, + ByteBuf::from(vec![9; 32]), + ); + let by_access_method = prepare_account_delegation(¶ms, None)?.unwrap(); + + let (prepared, session_principal) = create_session(&env, canister_id, identity_number); + let by_session = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )? + .unwrap(); + + assert_eq!(by_session.user_key, by_access_method.user_key); + assert_eq!( + prepared.account_principal, + Principal::self_authenticating(&by_access_method.user_key) + ); + Ok(()) +} +/// A caller the session index does not know is refused, whatever else it holds. +#[test] +fn should_refuse_a_caller_that_is_not_the_session() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, _) = create_session(&env, canister_id, identity_number); + + let result = app_prepare_delegation( + &env, + canister_id, + principal_1(), + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + + assert_eq!(result, Err(AppSessionError::NoMatchingSession)); + + Ok(()) +} + +#[test] +fn should_refuse_a_refresh_once_the_session_has_expired() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let mut request = session_request(identity_number); + request.valid_for = Some(10 * 60 * 1_000_000_000); + let prepared = prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); + let session_principal = Principal::self_authenticating(&prepared.user_key); + + env.advance_time(Duration::from_secs(11 * 60)); + + let result = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + + assert_eq!(result, Err(AppSessionError::NoMatchingSession)); + + Ok(()) +} + +/// The 5-minute cap is a property of the design, not something the app asks for, so the +/// `get` half re-derives it rather than trusting the value it is handed. Longer-lived +/// delegations over the same account seed exist, and witnessing one here would hand the +/// session an artifact that outlives it. +#[test] +fn should_refuse_an_app_delegation_longer_than_the_ttl() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::{ + prepare_account_delegation, AccountDelegationParams, + }; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let app_key = ByteBuf::from(vec![7; 32]); + + // A 30-day delegation over the same account seed, for the same key. + let params = AccountDelegationParams::new( + &env, + canister_id, + principal_1(), + identity_number, + ORIGIN.to_string(), + None, + app_key.clone(), + ); + let long_lived = + prepare_account_delegation(¶ms, Some(30 * 24 * 60 * 60 * 1_000_000_000))?.unwrap(); + assert!(long_lived.expiration > time(&env) + APP_DELEGATION_TTL_NS); + + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + let result = app_get_delegation( + &env, + canister_id, + session_principal, + AppGetDelegationRequest { + session_key: app_key, + expiration: long_lived.expiration, + }, + )?; + + assert!(matches!(result, Err(AppSessionError::NoMatchingSession))); + + Ok(()) +} + +/// A consent that differs from the held one is a different session, so a downgrade is +/// not silently discarded. +#[test] +fn should_not_reuse_a_session_across_a_consent_change() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let full_access = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + + let mut downgraded = session_request(identity_number); + downgraded.permissions = Some(Permissions::Queries); + env.advance_time(Duration::from_secs(60)); + let read_only = prepare_account_session(&env, canister_id, principal_1(), downgraded)?.unwrap(); + + assert_ne!(read_only.created_at, full_access.created_at); + assert_ne!(read_only.user_key, full_access.user_key); + + let minted = app_prepare_delegation( + &env, + canister_id, + Principal::self_authenticating(&read_only.user_key), + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )? + .unwrap(); + let signed = app_get_delegation( + &env, + canister_id, + Principal::self_authenticating(&read_only.user_key), + AppGetDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + expiration: minted.expiration, + }, + )? + .unwrap(); + assert_eq!( + signed.delegation.permissions, + Some("queries".to_string()), + "the downgraded consent must reach the minted delegation" + ); + + // The browser now holds one session, not two. + let refreshed_old = app_prepare_delegation( + &env, + canister_id, + Principal::self_authenticating(&full_access.user_key), + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + assert_eq!(refreshed_old, Err(AppSessionError::NoMatchingSession)); + + Ok(()) +} + +/// A session whose device the registry cap dropped could not be signed out from +/// settings, so it goes with the record. +#[test] +fn should_end_the_sessions_of_a_browser_the_registry_dropped() -> Result<(), RejectResponse> { + const MAX_SESSION_DEVICES: u32 = 20; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let (_, first_principal) = create_session(&env, canister_id, identity_number); + + for index in 0..MAX_SESSION_DEVICES { + let mut request = session_request_from(identity_number, &BrowserKey::new(index as u8 + 2)); + request.device_name = format!("browser-{index}"); + request.origin = format!("https://dapp-{index}.com"); + prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); + } + + let refreshed = app_prepare_delegation( + &env, + canister_id, + first_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + assert_eq!(refreshed, Err(AppSessionError::NoMatchingSession)); + + Ok(()) +} + +/// An app delegation cannot renew itself: its principal resolves to the locator, but no +/// session's seed will ever equal it, because the two seed families are domain separated. +#[test] +fn should_refuse_an_app_delegation_renewing_itself() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + let minted = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )? + .unwrap(); + let account_principal = Principal::self_authenticating(&minted.user_key); + + let result = app_prepare_delegation( + &env, + canister_id, + account_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + + assert_eq!(result, Err(AppSessionError::NoMatchingSession)); + + Ok(()) +} /// Registering a browser happens once per browser per anchor, so it is rare enough to /// archive, unlike the per-sign-in events the account design keeps out of the archive. @@ -187,6 +520,47 @@ fn should_archive_a_browser_registration_with_the_name_redacted() -> Result<(), Ok(()) } +/// Naming a default account keeps its principal, so it must keep its sessions. Before the +/// session seed was built on the account seed, naming it signed the user out of every app +/// using that account. +#[test] +fn should_keep_a_session_alive_when_the_default_account_is_named() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::update_account; + use internet_identity_interface::internet_identity::types::AccountUpdate; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + // Naming the default account materializes it: the reference keeps its sessions and + // gains an account number. + update_account( + &env, + canister_id, + principal_1(), + identity_number, + ORIGIN.to_string(), + None, + AccountUpdate { + name: Some("work".to_string()), + }, + )? + .unwrap(); + + let refreshed = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + + assert!(refreshed.is_ok(), "naming an account ended its sessions"); + + Ok(()) +} /// A request naming an account the identity does not hold is the one failure a caller can /// provoke here, so it must be refused before anything is written. Otherwise a rejected @@ -533,6 +907,49 @@ fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectRespons Ok(()) } +/// Rotation changes what the browser proves with, not which browser it is, and sessions +/// record the browser. So a rotation must not cost the user their session. +#[test] +fn should_keep_the_browser_entry_across_a_rotation() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + env.advance_time(Duration::from_secs(60)); + let rotated = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + // A ceremony replaces the session, so what a rotation must not cost is the browser's + // identity: same entry, new session. + assert_eq!(rotated.device_id, first.device_id); + assert_ne!(rotated.created_at, first.created_at); + + assert!(app_prepare_delegation( + &env, + canister_id, + Principal::self_authenticating(&rotated.user_key), + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )? + .is_ok()); + + Ok(()) +} /// A key nobody holds cannot be announced: without the successor's own signature, a key read /// off the wire could be planted as another browser's successor and claimed later. @@ -586,3 +1003,43 @@ fn should_refuse_a_successor_another_browser_holds_even_when_proven() -> Result< Ok(()) } +/// A refresh names nothing and attaches nothing: the caller is resolved from its own +/// signature, so an app that never held a session cannot mint by naming an account. +#[test] +fn should_mint_for_the_calling_session_and_nobody_else() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (prepared, session_principal) = create_session(&env, canister_id, identity_number); + + let minted = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )? + .unwrap(); + + // What the session mints is the account's own principal, unchanged by any of this. + assert_eq!( + Principal::self_authenticating(&minted.user_key), + prepared.account_principal + ); + + // The account principal is not a credential: holding it mints nothing. + assert_eq!( + app_prepare_delegation( + &env, + canister_id, + prepared.account_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?, + Err(AppSessionError::NoMatchingSession) + ); + + Ok(()) +} From 3b1db8fc380d0c70c410294ac9e299a616772e26 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 03:18:44 +0200 Subject: [PATCH 3/6] feat(be): record that a session is still in use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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) --- src/internet_identity/src/sessions.rs | 23 +- src/internet_identity/src/storage.rs | 80 +++++ src/internet_identity/src/storage/anchor.rs | 16 + src/internet_identity/src/storage/tests.rs | 280 ++++++++++++++++++ .../tests/integration/sessions.rs | 90 +++++- 5 files changed, 484 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 7bd7f711355..189ffcc4ca1 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -11,6 +11,7 @@ use crate::delegation::{ use crate::sessions::device_key::verify_device_keys; use crate::state::{self, storage_borrow, storage_borrow_mut}; use crate::storage::account::{Account, ReadAccountParams, SessionRecord}; +use crate::storage::storable::account_locator::StorableAccountLocator; use crate::storage::{CreateSessionParams, StorageError}; use crate::{update_root_hash, DAY_NS, MINUTE_NS}; use candid::Principal; @@ -308,7 +309,19 @@ pub fn app_prepare_delegation( request: AppPrepareDelegationRequest, ) -> Result { let now = time(); - let (account, session) = authorize_session(now)?; + let (locator, account, session) = authorize_session(now)?; + + storage_borrow_mut(|storage| { + storage.stamp_session_refresh( + locator.anchor_number, + locator.application_number, + locator.account_number, + session.created_at, + session.device_id, + now, + ) + }) + .map_err(|err| AppSessionError::InternalCanisterError(err.to_string()))?; let expiration = u64::min( now.saturating_add(APP_DELEGATION_TTL_NS), @@ -338,7 +351,7 @@ pub fn app_get_delegation( request: AppGetDelegationRequest, ) -> Result { let now = time(); - let (account, session) = authorize_session(now)?; + let (_, account, session) = authorize_session(now)?; if request.expiration > now.saturating_add(APP_DELEGATION_TTL_NS) || request.expiration > session.valid_till @@ -380,7 +393,9 @@ pub fn app_get_delegation( /// The session index is keyed by the principal a session's chain is rooted at, so a hit is /// itself the proof that the caller is that session: nothing is named in the request and /// nothing is attached to it. -fn authorize_session(now: Timestamp) -> Result<(Account, SessionRecord), AppSessionError> { +fn authorize_session( + now: Timestamp, +) -> Result<(StorableAccountLocator, Account, SessionRecord), AppSessionError> { let handle = storage_borrow(|storage| storage.lookup_session_with_principal(caller())) .ok_or(AppSessionError::NoMatchingSession)?; let locator = storage_borrow(|storage| storage.lookup_account_with_principal(handle.account())) @@ -407,7 +422,7 @@ fn authorize_session(now: Timestamp) -> Result<(Account, SessionRecord), AppSess if session.is_expired(now) { return Err(AppSessionError::NoMatchingSession); } - Ok((account, session)) + Ok((locator, account, session)) } fn account_seed(account: &Account) -> Result { diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 09e530223f7..4ba35e00f7a 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2062,6 +2062,86 @@ impl Storage { Ok(remaining) } + /// Records that a session was used. Reports whether a session matched. The + /// reference's `last_used` rides on the same write. + pub fn stamp_session_refresh( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + created_at: Timestamp, + device_id: SessionDeviceId, + now: Timestamp, + ) -> Result { + let Some(references) = self.lookup_account_references(anchor_number, application_number) + else { + return Ok(false); + }; + let mut references: Vec = + references.into_iter().map(Into::into).collect(); + + let Some(reference) = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + else { + return Ok(false); + }; + let Some(session) = reference + .sessions + .iter_mut() + .find(|session| session.created_at == created_at && session.device_id == device_id) + else { + return Ok(false); + }; + + session.last_refreshed = Some(now); + reference.last_used = Some(now); + + // This row is being rewritten anyway, so its dead sessions go now. It costs one + // pass over a list already in memory and no write of its own, and it means every + // row anyone still uses stays clean without anything having to sweep for it. + let mut expired: Vec<(Option, SessionRecord)> = vec![]; + for reference in references.iter_mut() { + let account_number = reference.account_number; + reference.sessions.retain(|session| { + if session.is_expired(now) { + expired.push((account_number, session.clone())); + return false; + } + true + }); + } + + self.write_reference_list(anchor_number, application_number, references)?; + for (account_number, session) in &expired { + self.unindex_sessions( + anchor_number, + application_number, + *account_number, + std::slice::from_ref(session), + ); + } + if !expired.is_empty() { + self.change_session_count(anchor_number, expired.len(), 0)?; + } + self.stamp_session_device_use(anchor_number, device_id, now)?; + Ok(true) + } + + /// Advances the device registry's `last_used` for the browser driving this session. + fn stamp_session_device_use( + &mut self, + anchor_number: AnchorNumber, + device_id: SessionDeviceId, + now: Timestamp, + ) -> Result<(), StorageError> { + let mut anchor = self.read(anchor_number)?; + if !anchor.stamp_session_device_use(device_id, now) { + return Ok(()); + } + self.write(anchor) + } + /// Drops the index entries of sessions that have just been removed from a row. fn unindex_sessions( &mut self, diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 08fe17055e6..8780335406e 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -725,6 +725,22 @@ impl Anchor { &self.session_devices } + /// Advances a device's `last_used`. Reports whether anything changed, so an unknown + /// device or a repeat inside one message costs no anchor write. + pub fn stamp_session_device_use(&mut self, device_id: SessionDeviceId, now: Timestamp) -> bool { + match self + .session_devices + .iter_mut() + .find(|device| device.id == device_id) + { + Some(device) if device.last_used < now => { + device.last_used = now; + true + } + _ => false, + } + } + /// Resolves the browser a sign-in came from by the public key it proved possession of, /// registering it when this anchor holds neither that key nor a successor equal to it. /// diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 0f23d5ce4ba..6f41bcd27af 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4611,3 +4611,283 @@ mod session_consent_change_tests { assert_eq!(held, vec![false, true]); } } + +mod session_refresh_stamp_tests { + use crate::storage::account::{AccountReference, SessionRecord}; + use crate::storage::CreateSessionParams; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::{AnchorNumber, ApplicationNumber}; + use pretty_assertions::assert_eq; + use serde_bytes::ByteBuf; + + const ORIGIN: &str = "https://example.com"; + + fn storage_with_session() -> (Storage, AnchorNumber, ApplicationNumber, u64) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + let session = storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: 1, + valid_till: u64::MAX, + read_only: false, + now: 1_000, + }) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + ( + storage, + anchor_number, + application_number, + session.created_at, + ) + } + + fn reference(storage: &Storage, anchor_number: AnchorNumber) -> AccountReference { + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .into_iter() + .map(AccountReference::from) + .find(|reference| reference.account_number.is_none()) + .unwrap() + } + + fn session_of(storage: &Storage, anchor_number: AnchorNumber) -> SessionRecord { + reference(storage, anchor_number).sessions.remove(0) + } + + #[test] + fn a_refresh_stamps_the_session_and_the_reference() { + let (mut storage, anchor_number, application_number, created_at) = storage_with_session(); + + let stamped = storage + .stamp_session_refresh( + anchor_number, + application_number, + None, + created_at, + 1, + 2_000, + ) + .unwrap(); + + assert!(stamped); + assert_eq!( + session_of(&storage, anchor_number).last_refreshed, + Some(2_000) + ); + assert_eq!(reference(&storage, anchor_number).last_used, Some(2_000)); + } + + #[test] + fn every_refresh_advances_the_stamp() { + let (mut storage, anchor_number, application_number, created_at) = storage_with_session(); + + for now in [1_001, 1_002, 1_003] { + assert!(storage + .stamp_session_refresh(anchor_number, application_number, None, created_at, 1, now) + .unwrap()); + assert_eq!( + session_of(&storage, anchor_number).last_refreshed, + Some(now) + ); + } + } + + /// The row is rewritten anyway, so the refresh is where a dead sibling is collected — + /// index entry and session count included, since nothing else will come for them. + #[test] + fn a_refresh_collects_the_dead_sessions_beside_it() { + let (mut storage, anchor_number, application_number, created_at) = storage_with_session(); + + let dead = storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: 9, + valid_till: 1_500, + read_only: false, + now: 1_000, + }) + .unwrap(); + let dead_principal = storage + .session_principal(anchor_number, application_number, None, &dead) + .unwrap(); + assert!(storage.lookup_session_with_principal(dead_principal).is_some()); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); + + assert!(storage + .stamp_session_refresh(anchor_number, application_number, None, created_at, 1, 2_000) + .unwrap()); + + let sessions = reference(&storage, anchor_number).sessions; + assert_eq!(sessions.len(), 1, "the expired sibling was left behind"); + assert_eq!(sessions[0].device_id, 1); + assert!( + storage.lookup_session_with_principal(dead_principal).is_none(), + "the expired sibling's index entry outlived it" + ); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 1); + } + + #[test] + fn a_stamp_for_a_session_that_is_gone_writes_nothing() { + let (mut storage, anchor_number, application_number, _) = storage_with_session(); + + let wrote = storage + .stamp_session_refresh(anchor_number, application_number, None, 9_999, 1, 5_000) + .unwrap(); + + assert!(!wrote); + } + + #[test] + fn stamping_leaves_a_second_device_alone() { + let (mut storage, anchor_number, application_number, created_at) = storage_with_session(); + storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: 2, + valid_till: u64::MAX, + read_only: false, + now: 1_000, + }) + .unwrap(); + let now = 2_000; + + storage + .stamp_session_refresh(anchor_number, application_number, None, created_at, 1, now) + .unwrap(); + + let sessions = reference(&storage, anchor_number).sessions; + assert_eq!(sessions.len(), 2); + let stamped = sessions.iter().find(|s| s.device_id == 1).unwrap(); + let untouched = sessions.iter().find(|s| s.device_id == 2).unwrap(); + assert_eq!(stamped.last_refreshed, Some(now)); + assert_eq!(untouched.last_refreshed, None); + } + + fn storage_with_registered_device() -> ( + Storage, + AnchorNumber, + ApplicationNumber, + u64, + u32, + ) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let mut anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + let (device_id, _) = anchor + .resolve_session_device( + ByteBuf::from(vec![1; 91]), + ByteBuf::from(vec![2; 91]), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + storage.write(anchor).unwrap(); + let session = storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id, + valid_till: u64::MAX, + read_only: false, + now: 1_000, + }) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + ( + storage, + anchor_number, + application_number, + session.created_at, + device_id, + ) + } + + fn device_last_used(storage: &Storage, anchor_number: AnchorNumber) -> u64 { + storage.read(anchor_number).unwrap().session_devices()[0].last_used + } + + #[test] + fn a_refresh_advances_the_device_registry() { + let (mut storage, anchor_number, application_number, created_at, device_id) = + storage_with_registered_device(); + + storage + .stamp_session_refresh( + anchor_number, + application_number, + None, + created_at, + device_id, + 9_000, + ) + .unwrap(); + + assert_eq!(device_last_used(&storage, anchor_number), 9_000); + } + + #[test] + fn a_refresh_leaves_the_device_enrolment_timestamp_alone() { + let (mut storage, anchor_number, application_number, created_at, device_id) = + storage_with_registered_device(); + + storage + .stamp_session_refresh( + anchor_number, + application_number, + None, + created_at, + device_id, + 9_000, + ) + .unwrap(); + + let device = storage.read(anchor_number).unwrap().session_devices()[0].clone(); + assert_eq!(device.created_at, 1_000); + assert_eq!(device.last_used, 9_000); + } + + #[test] + fn a_refresh_for_a_device_the_anchor_never_registered_still_stamps_the_session() { + let (mut storage, anchor_number, application_number, created_at) = storage_with_session(); + + let stamped = storage + .stamp_session_refresh( + anchor_number, + application_number, + None, + created_at, + 1, + 9_000, + ) + .unwrap(); + + assert!(stamped); + assert_eq!( + session_of(&storage, anchor_number).last_refreshed, + Some(9_000) + ); + } +} diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 898b68dd6a6..a4e3d483036 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -11,7 +11,7 @@ use canister_tests::framework::{ use internet_identity_interface::internet_identity::types::{ AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, - PrepareAccountSessionResponse, + PrepareAccountSessionResponse, SessionDeviceInfo, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; @@ -520,6 +520,94 @@ fn should_archive_a_browser_registration_with_the_name_redacted() -> Result<(), Ok(()) } +#[test] +fn should_stamp_every_refresh() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::get_accounts; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + let refresh = |env: &PocketIc| { + app_prepare_delegation( + env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + ) + .unwrap() + .unwrap() + }; + let last_used = |env: &PocketIc| -> Result, RejectResponse> { + Ok(get_accounts( + env, + canister_id, + principal_1(), + identity_number, + ORIGIN.to_string(), + )? + .unwrap()[0] + .last_used) + }; + + let before = last_used(&env)?; + + env.advance_time(Duration::from_secs(60)); + refresh(&env); + let after_a_minute = last_used(&env)?; + assert!(after_a_minute > before); + + env.advance_time(Duration::from_secs(60)); + refresh(&env); + assert!(last_used(&env)? > after_a_minute); + + Ok(()) +} + +#[test] +fn should_advance_the_device_last_used_on_every_refresh() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + let device = |env: &PocketIc| -> Result { + Ok( + identity_info(env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices + .unwrap()[0] + .clone(), + ) + }; + + let enrolled = device(&env)?; + assert_eq!(enrolled.created_at, enrolled.last_used); + + env.advance_time(Duration::from_secs(300)); + app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + ) + .unwrap() + .unwrap(); + + let refreshed = device(&env)?; + assert!(refreshed.last_used > enrolled.last_used); + assert_eq!(refreshed.created_at, enrolled.created_at); + + Ok(()) +} + /// Naming a default account keeps its principal, so it must keep its sessions. Before the /// session seed was built on the account seed, naming it signed the user out of every app /// using that account. From b368fe91506815488706d7f243f8a2c9c718a701 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 03:22:43 +0200 Subject: [PATCH 4/6] feat(be): let an app sign its own session out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/api/internet_identity/api_v2.rs | 14 +++ .../lib/generated/internet_identity_idl.js | 1 + .../generated/internet_identity_types.d.ts | 6 ++ src/internet_identity/internet_identity.did | 5 + src/internet_identity/src/main.rs | 5 + src/internet_identity/src/sessions.rs | 33 ++++++- src/internet_identity/src/storage.rs | 75 +++++++++++++++ src/internet_identity/src/storage/tests.rs | 93 +++++++++++++++++++ .../tests/integration/sessions.rs | 73 ++++++++++++++- 9 files changed, 301 insertions(+), 4 deletions(-) diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index fc01f4dd450..1d28236b0eb 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -818,3 +818,17 @@ pub fn app_get_delegation( ) -> Result, RejectResponse> { query_candid_as(env, canister_id, sender, "app_get_delegation", (request,)).map(|(x,)| x) } + +pub fn app_revoke_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, +) -> Result<(), RejectResponse> { + env.update_call( + canister_id, + sender, + "app_revoke_session", + candid::encode_args(()).expect("encode app_revoke_session args"), + ) + .map(|_| ()) +} diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 741f668c387..093d970b764 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -917,6 +917,7 @@ export const idlFactory = ({ IDL }) => { ], [], ), + 'app_revoke_session' : IDL.Func([], [], []), 'authn_method_add' : IDL.Func( [IdentityNumber, AuthnMethodData], [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : AuthnMethodAddError })], diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index ee29ae5eccc..9007fa2033f 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1912,6 +1912,12 @@ export interface _SERVICE { { 'Ok' : AppPrepareDelegationResponse } | { 'Err' : AppSessionError } >, + /** + * Signs the calling session out. 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. An app can revoke only its own session. + */ + 'app_revoke_session' : ActorMethod<[], undefined>, /** * Adds a new authentication method to the identity. * Requires authentication. diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 86335d290ef..c2732fb09e9 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1967,6 +1967,11 @@ service : (opt InternetIdentityInit) -> { app_prepare_delegation : (AppPrepareDelegationRequest) -> (variant { Ok : AppPrepareDelegationResponse; Err : AppSessionError }); app_get_delegation : (AppGetDelegationRequest) -> (variant { Ok : SignedDelegation; Err : AppSessionError }) query; + // Signs the calling session out. 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. An app can revoke only its own session. + app_revoke_session : () -> (); + prepare_account_delegation : ( anchor_number : UserNumber, origin : FrontendHostname, diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 5cee41653c8..ad96e942853 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -511,6 +511,11 @@ fn app_prepare_delegation( sessions::app_prepare_delegation(request) } +#[update] +fn app_revoke_session() { + sessions::app_revoke_session() +} + #[query] fn app_get_delegation( request: AppGetDelegationRequest, diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 189ffcc4ca1..3db5cd3f9f9 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -396,6 +396,35 @@ pub fn app_get_delegation( fn authorize_session( now: Timestamp, ) -> Result<(StorableAccountLocator, Account, SessionRecord), AppSessionError> { + let matched = match_session()?; + if matched.2.is_expired(now) { + return Err(AppSessionError::NoMatchingSession); + } + Ok(matched) +} + +/// Signs the caller's own session out. A caller cannot produce another session's +/// principal, so the seed match is the whole authorization. Always succeeds. +pub fn app_revoke_session() { + let Ok((locator, _, session)) = match_session() else { + return; + }; + // Trapping rather than reporting success: the caller is told nothing either way, so a + // storage failure that left the session live would end as a silent no-op. A trap rolls + // the message back and reaches the caller as a reject. + storage_borrow_mut(|storage| { + storage.remove_session( + locator.anchor_number, + locator.application_number, + locator.account_number, + session.created_at, + session.device_id, + ) + }) + .expect("failed to remove a session that was just matched"); +} + +fn match_session() -> Result<(StorableAccountLocator, Account, SessionRecord), AppSessionError> { let handle = storage_borrow(|storage| storage.lookup_session_with_principal(caller())) .ok_or(AppSessionError::NoMatchingSession)?; let locator = storage_borrow(|storage| storage.lookup_account_with_principal(handle.account())) @@ -419,9 +448,7 @@ fn authorize_session( session.device_id == handle.device_id && session.created_at == handle.created_at }) .ok_or(AppSessionError::NoMatchingSession)?; - if session.is_expired(now) { - return Err(AppSessionError::NoMatchingSession); - } + Ok((locator, account, session)) } diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 4ba35e00f7a..80c3b782dbe 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1957,6 +1957,43 @@ impl Storage { Ok(count) } + /// Removes one session. Returns whether anything was removed. + pub fn remove_session( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + created_at: Timestamp, + device_id: SessionDeviceId, + ) -> Result { + // One browser holds one session per account, so the browser identifies it. The + // creation time is a guard: it stops a caller removing a session that replaced the + // one it matched. + let present = self + .lookup_account_references(anchor_number, application_number) + .map(|list| { + list.into_iter() + .map(AccountReference::from) + .any(|reference| { + reference.account_number == account_number + && reference.sessions.iter().any(|session| { + session.device_id == device_id && session.created_at == created_at + }) + }) + }) + .unwrap_or(false); + if !present { + return Ok(false); + } + + let dropped = + self.drop_session(anchor_number, application_number, account_number, device_id)?; + if dropped > 0 { + self.change_session_count(anchor_number, dropped, 0)?; + } + Ok(dropped > 0) + } + /// Walks the anchor's rows once and reclaims down to the watermark, taking sessions in /// [`SessionRecord::reclaim_order`]: dead ones first, then the least recently used. /// @@ -2142,6 +2179,44 @@ impl Storage { self.write(anchor) } + /// Removes one browser's session from one account reference, index entry included, and + /// reports whether anything went. Keyed by browser rather than by creation time, since + /// two browsers signing in during one round share a `created_at`. + fn drop_session( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + device_id: SessionDeviceId, + ) -> Result { + let mut references: Vec = + match self.lookup_account_references(anchor_number, application_number) { + Some(list) => list.into_iter().map(Into::into).collect(), + None => return Ok(0), + }; + let Some(reference) = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + else { + return Ok(0); + }; + let dropped: Vec = reference + .sessions + .iter() + .filter(|session| session.device_id == device_id) + .cloned() + .collect(); + if dropped.is_empty() { + return Ok(0); + } + reference + .sessions + .retain(|session| session.device_id != device_id); + self.write_reference_list(anchor_number, application_number, references)?; + self.unindex_sessions(anchor_number, application_number, account_number, &dropped); + Ok(dropped.len()) + } + /// Drops the index entries of sessions that have just been removed from a row. fn unindex_sessions( &mut self, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 6f41bcd27af..cea4791e25d 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4891,3 +4891,96 @@ mod session_refresh_stamp_tests { ); } } + +mod session_removal_tests { + use crate::storage::account::AccountReference; + use crate::storage::CreateSessionParams; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + const ORIGIN: &str = "https://example.com"; + + fn storage_with_sessions(devices: &[u32]) -> (Storage, AnchorNumber, u64) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + for device_id in devices { + storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: *device_id, + valid_till: u64::MAX, + read_only: false, + now: 1_000, + }) + .unwrap(); + } + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + (storage, anchor_number, application_number) + } + + fn sessions(storage: &Storage, anchor_number: AnchorNumber) -> Vec { + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .into_iter() + .map(AccountReference::from) + .find(|reference| reference.account_number.is_none()) + .unwrap() + .sessions + .into_iter() + .map(|session| session.device_id) + .collect() + } + + #[test] + fn removing_a_session_leaves_the_others() { + let (mut storage, anchor_number, application_number) = storage_with_sessions(&[1, 2, 3]); + + let removed = storage + .remove_session(anchor_number, application_number, None, 1_000, 2) + .unwrap(); + + assert!(removed); + assert_eq!(sessions(&storage, anchor_number), vec![1, 3]); + } + + #[test] + fn removing_a_session_twice_reports_nothing_removed() { + let (mut storage, anchor_number, application_number) = storage_with_sessions(&[1]); + storage + .remove_session(anchor_number, application_number, None, 1_000, 1) + .unwrap(); + + let removed = storage + .remove_session(anchor_number, application_number, None, 1_000, 1) + .unwrap(); + + assert!(!removed); + assert_eq!(sessions(&storage, anchor_number), Vec::::new()); + } + + #[test] + fn removing_the_last_session_keeps_the_reference() { + let (mut storage, anchor_number, application_number) = storage_with_sessions(&[1]); + + storage + .remove_session(anchor_number, application_number, None, 1_000, 1) + .unwrap(); + + assert!(storage + .lookup_account_references(anchor_number, application_number) + .is_some()); + } +} diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index a4e3d483036..0636f0b14d3 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -2,7 +2,8 @@ use candid::Principal; use canister_tests::api::internet_identity::api_v2::{ - app_get_delegation, app_prepare_delegation, get_account_session, prepare_account_session, + app_get_delegation, app_prepare_delegation, app_revoke_session, get_account_session, + prepare_account_session, }; use canister_tests::flows; use canister_tests::framework::{ @@ -608,6 +609,76 @@ fn should_advance_the_device_last_used_on_every_refresh() -> Result<(), RejectRe Ok(()) } +#[test] +fn should_end_access_when_the_app_signs_out() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + let refresh = |env: &PocketIc| { + app_prepare_delegation( + env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + ) + .unwrap() + }; + assert!(refresh(&env).is_ok()); + + app_revoke_session(&env, canister_id, session_principal)?; + + assert_eq!(refresh(&env), Err(AppSessionError::NoMatchingSession)); + + Ok(()) +} + +#[test] +fn should_treat_a_repeated_sign_out_as_success() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + for _ in 0..3 { + app_revoke_session(&env, canister_id, session_principal)?; + } + + Ok(()) +} + +#[test] +fn should_leave_another_browsers_session_alone() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (first, first_principal) = create_session(&env, canister_id, identity_number); + + let mut second_request = session_request_from(identity_number, &BrowserKey::new(2)); + second_request.device_name = "Firefox on Linux".to_string(); + let second = + prepare_account_session(&env, canister_id, principal_1(), second_request)?.unwrap(); + let second_principal = Principal::self_authenticating(&second.user_key); + assert_ne!(second.user_key, first.user_key); + + app_revoke_session(&env, canister_id, first_principal)?; + + let still_works = app_prepare_delegation( + &env, + canister_id, + second_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + assert!(still_works.is_ok()); + + Ok(()) +} + /// Naming a default account keeps its principal, so it must keep its sessions. Before the /// session seed was built on the account seed, naming it signed the user out of every app /// using that account. From 9a4941c631e3386e340a99433dfb7a7ce7a6fc80 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 03:27:28 +0200 Subject: [PATCH 5/6] feat(be): revoke sessions from the user's own settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/api/internet_identity/api_v2.rs | 34 ++++ .../lib/generated/internet_identity_idl.js | 24 +++ .../generated/internet_identity_types.d.ts | 27 ++++ src/internet_identity/internet_identity.did | 23 +++ src/internet_identity/src/main.rs | 10 ++ src/internet_identity/src/sessions.rs | 35 +++- src/internet_identity/src/storage.rs | 45 ++++++ src/internet_identity/src/storage/tests.rs | 139 ++++++++++++++++ .../tests/integration/sessions.rs | 151 +++++++++++++++++- .../src/internet_identity/types.rs | 22 +++ 10 files changed, 507 insertions(+), 3 deletions(-) diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index 1d28236b0eb..789d7b4755d 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -832,3 +832,37 @@ pub fn app_revoke_session( ) .map(|_| ()) } + +pub fn revoke_account_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: RevokeAccountSessionRequest, +) -> Result, RejectResponse> { + call_candid_as( + env, + canister_id, + RawEffectivePrincipal::None, + sender, + "revoke_account_session", + (request,), + ) + .map(|(x,)| x) +} + +pub fn revoke_device_sessions( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: RevokeDeviceSessionsRequest, +) -> Result, RejectResponse> { + call_candid_as( + env, + canister_id, + RawEffectivePrincipal::None, + sender, + "revoke_device_sessions", + (request,), + ) + .map(|(x,)| x) +} diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 093d970b764..96c8286d814 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -807,6 +807,20 @@ export const idlFactory = ({ IDL }) => { 'canister_full' : IDL.Null, 'registered' : IDL.Record({ 'user_number' : UserNumber }), }); + const RevokeAccountSessionRequest = IDL.Record({ + 'origin' : IDL.Text, + 'created_at' : Timestamp, + 'account_number' : IDL.Opt(AccountNumber), + 'identity_number' : UserNumber, + }); + const SessionRevokeError = IDL.Variant({ + 'InternalCanisterError' : IDL.Text, + 'Unauthorized' : IDL.Principal, + }); + const RevokeDeviceSessionsRequest = IDL.Record({ + 'device_id' : IDL.Nat32, + 'identity_number' : UserNumber, + }); const SetDefaultAccountError = IDL.Variant({ 'NoSuchOrigin' : IDL.Record({ 'anchor_number' : UserNumber }), 'NoSuchAnchor' : IDL.Null, @@ -1440,6 +1454,16 @@ export const idlFactory = ({ IDL }) => { ), 'remove' : IDL.Func([UserNumber, DeviceKey], [], []), 'replace' : IDL.Func([UserNumber, DeviceKey, DeviceData], [], []), + 'revoke_account_session' : IDL.Func( + [RevokeAccountSessionRequest], + [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : SessionRevokeError })], + [], + ), + 'revoke_device_sessions' : IDL.Func( + [RevokeDeviceSessionsRequest], + [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : SessionRevokeError })], + [], + ), 'set_default_account' : IDL.Func( [UserNumber, FrontendHostname, IDL.Opt(AccountNumber)], [IDL.Variant({ 'Ok' : AccountInfo, 'Err' : SetDefaultAccountError })], diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index 9007fa2033f..1c9c93f4284 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1619,6 +1619,16 @@ export type RegistrationFlowNextStep = { 'Finish' : null }; export type RegistrationId = string; +export interface RevokeAccountSessionRequest { + 'origin' : string, + 'created_at' : Timestamp, + 'account_number' : [] | [AccountNumber], + 'identity_number' : UserNumber, +} +export interface RevokeDeviceSessionsRequest { + 'device_id' : number, + 'identity_number' : UserNumber, +} /** * DNSSEC proof bundle and supporting types — see * `internet_identity_interface::types::dnssec`. @@ -1653,6 +1663,8 @@ export interface SessionDeviceInfo { 'last_used' : Timestamp, } export type SessionKey = PublicKey; +export type SessionRevokeError = { 'InternalCanisterError' : string } | + { 'Unauthorized' : Principal }; export type SetDefaultAccountError = { 'NoSuchOrigin' : { 'anchor_number' : UserNumber } } | @@ -2549,6 +2561,21 @@ export interface _SERVICE { * Atomically replace device matching the device key with the new device data */ 'replace' : ActorMethod<[UserNumber, DeviceKey, DeviceData], undefined>, + /** + * Revocation from the user's own settings, authenticated by an anchor access method + * rather than by a session chain. Sessions are named by locator, never by principal, + * so these do not touch the principal index. + */ + 'revoke_account_session' : ActorMethod< + [RevokeAccountSessionRequest], + { 'Ok' : null } | + { 'Err' : SessionRevokeError } + >, + 'revoke_device_sessions' : ActorMethod< + [RevokeDeviceSessionsRequest], + { 'Ok' : null } | + { 'Err' : SessionRevokeError } + >, 'set_default_account' : ActorMethod< [UserNumber, FrontendHostname, [] | [AccountNumber]], { 'Ok' : AccountInfo } | diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index c2732fb09e9..2c5fdf472bd 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1092,6 +1092,23 @@ type AppGetDelegationRequest = record { expiration : Timestamp; }; +type RevokeAccountSessionRequest = record { + identity_number : UserNumber; + origin : text; + account_number : opt AccountNumber; + created_at : Timestamp; +}; + +type RevokeDeviceSessionsRequest = record { + identity_number : UserNumber; + device_id : nat32; +}; + +type SessionRevokeError = variant { + Unauthorized : principal; + InternalCanisterError : text; +}; + type AppSessionError = variant { // No usable session behind this caller: revoked, expired, pruned, or never one at // all. One outcome, because which of those it is depends on whether a prune has run @@ -1972,6 +1989,12 @@ service : (opt InternetIdentityInit) -> { // session was already gone. An app can revoke only its own session. app_revoke_session : () -> (); + // Revocation from the user's own settings, authenticated by an anchor access method + // rather than by a session chain. Sessions are named by locator, never by principal, + // so these do not touch the principal index. + revoke_account_session : (RevokeAccountSessionRequest) -> (variant { Ok; Err : SessionRevokeError }); + revoke_device_sessions : (RevokeDeviceSessionsRequest) -> (variant { Ok; Err : SessionRevokeError }); + prepare_account_delegation : ( anchor_number : UserNumber, origin : FrontendHostname, diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index ad96e942853..6d9b9482b40 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -511,6 +511,16 @@ fn app_prepare_delegation( sessions::app_prepare_delegation(request) } +#[update] +fn revoke_account_session(request: RevokeAccountSessionRequest) -> Result<(), SessionRevokeError> { + sessions::revoke_account_session(request) +} + +#[update] +fn revoke_device_sessions(request: RevokeDeviceSessionsRequest) -> Result<(), SessionRevokeError> { + sessions::revoke_device_sessions(request) +} + #[update] fn app_revoke_session() { sessions::app_revoke_session() diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 3db5cd3f9f9..4d5e0d55e2a 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -25,7 +25,8 @@ use internet_identity_interface::internet_identity::types::{ AccountNumber, AccountSessionError, AnchorNumber, AppGetDelegationRequest, AppPrepareDelegationRequest, AppPrepareDelegationResponse, AppSessionError, ApplicationNumber, Delegation, FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, - PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp, + PrepareAccountSessionRequest, PrepareAccountSessionResponse, RevokeAccountSessionRequest, + RevokeDeviceSessionsRequest, SessionRevokeError, SignedDelegation, Timestamp, }; use serde_bytes::ByteBuf; @@ -458,3 +459,35 @@ fn account_seed(account: &Account) -> Result { })?; Ok(account.calculate_seed_with_salt(&salt)) } + +pub fn revoke_account_session( + request: RevokeAccountSessionRequest, +) -> Result<(), SessionRevokeError> { + check_authorization(request.identity_number) + .map_err(|err| SessionRevokeError::Unauthorized(err.principal))?; + check_frontend_length(&request.origin); + + storage_borrow_mut(|storage| { + storage.revoke_account_sessions( + request.identity_number, + &request.origin, + request.account_number, + request.created_at, + ) + }) + .map(|_| ()) + .map_err(|err| SessionRevokeError::InternalCanisterError(err.to_string())) +} + +pub fn revoke_device_sessions( + request: RevokeDeviceSessionsRequest, +) -> Result<(), SessionRevokeError> { + check_authorization(request.identity_number) + .map_err(|err| SessionRevokeError::Unauthorized(err.principal))?; + + storage_borrow_mut(|storage| { + storage.revoke_device_sessions(request.identity_number, request.device_id) + }) + .map(|_| ()) + .map_err(|err| SessionRevokeError::InternalCanisterError(err.to_string())) +} diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 80c3b782dbe..82a981058c7 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1957,6 +1957,51 @@ impl Storage { Ok(count) } + /// Removes the sessions an anchor names by locator and creation time. Two browsers + /// signing in during the same round share a `created_at`, so this can match both. + pub fn revoke_account_sessions( + &mut self, + anchor_number: AnchorNumber, + origin: &FrontendHostname, + account_number: Option, + created_at: Timestamp, + ) -> Result { + let Some(application_number) = self.lookup_application_number_with_origin(origin) else { + return Ok(0); + }; + let Some(references) = self.lookup_account_references(anchor_number, application_number) + else { + return Ok(0); + }; + let mut references: Vec = + references.into_iter().map(Into::into).collect(); + + let Some(reference) = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + else { + return Ok(0); + }; + + let dropped: Vec = reference + .sessions + .iter() + .filter(|session| session.created_at == created_at) + .cloned() + .collect(); + if dropped.is_empty() { + return Ok(0); + } + reference + .sessions + .retain(|session| session.created_at != created_at); + + self.write_reference_list(anchor_number, application_number, references)?; + self.unindex_sessions(anchor_number, application_number, account_number, &dropped); + self.change_session_count(anchor_number, dropped.len(), 0)?; + Ok(dropped.len() as u64) + } + /// Removes one session. Returns whether anything was removed. pub fn remove_session( &mut self, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index cea4791e25d..f7dd411f8c2 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4984,3 +4984,142 @@ mod session_removal_tests { .is_some()); } } + +mod session_revocation_tests { + use crate::storage::account::AccountReference; + use crate::storage::CreateSessionParams; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + fn storage_with_anchor() -> (Storage, AnchorNumber) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + fn create( + storage: &mut Storage, + anchor_number: AnchorNumber, + origin: &str, + device_id: u32, + now: u64, + ) { + storage + .create_session(CreateSessionParams { + anchor_number, + origin: origin.to_string(), + account_number: None, + device_id, + valid_till: u64::MAX, + read_only: false, + now, + }) + .unwrap(); + } + + fn device_ids( + storage: &Storage, + anchor_number: AnchorNumber, + origin: &str, + ) -> Vec { + let application_number = storage + .lookup_application_number_with_origin(&origin.to_string()) + .unwrap(); + storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .into_iter() + .map(AccountReference::from) + .find(|reference| reference.account_number.is_none()) + .unwrap() + .sessions + .into_iter() + .map(|session| session.device_id) + .collect() + } + + #[test] + fn signing_a_browser_out_sweeps_every_application() { + let (mut storage, anchor_number) = storage_with_anchor(); + create(&mut storage, anchor_number, "https://a.com", 1, 1_000); + create(&mut storage, anchor_number, "https://b.com", 1, 1_000); + create(&mut storage, anchor_number, "https://a.com", 2, 1_000); + + let removed = storage.revoke_device_sessions(anchor_number, 1).unwrap(); + + assert_eq!(removed, 2); + assert_eq!( + device_ids(&storage, anchor_number, "https://a.com"), + vec![2] + ); + assert_eq!( + device_ids(&storage, anchor_number, "https://b.com"), + Vec::::new() + ); + } + + #[test] + fn signing_a_browser_out_leaves_another_anchor_alone() { + let (mut storage, anchor_number) = storage_with_anchor(); + let other = storage.allocate_anchor(0).unwrap(); + let other_anchor_number = other.anchor_number(); + storage.write(other).unwrap(); + create(&mut storage, anchor_number, "https://a.com", 1, 1_000); + create(&mut storage, other_anchor_number, "https://a.com", 1, 1_000); + + storage.revoke_device_sessions(anchor_number, 1).unwrap(); + + assert_eq!( + device_ids(&storage, other_anchor_number, "https://a.com"), + vec![1] + ); + } + + #[test] + fn signing_out_a_browser_with_nothing_to_revoke_writes_nothing() { + let (mut storage, anchor_number) = storage_with_anchor(); + create(&mut storage, anchor_number, "https://a.com", 1, 1_000); + + let removed = storage.revoke_device_sessions(anchor_number, 9).unwrap(); + + assert_eq!(removed, 0); + assert_eq!( + device_ids(&storage, anchor_number, "https://a.com"), + vec![1] + ); + } + + #[test] + fn revoking_by_creation_time_covers_same_round_siblings() { + let (mut storage, anchor_number) = storage_with_anchor(); + create(&mut storage, anchor_number, "https://a.com", 1, 1_000); + create(&mut storage, anchor_number, "https://a.com", 2, 1_000); + create(&mut storage, anchor_number, "https://a.com", 3, 2_000); + + let removed = storage + .revoke_account_sessions(anchor_number, &"https://a.com".to_string(), None, 1_000) + .unwrap(); + + assert_eq!(removed, 2); + assert_eq!( + device_ids(&storage, anchor_number, "https://a.com"), + vec![3] + ); + } + + #[test] + fn revoking_at_an_unknown_origin_is_a_no_op() { + let (mut storage, anchor_number) = storage_with_anchor(); + + let removed = storage + .revoke_account_sessions(anchor_number, &"https://nope.com".to_string(), None, 1_000) + .unwrap(); + + assert_eq!(removed, 0); + } +} diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 0636f0b14d3..81fbea58741 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -3,7 +3,7 @@ use candid::Principal; use canister_tests::api::internet_identity::api_v2::{ app_get_delegation, app_prepare_delegation, app_revoke_session, get_account_session, - prepare_account_session, + prepare_account_session, revoke_account_session, revoke_device_sessions, }; use canister_tests::flows; use canister_tests::framework::{ @@ -12,7 +12,8 @@ use canister_tests::framework::{ use internet_identity_interface::internet_identity::types::{ AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, - PrepareAccountSessionResponse, SessionDeviceInfo, + PrepareAccountSessionResponse, RevokeAccountSessionRequest, RevokeDeviceSessionsRequest, + SessionDeviceInfo, SessionRevokeError, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; @@ -679,6 +680,152 @@ fn should_leave_another_browsers_session_alone() -> Result<(), RejectResponse> { Ok(()) } +#[test] +fn should_revoke_one_session_from_settings() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (prepared, session_principal) = create_session(&env, canister_id, identity_number); + + revoke_account_session( + &env, + canister_id, + principal_1(), + RevokeAccountSessionRequest { + identity_number, + origin: ORIGIN.to_string(), + account_number: None, + created_at: prepared.created_at, + }, + )? + .unwrap(); + + let refreshed = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )?; + assert_eq!(refreshed, Err(AppSessionError::NoMatchingSession)); + + Ok(()) +} + +#[test] +fn should_refuse_revocation_by_another_anchor() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (prepared, _) = create_session(&env, canister_id, identity_number); + + let result = revoke_account_session( + &env, + canister_id, + Principal::anonymous(), + RevokeAccountSessionRequest { + identity_number, + origin: ORIGIN.to_string(), + account_number: None, + created_at: prepared.created_at, + }, + )?; + + assert!(matches!(result, Err(SessionRevokeError::Unauthorized(_)))); + + Ok(()) +} + +#[test] +fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let (_, first_principal) = create_session(&env, canister_id, identity_number); + let mut other_app = session_request(identity_number); + other_app.origin = "https://another-dapp.com".to_string(); + let second_app = prepare_account_session(&env, canister_id, principal_1(), other_app)?.unwrap(); + let second_principal = Principal::self_authenticating(&second_app.user_key); + + let mut other_browser = session_request_from(identity_number, &BrowserKey::new(2)); + other_browser.device_name = "Firefox on Linux".to_string(); + let untouched = + prepare_account_session(&env, canister_id, principal_1(), other_browser)?.unwrap(); + let untouched_principal = Principal::self_authenticating(&untouched.user_key); + + // Settings names a browser by the id `identity_info` reports, never by its key. + let device_id = identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices + .unwrap() + .into_iter() + .find(|device| device.name == "Chrome on MacBook") + .expect("the browser that signed in should be listed") + .id; + + revoke_device_sessions( + &env, + canister_id, + principal_1(), + RevokeDeviceSessionsRequest { + identity_number, + device_id, + }, + )? + .unwrap(); + + let refresh = |principal: Principal| { + app_prepare_delegation( + &env, + canister_id, + principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + ) + .unwrap() + }; + + assert_eq!( + refresh(first_principal), + Err(AppSessionError::NoMatchingSession) + ); + assert_eq!( + refresh(second_principal), + Err(AppSessionError::NoMatchingSession) + ); + assert!(refresh(untouched_principal).is_ok()); + + let devices = identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .session_devices + .unwrap(); + assert!(devices.iter().any(|device| device.id == device_id)); + + // The browser keeps its id, so signing in again puts a session back in the slot the + // revoked one occupied. The revoked chain must not reach it. + env.advance_time(Duration::from_secs(60)); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + + assert_eq!( + refresh(first_principal), + Err(AppSessionError::NoMatchingSession), + "a revoked session came back when its browser signed in again" + ); + + Ok(()) +} + /// Naming a default account keeps its principal, so it must keep its sessions. Before the /// session seed was built on the account seed, naming it signed the user out of every app /// using that account. diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 654d7de6c27..3dfacdee374 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -845,3 +845,25 @@ pub enum AppSessionError { NoMatchingSession, InternalCanisterError(String), } + +/// Revokes one session of an anchor, named by where it was created. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct RevokeAccountSessionRequest { + pub identity_number: IdentityNumber, + pub origin: FrontendHostname, + pub account_number: Option, + pub created_at: Timestamp, +} + +/// Signs one browser out of every app it is signed into. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct RevokeDeviceSessionsRequest { + pub identity_number: IdentityNumber, + pub device_id: SessionDeviceId, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum SessionRevokeError { + Unauthorized(Principal), + InternalCanisterError(String), +} From 12b3718cf857ea4d7bd520a1932220ee741ff0ec Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 03:38:03 +0200 Subject: [PATCH 6/6] feat(fe): hand apps a session to re-issue their own delegations from `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) --- .../src/lib/stores/app-session.store.test.ts | 134 ++++++++ .../src/lib/stores/app-session.store.ts | 152 +++++++++ .../src/lib/stores/browser-key.store.test.ts | 225 ++++++++++++++ .../src/lib/stores/browser-key.store.ts | 160 ++++++++++ .../lib/stores/channelHandlers/delegation.ts | 14 +- .../channelHandlers/describeBrowser.test.ts | 178 +++++++++++ .../stores/channelHandlers/describeBrowser.ts | 82 +++++ .../src/lib/stores/channelHandlers/icrc25.ts | 5 +- .../lib/stores/channelHandlers/serialize.ts | 17 + .../channelHandlers/sessionDelegation.test.ts | 222 ++++++++++++++ .../channelHandlers/sessionDelegation.ts | 290 ++++++++++++++++++ src/frontend/src/lib/stores/channelStore.ts | 5 + .../src/routes/(new-styling)/+page.svelte | 2 + .../authorize/views/ContinueView.svelte | 4 + .../routes/(new-styling)/cli/+layout.svelte | 2 + .../manage/(authenticated)/+layout.svelte | 3 + .../(new-styling)/recovery/+page.svelte | 2 + 17 files changed, 1484 insertions(+), 13 deletions(-) create mode 100644 src/frontend/src/lib/stores/app-session.store.test.ts create mode 100644 src/frontend/src/lib/stores/app-session.store.ts create mode 100644 src/frontend/src/lib/stores/browser-key.store.test.ts create mode 100644 src/frontend/src/lib/stores/browser-key.store.ts create mode 100644 src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts create mode 100644 src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts create mode 100644 src/frontend/src/lib/stores/channelHandlers/serialize.ts create mode 100644 src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts create mode 100644 src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts diff --git a/src/frontend/src/lib/stores/app-session.store.test.ts b/src/frontend/src/lib/stores/app-session.store.test.ts new file mode 100644 index 00000000000..50d53696cdd --- /dev/null +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { + appSessionFor, + appSessionsForOrigin, + discardAppSession, + purgeAppSessions, + storeAppSession, + type AppSessionRecord, +} from "./app-session.store"; + +const ORIGIN = "https://app.example.com"; + +const record = (expiresAtMillis: number): AppSessionRecord => ({ + keyPair: {} as CryptoKeyPair, + chainJson: "{}", + expiresAtMillis, + createdAtNanos: BigInt(1_000), + accessLevel: "full-access" as const, + accountPrincipal: "2vxsx-fae", +}); + +const anHourFromNow = () => Date.now() + 60 * 60 * 1000; + +describe("app session store", () => { + beforeEach(async () => { + await purgeAppSessions(BigInt(10_000)); + await purgeAppSessions(BigInt(10_001)); + vi.useRealTimers(); + }); + + it("returns a stored session for the same identity, account and origin", async () => { + const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; + await storeAppSession(key, record(anHourFromNow())); + + await expect(appSessionFor(key)).resolves.toMatchObject({ + accountPrincipal: "2vxsx-fae", + }); + }); + + it("keeps accounts of one identity apart", async () => { + const identityNumber = BigInt(10_000); + await storeAppSession( + { identityNumber, origin: ORIGIN }, + record(anHourFromNow()), + ); + + await expect( + appSessionFor({ + identityNumber, + accountNumber: BigInt(3), + origin: ORIGIN, + }), + ).resolves.toBeUndefined(); + }); + + it("does not serve a session that is about to expire", async () => { + const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; + await storeAppSession(key, record(Date.now() + 60 * 1000)); + + await expect(appSessionFor(key)).resolves.toBeUndefined(); + }); + + it("discards a session", async () => { + const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; + await storeAppSession(key, record(anHourFromNow())); + + await discardAppSession(key); + + await expect(appSessionFor(key)).resolves.toBeUndefined(); + }); + + it("lists every identity holding a session at one origin", async () => { + await storeAppSession( + { identityNumber: BigInt(10_000), origin: ORIGIN }, + record(anHourFromNow()), + ); + await storeAppSession( + { + identityNumber: BigInt(10_001), + accountNumber: BigInt(7), + origin: ORIGIN, + }, + record(anHourFromNow()), + ); + await storeAppSession( + { identityNumber: BigInt(10_000), origin: "https://other.example.com" }, + record(anHourFromNow()), + ); + + const held = await appSessionsForOrigin(ORIGIN); + + expect(held).toHaveLength(2); + expect(held.map((entry) => entry.identityNumber).sort()).toEqual([ + BigInt(10_000), + BigInt(10_001), + ]); + expect( + held.find((entry) => entry.identityNumber === BigInt(10_001)) + ?.accountNumber, + ).toBe(BigInt(7)); + }); + + it("omits expiring sessions from the origin listing", async () => { + await storeAppSession( + { identityNumber: BigInt(10_000), origin: ORIGIN }, + record(Date.now() + 60 * 1000), + ); + + await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); + }); + + it("purges every session of one identity", async () => { + await storeAppSession( + { identityNumber: BigInt(10_000), origin: ORIGIN }, + record(anHourFromNow()), + ); + await storeAppSession( + { identityNumber: BigInt(10_000), origin: "https://other.example.com" }, + record(anHourFromNow()), + ); + await storeAppSession( + { identityNumber: BigInt(10_001), origin: ORIGIN }, + record(anHourFromNow()), + ); + + await purgeAppSessions(BigInt(10_000)); + + await expect(appSessionsForOrigin(ORIGIN)).resolves.toHaveLength(1); + await expect( + appSessionsForOrigin("https://other.example.com"), + ).resolves.toEqual([]); + }); +}); diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts new file mode 100644 index 00000000000..a9e8dfac1b3 --- /dev/null +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -0,0 +1,152 @@ +import type { AccessLevel } from "$lib/utils/accessLevel"; +import { + createStore, + get as idbGet, + set as idbSet, + del as idbDel, + entries as idbEntries, +} from "idb-keyval"; + +/** + * A session held for one `(identity, account, origin)`, so returning to an app, or + * arriving at a sibling of one, can re-issue without another ceremony. + * + * The keypair is non-extractable and never leaves this origin; the app receives a chain + * extended to its own key, not this one. + */ +export interface AppSessionRecord { + keyPair: CryptoKeyPair; + chainJson: string; + expiresAtMillis: number; + createdAtNanos: bigint; + /** What the user consented to when this session was created, so a later request for more + * is not answered with less. */ + accessLevel: AccessLevel; + /** The principal apps see for this account, so a hint can select between sessions. */ + accountPrincipal: string; +} + +const APP_SESSION_STORE = createStore("ii-app-sessions", "sessions"); + +// Treat the last 5 minutes as already expired, so a session is never served that +// expires between the check here and validation on the IC. +const EXPIRY_MARGIN_MS = 5 * 60 * 1000; + +/** Bytes read back from IndexedDB can arrive from another realm, where an + * `instanceof Uint8Array` check fails. */ +const normalize = (record: AppSessionRecord): AppSessionRecord => ({ + ...record, +}); + +const sessionKey = ({ + identityNumber, + accountNumber, + origin, +}: { + identityNumber: bigint; + accountNumber?: bigint; + origin: string; +}): string => + `${identityNumber.toString()}:${accountNumber?.toString() ?? "default"}:${origin}`; + +export const storeAppSession = async ( + key: { identityNumber: bigint; accountNumber?: bigint; origin: string }, + record: AppSessionRecord, +): Promise => { + await idbSet(sessionKey(key), record, APP_SESSION_STORE); +}; + +export const appSessionFor = async (key: { + identityNumber: bigint; + accountNumber?: bigint; + origin: string; +}): Promise => { + let record: AppSessionRecord | undefined; + try { + record = await idbGet(sessionKey(key), APP_SESSION_STORE); + } catch { + return undefined; + } + if (record === undefined) { + return undefined; + } + if (record.expiresAtMillis - EXPIRY_MARGIN_MS <= Date.now()) { + await discardAppSession(key); + return undefined; + } + return normalize(record); +}; + +export const discardAppSession = async (key: { + identityNumber: bigint; + accountNumber?: bigint; + origin: string; +}): Promise => { + try { + await idbDel(sessionKey(key), APP_SESSION_STORE); + } catch { + // A session that cannot be discarded locally is still revocable canister-side. + } +}; + +/** Every session this identity holds, for the sibling lookup and for sign-out. */ +export const appSessionsForOrigin = async ( + origin: string, +): Promise< + { identityNumber: bigint; accountNumber?: bigint; record: AppSessionRecord }[] +> => { + let stored: [IDBValidKey, AppSessionRecord][]; + try { + stored = await idbEntries(APP_SESSION_STORE); + } catch { + return []; + } + + const now = Date.now(); + return stored.flatMap(([key, record]) => { + if (typeof key !== "string") { + return []; + } + const separator = key.indexOf(":"); + const accountSeparator = key.indexOf(":", separator + 1); + if (separator === -1 || accountSeparator === -1) { + return []; + } + if (key.slice(accountSeparator + 1) !== origin) { + return []; + } + if (record.expiresAtMillis - EXPIRY_MARGIN_MS <= now) { + return []; + } + const accountPart = key.slice(separator + 1, accountSeparator); + return [ + { + identityNumber: BigInt(key.slice(0, separator)), + accountNumber: + accountPart === "default" ? undefined : BigInt(accountPart), + record: normalize(record), + }, + ]; + }); +}; + +export const purgeAppSessions = async ( + identityNumber: bigint, +): Promise => { + let stored: [IDBValidKey, AppSessionRecord][]; + try { + stored = await idbEntries(APP_SESSION_STORE); + } catch { + return; + } + const prefix = `${identityNumber.toString()}:`; + await Promise.all( + stored + .map(([key]) => key) + .filter( + (key): key is string => + typeof key === "string" && key.startsWith(prefix), + ) + .map((key) => idbDel(key, APP_SESSION_STORE).catch(() => {})), + ); +}; diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts new file mode 100644 index 00000000000..6ebcd356a61 --- /dev/null +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -0,0 +1,225 @@ +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { clear, createStore } from "idb-keyval"; +import { currentDeviceId, withBrowserProof } from "./browser-key.store"; + +/// Names the same store the module under test writes to, so a test can wipe it. +const BROWSER_KEY_STORE = createStore("ii-browser-keys", "keys"); + +const SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-device-key"); +const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( + "ii-session-device-successor", +); + +const signedMessage = ( + domain: Uint8Array, + sessionKey: Uint8Array, + otherKey: Uint8Array, +): Uint8Array => { + const message = new Uint8Array( + domain.length + sessionKey.length + otherKey.length, + ); + message.set(domain); + message.set(sessionKey, domain.length); + message.set(otherKey, domain.length + sessionKey.length); + return message; +}; + +const verify = async ( + publicKey: Uint8Array, + signature: Uint8Array, + message: Uint8Array, +): Promise => { + const key = await crypto.subtle.importKey( + "spki", + new Uint8Array(publicKey), + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + return crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + new Uint8Array(signature), + new Uint8Array(message), + ); +}; + +const sessionKey = (seed: number) => new Uint8Array(62).fill(seed); + +const IDENTITY = BigInt(10_000); + +/** Signs in and rotates, the way a successful ceremony does. */ +const signIn = (identityNumber: bigint, seed: number, deviceId = 1) => + withBrowserProof(identityNumber, sessionKey(seed), async (proof) => { + await proof.accept(deviceId); + return proof; + }); + +/** Signs in without accepting, the way a call that fails or never returns leaves it. */ +const attempt = (identityNumber: bigint, seed: number) => + withBrowserProof(identityNumber, sessionKey(seed), (proof) => + Promise.resolve(proof), + ); + +/** jsdom has no Web Locks, so this is what serialisation is tested against. */ +const stubLockApi = (): void => { + let tail: Promise = Promise.resolve(); + Object.defineProperty(navigator, "locks", { + configurable: true, + value: { + request: (_name: string, run: () => Promise) => { + const next = tail.then(run); + tail = next.then( + () => undefined, + () => undefined, + ); + return next; + }, + }, + }); +}; + +const withoutLockApi = (): void => { + Object.defineProperty(navigator, "locks", { + configurable: true, + value: undefined, + }); +}; + +describe("browser key", () => { + beforeEach(async () => { + await clear(BROWSER_KEY_STORE); + withoutLockApi(); + }); + + it("signs the session key and the successor under the domain the canister verifies", async () => { + const key = sessionKey(1); + + const proof = await attempt(IDENTITY, 1); + + await expect( + verify( + proof.publicKey, + proof.signature, + signedMessage(SIGNATURE_DOMAIN, key, proof.nextPublicKey), + ), + ).resolves.toBe(true); + }); + + it("does not sign the session key alone", async () => { + const proof = await attempt(IDENTITY, 1); + + await expect( + verify(proof.publicKey, proof.signature, sessionKey(1)), + ).resolves.toBe(false); + }); + + it("announces a successor it does not yet use", async () => { + const proof = await attempt(IDENTITY, 1); + + expect(proof.nextPublicKey).not.toEqual(proof.publicKey); + }); + + it("rotates to the successor once a sign-in is accepted", async () => { + const first = await signIn(IDENTITY, 1); + + const second = await attempt(IDENTITY, 2); + + expect(second.publicKey).toEqual(first.nextPublicKey); + }); + + it("keeps the current key when a sign-in is not accepted", async () => { + const first = await attempt(IDENTITY, 1); + + const second = await attempt(IDENTITY, 2); + + expect(second.publicKey).toEqual(first.publicKey); + expect(second.nextPublicKey).not.toEqual(first.nextPublicKey); + }); + + it("holds a separate key per identity", async () => { + const first = await attempt(IDENTITY, 1); + + const second = await attempt(BigInt(10_001), 1); + + expect(second.publicKey).not.toEqual(first.publicKey); + }); + + it("registers a fresh key once storage is cleared", async () => { + const before = await signIn(IDENTITY, 1); + await clear(BROWSER_KEY_STORE); + + const after = await attempt(IDENTITY, 1); + + expect(after.publicKey).not.toEqual(before.publicKey); + expect(after.publicKey).not.toEqual(before.nextPublicKey); + }); + + it("serialises concurrent sign-ins, so the second builds on the first", async () => { + stubLockApi(); + + const [first, second] = await Promise.all([ + signIn(IDENTITY, 1), + signIn(IDENTITY, 2), + ]); + + expect(second.publicKey).toEqual(first.nextPublicKey); + }); + + it("still signs in on a browser without the lock API", async () => { + const proof = await attempt(IDENTITY, 1); + + expect(proof.publicKey.length).toBe(91); + }); + + it("exports the keys in the encoding the canister parses", async () => { + const proof = await attempt(IDENTITY, 1); + + expect(proof.publicKey.length).toBe(91); + expect(proof.nextPublicKey.length).toBe(91); + expect(proof.signature.length).toBe(64); + }); + + it("remembers which browser the canister said this is", async () => { + await signIn(IDENTITY, 1, 7); + + await expect(currentDeviceId(IDENTITY)).resolves.toBe(7); + }); + + it("knows of no browser before a sign-in is accepted", async () => { + await attempt(IDENTITY, 1); + + await expect(currentDeviceId(IDENTITY)).resolves.toBeUndefined(); + }); + + it("has the successor sign for itself, so an unheld key cannot be announced", async () => { + const key = sessionKey(1); + + const proof = await attempt(IDENTITY, 1); + + await expect( + verify( + proof.nextPublicKey, + proof.nextSignature, + signedMessage(SUCCESSOR_SIGNATURE_DOMAIN, key, proof.publicKey), + ), + ).resolves.toBe(true); + }); + + it("keeps the two signatures in their own roles", async () => { + const key = sessionKey(1); + + const proof = await attempt(IDENTITY, 1); + + // The successor's signature must not verify as the current key's, or one could be + // replayed as the other. + await expect( + verify( + proof.publicKey, + proof.nextSignature, + signedMessage(SIGNATURE_DOMAIN, key, proof.nextPublicKey), + ), + ).resolves.toBe(false); + }); +}); diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts new file mode 100644 index 00000000000..abc2018fab2 --- /dev/null +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -0,0 +1,160 @@ +import { createStore, get as idbGet, set as idbSet } from "idb-keyval"; + +/** + * The key this browser proves itself with when it creates a session, and the id the + * canister attributed it to. + * + * The key never leaves this origin: it appears in no delegation chain and in nothing an app + * receives, which is what lets it identify the browser without letting two apps recognise + * it. It is replaced at every sign-in, so a copy of it taken off disk stops working as soon + * as this browser signs in again. + */ +interface BrowserKeyRecord { + keyPair: CryptoKeyPair; + /** Absent until a sign-in has told us which browser we are. */ + deviceId?: number; +} + +const BROWSER_KEY_STORE = createStore("ii-browser-keys", "keys"); + +/** Must match the domains the canister verifies the two signatures under. */ +const SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-device-key"); +const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( + "ii-session-device-successor", +); + +/** + * One key per identity, so nothing stored here links two of the user's identities to the + * same browser. + */ +const storageKey = (identityNumber: bigint): string => + identityNumber.toString(); + +const generate = (): Promise => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, [ + "sign", + "verify", + ]) as Promise; + +const read = async ( + identityNumber: bigint, +): Promise => { + try { + return await idbGet( + storageKey(identityNumber), + BROWSER_KEY_STORE, + ); + } catch { + return undefined; + } +}; + +const write = async ( + identityNumber: bigint, + record: BrowserKeyRecord, +): Promise => { + try { + await idbSet(storageKey(identityNumber), record, BROWSER_KEY_STORE); + } catch { + // A browser that cannot keep its key signs in as a new one next time, which the + // identity sees as a new browser rather than as a failure. + } +}; + +const exported = (key: CryptoKey): Promise => + crypto.subtle.exportKey("spki", key).then((spki) => new Uint8Array(spki)); + +const signed = async ( + key: CryptoKey, + domain: Uint8Array, + sessionKey: Uint8Array, + otherKey: Uint8Array, +): Promise => { + const message = new Uint8Array( + domain.length + sessionKey.length + otherKey.length, + ); + message.set(domain); + message.set(sessionKey, domain.length); + message.set(otherKey, domain.length + sessionKey.length); + return new Uint8Array( + await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, message), + ); +}; + +export interface BrowserProof { + publicKey: Uint8Array; + nextPublicKey: Uint8Array; + signature: Uint8Array; + /** By the successor itself, so a key the browser does not hold cannot be announced. */ + nextSignature: Uint8Array; + /** Rotates to the successor. Called once the canister has accepted the sign-in. */ + accept: (deviceId: number) => Promise; +} + +/** Serialises sign-ins for one identity: two at once would leave us holding a key the + * canister never accepted, which reads as a different browser. */ +const exclusively = async ( + identityNumber: bigint, + run: () => Promise, +): Promise => { + const locks = navigator.locks; + if (locks === undefined) { + return run(); + } + // Awaited, because `request` types its callback's return as the value it resolves to, + // so the promise `run` returns would otherwise nest. + return await locks.request(`ii-browser-key:${identityNumber}`, run); +}; + +/** + * Proves possession of this browser's key and announces the successor it rotates to. + * + * The proof covers the session key, which is fresh for every session, so it is good for + * exactly one sign-in. `accept` is what advances this browser to the successor, and until + * it is called the current key stays in place — so a call that never comes back leaves both + * sides on the key the canister still holds. + */ +export const withBrowserProof = ( + identityNumber: bigint, + sessionKey: Uint8Array, + signIn: (proof: BrowserProof) => Promise, +): Promise => + exclusively(identityNumber, async () => { + const stored = await read(identityNumber); + let keyPair = stored?.keyPair; + if (keyPair === undefined) { + // Kept before the call, not after: a first sign-in whose response is lost has still + // registered this key, and coming back with a different one would enrol us twice. + keyPair = await generate(); + await write(identityNumber, { keyPair }); + } + const successor = await generate(); + const [publicKey, nextPublicKey] = await Promise.all([ + exported(keyPair.publicKey), + exported(successor.publicKey), + ]); + + const [signature, nextSignature] = await Promise.all([ + signed(keyPair.privateKey, SIGNATURE_DOMAIN, sessionKey, nextPublicKey), + signed( + successor.privateKey, + SUCCESSOR_SIGNATURE_DOMAIN, + sessionKey, + publicKey, + ), + ]); + + return signIn({ + publicKey, + nextPublicKey, + signature, + nextSignature, + accept: (deviceId) => + write(identityNumber, { keyPair: successor, deviceId }), + }); + }); + +/** Which browser the canister knows this one as, for the settings list to mark it. */ +export const currentDeviceId = async ( + identityNumber: bigint, +): Promise => (await read(identityNumber))?.deviceId; diff --git a/src/frontend/src/lib/stores/channelHandlers/delegation.ts b/src/frontend/src/lib/stores/channelHandlers/delegation.ts index e6c8f78275b..bb555bb26ee 100644 --- a/src/frontend/src/lib/stores/channelHandlers/delegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/delegation.ts @@ -26,17 +26,7 @@ import { attributeConsentStore, } from "$lib/stores/attributeConsent.store"; import { get } from "svelte/store"; - -/** Serialize delegation requests so a malicious dapp sending several in - * parallel can't race the authorization state (effective origin, auth - * flow, authorized account) against itself. */ -let delegationQueueTail: Promise = Promise.resolve(); -const serializeDelegationRequest = (fn: () => Promise): Promise => { - const prev = delegationQueueTail; - const next = prev.then(fn); - delegationQueueTail = next.catch(() => {}); - return next; -}; +import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; /** * ICRC-34: handle a delegation request from the relying party. @@ -66,7 +56,7 @@ export const handleDelegationRequest = return; } - await serializeDelegationRequest(async () => { + await serializeAuthorizationRequest(async () => { try { const params = result.data; diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts new file mode 100644 index 00000000000..160a89a1d56 --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { browserLabel, describeBrowser } from "./describeBrowser"; + +const CHROME_ANDROID = + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36"; +const FIREFOX_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0"; +const IPAD_DESKTOP_MODE = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15"; + +const AGENTS: [string, string, number][] = [ + [ + "Chrome on iPhone", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/125.0.6422.80 Mobile/15E148 Safari/604.1", + 5, + ], + [ + "Firefox on iPhone", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/126.1 Mobile/15E148 Safari/605.1.15", + 5, + ], + [ + "Edge on iPhone", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 EdgiOS/125.2535.60 Mobile/15E148 Safari/605.1.15", + 5, + ], + [ + "Opera on iPhone", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPT/4.4.0 Mobile/15E148 Safari/604.1", + 5, + ], + [ + "Safari on iPhone", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + 5, + ], + [ + "Safari on iPad", + "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + 5, + ], + ["Safari on iPad", IPAD_DESKTOP_MODE, 5], + ["Safari on Mac", IPAD_DESKTOP_MODE, 0], + ["Firefox on Mac", FIREFOX_MAC, 0], + ["Chrome on Android", CHROME_ANDROID, 5], + [ + "Firefox on Android", + "Mozilla/5.0 (Android 14; Mobile; rv:126.0) Gecko/126.0 Firefox/126.0", + 5, + ], + [ + "Samsung Internet on Android", + "Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36", + 5, + ], + [ + "Edge on Android", + "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36 EdgA/125.0.2535.51", + 5, + ], + [ + "DuckDuckGo on Android", + "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/125.0.0.0 Mobile DuckDuckGo/5 Safari/537.36", + 5, + ], + [ + "Edge on Windows", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51", + 0, + ], + [ + "Opera on Windows", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0", + 0, + ], + [ + "Chrome on Windows", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + 0, + ], + [ + "Vivaldi on Linux", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Vivaldi/6.7.3329.41", + 0, + ], + [ + "Chrome on Chromebook", + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + 0, + ], + ["Browser on an unknown device", "curl/8.4.0", 0], +]; + +const stub = (props: Record): void => { + for (const [name, value] of Object.entries(props)) { + Object.defineProperty(navigator, name, { value, configurable: true }); + } +}; + +describe("browserLabel", () => { + it.each(AGENTS)("reads %s", (expected, agent, touchPoints) => { + expect(browserLabel({ agent, touchPoints })).toBe(expected); + }); + + it("names the device itself when the platform reports a model", () => { + expect( + browserLabel({ + agent: CHROME_ANDROID, + touchPoints: 5, + model: "SM-S918B", + }), + ).toBe("Chrome on SM-S918B"); + }); + + it("leaves the label alone when the model is empty", () => { + expect( + browserLabel({ agent: CHROME_ANDROID, touchPoints: 5, model: "" }), + ).toBe("Chrome on Android"); + }); + + it("drops a model that would push the name past the canister's limit", () => { + expect( + browserLabel({ + agent: CHROME_ANDROID, + touchPoints: 5, + model: "M".repeat(200), + }), + ).toBe("Chrome on Android"); + }); +}); + +describe("describeBrowser", () => { + afterEach(() => { + stub({ userAgentData: undefined }); + }); + + it("appends the model the platform reports", async () => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + getHighEntropyValues: () => Promise.resolve({ model: "Pixel 5" }), + }, + }); + + await expect(describeBrowser()).resolves.toBe("Chrome on Pixel 5"); + }); + + it("falls back to the platform when no model is available", async () => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + getHighEntropyValues: () => Promise.resolve({ model: "" }), + }, + }); + + await expect(describeBrowser()).resolves.toBe("Chrome on Android"); + }); + + it("falls back when the platform refuses the question", async () => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + getHighEntropyValues: () => Promise.reject(new Error("not allowed")), + }, + }); + + await expect(describeBrowser()).resolves.toBe("Chrome on Android"); + }); + + it("falls back on a browser without the API", async () => { + stub({ userAgent: FIREFOX_MAC, maxTouchPoints: 0 }); + + await expect(describeBrowser()).resolves.toBe("Firefox on Mac"); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts new file mode 100644 index 00000000000..20711c421dd --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts @@ -0,0 +1,82 @@ +/** + * The label a browser gives itself when it registers a session device. + * + * Self-reported, so it is something the user reads rather than evidence about where a + * session came from. + */ + +/** Ordered most specific first: every later token also appears in the earlier ones' agents. */ +const BROWSERS: [RegExp, string][] = [ + [/CriOS\//, "Chrome"], + [/FxiOS\//, "Firefox"], + [/EdgiOS\//, "Edge"], + [/OPiOS\/|OPT\//, "Opera"], + [/Firefox\//, "Firefox"], + [/EdgA\/|Edg\//, "Edge"], + [/OPR\//, "Opera"], + [/SamsungBrowser\//, "Samsung Internet"], + [/Vivaldi\//, "Vivaldi"], + [/DuckDuckGo\//, "DuckDuckGo"], + [/Chrome\//, "Chrome"], + [/Safari\//, "Safari"], +]; + +const MAX_DEVICE_NAME_BYTES = 128; + +const browserOf = (agent: string): string => + BROWSERS.find(([token]) => token.test(agent))?.[1] ?? "Browser"; + +/** Names the device where a device word exists, since that is what its owner calls it. */ +const platformOf = (agent: string, touchPoints: number): string => { + if (/CrOS/.test(agent)) return "Chromebook"; + if (/Android/.test(agent)) return "Android"; + if (/iPhone|iPod/.test(agent)) return "iPhone"; + if (/iPad/.test(agent)) return "iPad"; + // An iPad in desktop mode sends a Mac agent. A Mac reports no touch points. + if (/Macintosh|Mac OS X/.test(agent)) return touchPoints > 0 ? "iPad" : "Mac"; + if (/Windows/.test(agent)) return "Windows"; + if (/Linux|X11/.test(agent)) return "Linux"; + return "an unknown device"; +}; + +const withinLimit = (label: string): boolean => + new TextEncoder().encode(label).length <= MAX_DEVICE_NAME_BYTES; + +export const browserLabel = ({ + agent, + touchPoints, + model, +}: { + agent: string; + touchPoints: number; + model?: string; +}): string => { + const browser = browserOf(agent); + const named = `${browser} on ${model}`; + return model !== undefined && model !== "" && withinLimit(named) + ? named + : `${browser} on ${platformOf(agent, touchPoints)}`; +}; + +/** Populated on Android, and the only thing that names the device itself. */ +const modelOf = async (): Promise => { + const userAgentData = ( + navigator as Navigator & { + userAgentData?: { + getHighEntropyValues?: (hints: string[]) => Promise<{ model?: string }>; + }; + } + ).userAgentData; + try { + return (await userAgentData?.getHighEntropyValues?.(["model"]))?.model; + } catch { + return undefined; + } +}; + +export const describeBrowser = async (): Promise => + browserLabel({ + agent: navigator.userAgent, + touchPoints: navigator.maxTouchPoints, + model: await modelOf(), + }); diff --git a/src/frontend/src/lib/stores/channelHandlers/icrc25.ts b/src/frontend/src/lib/stores/channelHandlers/icrc25.ts index 61314a54bc2..ce50bb8c75c 100644 --- a/src/frontend/src/lib/stores/channelHandlers/icrc25.ts +++ b/src/frontend/src/lib/stores/channelHandlers/icrc25.ts @@ -23,7 +23,10 @@ const supportedStandards = [ }, ]; -const scopes = [{ method: "icrc34_delegation" }]; +const scopes = [ + { method: "icrc34_delegation" }, + { method: "ii_session_delegation" }, +]; /** ICRC-25: respond with the list of supported standards. */ export const handleSupportedStandards = diff --git a/src/frontend/src/lib/stores/channelHandlers/serialize.ts b/src/frontend/src/lib/stores/channelHandlers/serialize.ts new file mode 100644 index 00000000000..847695a682f --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/serialize.ts @@ -0,0 +1,17 @@ +/** + * Runs authorization-bearing requests one at a time. + * + * Several handlers drive the same authorization state — the effective origin, the auth + * flow, the authorized account — so a dapp sending requests in parallel could otherwise + * race them against each other and have the user approve a screen naming one origin + * while another is answered. + */ +let queueTail: Promise = Promise.resolve(); + +export const serializeAuthorizationRequest = ( + run: () => Promise, +): Promise => { + const next = queueTail.then(run); + queueTail = next.catch(() => {}); + return next; +}; diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts new file mode 100644 index 00000000000..299efae1a2e --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity"; + +const CANISTER_ID_TEXT = "rwlgt-iiaaa-aaaaa-aaaaa-cai"; +const ORIGIN = "https://app.example.com"; + +vi.mock("$lib/globals", async () => { + const { Principal } = await import("@icp-sdk/core/principal"); + return { + canisterId: Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai"), + backendCanisterConfig: { openid_configs: [] }, + frontendCanisterConfig: { related_origins: [], dev_csp: [] }, + }; +}); +vi.mock("$lib/utils/validateDerivationOrigin", () => ({ + validateDerivationOrigin: vi.fn(() => Promise.resolve({ result: "valid" })), +})); +vi.mock("$lib/utils/iiConnection", () => ({ + remapToLegacyDomain: (origin: string) => origin, +})); + +const setRequestContext = vi.fn(); +vi.mock("$lib/stores/authorization.store", () => ({ + authorizationStore: { + setRequestContext: (...args: unknown[]) => setRequestContext(...args), + }, + authorizedStore: { subscribe: () => () => {} }, +})); + +import { handleSessionDelegationRequest } from "./sessionDelegation"; +import { + purgeAppSessions, + storeAppSession, +} from "$lib/stores/app-session.store"; + +const channelWith = () => { + const sent: unknown[] = []; + return { + channel: { + origin: ORIGIN, + closed: false, + resumeToken: "token", + addEventListener: () => () => {}, + send: (response: unknown) => { + sent.push(response); + return Promise.resolve(); + }, + close: async () => {}, + }, + sent, + }; +}; + +const storedSession = async (identityNumber: bigint) => { + const key = await ECDSAKeyIdentity.generate({ extractable: false }); + const root = await ECDSAKeyIdentity.generate({ extractable: true }); + const chain = await DelegationChain.create( + root, + key.getPublicKey(), + new Date(Date.now() + 60 * 60 * 1000), + ); + await storeAppSession( + { identityNumber, origin: ORIGIN }, + { + keyPair: key.getKeyPair(), + chainJson: JSON.stringify(chain.toJSON()), + expiresAtMillis: Date.now() + 60 * 60 * 1000, + createdAtNanos: BigInt(1_000), + accessLevel: "full-access" as const, + accountPrincipal: "2vxsx-fae", + }, + ); +}; + +const appKey = async () => { + const identity = await ECDSAKeyIdentity.generate({ extractable: true }); + const der = new Uint8Array(identity.getPublicKey().toDer()); + return btoa(String.fromCharCode(...der)); +}; + +describe("ii_session_delegation", () => { + beforeEach(async () => { + setRequestContext.mockClear(); + await purgeAppSessions(BigInt(10_000)); + await purgeAppSessions(BigInt(10_001)); + }); + + it("ignores a request for another method", async () => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "icrc34_delegation", + }); + + expect(sent).toEqual([]); + expect(onError).not.toHaveBeenCalled(); + }); + + it("rejects params that carry no session key", async () => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: {}, + }); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); + expect(onError).toHaveBeenCalledWith("invalid-request"); + }); + + it("re-issues from a held session without a ceremony", async () => { + await storedSession(BigInt(10_000)); + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(onError).not.toHaveBeenCalled(); + expect(setRequestContext).not.toHaveBeenCalled(); + expect(sent).toHaveLength(1); + const result = (sent[0] as { result: Record }).result; + // The chain is the whole answer: nothing else travels with it, and nothing has to be + // attached to the calls the app makes with it. + expect(Object.keys(result).sort()).toEqual([ + "publicKey", + "signerDelegation", + ]); + }); + + it("restricts the session chain to the II canister", async () => { + await storedSession(BigInt(10_000)); + const { channel, sent } = channelWith(); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + const result = ( + sent[0] as { + result: { signerDelegation: { delegation: { targets?: string[] } }[] }; + } + ).result; + const targets = result.signerDelegation + .map((signed) => signed.delegation.targets) + .filter((value): value is string[] => value !== undefined); + expect(targets).toEqual([[CANISTER_ID_TEXT]]); + }); + + it("asks for a ceremony when more than one identity holds a session", async () => { + await storedSession(BigInt(10_000)); + await storedSession(BigInt(10_001)); + const { channel } = channelWith(); + + const handled = handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + await Promise.race([ + handled, + new Promise((resolve) => setTimeout(resolve, 50)), + ]); + + expect(setRequestContext).toHaveBeenCalledWith(ORIGIN, undefined); + }); +}); + +describe("recovering from a revoked session", () => { + it("does not answer from a held record once the ceremony path is taken", async () => { + await storedSession(BigInt(10_000)); + await storedSession(BigInt(10_001)); + const { channel } = channelWith(); + + const handled = handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + await Promise.race([ + handled, + new Promise((resolve) => setTimeout(resolve, 50)), + ]); + + expect(setRequestContext).toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts new file mode 100644 index 00000000000..afb5e7feabd --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -0,0 +1,290 @@ +import type { Channel, JsonRequest } from "$lib/utils/transport/utils"; +import { + Base64ToBytesCodec, + Base64ToPublicKeyCodec, + INVALID_PARAMS_ERROR_CODE, + OriginSchema, + StringToBigIntCodec, +} from "$lib/utils/transport/utils"; +import { + authorizationStore, + authorizedStore, +} from "$lib/stores/authorization.store"; +import { authenticationStore } from "$lib/stores/authentication.store"; +import { + appSessionsForOrigin, + storeAppSession, + type AppSessionRecord, +} from "$lib/stores/app-session.store"; +import { validateDerivationOrigin } from "$lib/utils/validateDerivationOrigin"; +import { remapToLegacyDomain } from "$lib/utils/iiConnection"; +import { toPermissionsArg } from "$lib/utils/accessLevel"; +import { retryFor, throwCanisterError, waitForStore } from "$lib/utils/utils"; +import { canisterId } from "$lib/globals"; +import { Principal } from "@icp-sdk/core/principal"; +import { + Delegation, + DelegationChain, + ECDSAKeyIdentity, +} from "@icp-sdk/core/identity"; +import type { PublicKey, Signature } from "@icp-sdk/core/agent"; +import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; +import { withBrowserProof } from "$lib/stores/browser-key.store"; +import { describeBrowser } from "$lib/stores/channelHandlers/describeBrowser"; +import { z } from "zod"; +import type { ChannelError } from "$lib/stores/channelStore"; + +export const SESSION_DELEGATION_METHOD = "ii_session_delegation"; + +const SessionParamsCodec = z.object({ + sessionPublicKey: Base64ToPublicKeyCodec, + icrc95DerivationOrigin: z.optional(OriginSchema), +}); + +/** + * Unlike the ICRC-34 result, this one carries `targets`. The session chain is restricted + * to the II canister, so an app that reaches for it where it meant its app delegation + * fails immediately and visibly instead of appearing to work. + */ +const SessionResultSchema = z.codec( + z.object({ + publicKey: z.base64(), + signerDelegation: z.array( + z.object({ + delegation: z.object({ + pubkey: z.base64(), + expiration: z.string(), + targets: z.optional(z.array(z.string())), + }), + signature: z.base64(), + }), + ), + }), + z.custom<{ + chain: DelegationChain; + }>(), + { + decode: ({ publicKey, signerDelegation }) => ({ + chain: DelegationChain.fromDelegations( + signerDelegation.map( + ({ delegation: { pubkey, expiration, targets }, signature }) => ({ + delegation: new Delegation( + Base64ToBytesCodec.decode(pubkey), + StringToBigIntCodec.decode(expiration), + targets?.map((target) => Principal.fromText(target)), + ), + signature: Base64ToBytesCodec.decode(signature) as Signature, + }), + ), + Base64ToBytesCodec.decode(publicKey), + ), + }), + encode: ({ chain }) => ({ + publicKey: Base64ToBytesCodec.encode( + new Uint8Array(chain.publicKey) as Uint8Array, + ), + signerDelegation: chain.delegations.map((signed) => ({ + delegation: { + pubkey: Base64ToBytesCodec.encode( + new Uint8Array(signed.delegation.pubkey) as Uint8Array, + ), + expiration: signed.delegation.expiration.toString(), + targets: signed.delegation.targets?.map((target) => target.toText()), + }, + signature: Base64ToBytesCodec.encode( + new Uint8Array(signed.signature) as Uint8Array, + ), + })), + }), + }, +); + +const extendToApp = async ( + record: AppSessionRecord, + appPublicKey: PublicKey, +): Promise => + DelegationChain.create( + await ECDSAKeyIdentity.fromKeyPair(record.keyPair), + appPublicKey, + new Date(record.expiresAtMillis), + { + previous: DelegationChain.fromJSON(JSON.parse(record.chainJson)), + targets: [Principal.from(canisterId)], + }, + ); + +/** + * Obtains the session an app re-issues its own delegations from. + * + * The response carries a session and nothing else: the app mints its first app + * delegation through `app_prepare_delegation`, the same call it uses for every + * subsequent one, so `icrc34_delegation` keeps behaving exactly as it does today and an + * app that cannot refresh simply never calls this. + */ +export const handleSessionDelegationRequest = + (channel: Channel, onError: (error: ChannelError) => void) => + async (request: JsonRequest) => { + if ( + request.id === undefined || + request.method !== SESSION_DELEGATION_METHOD + ) { + return; + } + const requestId = request.id; + + const parsed = SessionParamsCodec.safeParse(request.params); + if (!parsed.success) { + await channel.send({ + jsonrpc: "2.0", + id: requestId, + error: { + code: INVALID_PARAMS_ERROR_CODE, + message: z.prettifyError(parsed.error), + }, + }); + onError("invalid-request"); + return; + } + + await serializeAuthorizationRequest(async () => { + try { + const params = parsed.data; + const validation = await validateDerivationOrigin({ + requestOrigin: channel.origin, + derivationOrigin: params.icrc95DerivationOrigin, + }); + if (validation.result === "invalid") { + onError("unverified-origin"); + return; + } + + const effectiveOrigin = remapToLegacyDomain( + params.icrc95DerivationOrigin ?? channel.origin, + ); + + const held = await appSessionsForOrigin(effectiveOrigin); + const reusable = held.length === 1 ? held[0] : undefined; + if (reusable !== undefined) { + const chain = await extendToApp( + reusable.record, + params.sessionPublicKey, + ); + await channel.send({ + jsonrpc: "2.0", + id: requestId, + result: SessionResultSchema.encode({ + chain, + }), + }); + return; + } + + const created = await createSession(effectiveOrigin); + const chain = await extendToApp( + created.record, + params.sessionPublicKey, + ); + await channel.send({ + jsonrpc: "2.0", + id: requestId, + result: SessionResultSchema.encode({ + chain, + }), + }); + } catch (error) { + console.error(error); + onError("delegation-failed"); + } + }); + }; + +const createSession = async ( + effectiveOrigin: string, +): Promise<{ record: AppSessionRecord }> => { + authorizationStore.setRequestContext(effectiveOrigin, undefined); + const authorized = await waitForStore(authorizedStore); + const [accountNumber, { identityNumber, actor, authMethod }] = + await Promise.all([ + authorized.accountNumberPromise, + waitForStore(authenticationStore), + ]); + // An SSO organization caps how long its sign-ins stay valid, and a session must not + // outlive that, so an SSO identity sends a duration even when the user picked none. + const ssoSessionMaxAgeNs = + "openid" in authMethod ? authMethod.openid.ssoSessionMaxAgeNs : undefined; + const validFor = + ssoSessionMaxAgeNs !== undefined && + (authorized.maxTimeToLive === undefined || + authorized.maxTimeToLive > ssoSessionMaxAgeNs) + ? ssoSessionMaxAgeNs + : authorized.maxTimeToLive; + + const key = { identityNumber, accountNumber, origin: effectiveOrigin }; + const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); + const iiPublicKey = new Uint8Array(iiKey.getPublicKey().toDer()); + const deviceName = await describeBrowser(); + + const prepared = await withBrowserProof( + identityNumber, + iiPublicKey, + async (browser) => { + const prepared = await actor + .prepare_account_session({ + identity_number: identityNumber, + origin: effectiveOrigin, + account_number: accountNumber !== undefined ? [accountNumber] : [], + session_key: iiPublicKey, + device_name: deviceName, + device_key: browser.publicKey, + next_device_key: browser.nextPublicKey, + device_key_signature: browser.signature, + next_device_key_signature: browser.nextSignature, + permissions: toPermissionsArg(authorized.accessLevel), + // The duration the user chose at consent, clamped by the canister. Dropping it + // would honour half of a consent and silently discard the other half. + valid_for: validFor !== undefined ? [validFor] : [], + }) + .then(throwCanisterError); + await browser.accept(prepared.device_id); + return prepared; + }, + ); + + const fetched = await retryFor(5, () => + actor + .get_account_session({ + identity_number: identityNumber, + origin: effectiveOrigin, + account_number: accountNumber !== undefined ? [accountNumber] : [], + session_key: iiPublicKey, + expiration: prepared.expiration, + }) + .then(throwCanisterError), + ); + + const canisterChain = DelegationChain.fromDelegations( + [ + { + delegation: new Delegation( + new Uint8Array(fetched.signed_delegation.delegation.pubkey), + fetched.signed_delegation.delegation.expiration, + ), + signature: new Uint8Array( + fetched.signed_delegation.signature, + ) as Signature, + }, + ], + new Uint8Array(prepared.user_key), + ); + + const record: AppSessionRecord = { + keyPair: iiKey.getKeyPair(), + chainJson: JSON.stringify(canisterChain.toJSON()), + expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), + createdAtNanos: prepared.created_at, + accessLevel: authorized.accessLevel, + accountPrincipal: prepared.account_principal.toText(), + }; + await storeAppSession(key, record); + return { record }; +}; diff --git a/src/frontend/src/lib/stores/channelStore.ts b/src/frontend/src/lib/stores/channelStore.ts index 0b8660e124a..05bd10f1a35 100644 --- a/src/frontend/src/lib/stores/channelStore.ts +++ b/src/frontend/src/lib/stores/channelStore.ts @@ -23,6 +23,7 @@ import { handlePermissions, } from "$lib/stores/channelHandlers/icrc25"; import { handleDelegationRequest } from "$lib/stores/channelHandlers/delegation"; +import { handleSessionDelegationRequest } from "$lib/stores/channelHandlers/sessionDelegation"; import { handleLegacyAttributes, handleIcrc3OneClickOpenIdAttributes, @@ -99,6 +100,10 @@ export const channelStore: ChannelStore = { "request", handleDelegationRequest(channel, onError), ); + channel.addEventListener( + "request", + handleSessionDelegationRequest(channel, onError), + ); channel.addEventListener( "request", handleLegacyAttributes(channel, onError), diff --git a/src/frontend/src/routes/(new-styling)/+page.svelte b/src/frontend/src/routes/(new-styling)/+page.svelte index 9ec564c71ab..4aa8bfcaba8 100644 --- a/src/frontend/src/routes/(new-styling)/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/+page.svelte @@ -14,6 +14,7 @@ import { beforeNavigate, preloadData } from "$app/navigation"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; import { purgeSession } from "$lib/stores/session-delegation.store"; + import { purgeAppSessions } from "$lib/stores/app-session.store"; import { goto } from "$app/navigation"; import { toaster } from "$lib/components/utils/toaster"; import { @@ -111,6 +112,7 @@ $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); void purgeSession(identityNumber); + void purgeAppSessions(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { diff --git a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte index dd2d152f647..b0c2e502e44 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte @@ -22,6 +22,7 @@ actorForIdentity, purgeSession, } from "$lib/stores/session-delegation.store"; + import { purgeAppSessions } from "$lib/stores/app-session.store"; import { throwCanisterError, isCanisterError } from "$lib/utils/utils"; import type { ActorSubclass } from "@icp-sdk/core/agent"; import type { @@ -263,6 +264,7 @@ err.type === "Unauthorized" ) { void purgeSession(selectedIdentityNumber); + void purgeAppSessions(selectedIdentityNumber); } else { throw err; } @@ -351,6 +353,7 @@ err.type === "Unauthorized" ) { void purgeSession(selectedIdentityNumber); + void purgeAppSessions(selectedIdentityNumber); } else { throw err; } @@ -482,6 +485,7 @@ err.type === "Unauthorized" ) { void purgeSession(selectedIdentityNumber); + void purgeAppSessions(selectedIdentityNumber); } else { throw err; } diff --git a/src/frontend/src/routes/(new-styling)/cli/+layout.svelte b/src/frontend/src/routes/(new-styling)/cli/+layout.svelte index ed4568e8fdd..ef2c685e1a2 100644 --- a/src/frontend/src/routes/(new-styling)/cli/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/cli/+layout.svelte @@ -3,6 +3,7 @@ import { ChevronDownIcon, UserIcon } from "@lucide/svelte"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; import { purgeSession } from "$lib/stores/session-delegation.store"; + import { purgeAppSessions } from "$lib/stores/app-session.store"; import { t } from "$lib/stores/locale.store"; import { AuthWizard } from "$lib/components/wizards/auth"; import Header from "$lib/components/layout/Header.svelte"; @@ -64,6 +65,7 @@ $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); void purgeSession(identityNumber); + void purgeAppSessions(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte index ac141610d2c..f2648c978b6 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte @@ -26,6 +26,7 @@ import { DelegationIdentity } from "@icp-sdk/core/identity"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; import { purgeSession } from "$lib/stores/session-delegation.store"; + import { purgeAppSessions } from "$lib/stores/app-session.store"; import { sessionStore } from "$lib/stores/session.store"; import { locales, localeStore, t } from "$lib/stores/locale.store"; import { AuthLastUsedFlow } from "$lib/flows/authLastUsedFlow.svelte"; @@ -119,6 +120,7 @@ const identityNumber = $authenticatedStore.identityNumber; lastUsedIdentitiesStore.removeIdentity(identityNumber); void purgeSession(identityNumber); + void purgeAppSessions(identityNumber); sessionStore.reset(); window.location.replace("/"); }; @@ -130,6 +132,7 @@ $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); void purgeSession(identityNumber); + void purgeAppSessions(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { const identityName = diff --git a/src/frontend/src/routes/(new-styling)/recovery/+page.svelte b/src/frontend/src/routes/(new-styling)/recovery/+page.svelte index 33ee45c45b1..4dd9999fecc 100644 --- a/src/frontend/src/routes/(new-styling)/recovery/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/recovery/+page.svelte @@ -39,6 +39,7 @@ import { handleError } from "$lib/components/utils/error"; import { authenticationStore } from "$lib/stores/authentication.store"; import { purgeSession } from "$lib/stores/session-delegation.store"; + import { purgeAppSessions } from "$lib/stores/app-session.store"; import { authenticateWithSession } from "$lib/utils/authentication"; import { goto, preloadData } from "$app/navigation"; import { page } from "$app/state"; @@ -201,6 +202,7 @@ showRecoveryDialog = false; authenticationStore.reset(); void purgeSession(identityNumber); + void purgeAppSessions(identityNumber); handleError(error); } };