From c850d4391e44cb9a6f364903c8b3ef4f42ccc491 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 08:13:30 +0200 Subject: [PATCH 001/298] feat(be): track default accounts, bounded by eviction and reaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canister records only materialized accounts today, so the common case — a sign-in at an origin where the anchor holds no account — persists nothing. Tracking it is one change with the two things that bound it, because none of the three stands alone: tracking makes every sign-in at a new origin mint an application row from an operation with no per-anchor cap, eviction is what makes an application's reference count fall, and reaping is dead code without it. Implements docs/ongoing/tracked-default-accounts.md §5-§8 (D5-D12, D14, D15, D18). --- .../src/account_management.rs | 17 +- src/internet_identity/src/storage.rs | 198 ++++- .../src/storage/storable/account_reference.rs | 2 +- .../storable/account_reference_list.rs | 2 +- src/internet_identity/src/storage/tests.rs | 812 +++++++++++++++++- .../tests/integration/accounts.rs | 228 +++-- 6 files changed, 1150 insertions(+), 109 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 5d2c174143..1a6b4d4695 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -159,6 +159,7 @@ pub fn set_default_account_for_origin( origin: FrontendHostname, account_number: Option, ) -> Result { + check_frontend_length(&origin); let application_number = storage_borrow_mut(|storage| { storage.lookup_or_insert_application_number_with_origin(&origin) }); @@ -183,8 +184,12 @@ pub fn set_default_account_for_origin( }; storage_borrow_mut(|storage| { + storage + .ensure_account_reference_list(anchor_number, application_number) + .map_err(|err| SetDefaultAccountError::InternalCanisterError(err.to_string()))?; storage.set_anchor_application_config(anchor_number, application_number, config); - }); + Ok::<(), SetDefaultAccountError>(()) + })?; Ok(account) } @@ -351,6 +356,11 @@ pub async fn prepare_account_delegation( let effective_duration_ns = expiration.saturating_sub(time()); let seed = account.calculate_seed(); + storage_borrow_mut(|storage| { + storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()) + }) + .map_err(|err| AccountDelegationError::InternalCanisterError(err.to_string()))?; + state::signature_map_mut(|sigs| { add_delegation_signature( sigs, @@ -362,11 +372,6 @@ pub async fn prepare_account_delegation( }); update_root_hash(); - storage_borrow_mut(|storage| { - let _ = - storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()); - }); - delegation_bookkeeping(origin, ii_domain.clone(), effective_duration_ns); Ok(PrepareAccountDelegation { diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index bb414b27c4..7c609f3f05 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -301,6 +301,17 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; +/// Per-anchor cap on reference-list rows that hold nothing but a tracked default +/// account. +const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; + +/// Eviction target, below the cap. +const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS * 9 / 10; + +/// Bounds one message's eviction work. +const MAX_EVICTIONS_PER_CALL: u64 = + MAX_EVICTABLE_DEFAULT_ACCOUNTS - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; + /// The maximum number of anchors this canister can store. pub const MAX_ENTRIES: u64 = (MAX_MANAGED_WASM_PAGES - BUCKET_SIZE_IN_PAGES as u64) // deduct one bucket for the archive entries buffer * WASM_PAGE_SIZE_IN_BYTES @@ -1694,6 +1705,7 @@ impl Storage { } } + /// Stamps `last_used`, tracking the default account on first use at an origin. pub fn set_account_last_used( &mut self, anchor_number: AnchorNumber, @@ -1701,16 +1713,152 @@ impl Storage { account_number: Option, now: Timestamp, ) -> Result, StorageError> { - let application_number = self.lookup_application_number_with_origin(&origin); + if let Some(application_number) = self.lookup_application_number_with_origin(&origin) { + if self + .lookup_account_references(anchor_number, application_number) + .is_some() + { + return self.with_account_mut( + anchor_number, + Some(application_number), + account_number, + |account_reference, _| { + account_reference.last_used = Some(now); + }, + ); + } + } - self.with_account_mut( + if account_number.is_some() { + return Ok(None); + } + + let application_number = self.lookup_or_insert_application_number_with_origin(&origin); + self.write_reference_list( anchor_number, application_number, - account_number, - |account_reference, _| { - account_reference.last_used = Some(now); - }, - ) + vec![AccountReference { + account_number: None, + last_used: Some(now), + }], + )?; + self.evict_idle_tracked_defaults(anchor_number, application_number)?; + Ok(Some(())) + } + + /// Writes the reference-list row an `AnchorApplicationConfig` row implies, leaving + /// `last_used` unset. + pub fn ensure_account_reference_list( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + ) -> Result<(), StorageError> { + if self + .lookup_account_references(anchor_number, application_number) + .is_some() + { + return Ok(()); + } + + self.write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: None, + }], + )?; + self.evict_idle_tracked_defaults(anchor_number, application_number) + } + + /// Removes a reference-list row and everything derived from it. + fn remove_reference_list( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + ) -> Result<(), StorageError> { + let key = (anchor_number, application_number); + let Some(previous) = self + .stable_account_reference_list_memory + .get(&key) + .map(Vec::::from) + else { + return Ok(()); + }; + let application = self + .stable_application_memory + .get(&application_number) + .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; + + self.stable_account_reference_list_memory.remove(&key); + self.stable_anchor_application_config_memory.remove(&key); + + let deltas = ReferenceListDeltas::between(&previous, &[]); + self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas); + + Ok(()) + } + + /// Rows whose only reference is a tracked default. + fn evictable_default_rows( + &self, + anchor_number: AnchorNumber, + ) -> Vec<(ApplicationNumber, Option)> { + self.stable_account_reference_list_memory + .range( + (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), + ) + .filter_map(|((_, application_number), list)| { + let references = list.into_vec(); + match references.as_slice() { + [tracked_default] if tracked_default.account_number.is_none() => { + Some((application_number, tracked_default.last_used)) + } + _ => None, + } + }) + .collect() + } + + /// Upper bound on an anchor's evictable rows, from counters that already exist. + fn tracked_default_account_upper_bound(&self, anchor_number: AnchorNumber) -> u64 { + let counter = self.get_account_counter(anchor_number); + counter + .stored_account_references + .saturating_sub(counter.stored_accounts) + } + + /// Drops the least recently used evictable defaults once the anchor is at the cap. + fn evict_idle_tracked_defaults( + &mut self, + anchor_number: AnchorNumber, + just_written: ApplicationNumber, + ) -> Result<(), StorageError> { + if self.tracked_default_account_upper_bound(anchor_number) < MAX_EVICTABLE_DEFAULT_ACCOUNTS + { + return Ok(()); + } + + let mut candidates: Vec<_> = self + .evictable_default_rows(anchor_number) + .into_iter() + .filter(|(application_number, _)| *application_number != just_written) + .collect(); + if candidates.len() as u64 <= EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK { + return Ok(()); + } + + candidates.sort_by_key(|(application_number, last_used)| (*last_used, *application_number)); + + let victims = u64::min( + candidates.len() as u64 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK, + MAX_EVICTIONS_PER_CALL, + ); + for (application_number, _) in candidates.into_iter().take(victims as usize) { + self.remove_reference_list(anchor_number, application_number)?; + } + + Ok(()) } pub fn lookup_anchor_application_config( @@ -1819,14 +1967,34 @@ impl Storage { application.stored_accounts, application.stored_account_references, ); - self.stable_application_memory.insert( - application_number, - StorableApplication { - origin: application.origin, - stored_accounts, - stored_account_references, - }, - ); + if stored_account_references == 0 { + self.remove_unreferenced_application(application_number, &application.origin); + } else { + self.stable_application_memory.insert( + application_number, + StorableApplication { + origin: application.origin, + stored_accounts, + stored_account_references, + }, + ); + } + } + + /// Retires an application no anchor references any more. The number is never + /// reissued. + fn remove_unreferenced_application( + &mut self, + application_number: ApplicationNumber, + origin: &str, + ) { + self.stable_application_memory.remove(&application_number); + + let origin_key = StorableOriginSha256::from_origin(&origin.to_string()); + if self.lookup_application_with_origin_memory.get(&origin_key) == Some(application_number) { + self.lookup_application_with_origin_memory + .remove(&origin_key); + } } /// This is for testing purposes only, DO NOT use anywhere else! diff --git a/src/internet_identity/src/storage/storable/account_reference.rs b/src/internet_identity/src/storage/storable/account_reference.rs index 83950af7c9..0f516dbc1d 100644 --- a/src/internet_identity/src/storage/storable/account_reference.rs +++ b/src/internet_identity/src/storage/storable/account_reference.rs @@ -6,7 +6,7 @@ use internet_identity_interface::internet_identity::types::Timestamp; use minicbor::{Decode, Encode}; use std::borrow::Cow; -#[derive(Encode, Decode, Clone, Ord, Eq, PartialEq, PartialOrd, Default)] +#[derive(Encode, Decode, Clone, Debug, Ord, Eq, PartialEq, PartialOrd, Default)] #[cbor(map)] pub struct StorableAccountReference { #[n(0)] diff --git a/src/internet_identity/src/storage/storable/account_reference_list.rs b/src/internet_identity/src/storage/storable/account_reference_list.rs index 2f58720f32..00aba0483c 100644 --- a/src/internet_identity/src/storage/storable/account_reference_list.rs +++ b/src/internet_identity/src/storage/storable/account_reference_list.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; /// Vectors are not supported yet in ic-stable-structures, this file /// implements a struct to wrap this vector so it can be stored. -#[derive(Encode, Decode, Clone, Ord, Eq, PartialEq, PartialOrd, Default)] +#[derive(Encode, Decode, Clone, Debug, Ord, Eq, PartialEq, PartialOrd, Default)] #[cbor(transparent)] pub struct StorableAccountReferenceList(#[n(0)] Vec); diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 994de9b873..aaf099716e 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -491,7 +491,7 @@ fn should_set_account_last_used() { } #[test] -fn should_set_account_last_used_for_synthethic_account() { +fn should_track_the_default_account_on_first_use() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); @@ -501,12 +501,10 @@ fn should_set_account_last_used_for_synthethic_account() { let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Set last_used for the synthetic account (account_number = None) let timestamp = 555555u64; let result = storage.set_account_last_used(anchor_number, origin.clone(), None, timestamp); - assert!(result.unwrap().is_none()); + assert!(result.unwrap().is_some()); - // Verify last_used was updated for the synthetic account let read_account = storage .read_account(ReadAccountParams { anchor_number, @@ -515,8 +513,13 @@ fn should_set_account_last_used_for_synthethic_account() { known_app_num: None, }) .unwrap(); - // Because the account reference doesn't exist, the `last_used` is not updated. - assert_eq!(read_account.last_used, None); + assert_eq!(read_account.last_used, Some(timestamp)); + assert_eq!( + storage + .get_account_counter(anchor_number) + .stored_account_references, + 1 + ); } #[test] @@ -582,7 +585,7 @@ fn should_return_none_when_setting_last_used_for_nonexistent_account() { } #[test] -fn should_return_none_when_setting_last_used_for_nonexistent_origin() { +fn should_not_track_a_named_account_at_an_unknown_origin() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); @@ -591,13 +594,19 @@ fn should_return_none_when_setting_last_used_for_nonexistent_origin() { let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Try to set last_used for an origin that hasn't been registered let nonexistent_origin = "https://nonexistent.com".to_string(); let timestamp = 123456u64; - let result = storage.set_account_last_used(anchor_number, nonexistent_origin, None, timestamp); + let result = storage.set_account_last_used( + anchor_number, + nonexistent_origin.clone(), + Some(1), + timestamp, + ); - // Should return None because the origin/application doesn't exist assert!(result.unwrap().is_none()); + assert!(storage + .lookup_application_number_with_origin(&nonexistent_origin) + .is_none()); } fn sample_device() -> Device { @@ -2431,7 +2440,7 @@ mod application_number_allocator_tests { } #[test] - fn reseeding_after_a_reap_does_not_lower_the_allocator() { + fn reseeding_after_a_removal_does_not_lower_the_allocator() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); for origin in [ @@ -2485,3 +2494,784 @@ mod application_number_allocator_tests { ); } } + +mod default_account_tracking_tests { + use crate::storage::account::{AccountReference, CreateAccountParams, ReadAccountParams}; + 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()); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + #[test] + fn tracking_registers_the_application_and_the_reference() { + 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() + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + assert_eq!( + storage.lookup_account_references(anchor_number, application_number), + Some(vec![AccountReference { + account_number: None, + last_used: Some(1_000), + } + .into()]) + ); + } + + #[test] + fn tracking_twice_stamps_rather_than_appends() { + 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(); + storage + .set_account_last_used(anchor_number, origin.clone(), None, 2_000) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let references = storage + .lookup_account_references(anchor_number, application_number) + .unwrap(); + assert_eq!(references.len(), 1); + assert_eq!(references[0].last_used, Some(2_000)); + assert_eq!( + storage + .get_account_counter(anchor_number) + .stored_account_references, + 1 + ); + } + + #[test] + fn tracking_does_not_recreate_a_default_that_was_given_away() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: Some(9), + last_used: None, + }], + ) + .unwrap(); + + let result = storage.set_account_last_used(anchor_number, origin, None, 1_000); + + assert!(result.unwrap().is_none()); + let references = storage + .lookup_account_references(anchor_number, application_number) + .unwrap(); + assert_eq!(references.len(), 1); + assert_eq!(references[0].account_number, Some(9)); + } + + #[test] + fn creating_a_named_account_leaves_the_default_unused() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + + storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + + let default_account = storage + .read_account(ReadAccountParams { + account_number: None, + anchor_number, + origin: &origin, + known_app_num: None, + }) + .unwrap(); + assert_eq!(default_account.last_used, None); + } + + #[test] + fn a_config_row_implies_a_reference_list_row() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + storage + .ensure_account_reference_list(anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage.lookup_account_references(anchor_number, application_number), + Some(vec![AccountReference { + account_number: None, + last_used: None, + } + .into()]) + ); + } + + #[test] + fn ensuring_a_reference_list_does_not_disturb_an_existing_one() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + storage + .set_account_last_used(anchor_number, origin, None, 7_000) + .unwrap(); + + storage + .ensure_account_reference_list(anchor_number, application_number) + .unwrap(); + + let references = storage + .lookup_account_references(anchor_number, application_number) + .unwrap(); + assert_eq!(references.len(), 1); + assert_eq!(references[0].last_used, Some(7_000)); + } +} + +mod tracked_default_eviction_tests { + use crate::storage::account::{AccountReference, CreateAccountParams, ReadAccountParams}; + use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; + use crate::storage::{ + EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK, MAX_EVICTABLE_DEFAULT_ACCOUNTS, + MAX_EVICTIONS_PER_CALL, + }; + 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()); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + fn origin_of(index: u64) -> String { + format!("https://dapp-{index}.com") + } + + fn sign_in_at(storage: &mut Storage, anchor_number: AnchorNumber, index: u64) { + storage + .set_account_last_used(anchor_number, origin_of(index), None, index + 1) + .unwrap() + .unwrap(); + } + + #[test] + fn evicting_drops_the_least_recently_used_down_to_the_watermark() { + let (mut storage, anchor_number) = storage_with_anchor(); + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + sign_in_at(&mut storage, anchor_number, index); + } + + let evicted = MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; + assert_eq!( + storage.evictable_default_rows(anchor_number).len() as u64, + MAX_EVICTABLE_DEFAULT_ACCOUNTS - evicted + ); + + for index in 0..evicted { + assert!(storage + .lookup_application_number_with_origin(&origin_of(index)) + .is_none()); + } + for index in evicted..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + let application_number = storage + .lookup_application_number_with_origin(&origin_of(index)) + .unwrap(); + assert!(storage + .lookup_account_references(anchor_number, application_number) + .is_some()); + } + } + + #[test] + fn choosing_a_default_account_is_capped_too() { + let (mut storage, anchor_number) = storage_with_anchor(); + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS * 2 { + let application_number = + storage.lookup_or_insert_application_number_with_origin(&origin_of(index)); + storage + .ensure_account_reference_list(anchor_number, application_number) + .unwrap(); + } + + assert!( + storage.evictable_default_rows(anchor_number).len() as u64 + <= MAX_EVICTABLE_DEFAULT_ACCOUNTS + ); + } + + #[test] + fn the_row_a_sign_in_just_wrote_is_never_its_own_victim() { + let (mut storage, anchor_number) = storage_with_anchor(); + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 { + storage + .set_account_last_used(anchor_number, origin_of(index), None, 1) + .unwrap(); + } + + let newest_origin = "https://newest.com".to_string(); + storage + .set_account_last_used(anchor_number, newest_origin.clone(), None, 1) + .unwrap() + .unwrap(); + + let newest_application = storage + .lookup_application_number_with_origin(&newest_origin) + .unwrap(); + assert!(storage + .lookup_account_references(anchor_number, newest_application) + .is_some()); + } + + #[test] + fn one_call_evicts_at_most_a_bounded_batch() { + let (mut storage, anchor_number) = storage_with_anchor(); + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS * 3 { + let application_number = + storage.lookup_or_insert_application_number_with_origin(&origin_of(index)); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(index + 1), + }], + ) + .unwrap(); + } + let before = storage.evictable_default_rows(anchor_number).len() as u64; + + storage + .set_account_last_used( + anchor_number, + "https://trigger.com".to_string(), + None, + 9_999, + ) + .unwrap(); + + let after = storage.evictable_default_rows(anchor_number).len() as u64; + assert_eq!(before + 1 - after, MAX_EVICTIONS_PER_CALL); + } + + #[test] + fn signing_in_never_fails_on_the_cap() { + let (mut storage, anchor_number) = storage_with_anchor(); + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS * 2 { + sign_in_at(&mut storage, anchor_number, index); + } + + let rows = storage.evictable_default_rows(anchor_number).len() as u64; + assert!(rows <= MAX_EVICTABLE_DEFAULT_ACCOUNTS); + let newest = storage + .lookup_application_number_with_origin(&origin_of( + MAX_EVICTABLE_DEFAULT_ACCOUNTS * 2 - 1, + )) + .unwrap(); + assert!(storage + .lookup_account_references(anchor_number, newest) + .is_some()); + } + + #[test] + fn a_never_used_default_is_evicted_before_a_used_one() { + let (mut storage, anchor_number) = storage_with_anchor(); + let never_used_origin = "https://never-used.com".to_string(); + let never_used_application = + storage.lookup_or_insert_application_number_with_origin(&never_used_origin); + storage + .ensure_account_reference_list(anchor_number, never_used_application) + .unwrap(); + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + sign_in_at(&mut storage, anchor_number, index); + } + + assert!(storage + .lookup_account_references(anchor_number, never_used_application) + .is_none()); + } + + #[test] + fn a_default_sharing_a_row_with_a_named_account_is_not_evictable() { + let (mut storage, anchor_number) = storage_with_anchor(); + let shared_origin = "https://has-a-named-account.com".to_string(); + storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: shared_origin.clone(), + }) + .unwrap(); + let shared_application = storage + .lookup_application_number_with_origin(&shared_origin) + .unwrap(); + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + sign_in_at(&mut storage, anchor_number, index); + } + + let references = storage + .lookup_account_references(anchor_number, shared_application) + .unwrap(); + assert_eq!(references.len(), 2); + assert!(references.iter().any(|r| r.account_number.is_none())); + } + + #[test] + fn eviction_removes_the_config_row_and_the_counters_follow() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + storage + .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .unwrap(); + storage.set_anchor_application_config( + anchor_number, + application_number, + AnchorApplicationConfig { + default_account_number: None, + }, + ); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert!(storage + .stable_anchor_application_config_memory + .get(&(anchor_number, application_number)) + .is_none()); + assert_eq!( + storage + .get_account_counter(anchor_number) + .stored_account_references, + 0 + ); + assert!(storage + .stable_application_memory + .get(&application_number) + .is_none()); + assert_eq!( + storage + .get_total_accounts_counter() + .stored_account_references, + 0 + ); + } + + #[test] + fn eviction_is_an_exact_round_trip() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + storage + .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .unwrap(); + let before = storage + .read_account(ReadAccountParams { + account_number: None, + anchor_number, + origin: &origin, + known_app_num: None, + }) + .unwrap(); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + storage + .set_account_last_used(anchor_number, origin.clone(), None, 2_000) + .unwrap(); + + let after = storage + .read_account(ReadAccountParams { + account_number: None, + anchor_number, + origin: &origin, + known_app_num: None, + }) + .unwrap(); + + assert_eq!(before.anchor_number, after.anchor_number); + assert_eq!(before.origin, after.origin); + assert_eq!(before.account_number, None); + assert_eq!(after.account_number, None); + assert_eq!(after.last_used, Some(2_000)); + } + + #[test] + fn removing_a_row_that_does_not_exist_is_a_no_op() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage + .get_account_counter(anchor_number) + .stored_account_references, + 0 + ); + } + + #[test] + fn eviction_never_leaves_an_empty_row_behind() { + let (mut storage, anchor_number) = storage_with_anchor(); + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + sign_in_at(&mut storage, anchor_number, index); + } + + let empty_rows = storage + .stable_account_reference_list_memory + .range((anchor_number, 0)..=(anchor_number, u64::MAX)) + .filter(|(_, list)| list.clone().into_vec().is_empty()) + .count(); + assert_eq!(empty_rows, 0); + } + + #[test] + fn the_upper_bound_ignores_named_accounts() { + let (mut storage, anchor_number) = storage_with_anchor(); + for index in 0..3 { + storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: format!("named-{index}"), + origin: origin_of(index), + }) + .unwrap(); + } + + assert_eq!( + storage.tracked_default_account_upper_bound(anchor_number), + 3 + ); + + sign_in_at(&mut storage, anchor_number, 100); + + assert_eq!( + storage.tracked_default_account_upper_bound(anchor_number), + 4 + ); + assert_eq!(storage.evictable_default_rows(anchor_number).len(), 1); + } + + #[test] + fn eviction_only_touches_the_anchor_that_is_at_the_cap() { + let (mut storage, anchor_number) = storage_with_anchor(); + let other_anchor = storage.allocate_anchor(0).unwrap(); + let other_anchor_number = other_anchor.anchor_number(); + storage.write(other_anchor).unwrap(); + storage + .set_account_last_used(other_anchor_number, origin_of(0), None, 1) + .unwrap(); + + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + sign_in_at(&mut storage, anchor_number, index); + } + + let application_number = storage + .lookup_application_number_with_origin(&origin_of(0)) + .unwrap(); + assert!(storage + .lookup_account_references(other_anchor_number, application_number) + .is_some()); + } + + #[test] + fn a_default_reference_survives_when_only_named_accounts_are_evictable_candidates() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: Some(1), + last_used: None, + }], + ) + .unwrap(); + + assert_eq!(storage.evictable_default_rows(anchor_number).len(), 0); + } +} + +mod application_removal_tests { + use crate::storage::account::{AccountReference, CreateAccountParams}; + use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; + use crate::storage::storable::application::StorableOriginSha256; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + fn storage_with_anchors() -> (Storage, AnchorNumber, AnchorNumber) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + let first = storage.allocate_anchor(0).unwrap(); + let first_number = first.anchor_number(); + storage.write(first).unwrap(); + let second = storage.allocate_anchor(0).unwrap(); + let second_number = second.anchor_number(); + storage.write(second).unwrap(); + (storage, first_number, second_number) + } + + #[test] + fn the_last_reference_leaving_removes_the_application() { + let (mut storage, anchor_number, _) = storage_with_anchors(); + 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(); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert!(storage + .lookup_application_number_with_origin(&origin) + .is_none()); + assert!(storage + .stable_application_memory + .get(&application_number) + .is_none()); + assert_eq!(storage.get_total_application_count(), 0); + } + + #[test] + fn an_application_another_anchor_still_references_is_kept() { + let (mut storage, anchor_number, other_anchor_number) = storage_with_anchors(); + let origin = "https://example.com".to_string(); + storage + .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .unwrap(); + storage + .set_account_last_used(other_anchor_number, origin.clone(), None, 2_000) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage.lookup_application_number_with_origin(&origin), + Some(application_number) + ); + assert_eq!( + storage + .stable_application_memory + .get(&application_number) + .unwrap() + .stored_account_references, + 1 + ); + } + + #[test] + fn a_removed_number_is_never_reissued() { + let (mut storage, anchor_number, _) = storage_with_anchors(); + let removed_origin = "https://removed.com".to_string(); + let kept_origin = "https://kept.com".to_string(); + storage + .set_account_last_used(anchor_number, removed_origin.clone(), None, 1_000) + .unwrap(); + storage + .set_account_last_used(anchor_number, kept_origin.clone(), None, 2_000) + .unwrap(); + let removed_number = storage + .lookup_application_number_with_origin(&removed_origin) + .unwrap(); + let kept_number = storage + .lookup_application_number_with_origin(&kept_origin) + .unwrap(); + + storage + .remove_reference_list(anchor_number, removed_number) + .unwrap(); + storage + .set_account_last_used(anchor_number, "https://fresh.com".to_string(), None, 3_000) + .unwrap(); + + let fresh_number = storage + .lookup_application_number_with_origin(&"https://fresh.com".to_string()) + .unwrap(); + assert_ne!(fresh_number, removed_number); + assert_ne!(fresh_number, kept_number); + assert_eq!( + storage.lookup_application_number_with_origin(&kept_origin), + Some(kept_number) + ); + } + + #[test] + fn a_removed_origin_signed_into_again_gets_a_fresh_application() { + let (mut storage, anchor_number, _) = storage_with_anchors(); + let origin = "https://example.com".to_string(); + storage + .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .unwrap(); + let first_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + storage + .remove_reference_list(anchor_number, first_number) + .unwrap(); + + storage + .set_account_last_used(anchor_number, origin.clone(), None, 2_000) + .unwrap(); + + let second_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + assert_ne!(second_number, first_number); + assert!(storage + .lookup_account_references(anchor_number, second_number) + .is_some()); + } + + #[test] + fn an_application_holding_a_named_account_survives_a_default_leaving() { + let (mut storage, anchor_number, other_anchor_number) = storage_with_anchors(); + let origin = "https://example.com".to_string(); + storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + storage + .write_reference_list( + other_anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(1_000), + }], + ) + .unwrap(); + + storage + .remove_reference_list(other_anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage.lookup_application_number_with_origin(&origin), + Some(application_number) + ); + } + + #[test] + fn removal_leaves_no_config_row_behind() { + let (mut storage, anchor_number, _) = storage_with_anchors(); + let origin = "https://example.com".to_string(); + let named = storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + storage.set_anchor_application_config( + anchor_number, + application_number, + AnchorApplicationConfig { + default_account_number: named.account_number, + }, + ); + assert!(storage + .stable_anchor_application_config_memory + .get(&(anchor_number, application_number)) + .is_some()); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert!(storage + .stable_anchor_application_config_memory + .get(&(anchor_number, application_number)) + .is_none()); + assert!(storage + .lookup_application_number_with_origin(&origin) + .is_none()); + } + + #[test] + fn removal_leaves_an_origin_that_was_reallocated_alone() { + let (mut storage, anchor_number, _) = storage_with_anchors(); + 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 reallocated = application_number + 7; + storage + .lookup_application_with_origin_memory + .insert(StorableOriginSha256::from_origin(&origin), reallocated); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage.lookup_application_number_with_origin(&origin), + Some(reallocated) + ); + } +} diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 750091488a..40cd7b0ae5 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -2,15 +2,16 @@ use canister_tests::{ api::internet_identity::{ api_v2::{ create_account, get_account_delegation, get_account_delegation_with_read_only, - get_accounts, prepare_account_delegation, prepare_account_delegation_with_read_only, - update_account, AccountDelegationParams, + get_accounts, get_default_account, prepare_account_delegation, + prepare_account_delegation_with_read_only, set_default_account, update_account, + AccountDelegationParams, }, get_delegation, prepare_delegation, }, flows, framework::{ - device_data_2, env, install_ii_with_archive, principal_1, principal_2, time, - verify_delegation, + device_data_2, env, get_metrics, install_ii_with_archive, parse_metric, principal_1, + principal_2, time, verify_delegation, }, }; use internet_identity_interface::internet_identity::types::{ @@ -1349,77 +1350,6 @@ fn should_update_last_used_after_prepare_account_delegation() -> Result<(), Reje Ok(()) } -/// Verifies that the last_used field is not updated after prepare_account_delegation -/// for synthetic accounts when the user doesn't have any other account. -#[test] -fn should_not_update_last_used_synthetic_account_after_prepare_account_delegation( -) -> Result<(), RejectResponse> { - let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); - let user_number = flows::register_anchor(&env, canister_id); - let frontend_hostname = "https://some-dapp.com".to_string(); - let pub_session_key = ByteBuf::from("session public key"); - - // Retrieve the account before prepare_account_delegation to verify last_used is None - let accounts_before = get_accounts( - &env, - canister_id, - principal_1(), - user_number, - frontend_hostname.clone(), - ) - .unwrap() - .unwrap(); - - let account_before = accounts_before - .iter() - .find(|account| account.account_number.is_none()) - .expect("Account should exist in the list"); - - assert_eq!( - account_before.last_used, None, - "last_used should be None before prepare_account_delegation" - ); - - // Call prepare_account_delegation for the created account - let params = AccountDelegationParams::new( - &env, - canister_id, - principal_1(), - user_number, - frontend_hostname.clone(), - None, - pub_session_key, - ); - - prepare_account_delegation(¶ms, None).unwrap().unwrap(); - - // Retrieve the account again to check last_used - let accounts_list = get_accounts( - &env, - canister_id, - principal_1(), - user_number, - frontend_hostname, - ) - .unwrap() - .unwrap(); - - // Find the created account in the list (it should be at index 1, after the default account) - let updated_account = accounts_list - .iter() - .find(|account| account.account_number.is_none()) - .expect("Account should exist in the list"); - - // Verify last_used is now populated - assert!( - updated_account.last_used.is_none(), - "last_used should not be populated after prepare_account_delegation for synthetic accounts" - ); - - Ok(()) -} - /// Verifies that last_used is tracked independently for different accounts. #[test] fn should_update_last_used_independently_for_different_accounts() -> Result<(), RejectResponse> { @@ -1585,3 +1515,151 @@ fn should_update_last_used_independently_for_different_accounts() -> Result<(), Ok(()) } + +#[test] +fn should_track_the_default_account_on_first_sign_in() -> 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 origin = "https://untouched-dapp.com".to_string(); + + let accounts_before = get_accounts( + &env, + canister_id, + principal_1(), + identity_number, + origin.clone(), + )? + .unwrap(); + assert_eq!(accounts_before.len(), 1); + assert_eq!(accounts_before[0].account_number, None); + assert_eq!(accounts_before[0].last_used, None); + + let params = AccountDelegationParams::new( + &env, + canister_id, + principal_1(), + identity_number, + origin.clone(), + None, + ByteBuf::from(vec![1; 32]), + ); + prepare_account_delegation(¶ms, None)?.unwrap(); + + let accounts_after = + get_accounts(&env, canister_id, principal_1(), identity_number, origin)?.unwrap(); + assert_eq!(accounts_after.len(), 1); + assert_eq!(accounts_after[0].account_number, None); + assert!(accounts_after[0].last_used.is_some()); + + Ok(()) +} + +#[test] +fn should_track_a_chosen_default_account_without_marking_it_used() -> 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 origin = "https://untouched-dapp.com".to_string(); + let (references_before, _) = parse_metric( + &get_metrics(&env, canister_id), + "internet_identity_total_account_references_count", + ); + + set_default_account( + &env, + canister_id, + principal_1(), + identity_number, + origin.clone(), + None, + )? + .unwrap(); + + let default_account = get_default_account( + &env, + canister_id, + principal_1(), + identity_number, + origin.clone(), + )? + .unwrap(); + assert_eq!(default_account.account_number, None); + assert_eq!(default_account.last_used, None); + + let accounts = + get_accounts(&env, canister_id, principal_1(), identity_number, origin)?.unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].last_used, None); + + let (references_after, _) = parse_metric( + &get_metrics(&env, canister_id), + "internet_identity_total_account_references_count", + ); + assert_eq!(references_after, references_before + 1.0); + + Ok(()) +} + +#[test] +fn should_remove_unreferenced_applications_an_anchor_stops_referencing( +) -> Result<(), RejectResponse> { + const EVICTABLE_DEFAULT_ACCOUNTS_CAP: u64 = 500; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let evicted_origin = "https://dapp-0.com".to_string(); + let mut user_key_before_eviction = None; + for index in 0..EVICTABLE_DEFAULT_ACCOUNTS_CAP { + let params = AccountDelegationParams::new( + &env, + canister_id, + principal_1(), + identity_number, + format!("https://dapp-{index}.com"), + None, + ByteBuf::from(vec![1; 32]), + ); + let prepared = prepare_account_delegation(¶ms, None)? + .expect("signing in must never fail on the per-anchor cap"); + if index == 0 { + user_key_before_eviction = Some(prepared.user_key); + } + } + + let (application_count, _) = parse_metric( + &get_metrics(&env, canister_id), + "internet_identity_total_application_count", + ); + assert!( + application_count < EVICTABLE_DEFAULT_ACCOUNTS_CAP as f64, + "expected removal to reclaim applications, got {application_count}" + ); + + let params = AccountDelegationParams::new( + &env, + canister_id, + principal_1(), + identity_number, + evicted_origin.clone(), + None, + ByteBuf::from(vec![1; 32]), + ); + let prepared = prepare_account_delegation(¶ms, None)?.unwrap(); + assert_eq!(Some(prepared.user_key), user_key_before_eviction); + + let accounts = get_accounts( + &env, + canister_id, + principal_1(), + identity_number, + evicted_origin, + )? + .unwrap(); + assert_eq!(accounts.len(), 1); + assert!(accounts[0].last_used.is_some()); + + Ok(()) +} From 9d290c1927c4543240a7919d8cd8e2ecedb66c6b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 01:42:36 +0200 Subject: [PATCH 002/298] refactor(be): one write path for account reference lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every counter derived from a reference list is now diffed inside `write_reference_list` instead of being incremented at the six call sites that store one. `update_counters` and `AccountType` are gone; the anchor, application and global counters follow from the list itself, the way `rebuild_identity_account_counters` already defines them. The application row is resolved before anything is written, so a write against a reaped or missing application writes nothing at all rather than storing the row and leaving the counters half applied. Writing an empty list is rejected. `with_account_mut` and `set_account_last_used` return `Result` so those faults are not collapsed into "account not found". Implements docs/ongoing/tracked-default-accounts.md §4 (D3, D4) and §8.1 (D19). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/account_management.rs | 4 +- src/internet_identity/src/storage.rs | 252 ++++++++++++------ .../src/storage/storable/accounts_counter.rs | 29 +- src/internet_identity/src/storage/tests.rs | 240 ++++++++++++++++- 4 files changed, 415 insertions(+), 110 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index d92f52a62c..5d2c174143 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -362,9 +362,9 @@ pub async fn prepare_account_delegation( }); update_root_hash(); - // Update last used timestamp storage_borrow_mut(|storage| { - storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()); + let _ = + storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()); }); delegation_bookkeeping(origin, ii_domain.clone(), effective_duration_ns); diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5e9232a539..5efe46c645 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -116,7 +116,7 @@ use crate::storage::registration_rates::RegistrationRates; use crate::storage::storable::account::StorableAccount; use crate::storage::storable::account_number::StorableAccountNumber; use crate::storage::storable::account_reference::StorableAccountReference; -use crate::storage::storable::accounts_counter::{AccountType, StorableAccountsCounter}; +use crate::storage::storable::accounts_counter::StorableAccountsCounter; use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; use crate::storage::storable::application::StorableOriginSha256; use crate::storage::storable::application_number::StorableApplicationNumber; @@ -1590,23 +1590,27 @@ impl Storage { /// /// # Returns /// - /// * `None` if both account and account_reference are not found - /// * `Some(T)` if the account or account_reference are found where T is the result of the function `f`. + /// * `Ok(None)` if both account and account_reference are not found + /// * `Ok(Some(T))` if the account or account_reference are found where T is the result of the function `f`. + /// * `Err` if writing the reference list back failed fn with_account_mut( &mut self, anchor_number: AnchorNumber, application_number: Option, maybe_account_number: Option, f: F, - ) -> Option + ) -> Result, StorageError> where F: FnOnce(&mut StorableAccountReference, Option<&mut StorableAccount>) -> T, { match maybe_account_number { None => { // We are looking for a synthetic account - let (key, mut account_references) = - self.find_account_references(anchor_number, application_number)?; + let Some(((_, application_number), mut account_references)) = + self.find_account_references(anchor_number, application_number) + else { + return Ok(None); + }; let mut result = None; @@ -1617,17 +1621,25 @@ impl Storage { } } - let value = StorableAccountReferenceList::from_vec(account_references); - - self.stable_account_reference_list_memory.insert(key, value); + self.write_reference_list( + anchor_number, + application_number, + account_references.into_iter().map(Into::into).collect(), + )?; - result + Ok(result) } Some(account_number) => { // Account should be stored, otherwise, it was removed and we'll return `None`. - let mut storable_account = self.stable_account_memory.get(&account_number)?; - let (key, mut account_references) = - self.find_account_references(anchor_number, application_number)?; + let Some(mut storable_account) = self.stable_account_memory.get(&account_number) + else { + return Ok(None); + }; + let Some(((_, application_number), mut account_references)) = + self.find_account_references(anchor_number, application_number) + else { + return Ok(None); + }; let mut result = None; @@ -1638,13 +1650,15 @@ impl Storage { } } - let value = StorableAccountReferenceList::from_vec(account_references); - - self.stable_account_reference_list_memory.insert(key, value); + self.write_reference_list( + anchor_number, + application_number, + account_references.into_iter().map(Into::into).collect(), + )?; self.stable_account_memory .insert(account_number, storable_account); - result + Ok(result) } } } @@ -1655,7 +1669,7 @@ impl Storage { origin: FrontendHostname, account_number: Option, now: Timestamp, - ) -> Option<()> { + ) -> Result, StorageError> { let application_number = self.lookup_application_number_with_origin(&origin); self.with_account_mut( @@ -1695,44 +1709,93 @@ impl Storage { ); } - /// Updates the anchor account, application and account counters. - /// It doesn't update the account counter for Account type. - /// Because that one is updated when a new account number is allocated with `allocate_account_number`. - fn update_counters( + /// The single write path for an anchor's account reference list at one + /// application, including the counters derived from it. + fn write_reference_list( &mut self, - application_number: ApplicationNumber, anchor_number: AnchorNumber, - account_type: AccountType, + application_number: ApplicationNumber, + current: Vec, ) -> Result<(), StorageError> { - let anchor_account_counter = self + if current.is_empty() { + return Err(StorageError::EmptyAccountReferenceList { + anchor_number, + application_number, + }); + } + + let application = self + .stable_application_memory + .get(&application_number) + .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; + + let key = (anchor_number, application_number); + let previous = self + .stable_account_reference_list_memory + .get(&key) + .map(Vec::::from) + .unwrap_or_default(); + + let deltas = ReferenceListDeltas::between(&previous, ¤t); + + self.stable_account_reference_list_memory + .insert(key, current.into()); + self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas); + + Ok(()) + } + + fn apply_reference_counter_deltas( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + application: StorableApplication, + deltas: ReferenceListDeltas, + ) { + if deltas.is_empty() { + return; + } + + let anchor_counter = self .stable_anchor_account_counter_memory .get(&anchor_number) - .unwrap_or(StorableAccountsCounter { - stored_accounts: 0, - stored_account_references: 0, - }); + .unwrap_or_default(); + let (stored_accounts, stored_account_references) = deltas.apply( + anchor_counter.stored_accounts, + anchor_counter.stored_account_references, + ); self.stable_anchor_account_counter_memory.insert( anchor_number, - anchor_account_counter.increment(&account_type), + StorableAccountsCounter { + stored_accounts, + stored_account_references, + }, ); - // The account counter is updated when a new account number is allocated with `allocate_account_number`. - if account_type == AccountType::AccountReference { - let account_number = self.stable_account_counter_memory.get(); - self.stable_account_counter_memory - .set(account_number.increment(&account_type)) - .map_err(|_| StorageError::ErrorUpdatingAccountCounter)?; - } + let global_counter = self.stable_account_counter_memory.get().clone(); + let (_, global_references) = deltas.apply( + global_counter.stored_accounts, + global_counter.stored_account_references, + ); + self.stable_account_counter_memory + .set(StorableAccountsCounter { + stored_accounts: global_counter.stored_accounts, + stored_account_references: global_references, + }) + .expect("failed to update the global account counter"); - if let Some(mut application) = self.stable_application_memory.get(&application_number) { - match account_type { - AccountType::Account => application.stored_accounts += 1, - AccountType::AccountReference => application.stored_account_references += 1, - } - self.stable_application_memory - .insert(application_number, application); - } - Ok(()) + let (stored_accounts, stored_account_references) = deltas.apply( + application.stored_accounts, + application.stored_account_references, + ); + self.stable_application_memory.insert( + application_number, + StorableApplication { + origin: application.origin, + stored_accounts, + stored_account_references, + }, + ); } /// This is for testing purposes only, DO NOT use anywhere else! @@ -1777,7 +1840,7 @@ impl Storage { // Increments the `stable_account_counter_memory` account counter by one and returns the new number. fn allocate_account_number(&mut self) -> Result { let account_counter = self.stable_account_counter_memory.get(); - let updated_accounts_counter = account_counter.increment(&AccountType::Account); + let updated_accounts_counter = account_counter.increment_accounts(); let next_account_number = updated_accounts_counter.stored_accounts; self.stable_account_counter_memory .set(updated_accounts_counter) @@ -1868,21 +1931,15 @@ impl Storage { // Update application data let app_num = self.lookup_or_insert_application_number_with_origin(origin); - // Update counters with one more account. - self.update_counters(app_num, anchor_number, AccountType::Account)?; - // last_used will be set once the user signs in with the account. let last_used = None; // Process account references - match self + let references = match self .stable_account_reference_list_memory .get(&(anchor_number, app_num)) { None => { - // Two new account references were created. - self.update_counters(app_num, anchor_number, AccountType::AccountReference)?; - self.update_counters(app_num, anchor_number, AccountType::AccountReference)?; // If no list exists for this anchor & application, // Create and insert the default and additional account. // This is because we don't create default accounts explicitly. @@ -1894,23 +1951,20 @@ impl Storage { account_number: None, last_used, }; - self.stable_account_reference_list_memory.insert( - (anchor_number, app_num), - vec![default_account_reference, additional_account_reference].into(), - ); + vec![default_account_reference, additional_account_reference] } Some(existing_storable_list) => { - self.update_counters(app_num, anchor_number, AccountType::AccountReference)?; // If the list exists, push the new account and reinsert it to memory let mut refs_vec: Vec = existing_storable_list.into(); refs_vec.push(AccountReference { account_number: Some(account_number), last_used, }); - self.stable_account_reference_list_memory - .insert((anchor_number, app_num), refs_vec.into()); + refs_vec } - } + }; + + self.write_reference_list(anchor_number, app_num, references)?; // Return the new account Ok(Account::new( @@ -2129,7 +2183,7 @@ impl Storage { }, ); - let Some(Some(account_update_result)) = account_update_result else { + let Some(Some(account_update_result)) = account_update_result? else { return Err(StorageError::AccountNotFound { account_number }); }; @@ -2173,11 +2227,8 @@ impl Storage { self.set_anchor_application_config(anchor_number, application_number, config); } - // Update counters with one more account. - self.update_counters(application_number, anchor_number, AccountType::Account)?; - let account_references_key = (anchor_number, application_number); - match self + let references = match self .stable_account_reference_list_memory .get(&account_references_key) { @@ -2185,19 +2236,11 @@ impl Storage { // If no list exists for this anchor & application, // Create and insert the default account. // This is because we don't create default accounts explicitly. - let new_ref = AccountReference { + vec![AccountReference { account_number: Some(new_account_number), // The `last_used` field will be set when the user signs with this account. last_used: None, - }; - self.stable_account_reference_list_memory - .insert(account_references_key, vec![new_ref].into()); - // One new account reference was created. - self.update_counters( - application_number, - anchor_number, - AccountType::AccountReference, - )?; + }] } Some(existing_storable_list) => { // If the list exists, update the default account reference with the new account number. @@ -2219,10 +2262,11 @@ impl Storage { name: name.clone(), }); } - self.stable_account_reference_list_memory - .insert(account_references_key, refs_vec.into()); + refs_vec } - } + }; + + self.write_reference_list(anchor_number, application_number, references)?; // Return created default account Ok(Account::new_full( @@ -2477,6 +2521,43 @@ impl Storage { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ReferenceListDeltas { + accounts: i64, + references: i64, +} + +impl ReferenceListDeltas { + fn between(previous: &[AccountReference], current: &[AccountReference]) -> Self { + fn counts(references: &[AccountReference]) -> (i64, i64) { + let stored = references + .iter() + .filter(|reference| reference.account_number.is_some()) + .count() as i64; + (stored, references.len() as i64) + } + + let (previous_accounts, previous_references) = counts(previous); + let (current_accounts, current_references) = counts(current); + + Self { + accounts: current_accounts - previous_accounts, + references: current_references - previous_references, + } + } + + fn is_empty(&self) -> bool { + self.accounts == 0 && self.references == 0 + } + + fn apply(&self, accounts: u64, references: u64) -> (u64, u64) { + ( + accounts.saturating_add_signed(self.accounts), + references.saturating_add_signed(self.references), + ) + } +} + #[derive(Debug)] pub enum StorageError { AnchorNumberOutOfRange { @@ -2508,6 +2589,10 @@ pub enum StorageError { application_number: ApplicationNumber, }, ErrorUpdatingAccountCounter, + EmptyAccountReferenceList { + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + }, /// 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 @@ -2570,6 +2655,13 @@ impl fmt::Display for StorageError { "Origin not found for application number {application_number}", ), Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), + Self::EmptyAccountReferenceList { + anchor_number, + application_number, + } => write!( + f, + "refusing to store an empty account reference list for anchor {anchor_number} at application {application_number}" + ), Self::EmailRecoveryAddressAlreadyBound { existing_anchor } => write!( f, "recovery email is already bound to a different anchor ({existing_anchor})", diff --git a/src/internet_identity/src/storage/storable/accounts_counter.rs b/src/internet_identity/src/storage/storable/accounts_counter.rs index f7f5cc581e..51baf9de88 100644 --- a/src/internet_identity/src/storage/storable/accounts_counter.rs +++ b/src/internet_identity/src/storage/storable/accounts_counter.rs @@ -13,29 +13,14 @@ pub struct StorableAccountsCounter { pub stored_account_references: u64, } -#[derive(Clone, Debug, PartialEq)] -pub enum AccountType { - AccountReference, - Account, -} - impl StorableAccountsCounter { - pub fn increment(&self, account_type: &AccountType) -> Self { - match account_type { - AccountType::AccountReference => Self { - stored_account_references: self - .stored_account_references - .checked_add(1) - .expect("overflow in stored_account_references"), - stored_accounts: self.stored_accounts, - }, - AccountType::Account => Self { - stored_accounts: self - .stored_accounts - .checked_add(1) - .expect("overflow in stored_accounts"), - stored_account_references: self.stored_account_references, - }, + pub fn increment_accounts(&self) -> Self { + Self { + stored_accounts: self + .stored_accounts + .checked_add(1) + .expect("overflow in stored_accounts"), + stored_account_references: self.stored_account_references, } } } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 2fa6d9a42a..6e7dbcd9b5 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -455,7 +455,7 @@ fn should_set_account_last_used() { Some(account_number), timestamp, ); - assert!(result.is_some()); + assert!(result.unwrap().is_some()); // Verify last_used was updated let read_account = storage @@ -476,7 +476,7 @@ fn should_set_account_last_used() { Some(account_number), new_timestamp, ); - assert!(result.is_some()); + assert!(result.unwrap().is_some()); // Verify last_used was updated to the new timestamp let read_account = storage @@ -504,7 +504,7 @@ fn should_set_account_last_used_for_synthethic_account() { // Set last_used for the synthetic account (account_number = None) let timestamp = 555555u64; let result = storage.set_account_last_used(anchor_number, origin.clone(), None, timestamp); - assert!(result.is_none()); + assert!(result.unwrap().is_none()); // Verify last_used was updated for the synthetic account let read_account = storage @@ -542,7 +542,7 @@ fn should_set_account_last_used_for_synthetic_account_with_reference() { // Set last_used for the synthetic account (account_number = None) let timestamp = 555555u64; let result = storage.set_account_last_used(anchor_number, origin.clone(), None, timestamp); - assert!(result.is_some()); + assert!(result.unwrap().is_some()); // Verify last_used was updated for the synthetic account let read_account = storage @@ -578,7 +578,7 @@ fn should_return_none_when_setting_last_used_for_nonexistent_account() { ); // Should return None because the account doesn't exist - assert!(result.is_none()); + assert!(result.unwrap().is_none()); } #[test] @@ -597,7 +597,7 @@ fn should_return_none_when_setting_last_used_for_nonexistent_origin() { let result = storage.set_account_last_used(anchor_number, nonexistent_origin, None, timestamp); // Should return None because the origin/application doesn't exist - assert!(result.is_none()); + assert!(result.unwrap().is_none()); } fn sample_device() -> Device { @@ -2124,3 +2124,231 @@ fn test_anchor_storage_migration_round_trip() { ); } } + +mod reference_list_write_path_tests { + use crate::storage::account::{AccountReference, CreateAccountParams}; + use crate::storage::StorageError; + 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()); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + #[test] + fn rejects_writing_an_empty_list() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + let result = storage.write_reference_list(anchor_number, application_number, vec![]); + + assert!(matches!( + result, + Err(StorageError::EmptyAccountReferenceList { .. }) + )); + assert!(storage + .lookup_account_references(anchor_number, application_number) + .is_none()); + } + + #[test] + fn rejects_writing_for_an_unknown_application_without_writing_anything() { + let (mut storage, anchor_number) = storage_with_anchor(); + let unknown_application_number = 42u64; + + let result = storage.write_reference_list( + anchor_number, + unknown_application_number, + vec![AccountReference { + account_number: None, + last_used: None, + }], + ); + + assert!(matches!( + result, + Err(StorageError::OriginNotFoundForApplicationNumber { .. }) + )); + assert!(storage + .lookup_account_references(anchor_number, unknown_application_number) + .is_none()); + assert_eq!( + storage.get_account_counter(anchor_number), + crate::storage::account::AccountsCounter::default() + ); + assert_eq!( + storage + .get_total_accounts_counter() + .stored_account_references, + 0 + ); + } + + #[test] + fn a_zero_delta_write_still_requires_a_live_application() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + let references = vec![AccountReference { + account_number: Some(1), + last_used: None, + }]; + storage + .write_reference_list(anchor_number, application_number, references.clone()) + .unwrap(); + storage + .stable_application_memory + .remove(&application_number); + + let result = storage.write_reference_list(anchor_number, application_number, references); + + assert!(matches!( + result, + Err(StorageError::OriginNotFoundForApplicationNumber { .. }) + )); + } + + #[test] + fn derives_counters_from_added_references() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + storage + .write_reference_list( + anchor_number, + application_number, + vec![ + AccountReference { + account_number: None, + last_used: None, + }, + AccountReference { + account_number: Some(7), + last_used: None, + }, + ], + ) + .unwrap(); + + let anchor_counter = storage.get_account_counter(anchor_number); + assert_eq!(anchor_counter.stored_account_references, 2); + assert_eq!(anchor_counter.stored_accounts, 1); + + let application = storage.lookup_application_with_origin(&origin).unwrap(); + assert_eq!(application.stored_account_references, 2); + assert_eq!(application.stored_accounts, 1); + + assert_eq!( + storage + .get_total_accounts_counter() + .stored_account_references, + 2 + ); + } + + #[test] + fn materializing_a_default_moves_only_the_account_counter() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: None, + }], + ) + .unwrap(); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: Some(3), + last_used: None, + }], + ) + .unwrap(); + + let anchor_counter = storage.get_account_counter(anchor_number); + assert_eq!(anchor_counter.stored_account_references, 1); + assert_eq!(anchor_counter.stored_accounts, 1); + + let application = storage.lookup_application_with_origin(&origin).unwrap(); + assert_eq!(application.stored_account_references, 1); + assert_eq!(application.stored_accounts, 1); + } + + #[test] + fn rewriting_an_unchanged_list_leaves_counters_alone() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + let references = vec![AccountReference { + account_number: Some(1), + last_used: None, + }]; + + storage + .write_reference_list(anchor_number, application_number, references.clone()) + .unwrap(); + let after_first_write = storage.get_account_counter(anchor_number); + + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: Some(1), + last_used: Some(123), + }], + ) + .unwrap(); + + assert_eq!( + storage.get_account_counter(anchor_number), + after_first_write + ); + } + + #[test] + fn written_counters_match_a_rebuild() { + let (mut storage, anchor_number) = storage_with_anchor(); + + for origin in ["https://a.com", "https://b.com", "https://c.com"] { + let origin = origin.to_string(); + storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "account".to_string(), + origin: origin.clone(), + }) + .unwrap(); + storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "another account".to_string(), + origin, + }) + .unwrap(); + } + + let written = storage.get_account_counter(anchor_number); + storage.rebuild_identity_account_counters(anchor_number); + + assert_eq!(storage.get_account_counter(anchor_number), written); + assert_eq!(written.stored_account_references, 9); + assert_eq!(written.stored_accounts, 6); + } +} From e06729b8312b5148ff54e79f01c81d3a87c71d69 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 01:50:27 +0200 Subject: [PATCH 003/298] refactor(be): allocate application numbers from a monotonic cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lookup_application_with_origin_memory.len()` is only a safe source of new application numbers while nothing is ever removed. Once an application can be reaped, `len()` does not merely reuse a retired number, it collides with a live one: with `{0, 1, 2}`, removing `1` leaves `len() == 2`, which is owned. Numbers now come from a cell at memory index 33, seeded when storage is loaded rather than at the first allocation. Existing numbers are dense from zero, so the row count is the first free number at that moment; deferring the seed would let a removal shrink the count first and hand out a live number. Implements docs/ongoing/tracked-default-accounts.md §8.3 (D17). Co-Authored-By: Claude Opus 5 (1M context) --- src/internet_identity/src/storage.rs | 37 +++++- src/internet_identity/src/storage/tests.rs | 133 +++++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5efe46c645..071d99500d 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -209,6 +209,7 @@ const MCP_GRANT_MEMORY_INDEX: u8 = 29u8; // const DEPRECATED_MCP_REGISTRATION_URL_MEMORY_INDEX: u8 = 30u8; 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 ANCHOR_MEMORY_ID: MemoryId = MemoryId::new(ANCHOR_MEMORY_INDEX); const ARCHIVE_BUFFER_MEMORY_ID: MemoryId = MemoryId::new(ARCHIVE_BUFFER_MEMORY_INDEX); @@ -288,6 +289,10 @@ const MCP_CONFIG_MEMORY_ID: MemoryId = MemoryId::new(MCP_CONFIG_MEMORY_INDEX); /// `SHA-256(sso_domain, iss, ii_client_id, stable_id) -> AnchorNumber`. const SSO_STABLE_ID_INDEX_MEMORY_ID: MemoryId = MemoryId::new(SSO_STABLE_ID_INDEX_MEMORY_INDEX); +/// Monotonic `ApplicationNumber` allocator. A removed number is retired, never reissued. +const NEXT_APPLICATION_NUMBER_MEMORY_ID: MemoryId = + MemoryId::new(NEXT_APPLICATION_NUMBER_MEMORY_INDEX); + // The bucket size 128 is relatively low, to avoid wasting memory when using // multiple virtual memories for smaller amounts of data. // This value results in 256 GB of total managed memory, which should be enough @@ -376,6 +381,7 @@ pub struct Storage { ManagedMemory, >, stable_account_counter_memory: StableCell>, + next_application_number_memory: StableCell>, /// 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>, @@ -511,6 +517,7 @@ impl Storage { let stable_default_account_reference_memory = memory_manager.get(STABLE_DEFAULT_ACCOUNT_REFERENCE_MEMORY_ID); let stable_account_counter_memory = memory_manager.get(STABLE_ACCOUNT_COUNTER_MEMORY_ID); + let next_application_number_memory = memory_manager.get(NEXT_APPLICATION_NUMBER_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 = @@ -537,7 +544,7 @@ impl Storage { MinHeap::init(registration_current_rate_memory.clone()) .expect("failed to initialize registration current rate min heap"), ); - Self { + let mut storage = Self { header, header_memory, anchor_memory, @@ -593,6 +600,8 @@ impl Storage { StorableAccountsCounter::default(), ) .expect("stable_account_counter_memory"), + next_application_number_memory: StableCell::init(next_application_number_memory, 0) + .expect("next_application_number_memory"), stable_account_counter_discrepancy_counter_memory: StableCell::init( stable_account_counter_discrepancy_counter_memory, StorableDiscrepancyCounter::default(), @@ -648,7 +657,21 @@ impl Storage { sso_stable_id_index_memory.clone(), ), sso_stable_id_index_memory: StableBTreeMap::init(sso_stable_id_index_memory), - } + }; + storage.seed_application_number_allocator(); + storage + } + + /// Existing application numbers are dense from zero, so the row count is the + /// first free number. + fn seed_application_number_allocator(&mut self) { + let seeded = ApplicationNumber::max( + *self.next_application_number_memory.get(), + self.stable_application_memory.len(), + ); + self.next_application_number_memory + .set(seeded) + .expect("failed to seed the application number allocator"); } pub fn salt(&self) -> Option<&Salt> { @@ -1490,7 +1513,7 @@ impl Storage { { existing_number } else { - let new_number: ApplicationNumber = self.lookup_application_with_origin_memory.len(); + let new_number = self.allocate_application_number(); // Update the source of truth. self.lookup_application_with_origin_memory @@ -1508,6 +1531,14 @@ impl Storage { } } + fn allocate_application_number(&mut self) -> ApplicationNumber { + let new_number = *self.next_application_number_memory.get(); + self.next_application_number_memory + .set(new_number + 1) + .expect("failed to advance the application number allocator"); + new_number + } + pub fn lookup_application_number_with_origin( &self, origin: &FrontendHostname, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 6e7dbcd9b5..994de9b873 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2352,3 +2352,136 @@ mod reference_list_write_path_tests { assert_eq!(written.stored_accounts, 6); } } + +mod application_number_allocator_tests { + use crate::storage::storable::application::StorableApplication; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use pretty_assertions::assert_eq; + + fn application(origin: &str) -> StorableApplication { + StorableApplication { + origin: origin.to_string(), + stored_accounts: 0, + stored_account_references: 0, + } + } + + #[test] + fn allocates_dense_numbers_from_zero() { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + + let first = + storage.lookup_or_insert_application_number_with_origin(&"https://a.com".into()); + let second = + storage.lookup_or_insert_application_number_with_origin(&"https://b.com".into()); + let third = + storage.lookup_or_insert_application_number_with_origin(&"https://c.com".into()); + + assert_eq!((first, second, third), (0, 1, 2)); + } + + #[test] + fn returns_the_existing_number_for_a_known_origin() { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + let origin = "https://a.com".to_string(); + + let first = storage.lookup_or_insert_application_number_with_origin(&origin); + let again = storage.lookup_or_insert_application_number_with_origin(&origin); + + assert_eq!(first, again); + assert_eq!(storage.get_total_application_count(), 1); + } + + #[test] + fn seeds_past_applications_written_before_the_allocator_existed() { + let memory = VectorMemory::default(); + let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); + for (number, origin) in [ + (0, "https://a.com"), + (1, "https://b.com"), + (2, "https://c.com"), + ] { + storage + .stable_application_memory + .insert(number, application(origin)); + } + storage.next_application_number_memory.set(0).unwrap(); + storage.flush(); + + let mut storage = Storage::from_memory(memory); + let next = storage.lookup_or_insert_application_number_with_origin(&"https://d.com".into()); + + assert_eq!(next, 3); + } + + #[test] + fn never_reissues_the_number_of_a_removed_application() { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + for origin in ["https://a.com", "https://b.com", "https://c.com"] { + storage.lookup_or_insert_application_number_with_origin(&origin.into()); + } + + storage.stable_application_memory.remove(&1); + + let next = storage.lookup_or_insert_application_number_with_origin(&"https://d.com".into()); + + assert_eq!(next, 3); + assert!(storage.stable_application_memory.get(&2).is_some()); + } + + #[test] + fn reseeding_after_a_reap_does_not_lower_the_allocator() { + let memory = VectorMemory::default(); + let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); + for origin in [ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + ] { + storage.lookup_or_insert_application_number_with_origin(&origin.into()); + } + storage.flush(); + storage.stable_application_memory.remove(&1); + storage.stable_application_memory.remove(&2); + assert_eq!(storage.stable_application_memory.len(), 2); + + let mut storage = Storage::from_memory(memory.clone()); + let next = storage.lookup_or_insert_application_number_with_origin(&"https://e.com".into()); + + assert_eq!(next, 4); + assert_eq!( + storage.stable_application_memory.get(&3).unwrap().origin, + "https://d.com" + ); + + let mut storage = Storage::from_memory(memory); + assert_eq!( + storage.lookup_or_insert_application_number_with_origin(&"https://f.com".into()), + 5 + ); + } + + #[test] + fn a_removal_before_the_first_allocation_does_not_collide_with_a_live_number() { + let memory = VectorMemory::default(); + let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); + for origin in ["https://a.com", "https://b.com", "https://c.com"] { + storage.lookup_or_insert_application_number_with_origin(&origin.into()); + } + storage.next_application_number_memory.set(0).unwrap(); + storage.flush(); + + let mut storage = Storage::from_memory(memory); + storage.stable_application_memory.remove(&0); + + let next = storage.lookup_or_insert_application_number_with_origin(&"https://d.com".into()); + + assert_eq!(next, 3); + assert_eq!( + storage.stable_application_memory.get(&2).unwrap().origin, + "https://c.com" + ); + } +} From ead3f9cb8b47f12f3ab22d1cc93f49e9f455c67f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 02:00:05 +0200 Subject: [PATCH 004/298] fix(be): an emptied account reference list is not a default account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read_account` returned a synthetic default for the empty list, the inverse of what the row means. Absent means nothing ever happened at this application, so the default is reconstructible; empty means everything here was given away, so its principal must never be derived again. The branch that conflated them carried its own `XXX WARNING` saying so. Implements docs/ongoing/tracked-default-accounts.md §3.1 (D2). Co-Authored-By: Claude Opus 5 (1M context) --- src/internet_identity/src/storage.rs | 21 ++--------- .../src/storage/account/tests.rs | 36 +++++++++++++------ 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 071d99500d..bb414b27c4 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2038,8 +2038,8 @@ impl Storage { /// Returns the requested `Account`. /// If the anchor doesn't own this `Account`, returns None. /// If the `Account` is default but has been moved/deleted, returns None. - /// If the `Account` is default but ALL `Account`s for this origin have been moved or deleted, returns a default `Account`. - /// If the `Account` number doesn't esist, returns a default `Account`. + /// If the `Account` is default and ALL `Account`s for this origin have been moved or deleted, returns None. + /// If nothing has ever happened at this origin, returns a default `Account`. /// If the `Account` number exists but the `Account` doesn't exist, returns None. /// If the `Account` exists, returns it as `Account`. /// Optionally an application number can be passed if it is already known, so we don't look it up more than necessary. @@ -2069,23 +2069,6 @@ impl Storage { application_number.unwrap(), ) { - // if the list exists but is empty, we should still return a synthetic default account - // this should only happen if a named account was created, and then both it and the - // default account references were moved/deleted. - // XXX WARNING: this is done for the case that a user might have moved/deleted a default account - // and then reached the maximum accounts limit. If we don't return a synthetic default account here, - // they would be locked out of their account. - // However: if we implement account transfers at some point, and default accounts can be transfered, - // this would allow a user to regain access to their transferred default account. - if acc_ref_vec.is_empty() { - return Some(Account::new( - params.anchor_number, - params.origin.clone(), - None, - None, - )); - } - // if there is a default account in the list, we return it // else we return None, account has been moved or deleted // but there is another account in the list, so user can log in with that diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 4ee48cc1d4..78226e9a1b 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -493,13 +493,8 @@ fn should_count_accounts_different_anchors() { ); } -// XXX WARNING: this functionality exists for the case that a user might have moved/deleted a default account -// and then reached the maximum accounts limit. If we don't return a synthetic default account here, -// they would be locked out of their account. -// However: if we implement account transfers at some point, and default accounts can be transfered, -// this would allow a user to regain access to their transferred default account. #[test] -fn should_read_default_account_with_empty_reference_list() { +fn should_not_read_a_default_account_from_an_empty_reference_list() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); @@ -522,11 +517,32 @@ fn should_read_default_account_with_empty_reference_list() { origin: &origin, known_app_num: Some(app_num), }; - let default_account = storage.read_account(read_params).unwrap(); - // 4. Verify we get a synthetic default account - let expected_account = Account::synthetic(anchor_number, origin.clone()); - assert_eq!(default_account, expected_account); + assert_eq!(storage.read_account(read_params), None); +} + +#[test] +fn should_read_a_synthetic_default_account_when_no_reference_list_exists() { + let memory = VectorMemory::default(); + let mut storage = Storage::new((10_000, 3_784_873), memory); + + let anchor_number: AnchorNumber = 10_000; + let origin: FrontendHostname = "https://some.origin".to_string(); + let app_num = storage.lookup_or_insert_application_number_with_origin(&origin); + + let default_account = storage + .read_account(ReadAccountParams { + account_number: None, + anchor_number, + origin: &origin, + known_app_num: Some(app_num), + }) + .unwrap(); + + assert_eq!( + default_account, + Account::synthetic(anchor_number, origin.clone()) + ); } #[test] From 3f158fcad47163144f4223aebccbc72f10372afd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 01:40:49 +0200 Subject: [PATCH 005/298] feat(be): index accounts by the principal a dapp sees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchors are reverse-indexed by OpenID credential, passkey, recovery phrase and email; accounts are not, so given the principal a dapp sees, the canister cannot tell which anchor, application or account produced it. A new map at memory index 34 maps that principal to the `(anchor, application, account)` triple, maintained by the same write path that keeps the counters. It diffs values rather than keys: materializing a default account rewrites `None` to `Some(n)` with `seed_from_anchor` set, so the principal is unchanged while the locator gains an account number, and a key-only difference would leave a stale value. Removals are compare-and-delete, so a future move cannot drop the recipient's live entry. Deriving a principal needs the salt, which is set lazily on delegation paths, so the account-mutating endpoints await `ensure_salt_set` on entry. That is ahead of `create_account`'s cap check rather than between the check and its write, so the two still land in one message. Implements docs/ongoing/tracked-default-accounts.md §9.1-§9.4 (D20, D21, D22). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/account_management.rs | 71 ++--- src/internet_identity/src/delegation.rs | 33 ++- src/internet_identity/src/main.rs | 9 +- src/internet_identity/src/storage.rs | 135 ++++++++- src/internet_identity/src/storage/account.rs | 14 +- .../src/storage/account/tests.rs | 7 + src/internet_identity/src/storage/storable.rs | 1 + .../src/storage/storable/account_locator.rs | 33 +++ src/internet_identity/src/storage/tests.rs | 279 ++++++++++++++++++ 9 files changed, 518 insertions(+), 64 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/account_locator.rs diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 1a6b4d4695..26ec73a81f 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -468,13 +468,18 @@ fn post_account_operation_bookkeeping(anchor_number: AnchorNumber, operation: Op #[cfg(test)] fn post_account_operation_bookkeeping(_anchor_number: AnchorNumber, _operation: Operation) {} +#[cfg(test)] +fn storage_with_salt() -> Storage { + let mut storage = Storage::new((0, 10000), ic_stable_structures::VectorMemory::default()); + storage.update_salt([17u8; 32]); + storage +} + #[test] fn should_create_account_for_origin() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); let name = "Alice".to_string(); @@ -495,10 +500,8 @@ fn should_create_account_for_origin() { #[test] fn should_fail_to_create_accounts_above_max() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let name = "Alice".to_string(); for i in 0..=MAX_ANCHOR_ACCOUNTS { @@ -516,10 +519,8 @@ fn should_fail_to_create_accounts_above_max() { #[test] fn should_fail_to_update_default_accounts_above_max() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let name = "Alice".to_string(); for i in 0..MAX_ANCHOR_ACCOUNTS { @@ -543,10 +544,8 @@ fn should_fail_to_update_default_accounts_above_max() { #[test] fn should_get_accounts_for_origin() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); let name = "Alice".to_string(); @@ -583,10 +582,8 @@ fn should_get_accounts_for_origin() { #[test] fn should_only_get_own_accounts_for_origin() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let anchor_two = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); @@ -632,10 +629,8 @@ fn should_only_get_own_accounts_for_origin() { #[test] fn should_update_account_for_origin() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); let name = "Alice".to_string(); @@ -714,10 +709,8 @@ fn should_update_account_for_origin() { #[test] fn should_update_default_account_for_origin() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); let name = "Alice".to_string(); @@ -805,10 +798,8 @@ fn should_update_default_account_for_origin() { // It should error when the counters are at or above max and argument 'first_time' is false fn should_fail_check_or_rebuild_when_not_first_time() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); // create faulty counter entries @@ -831,10 +822,8 @@ fn should_fail_check_or_rebuild_when_not_first_time() { #[test] fn should_properly_recalculate_faulty_account_counter() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let name = "Alice".to_string(); @@ -862,10 +851,8 @@ fn should_properly_recalculate_faulty_account_counter() { #[test] fn should_properly_recalculate_faulty_account_counter_when_updating() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); // create faulty counter entries @@ -891,10 +878,8 @@ fn should_properly_recalculate_faulty_account_counter_when_updating() { #[test] fn should_increment_discrepancy_counter() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); // create faulty counter entries @@ -930,10 +915,8 @@ fn should_increment_discrepancy_counter() { #[test] fn should_get_default_account_for_origin() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); let anchor_number = anchor.anchor_number(); @@ -1089,10 +1072,8 @@ fn should_get_default_account_for_origin() { #[test] fn can_get_default_before_update_account_for_origin() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); let anchor_number = anchor.anchor_number(); @@ -1109,10 +1090,8 @@ fn can_get_default_before_update_account_for_origin() { #[test] fn should_get_updated_default_account_after_modification() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin = "https://example.com".to_string(); let anchor_number = anchor.anchor_number(); @@ -1145,10 +1124,8 @@ fn should_get_updated_default_account_after_modification() { #[test] fn should_succeed_get_default_account_for_nonexistent_anchor() { use crate::state::storage_replace; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let nonexistent_anchor = 99999; let origin = "https://example.com".to_string(); @@ -1169,10 +1146,8 @@ fn should_succeed_get_default_account_for_nonexistent_anchor() { #[test] fn should_get_default_account_for_different_origins() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + storage_replace(storage_with_salt()); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let origin1 = "https://app1.com".to_string(); let origin2 = "https://app2.com".to_string(); diff --git a/src/internet_identity/src/delegation.rs b/src/internet_identity/src/delegation.rs index 51aa2d102f..50ea7769dd 100644 --- a/src/internet_identity/src/delegation.rs +++ b/src/internet_identity/src/delegation.rs @@ -75,11 +75,17 @@ pub fn get_principal(anchor_number: AnchorNumber, frontend: FrontendHostname) -> } pub fn calculate_anchor_seed(anchor_number: AnchorNumber, frontend: &FrontendHostname) -> Hash { - let salt = state::salt(); + calculate_anchor_seed_with_salt(&state::salt(), anchor_number, frontend) +} +pub fn calculate_anchor_seed_with_salt( + salt: &[u8; 32], + anchor_number: AnchorNumber, + frontend: &FrontendHostname, +) -> Hash { let mut blob: Vec = vec![]; blob.push(salt.len() as u8); - blob.extend_from_slice(&salt); + blob.extend_from_slice(salt); let anchor_number_str = anchor_number.to_string(); let anchor_number_blob = anchor_number_str.bytes(); @@ -95,12 +101,14 @@ pub fn calculate_anchor_seed(anchor_number: AnchorNumber, frontend: &FrontendHos /// Calculate a seed only from an `AccountNumber` and `FrontendHostname`. /// This is only called when we're not dealing with a default account. /// The anchor number is not included because accounts are not tied to specific anchors. -pub fn calculate_account_seed(account_number: AccountNumber, frontend: &FrontendHostname) -> Hash { - let salt = state::salt(); - +pub fn calculate_account_seed_with_salt( + salt: &[u8; 32], + account_number: AccountNumber, + frontend: &FrontendHostname, +) -> Hash { let mut blob: Vec = vec![]; blob.push(salt.len() as u8); - blob.extend_from_slice(&salt); + blob.extend_from_slice(salt); blob.push(ACCOUNT_SEED_PREFIX.len() as u8); blob.extend(ACCOUNT_SEED_PREFIX.bytes()); @@ -123,8 +131,17 @@ fn hash_bytes(value: impl AsRef<[u8]>) -> Hash { } pub(crate) fn der_encode_canister_sig_key(seed: Vec) -> Vec { - let my_canister_id = id(); - CanisterSigPublicKey::new(my_canister_id, seed).to_der() + der_encode_canister_sig_key_for(id(), seed) +} + +pub(crate) fn der_encode_canister_sig_key_for(canister_id: Principal, seed: Vec) -> Vec { + CanisterSigPublicKey::new(canister_id, seed).to_der() +} + +/// The principal a dapp sees for an account: the self-authenticating principal over +/// the DER-encoded canister signature key for the account's seed. +pub(crate) fn canister_sig_principal(canister_id: Principal, seed: Vec) -> Principal { + Principal::self_authenticating(der_encode_canister_sig_key_for(canister_id, seed)) } /// Adds a delegation signature for `pk` to the signature map. `permissions` diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 17cbbd3371..88bc103f29 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -407,11 +407,12 @@ fn get_accounts( } #[update] -fn create_account( +async fn create_account( anchor_number: AnchorNumber, origin: FrontendHostname, name: String, ) -> Result { + state::ensure_salt_set().await; match check_authorization(anchor_number) { Ok(_) => { // check if this anchor and acc are actually linked @@ -423,12 +424,13 @@ fn create_account( } #[update] -fn update_account( +async fn update_account( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, update: AccountUpdate, ) -> Result { + state::ensure_salt_set().await; match check_authorization(anchor_number) { Ok(_) => account_management::update_account_for_origin( anchor_number, @@ -470,11 +472,12 @@ impl From for SetDefaultAccountError { } #[update] -fn set_default_account( +async fn set_default_account( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, ) -> Result { + state::ensure_salt_set().await; check_authz_and_record_activity(anchor_number).map_err(SetDefaultAccountError::from)?; let result = diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 7c609f3f05..b8f5bd1a97 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -87,7 +87,7 @@ use candid::{CandidType, Deserialize, Principal}; use ic_cdk::api::stable::WASM_PAGE_SIZE_IN_BYTES; use ic_stable_structures::cell::ValueError; use std::borrow::Cow; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use std::io::Write; use std::ops::RangeInclusive; @@ -104,7 +104,7 @@ use ic_stable_structures::{ use identity_jose::jwk::Jwk; use internet_identity_interface::archive::types::BufferedEntry; -use crate::delegation::check_frontend_length; +use crate::delegation::{self, check_frontend_length}; use crate::openid::OpenIdCredentialKey; use crate::state::PersistentState; use crate::stats::event_stats::AggregationKey; @@ -114,6 +114,7 @@ use crate::storage::anchor::Anchor; use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; use crate::storage::storable::account::StorableAccount; +use crate::storage::storable::account_locator::StorableAccountLocator; use crate::storage::storable::account_number::StorableAccountNumber; use crate::storage::storable::account_reference::StorableAccountReference; use crate::storage::storable::accounts_counter::StorableAccountsCounter; @@ -210,6 +211,7 @@ const MCP_GRANT_MEMORY_INDEX: u8 = 29u8; 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 ANCHOR_MEMORY_ID: MemoryId = MemoryId::new(ANCHOR_MEMORY_INDEX); const ARCHIVE_BUFFER_MEMORY_ID: MemoryId = MemoryId::new(ARCHIVE_BUFFER_MEMORY_INDEX); @@ -293,6 +295,11 @@ const SSO_STABLE_ID_INDEX_MEMORY_ID: MemoryId = MemoryId::new(SSO_STABLE_ID_INDE const NEXT_APPLICATION_NUMBER_MEMORY_ID: MemoryId = MemoryId::new(NEXT_APPLICATION_NUMBER_MEMORY_INDEX); +/// 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_ACCOUNT_WITH_PRINCIPAL_MEMORY_ID: MemoryId = + MemoryId::new(LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_INDEX); + // The bucket size 128 is relatively low, to avoid wasting memory when using // multiple virtual memories for smaller amounts of data. // This value results in 256 GB of total managed memory, which should be enough @@ -393,6 +400,9 @@ pub struct Storage { >, stable_account_counter_memory: StableCell>, next_application_number_memory: StableCell>, + lookup_account_with_principal_memory_wrapper: MemoryWrapper>, + lookup_account_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>, @@ -529,6 +539,8 @@ impl Storage { memory_manager.get(STABLE_DEFAULT_ACCOUNT_REFERENCE_MEMORY_ID); let stable_account_counter_memory = memory_manager.get(STABLE_ACCOUNT_COUNTER_MEMORY_ID); 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 stable_account_counter_discrepancy_counter_memory = memory_manager.get(STABLE_ACCOUNT_COUNTER_DISCREPANCY_COUNTER_MEMORY_ID); let lookup_anchor_with_openid_credential_memory = @@ -613,6 +625,12 @@ impl Storage { .expect("stable_account_counter_memory"), next_application_number_memory: StableCell::init(next_application_number_memory, 0) .expect("next_application_number_memory"), + lookup_account_with_principal_memory_wrapper: MemoryWrapper::new( + lookup_account_with_principal_memory.clone(), + ), + lookup_account_with_principal_memory: StableBTreeMap::init( + lookup_account_with_principal_memory, + ), stable_account_counter_discrepancy_counter_memory: StableCell::init( stable_account_counter_discrepancy_counter_memory, StorableDiscrepancyCounter::default(), @@ -1790,6 +1808,8 @@ impl Storage { .get(&application_number) .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; + self.sync_account_principal_index(anchor_number, application_number, &previous, &[])?; + self.stable_account_reference_list_memory.remove(&key); self.stable_anchor_application_config_memory.remove(&key); @@ -1917,6 +1937,8 @@ impl Storage { let deltas = ReferenceListDeltas::between(&previous, ¤t); + self.sync_account_principal_index(anchor_number, application_number, &previous, ¤t)?; + self.stable_account_reference_list_memory .insert(key, current.into()); self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas); @@ -1924,6 +1946,94 @@ impl Storage { Ok(()) } + /// Keeps the principal index in step with one reference-list write, diffing values + /// rather than keys. + fn sync_account_principal_index( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + previous: &[AccountReference], + current: &[AccountReference], + ) -> Result<(), StorageError> { + let salt = *self.salt().ok_or(StorageError::SaltNotSet)?; + let origin = self + .stable_application_memory + .get(&application_number) + .map(|application| application.origin) + .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; + + let previous_entries = + self.account_principals(anchor_number, application_number, &origin, &salt, previous); + let current_entries = + self.account_principals(anchor_number, application_number, &origin, &salt, current); + + for (principal, locator) in &previous_entries { + if current_entries.contains_key(principal) { + continue; + } + if self + .lookup_account_with_principal_memory + .get(principal) + .is_some_and(|stored| stored.anchor_number == locator.anchor_number) + { + self.lookup_account_with_principal_memory.remove(principal); + } + } + + for (principal, locator) in current_entries { + if self.lookup_account_with_principal_memory.get(&principal) == Some(locator.clone()) { + continue; + } + self.lookup_account_with_principal_memory + .insert(principal, locator); + } + + Ok(()) + } + + /// The principals a set of references derives to. A reference whose account row is + /// gone derives nothing and is skipped. + fn account_principals( + &self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + origin: &FrontendHostname, + salt: &[u8; 32], + references: &[AccountReference], + ) -> BTreeMap { + references + .iter() + .filter_map(|reference| { + let account = match reference.account_number { + None => Account::new(anchor_number, origin.clone(), None, None), + Some(account_number) => { + let stored = self.stable_account_memory.get(&account_number)?; + Account::new_full( + anchor_number, + origin.clone(), + Some(stored.name), + Some(account_number), + reference.last_used, + stored.seed_from_anchor, + ) + } + }; + let principal = delegation::canister_sig_principal( + canister_id(), + account.calculate_seed_with_salt(salt).to_vec(), + ); + Some(( + principal, + StorableAccountLocator { + anchor_number, + application_number, + account_number: reference.account_number, + }, + )) + }) + .collect() + } + fn apply_reference_counter_deltas( &mut self, anchor_number: AnchorNumber, @@ -2661,6 +2771,10 @@ impl Storage { "stable_account_reference_list".to_string(), self.stable_account_reference_list_memory_wrapper.size(), ), + ( + "lookup_account_with_principal".to_string(), + self.lookup_account_with_principal_memory_wrapper.size(), + ), ( "stable_anchor_application_config".to_string(), self.stable_anchor_application_config_memory_wrapper.size(), @@ -2703,6 +2817,18 @@ impl Storage { } } +#[cfg(not(test))] +fn canister_id() -> Principal { + ic_cdk::id() +} + +/// `ic_cdk::id()` traps outside a canister, so the unit tests derive principals against +/// a fixed canister id. +#[cfg(test)] +fn canister_id() -> Principal { + Principal::from_slice(&[0, 0, 0, 0, 0, 0, 0, 7, 1, 1]) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ReferenceListDeltas { accounts: i64, @@ -2771,6 +2897,7 @@ pub enum StorageError { application_number: ApplicationNumber, }, ErrorUpdatingAccountCounter, + SaltNotSet, EmptyAccountReferenceList { anchor_number: AnchorNumber, application_number: ApplicationNumber, @@ -2837,6 +2964,10 @@ impl fmt::Display for StorageError { "Origin not found for application number {application_number}", ), Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), + Self::SaltNotSet => write!( + f, + "the salt is not set, so an account principal cannot be derived" + ), Self::EmptyAccountReferenceList { anchor_number, application_number, diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 622134f2e8..71237f37b9 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -164,19 +164,27 @@ impl Account { /// /// * `account` is the `Account` we're using for this delegation pub fn calculate_seed(&self) -> Hash { + self.calculate_seed_with_salt(&crate::state::salt()) + } + + pub fn calculate_seed_with_salt(&self, salt: &[u8; 32]) -> Hash { // If this is a non-stored default account, we derive from frontend and anchor if self.account_number.is_none() { - return delegation::calculate_anchor_seed(self.anchor_number, &self.origin); + return delegation::calculate_anchor_seed_with_salt( + salt, + self.anchor_number, + &self.origin, + ); } match (self.get_seed_anchor(), self.account_number) { (Some(seed_from_anchor), _) => { // If this is a stored default account, we derive from frontend and anchor - delegation::calculate_anchor_seed(seed_from_anchor, &self.origin) + delegation::calculate_anchor_seed_with_salt(salt, seed_from_anchor, &self.origin) } (None, Some(account_number)) => { // If this is an added account, we derive from the account number and origin. - delegation::calculate_account_seed(account_number, &self.origin) + delegation::calculate_account_seed_with_salt(salt, account_number, &self.origin) } (None, None) => trap("Attempted to calculate an account seed from an account without seed anchor or anchor number - this should never happen!") } diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 78226e9a1b..faf40a99ee 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -23,6 +23,7 @@ fn should_create_additional_account() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); // 1. Define additional account parameters let anchor_number: AnchorNumber = 10_000; @@ -99,6 +100,7 @@ fn should_list_accounts() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); // 1. Define additional account parameters let anchor_number: AnchorNumber = 10_000; @@ -164,6 +166,7 @@ fn should_list_all_identity_accounts() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); // 1. Define additional account parameters let anchor_number: AnchorNumber = 10_000; @@ -230,6 +233,7 @@ fn should_update_default_account() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); // 1. Define parameters let anchor_number: AnchorNumber = 10_000; @@ -283,6 +287,7 @@ fn should_update_additional_account() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); // 1. Define additional account parameters let anchor_number: AnchorNumber = 10_000; @@ -363,6 +368,7 @@ fn should_count_accounts_different_anchors() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); // --- Anchor 1 --- let anchor_1 = storage.allocate_anchor(0).unwrap(); @@ -550,6 +556,7 @@ fn should_not_read_account_from_wrong_anchor() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); // 1. Define parameters for two different anchors let anchor_number_1: AnchorNumber = 10_000; diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index abbe9e0f0d..3c7270e50f 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod account_locator; pub mod account_number; pub mod account_reference; pub mod account_reference_list; diff --git a/src/internet_identity/src/storage/storable/account_locator.rs b/src/internet_identity/src/storage/storable/account_locator.rs new file mode 100644 index 0000000000..4af4696dd4 --- /dev/null +++ b/src/internet_identity/src/storage/storable/account_locator.rs @@ -0,0 +1,33 @@ +use crate::storage::storable::account_number::StorableAccountNumber; +use crate::storage::storable::anchor_number::StorableAnchorNumber; +use crate::storage::storable::application_number::StorableApplicationNumber; +use ic_stable_structures::storable::Bound; +use ic_stable_structures::Storable; +use minicbor::{Decode, Encode}; +use std::borrow::Cow; + +/// The triple that identifies one account. Absent account number means the default. +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[cbor(map)] +pub struct StorableAccountLocator { + #[n(0)] + pub anchor_number: StorableAnchorNumber, + #[n(1)] + pub application_number: StorableApplicationNumber, + #[n(2)] + pub account_number: Option, +} + +impl Storable for StorableAccountLocator { + fn to_bytes(&self) -> Cow<'_, [u8]> { + let mut buffer = Vec::new(); + minicbor::encode(self, &mut buffer).expect("failed to encode StorableAccountLocator"); + Cow::Owned(buffer) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + minicbor::decode(&bytes).expect("failed to decode StorableAccountLocator") + } + + const BOUND: Bound = Bound::Unbounded; +} diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index aaf099716e..b23dd070d2 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -418,6 +418,7 @@ fn should_not_overwrite_device_credential_lookup() { fn should_set_account_last_used() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); let origin = "https://example.com".to_string(); // Create an anchor @@ -494,6 +495,7 @@ fn should_set_account_last_used() { fn should_track_the_default_account_on_first_use() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); let origin = "https://example.com".to_string(); // Create an anchor @@ -526,6 +528,7 @@ fn should_track_the_default_account_on_first_use() { fn should_set_account_last_used_for_synthetic_account_with_reference() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); + storage.update_salt([17u8; 32]); let origin = "https://example.com".to_string(); // Create an anchor @@ -2144,6 +2147,7 @@ mod reference_list_write_path_tests { 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(); @@ -2504,6 +2508,7 @@ mod default_account_tracking_tests { 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(); @@ -2666,6 +2671,7 @@ mod tracked_default_eviction_tests { 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(); @@ -3047,6 +3053,7 @@ mod application_removal_tests { fn storage_with_anchors() -> (Storage, AnchorNumber, AnchorNumber) { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt([17u8; 32]); let first = storage.allocate_anchor(0).unwrap(); let first_number = first.anchor_number(); storage.write(first).unwrap(); @@ -3275,3 +3282,275 @@ mod application_removal_tests { ); } } + +mod account_principal_index_tests { + use crate::delegation::canister_sig_principal; + use crate::storage::account::{Account, AccountReference, CreateAccountParams}; + use crate::storage::storable::account_locator::StorableAccountLocator; + use crate::storage::{canister_id, StorageError}; + use crate::Storage; + use candid::Principal; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + const SALT: [u8; 32] = [17u8; 32]; + + 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 default_account_principal(anchor_number: AnchorNumber, origin: &str) -> Principal { + let account = Account::new(anchor_number, origin.to_string(), None, None); + canister_sig_principal( + canister_id(), + account.calculate_seed_with_salt(&SALT).to_vec(), + ) + } + + #[test] + fn tracking_a_default_account_indexes_its_principal() { + 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(); + assert_eq!( + storage + .lookup_account_with_principal_memory + .get(&default_account_principal(anchor_number, &origin)), + Some(StorableAccountLocator { + anchor_number, + application_number, + account_number: None, + }) + ); + } + + #[test] + fn materializing_a_default_updates_the_locator_under_the_same_principal() { + 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 principal = default_account_principal(anchor_number, &origin); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + + let materialized = storage + .update_account(crate::storage::account::UpdateAccountParams { + account_number: None, + anchor_number, + name: "named default".to_string(), + origin: origin.clone(), + }) + .unwrap(); + + assert_eq!( + storage.lookup_account_with_principal_memory.get(&principal), + Some(StorableAccountLocator { + anchor_number, + application_number, + account_number: materialized.account_number, + }) + ); + } + + #[test] + fn a_named_account_gets_its_own_entry() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + + let named = storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let named_principal = canister_sig_principal( + canister_id(), + named.calculate_seed_with_salt(&SALT).to_vec(), + ); + assert_eq!( + storage + .lookup_account_with_principal_memory + .get(&named_principal), + Some(StorableAccountLocator { + anchor_number, + application_number, + account_number: named.account_number, + }) + ); + assert!(storage + .lookup_account_with_principal_memory + .get(&default_account_principal(anchor_number, &origin)) + .is_some()); + assert_ne!( + named_principal, + default_account_principal(anchor_number, &origin) + ); + } + + #[test] + fn distinct_anchors_and_origins_derive_distinct_principals() { + 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(); + + storage + .set_account_last_used(anchor_number, "https://a.com".to_string(), None, 1) + .unwrap(); + storage + .set_account_last_used(anchor_number, "https://b.com".to_string(), None, 2) + .unwrap(); + storage + .set_account_last_used(other_anchor_number, "https://a.com".to_string(), None, 3) + .unwrap(); + + let same_anchor_other_origin = default_account_principal(anchor_number, "https://b.com"); + let other_anchor_same_origin = + default_account_principal(other_anchor_number, "https://a.com"); + let base = default_account_principal(anchor_number, "https://a.com"); + + assert_ne!(base, same_anchor_other_origin); + assert_ne!(base, other_anchor_same_origin); + assert_eq!( + storage + .lookup_account_with_principal_memory + .get(&other_anchor_same_origin) + .unwrap() + .anchor_number, + other_anchor_number + ); + } + + #[test] + fn eviction_removes_the_index_entry() { + 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 principal = default_account_principal(anchor_number, &origin); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage.lookup_account_with_principal_memory.get(&principal), + None + ); + } + + #[test] + fn removing_an_application_leaves_no_dangling_index_entries() { + let (mut storage, anchor_number) = storage_with_anchor(); + for index in 0..5 { + storage + .set_account_last_used(anchor_number, format!("https://dapp-{index}.com"), None, 1) + .unwrap(); + } + let application_numbers: Vec<_> = (0..5) + .map(|index| { + storage + .lookup_application_number_with_origin(&format!("https://dapp-{index}.com")) + .unwrap() + }) + .collect(); + + for application_number in &application_numbers { + storage + .remove_reference_list(anchor_number, *application_number) + .unwrap(); + } + + assert_eq!(storage.lookup_account_with_principal_memory.len(), 0); + for application_number in &application_numbers { + assert!(storage + .stable_application_memory + .get(application_number) + .is_none()); + } + } + + #[test] + fn a_write_without_a_salt_is_refused() { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + let result = storage.write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(1), + }], + ); + + assert!(matches!(result, Err(StorageError::SaltNotSet))); + assert!(storage + .lookup_account_references(anchor_number, application_number) + .is_none()); + } + + #[test] + fn removing_an_entry_owned_by_another_anchor_is_refused() { + 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 principal = default_account_principal(anchor_number, &origin); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let other_anchor_number = anchor_number + 1; + storage.lookup_account_with_principal_memory.insert( + principal, + StorableAccountLocator { + anchor_number: other_anchor_number, + application_number, + account_number: None, + }, + ); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage + .lookup_account_with_principal_memory + .get(&principal) + .unwrap() + .anchor_number, + other_anchor_number + ); + } +} From 9c8cbf53a570db725777e4ffc8590f322166508c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 02:19:28 +0200 Subject: [PATCH 006/298] feat(be): sweep existing account references into the principal index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account references written before the index have no entry, and a lookup miss that could mean either "unknown principal" or "not yet indexed" is not something authorization can be built on. A batched sweep makes the miss unambiguous. The batch takes a cursor and a batch size and reports the next cursor, following the convention previous migrations used, driven by an interval timer that clears itself once a batch examines fewer keys than it asked for. It only inserts: a row derives exactly the entries the write path would have written for it, so a batch that runs twice writes the same values. A batch before the salt is set indexes nothing and leaves the sweep running. Implements docs/ongoing/tracked-default-accounts.md §9.5 (D23). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/api/internet_identity/api_v2.rs | 16 +++ src/internet_identity/src/main.rs | 69 ++++++++++ src/internet_identity/src/storage.rs | 76 ++++++++++ src/internet_identity/src/storage/tests.rs | 130 ++++++++++++++++++ .../tests/integration/accounts.rs | 64 ++++++++- 5 files changed, 349 insertions(+), 6 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 166229bd56..c45aa2af99 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -746,3 +746,19 @@ pub fn get_account_delegation_with_read_only( ) .map(|(x,)| x) } + +/// Hidden monitoring endpoint: `(indexed_entries, is_done)` for the account +/// principal index backfill. +pub fn account_principal_index_backfill_status( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, +) -> Result<(u64, bool), RejectResponse> { + query_candid_as( + env, + canister_id, + sender, + "account_principal_index_backfill_status", + (), + ) +} diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 88bc103f29..9671ece14f 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -15,6 +15,7 @@ use ic_canister_sig_creation::signature_map::LABEL_SIG; use ic_cdk::api::{caller, set_certified_data, trap}; use ic_cdk::call; use ic_cdk_macros::{init, post_upgrade, pre_upgrade, query, update}; +use ic_cdk_timers::TimerId; use internet_identity_interface::archive::types::{BufferedEntry, Operation}; use internet_identity_interface::http_gateway::{HttpRequest, HttpResponse}; use internet_identity_interface::internet_identity::types::attributes::{ @@ -37,7 +38,9 @@ use internet_identity_interface::internet_identity::types::vc_mvp::{ }; use internet_identity_interface::internet_identity::types::*; use serde_bytes::ByteBuf; +use std::cell::RefCell; use std::collections::HashMap; +use std::time::Duration; use storage::account::{AccountDelegationError, PrepareAccountDelegation}; use storage::{Salt, Storage}; @@ -831,6 +834,72 @@ fn initialize(maybe_arg: Option) { if let Some(openid_configs) = config.openid_configs { openid::setup(openid_configs); } + init_account_principal_index_backfill_timer(); +} + +const ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BACKOFF: Duration = Duration::from_secs(1); + +const ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BATCH_SIZE: u64 = 2_000; + +thread_local! { + static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR: RefCell> = const { RefCell::new(None) }; + static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE: RefCell = const { RefCell::new(false) }; + static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED: RefCell = const { RefCell::new(0) }; + static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID: RefCell> = const { RefCell::new(None) }; +} + +/// Returns `(indexed_entries, is_done)` so monitoring can track the sweep. +#[query(hidden = true)] +fn account_principal_index_backfill_status() -> (u64, bool) { + ( + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow(|indexed| *indexed), + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.with_borrow(|done| *done), + ) +} + +fn run_account_principal_index_backfill_batch() { + if ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.with_borrow(|done| *done) { + return; + } + + let cursor = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR.with_borrow(|cursor| *cursor); + let outcome = state::storage_borrow_mut(|storage| { + storage.backfill_account_principal_index_batch( + cursor, + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BATCH_SIZE, + ) + }); + + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow_mut(|indexed| { + *indexed = indexed.saturating_add(outcome.indexed); + }); + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR.replace(outcome.next_cursor); + + if outcome.is_done { + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.replace(true); + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID.with_borrow_mut(|id_slot| { + if let Some(timer_id) = id_slot.take() { + ic_cdk_timers::clear_timer(timer_id); + } + }); + let indexed = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow(|indexed| *indexed); + ic_cdk::println!("Account principal index backfill COMPLETED ({indexed} entries)."); + } +} + +/// Safe to call from both `init` and `post_upgrade`: with nothing to index the +/// first batch immediately reports completion. A batch before the salt exists +/// indexes nothing and leaves the sweep running, so it resumes once it is set. +fn init_account_principal_index_backfill_timer() { + let timer_id = ic_cdk_timers::set_timer_interval( + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BACKOFF, + run_account_principal_index_backfill_batch, + ); + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID.with_borrow_mut(|id_slot| { + if let Some(old_id) = id_slot.replace(timer_id) { + ic_cdk_timers::clear_timer(old_id); + } + }); } fn apply_install_arg(maybe_arg: Option) { diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index b8f5bd1a97..f1de93fd75 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1946,6 +1946,75 @@ impl Storage { Ok(()) } + /// Indexes one batch of existing reference-list rows. Entries are only inserted, + /// never removed, so a batch that runs twice writes the same values. + pub fn backfill_account_principal_index_batch( + &mut self, + cursor: Option<(AnchorNumber, ApplicationNumber)>, + batch_size: u64, + ) -> AccountPrincipalIndexBackfillOutcome { + let mut outcome = AccountPrincipalIndexBackfillOutcome { + next_cursor: cursor, + ..Default::default() + }; + + if batch_size == 0 { + outcome.is_done = true; + return outcome; + } + let Some(salt) = self.salt().copied() else { + return outcome; + }; + + use std::ops::Bound as RangeBound; + let range = match cursor { + Some(cursor) => (RangeBound::Excluded(cursor), RangeBound::Unbounded), + None => (RangeBound::Unbounded, RangeBound::Unbounded), + }; + + let mut examined = 0u64; + let rows: Vec<_> = self + .stable_account_reference_list_memory + .range(range) + .take(batch_size as usize) + .map(|(key, list)| { + examined += 1; + outcome.next_cursor = Some(key); + (key, Vec::::from(list)) + }) + .collect(); + + for ((anchor_number, application_number), references) in rows { + let Some(origin) = self + .stable_application_memory + .get(&application_number) + .map(|application| application.origin) + else { + continue; + }; + + for (principal, locator) in self.account_principals( + anchor_number, + application_number, + &origin, + &salt, + &references, + ) { + if self.lookup_account_with_principal_memory.get(&principal) + == Some(locator.clone()) + { + continue; + } + self.lookup_account_with_principal_memory + .insert(principal, locator); + outcome.indexed += 1; + } + } + + outcome.is_done = examined < batch_size; + outcome + } + /// Keeps the principal index in step with one reference-list write, diffing values /// rather than keys. fn sync_account_principal_index( @@ -2817,6 +2886,13 @@ impl Storage { } } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AccountPrincipalIndexBackfillOutcome { + pub next_cursor: Option<(AnchorNumber, ApplicationNumber)>, + pub indexed: u64, + pub is_done: bool, +} + #[cfg(not(test))] fn canister_id() -> Principal { ic_cdk::id() diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index b23dd070d2..a8424c2dd1 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3554,3 +3554,133 @@ mod account_principal_index_tests { ); } } + +mod account_principal_index_backfill_tests { + use crate::delegation::canister_sig_principal; + use crate::storage::account::{Account, AccountReference}; + use crate::storage::canister_id; + use crate::Storage; + use candid::Principal; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + const SALT: [u8; 32] = [17u8; 32]; + + fn storage_with_rows(rows: u64) -> (Storage, Vec) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt(SALT); + let mut anchors = vec![]; + for index in 0..rows { + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + anchors.push(anchor_number); + let application_number = storage + .lookup_or_insert_application_number_with_origin(&format!("https://d-{index}.com")); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(index + 1), + }], + ) + .unwrap(); + } + (storage, anchors) + } + + fn clear_index(storage: &mut Storage) { + let keys: Vec = storage + .lookup_account_with_principal_memory + .iter() + .map(|(key, _)| key) + .collect(); + for key in keys { + storage.lookup_account_with_principal_memory.remove(&key); + } + } + + #[test] + fn a_sweep_indexes_every_pre_existing_row() { + let (mut storage, anchors) = storage_with_rows(5); + clear_index(&mut storage); + assert_eq!(storage.lookup_account_with_principal_memory.len(), 0); + + let outcome = storage.backfill_account_principal_index_batch(None, 100); + + assert!(outcome.is_done); + assert_eq!(outcome.indexed, 5); + assert_eq!(storage.lookup_account_with_principal_memory.len(), 5); + for (index, anchor_number) in anchors.iter().enumerate() { + let account = + Account::new(*anchor_number, format!("https://d-{index}.com"), None, None); + let principal = canister_sig_principal( + canister_id(), + account.calculate_seed_with_salt(&SALT).to_vec(), + ); + assert_eq!( + storage + .lookup_account_with_principal_memory + .get(&principal) + .unwrap() + .anchor_number, + *anchor_number + ); + } + } + + #[test] + fn a_sweep_resumes_from_its_cursor() { + let (mut storage, _) = storage_with_rows(5); + clear_index(&mut storage); + + let first = storage.backfill_account_principal_index_batch(None, 2); + assert!(!first.is_done); + assert_eq!(first.indexed, 2); + + let second = storage.backfill_account_principal_index_batch(first.next_cursor, 2); + assert!(!second.is_done); + assert_eq!(second.indexed, 2); + + let third = storage.backfill_account_principal_index_batch(second.next_cursor, 2); + assert!(third.is_done); + assert_eq!(third.indexed, 1); + assert_eq!(storage.lookup_account_with_principal_memory.len(), 5); + } + + #[test] + fn a_repeated_sweep_writes_nothing_new() { + let (mut storage, _) = storage_with_rows(3); + + let outcome = storage.backfill_account_principal_index_batch(None, 100); + + assert!(outcome.is_done); + assert_eq!(outcome.indexed, 0); + assert_eq!(storage.lookup_account_with_principal_memory.len(), 3); + } + + #[test] + fn a_sweep_without_a_salt_indexes_nothing_and_stays_unfinished() { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + let anchor = storage.allocate_anchor(0).unwrap(); + storage.write(anchor).unwrap(); + + let outcome = storage.backfill_account_principal_index_batch(None, 100); + + assert!(!outcome.is_done); + assert_eq!(outcome.indexed, 0); + } + + #[test] + fn an_empty_batch_size_finishes_immediately() { + let (mut storage, _) = storage_with_rows(3); + + let outcome = storage.backfill_account_principal_index_batch(None, 0); + + assert!(outcome.is_done); + assert_eq!(outcome.indexed, 0); + } +} diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 40cd7b0ae5..994b3d7b7f 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -1,17 +1,18 @@ use canister_tests::{ api::internet_identity::{ api_v2::{ - create_account, get_account_delegation, get_account_delegation_with_read_only, - get_accounts, get_default_account, prepare_account_delegation, - prepare_account_delegation_with_read_only, set_default_account, update_account, - AccountDelegationParams, + account_principal_index_backfill_status, create_account, get_account_delegation, + get_account_delegation_with_read_only, get_accounts, get_default_account, + prepare_account_delegation, prepare_account_delegation_with_read_only, + set_default_account, update_account, AccountDelegationParams, }, get_delegation, prepare_delegation, }, flows, framework::{ - device_data_2, env, get_metrics, install_ii_with_archive, parse_metric, principal_1, - principal_2, time, verify_delegation, + device_data_2, env, get_metrics, install_ii_canister, install_ii_with_archive, + parse_metric, principal_1, principal_2, time, upgrade_ii_canister, verify_delegation, + II_WASM, II_WASM_PREVIOUS, }, }; use internet_identity_interface::internet_identity::types::{ @@ -1663,3 +1664,54 @@ fn should_remove_unreferenced_applications_an_anchor_stops_referencing( Ok(()) } + +/// Verifies that account references written before the principal index existed are +/// swept into it, so a lookup miss is unambiguous once the sweep reports completion. +#[test] +fn should_backfill_the_account_principal_index_after_an_upgrade() -> Result<(), RejectResponse> { + let env = env(); + // Installed from the release that has no index, so its account references are the + // ones the sweep has to pick up. + let canister_id = install_ii_canister(&env, II_WASM_PREVIOUS.clone()); + let identity_number = flows::register_anchor(&env, canister_id); + + for index in 0..3 { + create_account( + &env, + canister_id, + principal_1(), + identity_number, + format!("https://dapp-{index}.com"), + format!("account-{index}"), + )? + .unwrap(); + } + + let params = AccountDelegationParams::new( + &env, + canister_id, + principal_1(), + identity_number, + "https://dapp-0.com".to_string(), + None, + ByteBuf::from(vec![1; 32]), + ); + prepare_account_delegation(¶ms, None)?.unwrap(); + + upgrade_ii_canister(&env, canister_id, II_WASM.clone()); + + env.advance_time(Duration::from_secs(5)); + for _ in 0..5 { + env.tick(); + } + + let (indexed, is_done) = + account_principal_index_backfill_status(&env, canister_id, principal_1())?; + assert!(is_done, "the backfill should report completion"); + assert_eq!( + indexed, 6, + "three named accounts, each alongside the default reference backfilled with it" + ); + + Ok(()) +} From cfb384072080d9fda4b7d15088713bd74c71f6e6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 02:34:37 +0200 Subject: [PATCH 007/298] feat(be): store revocable sessions on the account reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session is `(created_at, valid_till, last_refreshed, device_id, read_only)` on the account reference, so it inherits the per-anchor caps that already bound references and needs no accounting of its own. The field is optional, so references written under the previous schema decode unchanged, and an empty list is not stored. Only `last_refreshed` is mutable, which is why it is the one field that will not feed the session's seed: a mutable input would change the session's principal every time it was stamped. It is also what the 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. A row carries no eviction exemption for holding a session. The row is what makes an app visible in settings, so sparing it would leave the user access they cannot see, and a session nobody can find is a session nobody can revoke. What an eviction costs is a ceremony rather than an account, since the account is computed and returns at the identical principal on the next sign-in. Implements docs/ongoing/revocable-app-sessions.md §4 and §4.2 (S1, S3). Co-Authored-By: Claude Opus 5 (1M context) --- src/internet_identity/src/storage.rs | 34 +-- src/internet_identity/src/storage/account.rs | 57 ++++- src/internet_identity/src/storage/storable.rs | 2 + .../src/storage/storable/account_reference.rs | 14 ++ .../src/storage/storable/session_device_id.rs | 1 + .../src/storage/storable/session_record.rs | 60 +++++ src/internet_identity/src/storage/tests.rs | 232 +++++++++++++----- .../src/internet_identity/types.rs | 2 + 8 files changed, 303 insertions(+), 99 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/session_device_id.rs create mode 100644 src/internet_identity/src/storage/storable/session_record.rs diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index f1de93fd75..40524aee2c 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1755,10 +1755,7 @@ impl Storage { self.write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: Some(now), - }], + vec![AccountReference::new(None, Some(now))], )?; self.evict_idle_tracked_defaults(anchor_number, application_number)?; Ok(Some(())) @@ -1781,10 +1778,7 @@ impl Storage { self.write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: None, - }], + vec![AccountReference::new(None, None)], )?; self.evict_idle_tracked_defaults(anchor_number, application_number) } @@ -1829,7 +1823,7 @@ impl Storage { (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), ) .filter_map(|((_, application_number), list)| { - let references = list.into_vec(); + let references: Vec = list.into(); match references.as_slice() { [tracked_default] if tracked_default.account_number.is_none() => { Some((application_number, tracked_default.last_used)) @@ -2321,23 +2315,15 @@ impl Storage { // If no list exists for this anchor & application, // Create and insert the default and additional account. // This is because we don't create default accounts explicitly. - let additional_account_reference = AccountReference { - account_number: Some(account_number), - last_used, - }; - let default_account_reference = AccountReference { - account_number: None, - last_used, - }; + let additional_account_reference = + AccountReference::new(Some(account_number), last_used); + let default_account_reference = AccountReference::new(None, last_used); vec![default_account_reference, additional_account_reference] } Some(existing_storable_list) => { // If the list exists, push the new account and reinsert it to memory let mut refs_vec: Vec = existing_storable_list.into(); - refs_vec.push(AccountReference { - account_number: Some(account_number), - last_used, - }); + refs_vec.push(AccountReference::new(Some(account_number), last_used)); refs_vec } }; @@ -2597,11 +2583,7 @@ impl Storage { // If no list exists for this anchor & application, // Create and insert the default account. // This is because we don't create default accounts explicitly. - vec![AccountReference { - account_number: Some(new_account_number), - // The `last_used` field will be set when the user signs with this account. - last_used: None, - }] + vec![AccountReference::new(Some(new_account_number), None)] } Some(existing_storable_list) => { // If the list exists, update the default account reference with the new account number. diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 71237f37b9..c0c226bcea 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -8,7 +8,7 @@ use ic_cdk::trap; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, ApplicationNumber, - FrontendHostname, Timestamp, UserKey, + FrontendHostname, SessionDeviceId, Timestamp, UserKey, }; use serde::{Deserialize, Serialize}; @@ -56,6 +56,56 @@ pub struct AccountsCounter { pub struct AccountReference { pub account_number: Option, // None is the unreserved synthetic account pub last_used: Option, + pub sessions: Vec, +} + +impl AccountReference { + pub fn new(account_number: Option, last_used: Option) -> Self { + Self { + account_number, + last_used, + sessions: vec![], + } + } +} + +/// A revocable session at one account. Only `last_refreshed` is mutable, which is why +/// it is the one field absent from the seed. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct SessionRecord { + pub created_at: Timestamp, + pub valid_till: Timestamp, + pub last_refreshed: Option, + pub device_id: SessionDeviceId, + pub read_only: bool, +} + +impl SessionRecord { + pub fn is_expired(&self, now: Timestamp) -> bool { + self.valid_till <= now + } + + /// How long this session stayed in service: the span from its creation to the last time + /// its app asked for a delegation. Bounded by the session's own lifetime. + pub fn demonstrated_use(&self) -> u64 { + self.last_refreshed + .map_or(0, |refreshed| refreshed.saturating_sub(self.created_at)) + } + + /// What the caps reclaim on, ascending: dead sessions first, then live ones by how + /// recently used, extended by how long they stayed in service. + /// + /// The extension is what separates an app in weekly use from one opened once and + /// abandoned, which recency alone gets backwards — the abandoned one was touched more + /// recently. `device_id` only makes the order total. + pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, SessionDeviceId) { + let last_used = self.last_refreshed.unwrap_or(self.created_at); + ( + !self.is_expired(now), + last_used.saturating_add(self.demonstrated_use()), + self.device_id, + ) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -141,10 +191,7 @@ impl Account { // Used in tests (for now) #[allow(dead_code)] pub fn to_reference(&self) -> AccountReference { - AccountReference { - account_number: self.account_number, - last_used: self.last_used, - } + AccountReference::new(self.account_number, self.last_used) } pub fn to_info(&self) -> AccountInfo { diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 3c7270e50f..4b94d30798 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -24,6 +24,8 @@ pub mod openid_credential_key; pub mod openid_jwks; pub mod passkey_credential; pub mod recovery_key; +pub mod session_device_id; +pub mod session_record; pub mod special_device_migration; pub mod sso_stable_id_key; pub mod storable_persistent_state; diff --git a/src/internet_identity/src/storage/storable/account_reference.rs b/src/internet_identity/src/storage/storable/account_reference.rs index 0f516dbc1d..c74aa5e199 100644 --- a/src/internet_identity/src/storage/storable/account_reference.rs +++ b/src/internet_identity/src/storage/storable/account_reference.rs @@ -1,5 +1,6 @@ use crate::storage::account::AccountReference; use crate::storage::storable::account_number::StorableAccountNumber; +use crate::storage::storable::session_record::StorableSessionRecord; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use internet_identity_interface::internet_identity::types::Timestamp; @@ -15,6 +16,8 @@ pub struct StorableAccountReference { // For example, it's not changed when the account is renamed. #[n(1)] pub last_used: Option, + #[n(2)] + pub sessions: Option>, } impl Storable for StorableAccountReference { @@ -36,6 +39,12 @@ impl From for AccountReference { AccountReference { account_number: value.account_number, last_used: value.last_used, + sessions: value + .sessions + .unwrap_or_default() + .into_iter() + .map(Into::into) + .collect(), } } } @@ -45,6 +54,11 @@ impl From for StorableAccountReference { StorableAccountReference { account_number: value.account_number, last_used: value.last_used, + sessions: if value.sessions.is_empty() { + None + } else { + Some(value.sessions.into_iter().map(Into::into).collect()) + }, } } } diff --git a/src/internet_identity/src/storage/storable/session_device_id.rs b/src/internet_identity/src/storage/storable/session_device_id.rs new file mode 100644 index 0000000000..1da8905dd8 --- /dev/null +++ b/src/internet_identity/src/storage/storable/session_device_id.rs @@ -0,0 +1 @@ +pub type StorableSessionDeviceId = u32; diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs new file mode 100644 index 0000000000..6ac550dd69 --- /dev/null +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -0,0 +1,60 @@ +use crate::storage::account::SessionRecord; +use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use ic_stable_structures::storable::Bound; +use ic_stable_structures::Storable; +use internet_identity_interface::internet_identity::types::Timestamp; +use minicbor::{Decode, Encode}; +use std::borrow::Cow; + +#[derive(Encode, Decode, Clone, Debug, Ord, Eq, PartialEq, PartialOrd)] +#[cbor(map)] +pub struct StorableSessionRecord { + #[n(0)] + pub created_at: Timestamp, + #[n(1)] + pub valid_till: Timestamp, + #[n(2)] + pub last_refreshed: Option, + #[n(3)] + pub device_id: StorableSessionDeviceId, + #[n(4)] + pub read_only: bool, +} + +impl Storable for StorableSessionRecord { + fn to_bytes(&self) -> Cow<'_, [u8]> { + let mut buffer = Vec::new(); + minicbor::encode(self, &mut buffer).expect("failed to encode StorableSessionRecord"); + Cow::Owned(buffer) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + minicbor::decode(&bytes).expect("failed to decode StorableSessionRecord") + } + + const BOUND: Bound = Bound::Unbounded; +} + +impl From for SessionRecord { + fn from(value: StorableSessionRecord) -> Self { + SessionRecord { + created_at: value.created_at, + valid_till: value.valid_till, + last_refreshed: value.last_refreshed, + device_id: value.device_id, + read_only: value.read_only, + } + } +} + +impl From for StorableSessionRecord { + fn from(value: SessionRecord) -> Self { + StorableSessionRecord { + created_at: value.created_at, + valid_till: value.valid_till, + last_refreshed: value.last_refreshed, + device_id: value.device_id, + read_only: value.read_only, + } + } +} diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index a8424c2dd1..36cf8c58f9 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2179,10 +2179,7 @@ mod reference_list_write_path_tests { let result = storage.write_reference_list( anchor_number, unknown_application_number, - vec![AccountReference { - account_number: None, - last_used: None, - }], + vec![AccountReference::new(None, None)], ); assert!(matches!( @@ -2209,10 +2206,7 @@ mod reference_list_write_path_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); - let references = vec![AccountReference { - account_number: Some(1), - last_used: None, - }]; + let references = vec![AccountReference::new(Some(1), None)]; storage .write_reference_list(anchor_number, application_number, references.clone()) .unwrap(); @@ -2239,14 +2233,8 @@ mod reference_list_write_path_tests { anchor_number, application_number, vec![ - AccountReference { - account_number: None, - last_used: None, - }, - AccountReference { - account_number: Some(7), - last_used: None, - }, + AccountReference::new(None, None), + AccountReference::new(Some(7), None), ], ) .unwrap(); @@ -2277,20 +2265,14 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: None, - }], + vec![AccountReference::new(None, None)], ) .unwrap(); storage .write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: Some(3), - last_used: None, - }], + vec![AccountReference::new(Some(3), None)], ) .unwrap(); @@ -2308,10 +2290,7 @@ mod reference_list_write_path_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); - let references = vec![AccountReference { - account_number: Some(1), - last_used: None, - }]; + let references = vec![AccountReference::new(Some(1), None)]; storage .write_reference_list(anchor_number, application_number, references.clone()) @@ -2322,10 +2301,7 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: Some(1), - last_used: Some(123), - }], + vec![AccountReference::new(Some(1), Some(123))], ) .unwrap(); @@ -2530,11 +2506,7 @@ mod default_account_tracking_tests { .unwrap(); assert_eq!( storage.lookup_account_references(anchor_number, application_number), - Some(vec![AccountReference { - account_number: None, - last_used: Some(1_000), - } - .into()]) + Some(vec![AccountReference::new(None, Some(1_000)).into()]) ); } @@ -2575,10 +2547,7 @@ mod default_account_tracking_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: Some(9), - last_used: None, - }], + vec![AccountReference::new(Some(9), None)], ) .unwrap(); @@ -2628,11 +2597,7 @@ mod default_account_tracking_tests { assert_eq!( storage.lookup_account_references(anchor_number, application_number), - Some(vec![AccountReference { - account_number: None, - last_used: None, - } - .into()]) + Some(vec![AccountReference::new(None, None).into()]) ); } @@ -2769,10 +2734,7 @@ mod tracked_default_eviction_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: Some(index + 1), - }], + vec![AccountReference::new(None, Some(index + 1))], ) .unwrap(); } @@ -2787,8 +2749,10 @@ mod tracked_default_eviction_tests { ) .unwrap(); - let after = storage.evictable_default_rows(anchor_number).len() as u64; - assert_eq!(before + 1 - after, MAX_EVICTIONS_PER_CALL); + assert_eq!( + before + 1 - storage.evictable_default_rows(anchor_number).len() as u64, + MAX_EVICTIONS_PER_CALL + ); } #[test] @@ -3031,10 +2995,7 @@ mod tracked_default_eviction_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: Some(1), - last_used: None, - }], + vec![AccountReference::new(Some(1), None)], ) .unwrap(); @@ -3201,10 +3162,7 @@ mod application_removal_tests { .write_reference_list( other_anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: Some(1_000), - }], + vec![AccountReference::new(None, Some(1_000))], ) .unwrap(); @@ -3507,10 +3465,7 @@ mod account_principal_index_tests { let result = storage.write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: Some(1), - }], + vec![AccountReference::new(None, Some(1))], ); assert!(matches!(result, Err(StorageError::SaltNotSet))); @@ -3582,10 +3537,7 @@ mod account_principal_index_backfill_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: Some(index + 1), - }], + vec![AccountReference::new(None, Some(index + 1))], ) .unwrap(); } @@ -3684,3 +3636,147 @@ mod account_principal_index_backfill_tests { assert_eq!(outcome.indexed, 0); } } + +mod session_record_tests { + use crate::storage::account::{AccountReference, SessionRecord}; + use crate::storage::storable::account_reference::StorableAccountReference; + use crate::{Storage, DAY_NS, MINUTE_NS}; + use ic_stable_structures::{Storable, VectorMemory}; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + fn session(created_at: u64, valid_till: u64) -> SessionRecord { + SessionRecord { + created_at, + valid_till, + last_refreshed: None, + device_id: 1, + read_only: false, + } + } + + 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) + } + + #[test] + fn a_reference_with_sessions_round_trips() { + let reference = AccountReference { + account_number: Some(3), + last_used: Some(9), + sessions: vec![session(1, 100), session(2, 200)], + }; + + let stored = StorableAccountReference::from(reference.clone()); + let decoded = + AccountReference::from(StorableAccountReference::from_bytes(stored.to_bytes())); + + assert_eq!(decoded, reference); + } + + #[test] + fn a_reference_written_before_sessions_existed_decodes_with_none() { + let stored = StorableAccountReference { + account_number: Some(1), + last_used: Some(5), + sessions: None, + }; + + let decoded = + AccountReference::from(StorableAccountReference::from_bytes(stored.to_bytes())); + + assert_eq!(decoded.sessions, vec![]); + assert_eq!(decoded.account_number, Some(1)); + assert_eq!(decoded.last_used, Some(5)); + } + + #[test] + fn an_empty_session_list_is_not_stored() { + let reference = AccountReference::new(Some(1), None); + + assert_eq!(StorableAccountReference::from(reference).sessions, None); + } + + /// A row is evictable on its shape alone. Sparing one because it holds a live session + /// would leave the user with access that settings cannot show them, and a session + /// nobody can find is a session nobody can revoke. + #[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); + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(1), + sessions: vec![session(1, u64::MAX)], + }], + ) + .unwrap(); + + assert_eq!(storage.evictable_default_rows(anchor_number).len(), 1); + } + + #[test] + fn reclaim_order_ranks_dead_sessions_first() { + let now = 1_000; + let expired = session(1, 500); + let live = SessionRecord { + last_refreshed: Some(900), + ..session(400, 10_000) + }; + let live_untouched = session(400, 10_000); + + assert!(expired.reclaim_order(now) < live.reclaim_order(now)); + 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; + // Signed in three months ago, still being opened every few days. + let weekly = SessionRecord { + last_refreshed: Some(now - 3 * DAY_NS), + ..session(now - 90 * DAY_NS, now + DAY_NS) + }; + // Signed in yesterday, used for five minutes, never opened again. + let one_sitting = SessionRecord { + last_refreshed: Some(now - DAY_NS + 5 * MINUTE_NS), + ..session(now - DAY_NS, now + DAY_NS) + }; + + assert!( + one_sitting.reclaim_order(now) < weekly.reclaim_order(now), + "the more recently touched session goes first, having stayed in service for minutes" + ); + } +} diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index ad1829ee6b..4dcf0b549a 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -14,6 +14,8 @@ pub type CanisterSigPublicKeyDer = PublicKey; pub type FrontendHostname = String; pub type ApplicationNumber = u64; pub type Timestamp = u64; // in nanos since epoch +/// Per-anchor label for one browser, so a browser's sessions can be revoked together. +pub type SessionDeviceId = u32; pub type Signature = ByteBuf; pub type DeviceConfirmationCode = String; pub type FailedAttemptsCounter = u8; From bd7536b9a6c869e3563bc6fbdf24a07a2391a921 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 19 Aug 2026 02:51:14 +0200 Subject: [PATCH 008/298] feat(be): register the browser a session was created from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions are per account, so a user who wants to sign one browser out has nothing to name it by. Anchors gain a device registry: `{id, key, pending, name, created_at, last_used}` per browser, capped at 20 because the anchor blob is read on nearly every authenticated path, with a monotonic per-anchor allocator so ids are never reused. A browser is identified by a public key it holds, and rotates that key at every sign-in: `key` is what it presented last, `pending` the successor it announced, and either resolves to the entry. Presenting the successor promotes it and retires the key it replaces, so a browser profile copied off disk cannot keep signing in alongside the original — whichever authenticates second presents a retired key and shows up as a new browser. Accepting both values is what makes a lost response harmless. A browser advances to its successor only once a sign-in has succeeded, so an unanswered call leaves it proving with the key the entry still holds rather than looking like a new machine. An announced successor that another browser of the same anchor already holds is refused. Presented keys are visible on the wire, so without that a caller could announce a key another browser is about to present and take over its entry when it does. The id never changes across rotations, which is why rotating costs a session nothing: sessions record the id, not the key. It is also what `revoke_device_sessions` and `identity_info` name, so neither has to carry a key. At the cap the least recently used record is dropped rather than the registration failing, which costs that browser its name in the session list and never costs anyone a sign-in. Eviction orders on `last_used`, not on `created_at`. Clearing browser storage loses the browser's key, so each wipe enrols a fresh record; ordering by enrolment would spend the cap evicting the browsers a user actually signs in from while the churn survives, and since eviction also ends the dropped browser's sessions, that signs them out on a device they never touched. Ordering on use makes each wipe's throwaway records evict each other instead. `last_used` is also what the settings list wants to read: "last used" is the question someone deciding what to sign out is asking, and enrolment does not answer it. Devices live on the anchor, so they ride on `identity_info` alongside `mcp_config` rather than needing a call of their own. Implements the registry in docs/ongoing/revocable-app-sessions-spec.md. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/generated/internet_identity_idl.js | 7 + .../generated/internet_identity_types.d.ts | 20 + .../(home)/smartActions.test.ts | 1 + src/internet_identity/internet_identity.did | 15 + .../src/email_recovery/remove.rs | 2 + src/internet_identity/src/main.rs | 17 + src/internet_identity/src/storage.rs | 2 + src/internet_identity/src/storage/anchor.rs | 160 ++++++ .../src/storage/anchor/tests.rs | 487 ++++++++++++++++++ src/internet_identity/src/storage/storable.rs | 1 + .../src/storage/storable/anchor.rs | 8 + .../src/storage/storable/session_device.rs | 37 ++ src/internet_identity/src/storage/tests.rs | 40 ++ .../src/verified_emails/remove.rs | 2 + .../tests/integration/upgrade.rs | 19 + .../src/internet_identity/types/api_v2.rs | 13 +- 16 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 src/internet_identity/src/storage/storable/session_device.rs diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index a129756ce3..55988f7be0 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -559,6 +559,12 @@ export const idlFactory = ({ IDL }) => { 'address' : IDL.Text, 'last_used' : IDL.Opt(Timestamp), }); + const SessionDeviceInfo = IDL.Record({ + 'id' : IDL.Nat32, + 'name' : IDL.Text, + 'created_at' : Timestamp, + 'last_used' : Timestamp, + }); const McpConfig = IDL.Record({ 'url' : IDL.Opt(IDL.Text), 'enabled' : IDL.Bool, @@ -575,6 +581,7 @@ export const idlFactory = ({ IDL }) => { 'name' : IDL.Opt(IDL.Text), 'email_recovery' : IDL.Opt(IDL.Vec(EmailRecoveryCredential)), 'created_at' : IDL.Opt(Timestamp), + 'session_devices' : IDL.Opt(IDL.Vec(SessionDeviceInfo)), 'mcp_config' : IDL.Opt(McpConfig), 'authn_method_registration' : IDL.Opt(AuthnMethodRegistrationInfo), 'openid_credentials' : IDL.Opt(IDL.Vec(OpenIdCredential)), 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 418784ba4a..5aacaa52da 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -942,6 +942,12 @@ export interface IdentityInfo { * The timestamp at which the anchor was created */ 'created_at' : [] | [Timestamp], + /** + * Browsers this anchor has signed in from (absent when it has never + * created a session), so the Settings UI can offer "sign this browser + * out" without a separate call. + */ + 'session_devices' : [] | [Array], /** * The anchor's synced trusted-MCP-server config (absent when the * anchor never wrote one). Carried here rather than read from the @@ -1528,6 +1534,20 @@ export type Salt = Uint8Array | number[]; export type SessionDelegationError = { 'NoSuchDelegation' : null } | { 'InternalCanisterError' : string } | { 'Unauthorized' : Principal }; +/** + * A browser an anchor has signed in from. The name is self-reported by the + * client, so it is a label for the user rather than evidence about where a + * session came from. + */ +export interface SessionDeviceInfo { + 'id' : number, + 'name' : string, + 'created_at' : Timestamp, + /** + * Advanced by a sign-in from this browser and by every session refresh it drives. + */ + 'last_used' : Timestamp, +} export type SessionKey = PublicKey; export type SetDefaultAccountError = { 'NoSuchOrigin' : { 'anchor_number' : UserNumber } diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts index 41bc9ef6c1..bb411b3ece 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts @@ -40,6 +40,7 @@ const baseIdentityInfo: IdentityInfo = { created_at: [], authn_method_registration: [], openid_credentials: [], + session_devices: [], mcp_config: [], }; diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index df31eff779..7c97b7f522 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1006,6 +1006,17 @@ type IdentityAuthnInfo = record { recovery_authn_methods : vec AuthnMethod; }; +// A browser an anchor has signed in from. The name is self-reported by the +// client, so it is a label for the user rather than evidence about where a +// session came from. +type SessionDeviceInfo = record { + id : nat32; + name : text; + created_at : Timestamp; + // Advanced by a sign-in from this browser and by every session refresh it drives. + last_used : Timestamp; +}; + type IdentityInfo = record { authn_methods : vec AuthnMethodData; authn_method_registration : opt AuthnMethodRegistrationInfo; @@ -1026,6 +1037,10 @@ type IdentityInfo = record { // shows a "limit reached" notice in the wizard when adding // beyond the cap. verified_emails : opt vec VerifiedEmail; + // Browsers this anchor has signed in from (absent when it has never + // created a session), so the Settings UI can offer "sign this browser + // out" without a separate call. + session_devices : opt vec SessionDeviceInfo; // The anchor's synced trusted-MCP-server config (absent when the // anchor never wrote one). Carried here rather than read from the // mcp_get_config query so the Settings UI has a certified value to diff --git a/src/internet_identity/src/email_recovery/remove.rs b/src/internet_identity/src/email_recovery/remove.rs index 2bcf28a491..e4cde76364 100644 --- a/src/internet_identity/src/email_recovery/remove.rs +++ b/src/internet_identity/src/email_recovery/remove.rs @@ -75,6 +75,8 @@ mod tests { fn anchor_with(address: Option<&str>) -> Anchor { let mut a = Anchor { + session_devices: vec![], + next_session_device_id: 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 9671ece14f..3195121e14 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -1155,6 +1155,22 @@ mod v2_api { Some(stored_verified_emails) }; + let stored_session_devices: Vec = state::anchor(identity_number) + .session_devices() + .iter() + .map(|device| SessionDeviceInfo { + id: device.id, + name: device.name.clone(), + created_at: device.created_at, + last_used: device.last_used, + }) + .collect(); + let session_devices = if stored_session_devices.is_empty() { + None + } else { + Some(stored_session_devices) + }; + let identity_info = IdentityInfo { authn_methods: anchor_info .devices @@ -1170,6 +1186,7 @@ mod v2_api { created_at: anchor_info.created_at, email_recovery, verified_emails, + session_devices, // The same config `mcp_get_config` serves, but certified: this is // an update call, so the Settings UI can render the trusted server // — and base the config it writes back — on a value no single node diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 40524aee2c..530b009e5d 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -885,6 +885,8 @@ impl Storage { created_at_ns: _, name: _, verified_emails: _, + session_devices: _, + next_session_device_id: _, }) = previous_anchor_maybe { ( diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 95d57cc5dc..9197acd7e7 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -6,6 +6,7 @@ use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCr use crate::storage::storable::fixed_anchor::StorableFixedAnchor; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; +use crate::storage::storable::session_device::StorableSessionDevice; use crate::storage::storable::special_device_migration::SpecialDeviceMigration; use crate::storage::storable::verified_email::StorableVerifiedEmail; use crate::{IC0_APP_ORIGIN, ID_AI_ORIGIN, INTERNETCOMPUTER_ORG_ORIGIN}; @@ -38,11 +39,66 @@ pub struct Anchor { pub(crate) email_recovery: Vec, /// Capped by `MAX_VERIFIED_EMAILS_PER_ANCHOR`. pub(crate) verified_emails: Vec, + /// Capped by `MAX_SESSION_DEVICES`. + pub(crate) session_devices: Vec, + pub(crate) next_session_device_id: SessionDeviceId, pub(crate) metadata: Option>, pub(crate) name: Option, pub(crate) created_at: Option, } +/// Bounds the device list, which rides on the anchor blob. +pub const MAX_SESSION_DEVICES: usize = 20; + +/// Why a browser's presented keys cannot be resolved. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionDeviceError { + /// The announced successor is a key another browser of this anchor already holds. + /// + /// Presented keys are visible on the wire, so without this a caller could announce a + /// key another browser is about to present and take over its entry when it does. + SuccessorAlreadyInUse, +} + +/// A browser this anchor has signed in from. The name is self-reported by the client. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionDevice { + pub id: SessionDeviceId, + /// The browser's own public key, DER-encoded. What the entry is looked up by. + pub key: PublicKey, + /// The successor the browser announced at its last sign-in, also accepted as a proof. + pub pending: PublicKey, + pub name: String, + pub created_at: Timestamp, + pub last_used: Timestamp, +} + +impl From for SessionDevice { + fn from(value: StorableSessionDevice) -> Self { + SessionDevice { + id: value.id, + key: ByteBuf::from(value.key), + pending: ByteBuf::from(value.pending), + name: value.name, + created_at: value.created_at, + last_used: value.last_used, + } + } +} + +impl From for StorableSessionDevice { + fn from(value: SessionDevice) -> Self { + StorableSessionDevice { + id: value.id, + key: value.key.into_vec(), + pending: value.pending.into_vec(), + name: value.name, + created_at: value.created_at, + last_used: value.last_used, + } + } +} + impl Device { /// Applies the values of `device_data` to self while leaving the other fields intact. pub fn apply_device_data(&mut self, device_data: DeviceData) { @@ -175,6 +231,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { openid_credentials, email_recovery, verified_emails, + session_devices, + next_session_device_id, metadata, name, created_at, @@ -194,6 +252,13 @@ impl From for (StorableFixedAnchor, StorableAnchor) { .map(StorableVerifiedEmail::from) .collect(), ); + let next_session_device_id = Some(next_session_device_id); + let session_devices = Some( + session_devices + .into_iter() + .map(StorableSessionDevice::from) + .collect(), + ); let (mut passkey_credentials, mut recovery_keys, mut recovery_devices) = (vec![], vec![], vec![]); @@ -433,6 +498,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { recovery_keys, email_recovery, verified_emails, + session_devices, + next_session_device_id, }, ) } @@ -448,6 +515,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { recovery_keys, email_recovery, verified_emails, + session_devices, + next_session_device_id, } = storable_anchor; let name = name.clone(); @@ -466,6 +535,12 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { .into_iter() .map(VerifiedEmail::from) .collect(); + let session_devices = session_devices + .unwrap_or_default() + .into_iter() + .map(SessionDevice::from) + .collect(); + let next_session_device_id = next_session_device_id.unwrap_or_default(); let mut devices = passkey_credentials .unwrap_or_default() @@ -560,6 +635,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { openid_credentials, email_recovery, verified_emails, + session_devices, + next_session_device_id, devices, metadata, } @@ -586,6 +663,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], + session_devices: vec![], + next_session_device_id: 0, anchor_number, devices, metadata, @@ -612,6 +691,12 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho .into_iter() .map(VerifiedEmail::from) .collect(); + let session_devices = storable_anchor + .session_devices + .unwrap_or_default() + .into_iter() + .map(SessionDevice::from) + .collect(); Anchor { anchor_number, @@ -619,6 +704,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials, email_recovery, verified_emails, + session_devices, + next_session_device_id: storable_anchor.next_session_device_id.unwrap_or_default(), metadata, name, created_at, @@ -627,6 +714,77 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho } impl Anchor { + pub fn session_devices(&self) -> &[SessionDevice] { + &self.session_devices + } + + /// 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. + /// + /// A proof from the successor promotes it, retiring the key it replaces. Either way the + /// entry then awaits `next_key`, which is what a browser presents once this sign-in has + /// reached it. + /// + /// At the cap the least recently used records are dropped, and their ids returned so + /// the caller can end their sessions too. + pub fn resolve_session_device( + &mut self, + key: PublicKey, + next_key: PublicKey, + name: String, + now: Timestamp, + ) -> Result<(SessionDeviceId, Vec), SessionDeviceError> { + let holder = |candidate: &PublicKey| { + self.session_devices + .iter() + .find(|device| device.key == *candidate || device.pending == *candidate) + .map(|device| device.id) + }; + if holder(&next_key).is_some() && holder(&next_key) != holder(&key) { + return Err(SessionDeviceError::SuccessorAlreadyInUse); + } + + if let Some(device) = self + .session_devices + .iter_mut() + .find(|device| device.key == key || device.pending == key) + { + device.key = key; + device.pending = next_key; + device.last_used = now; + return Ok((device.id, vec![])); + } + + let id = self.next_session_device_id; + self.next_session_device_id = self.next_session_device_id.saturating_add(1); + self.session_devices.push(SessionDevice { + id, + key, + pending: next_key, + name, + created_at: now, + last_used: now, + }); + + let mut dropped = vec![]; + while self.session_devices.len() > MAX_SESSION_DEVICES { + let least_recently_used = self + .session_devices + .iter() + .enumerate() + .min_by_key(|(_, device)| (device.last_used, device.id)) + .map(|(index, _)| index); + match least_recently_used { + Some(index) => { + dropped.push(self.session_devices.remove(index).id); + } + None => break, + } + } + + Ok((id, dropped)) + } + /// Creation of new anchors is restricted in order to make sure that the device checks are /// not accidentally bypassed. pub fn new(anchor_number: AnchorNumber, created_at: Timestamp) -> Anchor { @@ -637,6 +795,8 @@ impl Anchor { openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], + session_devices: vec![], + next_session_device_id: 0, metadata: None, name: None, } diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index 85d1dcf626..c49292b8a4 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -223,6 +223,8 @@ fn should_allow_protection_only_on_recovery_phrases() { fn should_prevent_mutation_when_invariants_are_violated() { let mut device1 = recovery_phrase(1, DeviceProtection::Unprotected); let mut anchor = Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ device1.clone(), @@ -245,6 +247,8 @@ fn should_prevent_mutation_when_invariants_are_violated() { #[test] fn should_prevent_addition_when_invariants_are_violated() { let mut anchor = Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ recovery_phrase(1, DeviceProtection::Unprotected), @@ -267,6 +271,8 @@ fn should_prevent_addition_when_invariants_are_violated() { fn should_allow_removal_when_invariants_are_violated() { let device1 = recovery_phrase(1, DeviceProtection::Unprotected); let mut anchor = Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ device1.clone(), @@ -1260,3 +1266,484 @@ mod mirror_verified_email_tests { .all(|e| e.address != "new@example.com")); } } + +mod session_device_tests { + use super::*; + use crate::storage::anchor::{SessionDeviceError, MAX_SESSION_DEVICES}; + use internet_identity_interface::internet_identity::types::PublicKey; + + fn anchor() -> Anchor { + Anchor::new(10_000, 0) + } + + fn browser_key(seed: u8) -> PublicKey { + ByteBuf::from(vec![seed; 91]) + } + + /// What a browser presenting `browser_key(seed)` announces it will rotate to. + fn successor_key(seed: u8) -> PublicKey { + ByteBuf::from(vec![seed; 92]) + } + + #[test] + fn an_unseen_key_registers_a_new_device() { + let mut anchor = anchor(); + + let (id, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome on MacBook".to_string(), + 1_000, + ) + .unwrap(); + + assert_eq!(id, 0); + assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.session_devices()[0].name, "Chrome on MacBook"); + assert_eq!(anchor.session_devices()[0].key, browser_key(1)); + assert_eq!(anchor.session_devices()[0].created_at, 1_000); + } + + #[test] + fn a_key_it_already_holds_reuses_the_device_and_leaves_its_name_alone() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome on MacBook".to_string(), + 1_000, + ) + .unwrap(); + + let (again, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Something else".to_string(), + 2_000, + ) + .unwrap(); + + assert_eq!(again, id); + assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.session_devices()[0].name, "Chrome on MacBook"); + } + + #[test] + fn registration_stamps_both_timestamps() { + let mut anchor = anchor(); + + anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + assert_eq!(anchor.session_devices()[0].created_at, 1_000); + assert_eq!(anchor.session_devices()[0].last_used, 1_000); + } + + #[test] + fn reuse_advances_last_used_and_leaves_created_at_alone() { + let mut anchor = anchor(); + anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 5_000, + ) + .unwrap(); + + assert_eq!(anchor.session_devices()[0].created_at, 1_000); + assert_eq!(anchor.session_devices()[0].last_used, 5_000); + } + + #[test] + fn a_key_this_anchor_has_not_seen_registers_a_fresh_device() { + let mut anchor = anchor(); + anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + let (id, _) = anchor + .resolve_session_device( + browser_key(2), + successor_key(2), + "Firefox".to_string(), + 2_000, + ) + .unwrap(); + + assert_eq!(id, 1); + assert_eq!(anchor.session_devices().len(), 2); + } + + #[test] + fn ids_are_never_reused() { + let mut anchor = anchor(); + let (first, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + anchor.session_devices.clear(); + + let (second, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + + assert_ne!(second, first); + assert_eq!(second, 1); + } + + #[test] + fn registering_past_the_cap_drops_the_least_recently_used_and_never_fails() { + let mut anchor = anchor(); + for index in 0..MAX_SESSION_DEVICES { + anchor + .resolve_session_device( + browser_key(index as u8), + successor_key(index as u8), + format!("device-{index}"), + index as u64 + 1, + ) + .unwrap(); + } + + let (newest, dropped) = anchor + .resolve_session_device( + browser_key(200), + successor_key(200), + "newest".to_string(), + 10_000, + ) + .unwrap(); + + assert_eq!(anchor.session_devices().len(), MAX_SESSION_DEVICES); + assert!(anchor.session_devices().iter().any(|d| d.id == newest)); + assert!(!anchor + .session_devices() + .iter() + .any(|d| d.name == "device-0")); + assert!(anchor + .session_devices() + .iter() + .any(|d| d.name == "device-1")); + assert_eq!(dropped, vec![0]); + } + + #[test] + fn the_cap_evicts_on_use_rather_than_on_enrolment() { + let mut anchor = anchor(); + let (first, _) = anchor + .resolve_session_device(browser_key(0), successor_key(0), "oldest".to_string(), 1) + .unwrap(); + for index in 1..MAX_SESSION_DEVICES { + anchor + .resolve_session_device( + browser_key(index as u8), + successor_key(index as u8), + format!("device-{index}"), + index as u64 + 1, + ) + .unwrap(); + } + anchor + .resolve_session_device( + browser_key(0), + successor_key(0), + "oldest".to_string(), + 9_000, + ) + .unwrap(); + + let (_, dropped) = anchor + .resolve_session_device( + browser_key(200), + successor_key(200), + "newest".to_string(), + 10_000, + ) + .unwrap(); + + assert_eq!(dropped, vec![1]); + assert!(anchor + .session_devices() + .iter() + .any(|device| device.id == first)); + } + + #[test] + fn a_browser_that_clears_storage_evicts_its_own_records_before_a_used_one() { + let mut anchor = anchor(); + let (kept, _) = anchor + .resolve_session_device(browser_key(0), successor_key(0), "phone".to_string(), 1) + .unwrap(); + for wipe in 0..MAX_SESSION_DEVICES as u64 { + anchor + .resolve_session_device( + browser_key(0), + successor_key(0), + "phone".to_string(), + 1_000 + wipe * 10, + ) + .unwrap(); + anchor + .resolve_session_device( + browser_key(wipe as u8 + 1), + successor_key(wipe as u8 + 1), + format!("wiped-{wipe}"), + 1_001 + wipe * 10, + ) + .unwrap(); + } + + assert!(anchor + .session_devices() + .iter() + .any(|device| device.id == kept)); + } + + #[test] + fn a_wiped_browser_presenting_a_fresh_key_is_a_new_device() { + let mut anchor = anchor(); + let (before, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + let (after, _) = anchor + .resolve_session_device( + browser_key(2), + successor_key(2), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + + assert_ne!(after, before); + assert_eq!(anchor.session_devices().len(), 2); + } + + #[test] + fn a_successor_is_accepted_and_takes_over_from_the_key_it_replaces() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + let (again, _) = anchor + .resolve_session_device( + successor_key(1), + browser_key(2), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + + assert_eq!(again, id); + assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.session_devices()[0].key, successor_key(1)); + assert_eq!(anchor.session_devices()[0].pending, browser_key(2)); + } + + #[test] + fn the_key_a_successor_replaced_is_retired() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + anchor + .resolve_session_device( + successor_key(1), + browser_key(2), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + + let (after, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(3), + "Chrome".to_string(), + 3_000, + ) + .unwrap(); + + assert_ne!(after, id); + assert_eq!(anchor.session_devices().len(), 2); + } + + /// A response that never reached the browser leaves it proving with the key the entry + /// still holds, which must not read as a new browser. + #[test] + fn the_current_key_still_resolves_when_a_response_was_lost() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + let (again, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(2), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + + assert_eq!(again, id); + assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.session_devices()[0].key, browser_key(1)); + assert_eq!(anchor.session_devices()[0].pending, successor_key(2)); + } + + #[test] + fn rotating_repeatedly_keeps_the_same_browser() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_session_device(browser_key(0), browser_key(1), "Chrome".to_string(), 1) + .unwrap(); + + for step in 1..10u8 { + let (again, _) = anchor + .resolve_session_device( + browser_key(step), + browser_key(step + 1), + "Chrome".to_string(), + step as u64 * 100, + ) + .unwrap(); + assert_eq!(again, id); + } + + assert_eq!(anchor.session_devices().len(), 1); + } + + /// Presented keys are visible on the wire, so announcing one another browser is about to + /// present would otherwise take over its entry when it does. + #[test] + fn a_successor_another_browser_holds_is_refused() { + let mut anchor = anchor(); + anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + let stealing_the_key = anchor.resolve_session_device( + browser_key(2), + browser_key(1), + "Firefox".to_string(), + 2_000, + ); + let stealing_the_successor = anchor.resolve_session_device( + browser_key(2), + successor_key(1), + "Firefox".to_string(), + 2_000, + ); + + assert_eq!( + stealing_the_key, + Err(SessionDeviceError::SuccessorAlreadyInUse) + ); + assert_eq!( + stealing_the_successor, + Err(SessionDeviceError::SuccessorAlreadyInUse) + ); + assert_eq!(anchor.session_devices().len(), 1); + } + + /// The browser that already holds it is re-announcing, which a retry does. + #[test] + fn re_announcing_its_own_successor_is_allowed() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + let (again, _) = anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + + assert_eq!(again, id); + assert_eq!(anchor.session_devices().len(), 1); + } + + #[test] + fn a_browser_that_never_rotates_keeps_working() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_session_device(browser_key(1), browser_key(1), "Chrome".to_string(), 1_000) + .unwrap(); + + let (again, _) = anchor + .resolve_session_device(browser_key(1), browser_key(1), "Chrome".to_string(), 2_000) + .unwrap(); + + assert_eq!(again, id); + assert_eq!(anchor.session_devices().len(), 1); + } +} diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 4b94d30798..9a73d75a02 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -24,6 +24,7 @@ pub mod openid_credential_key; pub mod openid_jwks; pub mod passkey_credential; pub mod recovery_key; +pub mod session_device; pub mod session_device_id; pub mod session_record; pub mod special_device_migration; diff --git a/src/internet_identity/src/storage/storable/anchor.rs b/src/internet_identity/src/storage/storable/anchor.rs index 60226068df..af496b3ab0 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -2,6 +2,8 @@ use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCr use crate::storage::storable::openid_credential::StorableOpenIdCredential; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; +use crate::storage::storable::session_device::StorableSessionDevice; +use crate::storage::storable::session_device_id::StorableSessionDeviceId; use crate::storage::storable::verified_email::StorableVerifiedEmail; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; @@ -32,6 +34,12 @@ pub struct StorableAnchor { /// `Option` so pre-existing anchors decode cleanly. #[n(6)] pub verified_emails: Option>, + /// Browsers this anchor has signed in from. Capped at `MAX_SESSION_DEVICES`. + #[n(7)] + pub session_devices: Option>, + /// Monotonic per-anchor allocator for `session_devices`. Ids are never reused. + #[n(8)] + pub next_session_device_id: Option, } impl Storable for StorableAnchor { diff --git a/src/internet_identity/src/storage/storable/session_device.rs b/src/internet_identity/src/storage/storable/session_device.rs new file mode 100644 index 0000000000..6acc7cbfbc --- /dev/null +++ b/src/internet_identity/src/storage/storable/session_device.rs @@ -0,0 +1,37 @@ +use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use ic_stable_structures::storable::Bound; +use ic_stable_structures::Storable; +use internet_identity_interface::internet_identity::types::Timestamp; +use minicbor::{Decode, Encode}; +use std::borrow::Cow; + +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[cbor(map)] +pub struct StorableSessionDevice { + #[n(0)] + pub id: StorableSessionDeviceId, + #[n(1)] + pub name: String, + #[n(2)] + pub created_at: Timestamp, + #[n(3)] + pub last_used: Timestamp, + #[cbor(n(4), with = "minicbor::bytes")] + pub key: Vec, + #[cbor(n(5), with = "minicbor::bytes")] + pub pending: Vec, +} + +impl Storable for StorableSessionDevice { + fn to_bytes(&self) -> Cow<'_, [u8]> { + let mut buffer = Vec::new(); + minicbor::encode(self, &mut buffer).expect("failed to encode StorableSessionDevice"); + Cow::Owned(buffer) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + minicbor::decode(&bytes).expect("failed to decode StorableSessionDevice") + } + + const BOUND: Bound = Bound::Unbounded; +} diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 36cf8c58f9..e0c048b516 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1316,6 +1316,8 @@ fn test_anchor_storage_migration_round_trip() { "empty anchor", storage.allocate_anchor(now).unwrap(), Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 0, devices: vec![], openid_credentials: vec![], @@ -1348,6 +1350,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 1, devices: vec![Device { pubkey: ByteBuf::from("recovery_key_pubkey"), @@ -1391,6 +1395,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 2, devices: vec![Device { pubkey: ByteBuf::from("passkey_pubkey"), @@ -1434,6 +1440,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 3, devices: vec![Device { pubkey: ByteBuf::from("passkey_no_origin"), @@ -1477,6 +1485,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 4, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey"), @@ -1520,6 +1530,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 5, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey_no_origin"), @@ -1563,6 +1575,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 6, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_auth"), @@ -1606,6 +1620,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 7, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_recovery"), @@ -1663,6 +1679,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 8, devices: vec![ Device { @@ -1707,6 +1725,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 9, devices: vec![], openid_credentials: vec![openid_credential(1)], @@ -1726,6 +1746,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 10, devices: vec![], openid_credentials: vec![], @@ -1758,6 +1780,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 11, devices: vec![Device { pubkey: ByteBuf::from("unknown_keytype_passkey"), @@ -1808,6 +1832,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 12, devices: vec![Device { pubkey: ByteBuf::from("device_with_metadata"), @@ -1845,6 +1871,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 13, devices: vec![], openid_credentials: vec![], @@ -1877,6 +1905,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 14, devices: vec![Device { pubkey: ByteBuf::from("protected_recovery_key"), @@ -1923,6 +1953,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 15, devices: vec![Device { pubkey: ByteBuf::from("protected_passkey"), @@ -1968,6 +2000,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 16, devices: vec![Device { pubkey: ByteBuf::from("unusual_device"), @@ -2011,6 +2045,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 17, devices: vec![Device { pubkey: ByteBuf::from("recovery_phrase_custom_alias"), @@ -2054,6 +2090,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 18, devices: vec![Device { pubkey: ByteBuf::from("platform_passkey"), @@ -2097,6 +2135,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 19, devices: vec![Device { pubkey: ByteBuf::from("unknown_keytype_passkey_2"), diff --git a/src/internet_identity/src/verified_emails/remove.rs b/src/internet_identity/src/verified_emails/remove.rs index 5dcd521f7b..6f39a75f94 100644 --- a/src/internet_identity/src/verified_emails/remove.rs +++ b/src/internet_identity/src/verified_emails/remove.rs @@ -31,6 +31,8 @@ mod tests { fn anchor_with(addresses: &[&str]) -> Anchor { let mut a = Anchor { + session_devices: vec![], + next_session_device_id: 0, anchor_number: 1, devices: vec![], openid_credentials: vec![], diff --git a/src/internet_identity/tests/integration/upgrade.rs b/src/internet_identity/tests/integration/upgrade.rs index c133653af1..4385bbcb04 100644 --- a/src/internet_identity/tests/integration/upgrade.rs +++ b/src/internet_identity/tests/integration/upgrade.rs @@ -143,3 +143,22 @@ fn should_not_allow_user_range_exceeding_capacity() { .unwrap(), ); } + +/// Verifies that an anchor stored before the session-device registry existed decodes +/// after an upgrade, and reports no devices rather than failing. +#[test] +fn should_report_no_session_devices_for_an_anchor_from_the_previous_release( +) -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_canister(&env, II_WASM_PREVIOUS.clone()); + let identity_number = flows::register_anchor(&env, canister_id); + + upgrade_ii_canister(&env, canister_id, II_WASM.clone()); + + let info = + api::api_v2::identity_info(&env, canister_id, principal_1(), identity_number)?.unwrap(); + assert_eq!(info.session_devices, None); + assert_eq!(info.authn_methods.len(), 1); + + Ok(()) +} diff --git a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs index fb6766262c..e062f3317a 100644 --- a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs +++ b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs @@ -1,5 +1,5 @@ use crate::internet_identity::types::openid::OpenIdCredentialData; -use crate::internet_identity::types::{CredentialId, PublicKey, Timestamp}; +use crate::internet_identity::types::{CredentialId, PublicKey, SessionDeviceId, Timestamp}; use candid::{CandidType, Deserialize, Principal}; use serde_bytes::ByteBuf; use std::collections::HashMap; @@ -77,6 +77,15 @@ pub struct IdentityAuthnInfo { pub recovery_authn_methods: Vec, } +/// A browser this anchor has signed in from. The name is self-reported by the client. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct SessionDeviceInfo { + pub id: SessionDeviceId, + pub name: String, + pub created_at: Timestamp, + pub last_used: Timestamp, +} + #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub struct IdentityInfo { pub authn_methods: Vec, @@ -99,6 +108,8 @@ pub struct IdentityInfo { /// `MAX_VERIFIED_EMAILS_PER_ANCHOR`. pub verified_emails: Option>, + /// Browsers this anchor has signed in from. `None` if it has never created a session. + pub session_devices: Option>, /// The anchor's synced trusted-MCP-server config (master toggle + /// trusted server URL). `None` for an anchor that never wrote one. /// From 960f5bee4f359831e2f1d39970894a3aca489717 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 19:32:30 +0200 Subject: [PATCH 009/298] feat(be): verify a browser's key and its announced successor A sign-in that names a browser has to prove the browser holds the key it names, and the successor it announces for its next sign-in. Two signatures, under two domains: the current key over the session key and the successor, and the successor over the session key and the current key. Separate domains are what stop either signature being replayed in the other's role. Ingress messages are public, so a key read off the wire could otherwise be announced by someone who does not hold it, and claimed when its browser next presents one. P-256 only, and no fallback: a key that does not parse, a signature of the wrong length and an empty signature are each refused rather than skipped. The module is annotated `allow(dead_code)` because its caller is the sign-in ceremony, which lands two PRs up. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 2 +- src/canister_tests/Cargo.toml | 2 + src/internet_identity/src/main.rs | 1 + src/internet_identity/src/sessions.rs | 5 + .../src/sessions/device_key.rs | 279 ++++++++++++++++++ 6 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 src/internet_identity/src/sessions.rs create mode 100644 src/internet_identity/src/sessions/device_key.rs diff --git a/Cargo.lock b/Cargo.lock index 39058e1ba6..2bae135c05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,7 @@ dependencies = [ "identity_jose", "internet_identity_interface", "lazy_static", + "p256", "pocket-ic", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5de1a4a933..b2a2e4021b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,7 @@ rsa = "0.9.10" minicbor = "1.0.0" # DNSSEC verifier deps (PR 1b on docs/ongoing/email-recovery.md §7) -p256 = { version = "0.13", default-features = false, features = ["ecdsa", "sha256"] } +p256 = { version = "0.13", default-features = false, features = ["ecdsa", "sha256", "pkcs8"] } ed25519-dalek = { version = "2.2", default-features = false } # Certification diff --git a/src/canister_tests/Cargo.toml b/src/canister_tests/Cargo.toml index 7a5ced79f0..e66a90eff2 100644 --- a/src/canister_tests/Cargo.toml +++ b/src/canister_tests/Cargo.toml @@ -8,6 +8,8 @@ base64.workspace = true flate2 = "1.0" hex.workspace = true lazy_static.workspace = true +# Signs the browser-key proof `prepare_account_session` requires. +p256.workspace = true regex.workspace = true serde.workspace = true serde_cbor.workspace = true diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 3195121e14..e7ca809157 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -67,6 +67,7 @@ mod mcp_registration; mod openid; mod session_delegation; +mod sessions; mod single_flight_cache; mod state; mod stats; diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs new file mode 100644 index 0000000000..11fef341fa --- /dev/null +++ b/src/internet_identity/src/sessions.rs @@ -0,0 +1,5 @@ +// 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; diff --git a/src/internet_identity/src/sessions/device_key.rs b/src/internet_identity/src/sessions/device_key.rs new file mode 100644 index 0000000000..ae9433f63a --- /dev/null +++ b/src/internet_identity/src/sessions/device_key.rs @@ -0,0 +1,279 @@ +//! Verifies that a sign-in request comes from a browser holding the key it names. + +use internet_identity_interface::internet_identity::types::{PublicKey, SessionKey}; +use p256::ecdsa::signature::Verifier; +use p256::ecdsa::{Signature, VerifyingKey}; +use p256::pkcs8::DecodePublicKey; + +/// Prefixed to the signed message so the browser key cannot be made to sign for another +/// purpose by presenting a message from one. +const DEVICE_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-device-key"; + +/// A different prefix for the successor's own signature, so neither signature can be +/// replayed in the other's role. +const SUCCESSOR_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-device-successor"; + +/// A browser key is P-256, and the signature the raw `r || s` pair WebCrypto produces. +const DEVICE_KEY_SIGNATURE_BYTES: usize = 64; + +/// Both keys sign: the current one over the session key and its successor, and the successor +/// over the session key and the key it replaces. +/// +/// The successor's own signature is what stops a key being announced by someone who does not +/// hold it — without it, keys read off the wire could be planted as another browser's +/// successor and claimed when that browser next presented one. +pub fn verify_device_keys( + device_key: &PublicKey, + device_key_signature: &[u8], + next_device_key: &PublicKey, + next_device_key_signature: &[u8], + session_key: &SessionKey, +) -> bool { + verify( + device_key, + device_key_signature, + &signed_message(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_device_key), + ) && verify( + next_device_key, + next_device_key_signature, + &signed_message(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, device_key), + ) +} + +fn verify(key: &PublicKey, signature: &[u8], message: &[u8]) -> bool { + if signature.len() != DEVICE_KEY_SIGNATURE_BYTES { + return false; + } + let Ok(key) = VerifyingKey::from_public_key_der(key) else { + return false; + }; + let Ok(signature) = Signature::from_slice(signature) else { + return false; + }; + key.verify(message, &signature).is_ok() +} + +/// Covers the other key as well as the session key: keys are visible on the wire, so a +/// signature that bound only the session key could be paired with one a caller chose. +fn signed_message(domain: &[u8], session_key: &SessionKey, other_key: &PublicKey) -> Vec { + let mut message = Vec::with_capacity(domain.len() + session_key.len() + other_key.len()); + message.extend_from_slice(domain); + message.extend_from_slice(session_key); + message.extend_from_slice(other_key); + message +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::signature::Signer; + use p256::ecdsa::SigningKey; + use serde_bytes::ByteBuf; + + /// 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, + ]; + + struct Key { + signing: SigningKey, + public: PublicKey, + } + + fn key(seed: u8) -> Key { + let signing = SigningKey::from_bytes(&[seed; 32].into()).unwrap(); + let point = VerifyingKey::from(&signing).to_encoded_point(false); + let mut der = P256_SPKI_HEADER.to_vec(); + der.extend_from_slice(point.as_bytes()); + Key { + signing, + public: ByteBuf::from(der), + } + } + + impl Key { + fn sign(&self, domain: &[u8], session_key: &SessionKey, other: &PublicKey) -> Vec { + let signature: Signature = + self.signing + .sign(&signed_message(domain, session_key, other)); + signature.to_bytes().to_vec() + } + + fn current(&self, session_key: &SessionKey, next: &PublicKey) -> Vec { + self.sign(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next) + } + + fn successor(&self, session_key: &SessionKey, current: &PublicKey) -> Vec { + self.sign(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, current) + } + } + + fn session_key(seed: u8) -> SessionKey { + ByteBuf::from(vec![seed; 62]) + } + + /// A rotation as an honest browser performs it: it holds both keys and signs with both. + fn rotation(current: &Key, next: &Key, session: &SessionKey) -> bool { + verify_device_keys( + ¤t.public, + ¤t.current(session, &next.public), + &next.public, + &next.successor(session, ¤t.public), + session, + ) + } + + #[test] + fn a_browser_holding_both_keys_is_accepted() { + assert!(rotation(&key(1), &key(2), &session_key(7))); + } + + #[test] + fn a_successor_the_caller_does_not_hold_is_refused() { + let current = key(1); + let announced = key(2); + let session = session_key(7); + + // Everything the wire carries, but signed only by the key the caller holds. + assert!(!verify_device_keys( + ¤t.public, + ¤t.current(&session, &announced.public), + &announced.public, + ¤t.current(&session, &announced.public), + &session + )); + } + + #[test] + fn a_successor_signature_replayed_as_the_current_one_is_refused() { + let current = key(1); + let next = key(2); + let session = session_key(7); + + assert!(!verify_device_keys( + ¤t.public, + ¤t.successor(&session, &next.public), + &next.public, + &next.successor(&session, ¤t.public), + &session + )); + } + + #[test] + fn a_signature_over_another_session_key_is_refused() { + let current = key(1); + let next = key(2); + + assert!(!verify_device_keys( + ¤t.public, + ¤t.current(&session_key(7), &next.public), + &next.public, + &next.successor(&session_key(7), ¤t.public), + &session_key(8) + )); + } + + #[test] + fn a_signature_paired_with_another_successor_is_refused() { + let current = key(1); + let announced = key(2); + let substituted = key(3); + let session = session_key(7); + + assert!(!verify_device_keys( + ¤t.public, + ¤t.current(&session, &announced.public), + &substituted.public, + &substituted.successor(&session, ¤t.public), + &session + )); + } + + #[test] + fn another_browsers_signature_is_refused() { + let current = key(1); + let other = key(9); + let next = key(2); + let session = session_key(7); + + assert!(!verify_device_keys( + ¤t.public, + &other.current(&session, &next.public), + &next.public, + &next.successor(&session, ¤t.public), + &session + )); + } + + #[test] + fn a_signature_over_the_bare_session_key_is_refused() { + let current = key(1); + let next = key(2); + let session = session_key(7); + let bare: Signature = current.signing.sign(&session); + + assert!(!verify_device_keys( + ¤t.public, + &bare.to_bytes(), + &next.public, + &next.successor(&session, ¤t.public), + &session + )); + } + + #[test] + fn a_key_that_is_not_a_p256_public_key_is_refused() { + let current = key(1); + let next = key(2); + let session = session_key(7); + + assert!(!verify_device_keys( + &ByteBuf::from(vec![0u8; 91]), + ¤t.current(&session, &next.public), + &next.public, + &next.successor(&session, ¤t.public), + &session + )); + } + + #[test] + fn a_signature_of_the_wrong_length_is_refused() { + let current = key(1); + let next = key(2); + let session = session_key(7); + let mut signature = current.current(&session, &next.public); + signature.push(0); + + assert!(!verify_device_keys( + ¤t.public, + &signature, + &next.public, + &next.successor(&session, ¤t.public), + &session + )); + } + + #[test] + fn an_empty_signature_is_refused() { + let current = key(1); + let next = key(2); + let session = session_key(7); + + assert!(!verify_device_keys( + ¤t.public, + &[], + &next.public, + &next.successor(&session, ¤t.public), + &session + )); + assert!(!verify_device_keys( + ¤t.public, + ¤t.current(&session, &next.public), + &next.public, + &[], + &session + )); + } +} From 5a34d25f3d40437bf0df96891cbd223447545029 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 19:56:22 +0200 Subject: [PATCH 010/298] feat(be): store a revocable session on the account reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session is a record on the account reference — created at, expires at, last refreshed, the browser that made it, and whether the user consented to queries only. Putting it there rather than in its own map means it inherits the caps that already bound references, and revoking, expiring and evicting reuse machinery that exists. Its identity is `H(salt, "session", account_seed, created_at, device_id)`, every field length-prefixed. Building on the account's own 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. Only `last_refreshed` is mutable, which is why it is the one field the seed does not take: a mutable input would change the session's principal every time it was stamped. A ceremony from a browser that already holds a session at this account replaces it, so a copy of the old chain stops working at the user's next sign-in rather than at its expiry. Expired records on the row go in the same write. The writers here have no caller yet — the ceremony that calls them is two PRs up — so they carry `allow(dead_code)` until it arrives. Co-Authored-By: Claude Opus 5 (1M context) --- src/internet_identity/src/delegation.rs | 29 + .../src/email_recovery/remove.rs | 1 + src/internet_identity/src/storage.rs | 381 ++++++++++++- 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 | 538 +++++++++++++++++- .../src/verified_emails/remove.rs | 1 + 10 files changed, 983 insertions(+), 24 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/session_handle.rs diff --git a/src/internet_identity/src/delegation.rs b/src/internet_identity/src/delegation.rs index 50ea7769dd..7aebe803ce 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 e4cde76364..669d5b32e0 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/storage.rs b/src/internet_identity/src/storage.rs index 530b009e5d..a0c4ff33a8 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); @@ -403,6 +408,11 @@ pub struct Storage { lookup_account_with_principal_memory_wrapper: MemoryWrapper>, lookup_account_with_principal_memory: StableBTreeMap>, + /// 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>, @@ -541,6 +551,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 = @@ -628,6 +640,9 @@ impl Storage { lookup_account_with_principal_memory_wrapper: MemoryWrapper::new( lookup_account_with_principal_memory.clone(), ), + lookup_session_with_principal_memory: StableBTreeMap::init( + lookup_session_with_principal_memory, + ), lookup_account_with_principal_memory: StableBTreeMap::init( lookup_account_with_principal_memory, ), @@ -887,6 +902,7 @@ impl Storage { verified_emails: _, session_devices: _, next_session_device_id: _, + session_count: _, }) = previous_anchor_maybe { ( @@ -1763,6 +1779,330 @@ impl Storage { Ok(Some(())) } + // Called by the sign-in ceremony, which lands two PRs up. + #[allow(dead_code)] + /// 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) + } + + /// 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(canister_id(), seed.to_vec())) + } + + /// 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) + } + + /// 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); + } + } + } + + // Called by the sign-in ceremony, which lands two PRs up. + #[allow(dead_code)] + /// 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)) + } + + // Called by the sign-in ceremony, which lands two PRs up. + #[allow(dead_code)] + 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) + } + + // Called by the sign-in ceremony, which lands two PRs up. + #[allow(dead_code)] + /// 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 + } + }; + + 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), Some(account_principal)) = ( + self.session_principal(anchor_number, application_number, account_number, &session), + self.account_principal_of(anchor_number, application_number, account_number), + ) { + self.lookup_session_with_principal_memory.insert( + principal, + StorableSessionHandle { + account_principal: account_principal.as_slice().to_vec(), + device_id, + created_at: session.created_at, + }, + ); + } + self.change_session_count(anchor_number, dropped.len(), 1)?; + + Ok(session) + } + + // Called by the sign-in ceremony, which lands two PRs up. + #[allow(dead_code)] + /// 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( + 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( @@ -1806,6 +2146,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); @@ -2870,6 +3227,18 @@ impl Storage { } } +// Constructed by the sign-in ceremony, which lands two PRs up. +#[allow(dead_code)] +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)>, @@ -2962,6 +3331,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 @@ -3039,6 +3414,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 9197acd7e7..08fe17055e 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 c49292b8a4..4f53d47eaf 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 9a73d75a02..a00dda2e1a 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 af496b3ab0..0f01cd6b32 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 0000000000..3f1770520a --- /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 e0c048b516..5d6cd6c5a0 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"), @@ -3680,6 +3700,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; @@ -3748,8 +3769,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, @@ -3765,6 +3786,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; @@ -3779,27 +3845,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; @@ -3820,3 +3865,450 @@ 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; + 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 account-principal index is keyed with one derivation and a session handle names + /// the account with another, so this crosses the two: what `create_session` stored has + /// to resolve back through the index it was derived against. + #[test] + fn a_session_handle_resolves_through_the_account_principal_index() { + 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 account_principal = storage + .account_principal_of(anchor_number, application_number, None) + .expect("the account it was just created for"); + let locator = storage + .lookup_account_with_principal_memory + .get(&account_principal) + .expect("the account principal index must resolve what create_session derived"); + + assert_eq!(locator.anchor_number, anchor_number); + assert_eq!(locator.application_number, application_number); + assert_eq!(session.device_id, 7); + } + + #[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 6f39a75f94..845958f4b5 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![], From a60eb4abb7150def8ebd90c9b59f1fd321f4d403 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:00 +0200 Subject: [PATCH 011/298] feat(be): cap sessions per identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five hundred stored records per identity, expired ones included, because nothing observes a session dying: a session expires with no write anywhere, so no counter can follow the live set. An expired record holds its slot until something reclaims it, and since it is the first thing reclaimed, a held slot is never taken from a session in use. Reaching the cap does not fail a sign-in. Reclaiming walks the identity's rows and takes dead sessions first, then the live ones by `last_used + (last_used − created_at)` — how recently used, extended by how long the session stayed in service. The extension is what separates an app in weekly use from one opened once and abandoned yesterday, which recency alone gets backwards, since the abandoned one was touched more recently. Note what the cap does not do: it puts no bound on a flood of sign-ins, because standing rises with use. What stands in the way is the ceremony — creating a session needs an access method — and a stronger bound is left for whoever finds this one too weak. 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. It reads every row rather than a prefix, because a truncated scan would undercount and the undercount would become the counter. Co-Authored-By: Claude Opus 5 (1M context) --- src/internet_identity/src/storage.rs | 157 ++++++++++++ src/internet_identity/src/storage/tests.rs | 264 +++++++++++++++++++-- 2 files changed, 402 insertions(+), 19 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index a0c4ff33a8..88140adb11 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -320,6 +320,21 @@ const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; /// Eviction target, below the cap. const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS * 9 / 10; +/// Session records one identity may hold, counted as stored rather than as live. +/// +/// Counting what is stored is what makes the cap cheap to trigger on: a session expires with +/// no write anywhere, so no counter can follow the live set — something would have to +/// decrement at the moment of expiry, and nothing runs then. An expired record holds its slot +/// until something reclaims it, and because it is the first thing reclaimed, a held slot is +/// never taken from a session in use. +/// +/// 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; @@ -1866,6 +1881,142 @@ impl Storage { Some(canister_sig_principal(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) + } + + /// 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) + } + /// Moves the count without considering the cap, for the paths that only remove. fn change_session_count( &mut self, @@ -1997,6 +2148,12 @@ impl Storage { } }; + // 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 { diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 5d6cd6c5a0..3546f3aa6a 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3869,7 +3869,9 @@ 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; + 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; @@ -3999,30 +4001,254 @@ mod session_creation_tests { assert!(sessions.iter().any(|s| s.device_id == 0)); } - /// The account-principal index is keyed with one derivation and a session handle names - /// the account with another, so this crosses the two: what `create_session` stored has - /// to resolve back through the index it was derived against. + /// The per-identity cap reclaims to a watermark rather than blocking, taking expired + /// records first and then the least recently used. #[test] - fn a_session_handle_resolves_through_the_account_principal_index() { + fn the_session_cap_reclaims_to_the_watermark() { let (mut storage, anchor_number) = storage_with_anchor(); - let session = storage - .create_session(params(anchor_number, 7, 1_000)) + 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 application_number = storage - .lookup_application_number_with_origin(&ORIGIN.to_string()) + 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(); - let account_principal = storage - .account_principal_of(anchor_number, application_number, None) - .expect("the account it was just created for"); - let locator = storage - .lookup_account_with_principal_memory - .get(&account_principal) - .expect("the account principal index must resolve what create_session derived"); + // 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(); - assert_eq!(locator.anchor_number, anchor_number); - assert_eq!(locator.application_number, application_number); - assert_eq!(session.device_id, 7); + 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] From 302a89df29f947933ac806f82c1042e62ac5b758 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:00 +0200 Subject: [PATCH 012/298] feat(be): sign a session to the II frontend at sign-in `prepare_account_session` creates a session and signs its delegation to the frontend's key; `get_account_session` witnesses it. This is what gives the storage beneath a caller, so the `allow(dead_code)` it carried goes with it. The request carries the browser's key, the successor it rotates to, and both proofs, which are checked before anything is written. So is the account: an account the identity does not hold is the one failure a caller can provoke, and returning it after the writes would leave a browser registered for a sign-in that never happened. Every failure after the first write traps instead, rolling the whole message back, because on the IC returning an error commits. `valid_for` is the lifetime the user chose at consent, clamped by the canister to between ten minutes and thirty days. Every ceremony creates, so it always applies: the replacement's expiry is measured from the ceremony that made it, and no session is renewed in place. This is a separate pair from `prepare_account_delegation`, not an option on it. Both mint, but one proves a live session and identifies the account by its principal while the other proves an access method and names the anchor outright. Merging them would mean one method with two authorizers and two argument shapes. 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 | 26 + 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/main.rs | 14 + src/internet_identity/src/sessions.rs | 302 ++++++++- src/internet_identity/src/storage.rs | 46 +- .../tests/integration/main.rs | 1 + .../tests/integration/sessions.rs | 576 ++++++++++++++++++ .../src/archive/types.rs | 6 + .../src/internet_identity/types.rs | 90 +++ 14 files changed, 1314 insertions(+), 34 deletions(-) create mode 100644 src/internet_identity/tests/integration/sessions.rs diff --git a/src/archive/archive.did b/src/archive/archive.did index 01b3d98f42..ea7e632052 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 5d53bf64f4..7aa1a693d8 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 c45aa2af99..d8f42dac24 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,29 @@ 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 efd7ffa149..2d1f5a415a 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 55988f7be0..b27a3de3e1 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 5aacaa52da..db772b6ea7 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 7c97b7f522..62d3766a60 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/main.rs b/src/internet_identity/src/main.rs index e7ca809157..d3101510e3 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 11fef341fa..087ac26ed2 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/storage.rs b/src/internet_identity/src/storage.rs index 88140adb11..29efa5f8d5 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1794,8 +1794,6 @@ impl Storage { Ok(Some(())) } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] /// Signs one browser out of everything, in a single message. pub fn revoke_device_sessions( &mut self, @@ -1912,6 +1910,23 @@ impl Storage { 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. /// @@ -2017,23 +2032,6 @@ impl Storage { Ok(remaining) } - /// 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) - } - /// Drops the index entries of sessions that have just been removed from a row. fn unindex_sessions( &mut self, @@ -2051,8 +2049,6 @@ impl Storage { } } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] /// The account a session handle names, together with its sessions. pub fn account_with_sessions( &self, @@ -2081,8 +2077,6 @@ impl Storage { Some((account, reference.sessions)) } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] pub fn account_sessions( &self, anchor_number: AnchorNumber, @@ -2101,8 +2095,6 @@ impl Storage { .map(|reference| reference.sessions) } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] /// Creates the session `prepare_account_session` mints an identity from, replacing /// whatever this browser already held at this account. pub fn create_session( @@ -2235,8 +2227,6 @@ impl Storage { Ok(session) } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] /// The principal an app sees for an account, which is what a session handle names. fn account_principal_of( &self, @@ -3384,8 +3374,6 @@ impl Storage { } } -// Constructed by the sign-in ceremony, which lands two PRs up. -#[allow(dead_code)] pub struct CreateSessionParams { pub anchor_number: AnchorNumber, pub origin: FrontendHostname, diff --git a/src/internet_identity/tests/integration/main.rs b/src/internet_identity/tests/integration/main.rs index 88238b8ed2..3e8383145b 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 0000000000..e448f5eb2c --- /dev/null +++ b/src/internet_identity/tests/integration/sessions.rs @@ -0,0 +1,576 @@ +//! 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 a60ae1a240..9064eb2cb8 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 4dcf0b549a..654d7de6c2 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 05514b2948aebf67aaeffe4979910a476131f9f8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:01 +0200 Subject: [PATCH 013/298] 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 | 26 + .../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 | 125 ++++- src/internet_identity/src/storage.rs | 16 + .../src/storage/storable/session_handle.rs | 7 + src/internet_identity/src/storage/tests.rs | 80 +++ .../tests/integration/sessions.rs | 473 +++++++++++++++++- 10 files changed, 850 insertions(+), 16 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 d8f42dac24..4f56922630 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -788,3 +788,29 @@ pub fn get_account_session( ) -> Result, RejectResponse> { query_candid_as(env, canister_id, sender, "get_account_session", (request,)).map(|(x,)| x) } + +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 b27a3de3e1..741f668c38 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 db772b6ea7..ee29ae5ecc 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 62d3766a60..86335d290e 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 d3101510e3..5cee41653c 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 087ac26ed2..7bd7f71135 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -10,18 +10,20 @@ 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, Delegation, - FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, + AccountNumber, AccountSessionError, AnchorNumber, AppGetDelegationRequest, + AppPrepareDelegationRequest, AppPrepareDelegationResponse, AppSessionError, ApplicationNumber, + Delegation, FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp, }; use serde_bytes::ByteBuf; @@ -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 29efa5f8d5..5ffda1bf35 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1850,6 +1850,22 @@ 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) + } + + /// 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 3f1770520a..7828c7aa0d 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 3546f3aa6a..6b8bf2cee0 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3568,6 +3568,35 @@ mod account_principal_index_tests { other_anchor_number ); } + + #[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 { @@ -4196,6 +4225,57 @@ 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 /// came back to, even though every one of them is newer than it. diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index e448f5eb2c..898b68dd6a 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,43 @@ 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> { let env = env(); @@ -116,6 +155,309 @@ 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. /// The self-reported name is redacted. @@ -178,6 +520,48 @@ 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 /// sign-in would still leave a browser in the user's list. @@ -523,6 +907,50 @@ 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. #[test] @@ -574,3 +1002,44 @@ 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 9f009c73f0c1fe3cebfbe8ef33ce336810a4570f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:02 +0200 Subject: [PATCH 014/298] 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 | 291 ++++++++++++++++++ .../tests/integration/sessions.rs | 90 +++++- 5 files changed, 495 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 7bd7f71135..189ffcc4ca 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 5ffda1bf35..7bc7ab5867 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2048,6 +2048,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 08fe17055e..8780335406 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 6b8bf2cee0..a2e67d01c7 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4618,3 +4618,294 @@ 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 898b68dd6a..a4e3d48303 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 7bb4cf4a9c7888205ad75ce5d9f85fc97a8e4682 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:02 +0200 Subject: [PATCH 015/298] 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 4f56922630..6584bdc449 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -814,3 +814,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 741f668c38..093d970b76 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 ee29ae5ecc..9007fa2033 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 86335d290e..c2732fb09e 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 5cee41653c..ad96e94285 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 189ffcc4ca..3db5cd3f9f 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 7bc7ab5867..b907d156e3 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1943,6 +1943,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. /// @@ -2128,6 +2165,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 a2e67d01c7..5266a19bb6 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4909,3 +4909,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 a4e3d48303..0636f0b14d 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 e4f36784257d16a608ff8de9c549a1cbc380be21 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:03 +0200 Subject: [PATCH 016/298] 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 6584bdc449..9420f1466c 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -828,3 +828,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 093d970b76..96c8286d81 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 9007fa2033..1c9c93f428 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 c2732fb09e..509e1df8e3 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. A session is named by locator here, never by + // principal, but removing one still drops its entry from the session 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 ad96e94285..6d9b9482b4 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 3db5cd3f9f..4d5e0d55e2 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 b907d156e3..e3fe322e83 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1943,6 +1943,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 5266a19bb6..349ea45f94 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5002,3 +5002,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 0636f0b14d..81fbea5874 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 654d7de6c2..3dfacdee37 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 d67df85efee206992393ad4afaa2922ffd4c60a7 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:04 +0200 Subject: [PATCH 017/298] refactor(fe): one queue for authorization-bearing requests The delegation handler serialised its own requests so two consent screens could not race. A second handler that also authorizes is coming, and it has to share that queue rather than keep its own: two queues would let one request paint over the other's screen. Lifts the queue into `serialize.ts` as `serializeAuthorizationRequest`, with no change to what the delegation handler does with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/stores/channelHandlers/delegation.ts | 14 ++------------ .../src/lib/stores/channelHandlers/serialize.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 src/frontend/src/lib/stores/channelHandlers/serialize.ts diff --git a/src/frontend/src/lib/stores/channelHandlers/delegation.ts b/src/frontend/src/lib/stores/channelHandlers/delegation.ts index e6c8f78275..bb555bb26e 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/serialize.ts b/src/frontend/src/lib/stores/channelHandlers/serialize.ts new file mode 100644 index 0000000000..847695a682 --- /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; +}; From 2179a8552524930a3bbfc3c1dc78c10a2fa2c987 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:05 +0200 Subject: [PATCH 018/298] feat(fe): name the browser a session was created from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings list shows the user which browsers hold a session, so each one needs a label they will recognise. Derived from the user agent, most-specific token first, because every later token appears inside the earlier ones' agents. It names the device where a device word exists — `Safari on iPhone`, `Chrome on Mac`, `Chrome on Chromebook` — and takes the platform's own device model where there is one, so an Android entry reads `Chrome on Pixel 5`. The label is coarse by construction: several distinct browsers report the same string, so `Chrome on Mac` twice is normal. That is why the settings list identifies an entry by its id rather than by this name, and why the canister treats it as a label rather than as evidence. Nothing imports it yet; the sign-in that sends it lands three PRs up. Co-Authored-By: Claude Opus 5 (1M context) --- .../channelHandlers/describeBrowser.test.ts | 178 ++++++++++++++++++ .../stores/channelHandlers/describeBrowser.ts | 82 ++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts create mode 100644 src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts 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 0000000000..160a89a1d5 --- /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 0000000000..20711c421d --- /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(), + }); From f7b8adfb1770859f43e3def4719dc2518c4c2100 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:05 +0200 Subject: [PATCH 019/298] feat(fe): hold a browser key and rotate it on every sign-in The canister identifies a browser by a key it proves possession of, so the frontend has to hold one. A non-extractable P-256 keypair in IndexedDB, per identity, and a successor generated alongside it: the sign-in signs with the current key and announces the successor, and the successor is promoted only once the canister confirms the sign-in. Promoting after confirmation rather than before is what survives a lost response: the browser still holds the key the canister has, so its next attempt presents the same one rather than a successor the canister never saw. Sign-ins are serialised with a web lock, because two at once would leave whichever wrote last holding a key the canister never accepted. Where the Web Locks API is missing the calls run unserialised, which is the accepted cost of not blocking sign-in on it. Nothing imports this yet; the sign-in that uses it lands two PRs up. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/stores/browser-key.store.test.ts | 225 ++++++++++++++++++ .../src/lib/stores/browser-key.store.ts | 160 +++++++++++++ 2 files changed, 385 insertions(+) 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 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 0000000000..6ebcd356a6 --- /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 0000000000..abc2018fab --- /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; From 76c881892f0a507ffabae0c201cbe32d48b1d2ca Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:41:06 +0200 Subject: [PATCH 020/298] feat(fe): keep an app's session across a page load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session outlives the tab that created it, so the frontend has to store the chain it holds — otherwise a sibling subdomain asking for the first time, or a user returning to a closed tab, would mean another ceremony. Keyed by identity, account and origin, in IndexedDB alongside the identity's own session. A record within five minutes of its expiry is not treated as usable, so a chain is never handed over that dies mid-request. The purge that already runs when an identity is discarded now takes these records with it, at the eight places that discard one. Leaving them would keep a chain for an identity the browser has forgotten. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/stores/app-session.store.test.ts | 137 ++++++++++++++++++ .../src/lib/stores/app-session.store.ts | 134 +++++++++++++++++ .../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 + 7 files changed, 284 insertions(+) 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 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 0000000000..a6ba11bc51 --- /dev/null +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { + 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(appSessionsForOrigin(ORIGIN)).resolves.toMatchObject([ + { + identityNumber: key.identityNumber, + record: { accountPrincipal: "2vxsx-fae" }, + }, + ]); + }); + + it("keeps accounts of one identity apart", async () => { + const identityNumber = BigInt(10_000); + await storeAppSession( + { identityNumber, origin: ORIGIN }, + record(anHourFromNow()), + ); + await storeAppSession( + { identityNumber, accountNumber: BigInt(3), origin: ORIGIN }, + record(anHourFromNow()), + ); + + const listed = await appSessionsForOrigin(ORIGIN); + expect(listed).toHaveLength(2); + expect(listed.map((entry) => entry.accountNumber)).toContain(undefined); + expect(listed.map((entry) => entry.accountNumber)).toContain(BigInt(3)); + }); + + 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(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); + }); + + it("discards a session", async () => { + const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; + await storeAppSession(key, record(anHourFromNow())); + + await discardAppSession(key); + + await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); + }); + + 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 0000000000..e3f6b9ea4e --- /dev/null +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -0,0 +1,134 @@ +import type { AccessLevel } from "$lib/utils/accessLevel"; +import { + createStore, + 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; + /** Names this session to `revoke_account_session`, which is how one session is revoked + * once a surface exists that lists them. */ + /** Names this session to `revoke_account_session`, which is how one session is revoked + * once a surface exists that lists them. */ + createdAtNanos: bigint; + /** What the user consented to when this session was created. Recorded for display; the + * canister enforces it at every mint, and an app cannot request a level of its own. */ + 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; + +/** Returns a copy, so a caller mutating the record cannot write back into the store + * through the object IndexedDB handed us. */ +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 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/routes/(new-styling)/+page.svelte b/src/frontend/src/routes/(new-styling)/+page.svelte index 9ec564c71a..4aa8bfcaba 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 dd2d152f64..b0c2e502e4 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 ed4568e8fd..ef2c685e1a 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 ac141610d2..f2648c978b 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 33ee45c45b..4dd9999fec 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); } }; From e0fdc2ca6662acb558899b04d70e8a31c413962d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:43:22 +0200 Subject: [PATCH 021/298] feat(fe): hand apps a session to re-issue their own delegations from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ii_session_delegation` is what an app calls instead of `icrc34_delegation` when it wants a session rather than a long-lived delegation. It answers with the session chain, extended to the app's own key, and the app mints five-minute delegations from it with no further browser involvement. The chain the app receives has `targets` restricted to the II canister. That is a developer guardrail, not a defence against a thief, who can refresh with it either way: revocability is the protection that matters. The consent duration is honoured — `valid_for` carries the lifetime the user chose, clamped by the canister — and an SSO organization's own cap still binds it, as it already does on the ICRC-34 path. The request carries only a session public key and an optional derivation origin. An app cannot ask for an access level or a lifetime, because both are the user's to decide at consent. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/stores/channelHandlers/icrc25.ts | 5 +- .../channelHandlers/sessionDelegation.test.ts | 92 ++++++ .../channelHandlers/sessionDelegation.ts | 272 ++++++++++++++++++ src/frontend/src/lib/stores/channelStore.ts | 5 + 4 files changed, 373 insertions(+), 1 deletion(-) 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/channelHandlers/icrc25.ts b/src/frontend/src/lib/stores/channelHandlers/icrc25.ts index 61314a54bc..ce50bb8c75 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/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts new file mode 100644 index 0000000000..f6f1da0b26 --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "fake-indexeddb/auto"; + +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 } 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, + }; +}; + +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"); + }); +}); 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 0000000000..73dca34d25 --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -0,0 +1,272 @@ +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 { + 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 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 4ca5c98164..9e989ae781 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, @@ -108,6 +109,10 @@ export const channelStore: ChannelStore = { "request", handleDelegationRequest(channel, onError), ); + channel.addEventListener( + "request", + handleSessionDelegationRequest(channel, onError), + ); channel.addEventListener( "request", handleLegacyAttributes(channel, onError), From b045ccaca3f4036a16e6dd8add493a17c5a389c1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 23 Aug 2026 20:43:02 +0200 Subject: [PATCH 022/298] feat(fe): let an app cap how long its session lasts ii_session_delegation took no lifetime, so an application had no way to ask for a session shorter than whatever the consent picker offered, and the request context was set with undefined where the ICRC-34 handler passes what the app asked for. The method now accepts maxTimeToLive on the same terms as ICRC-34: a ceiling rather than a request. What the user picks at consent wins, an SSO organization's cap narrows it further, and the canister clamps the result to between ten minutes and thirty days. Co-Authored-By: Claude Opus 5 (1M context) --- .../channelHandlers/sessionDelegation.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 73dca34d25..dc4631b2d6 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -37,6 +37,10 @@ export const SESSION_DELEGATION_METHOD = "ii_session_delegation"; const SessionParamsCodec = z.object({ sessionPublicKey: Base64ToPublicKeyCodec, + // How long the app is willing for the session to last. A ceiling rather than a + // request: what the user picks at consent wins, an SSO organization's cap + // narrows it further, and the canister clamps the result. + maxTimeToLive: z.optional(StringToBigIntCodec), icrc95DerivationOrigin: z.optional(OriginSchema), }); @@ -161,7 +165,10 @@ export const handleSessionDelegationRequest = params.icrc95DerivationOrigin ?? channel.origin, ); - const created = await createSession(effectiveOrigin); + const created = await createSession( + effectiveOrigin, + params.maxTimeToLive, + ); const chain = await extendToApp( created.record, params.sessionPublicKey, @@ -182,8 +189,9 @@ export const handleSessionDelegationRequest = const createSession = async ( effectiveOrigin: string, + requestedMaxTimeToLive: bigint | undefined, ): Promise<{ record: AppSessionRecord }> => { - authorizationStore.setRequestContext(effectiveOrigin, undefined); + authorizationStore.setRequestContext(effectiveOrigin, requestedMaxTimeToLive); const authorized = await waitForStore(authorizedStore); const [accountNumber, { identityNumber, actor, authMethod }] = await Promise.all([ @@ -194,12 +202,14 @@ const createSession = async ( // outlive that, so an SSO identity sends a duration even when the user picked none. const ssoSessionMaxAgeNs = "openid" in authMethod ? authMethod.openid.ssoSessionMaxAgeNs : undefined; + // What the user picked wins over what the app asked for; the app's value is the + // ceiling that applies when the picker offered nothing. + const requested = authorized.maxTimeToLive ?? requestedMaxTimeToLive; const validFor = ssoSessionMaxAgeNs !== undefined && - (authorized.maxTimeToLive === undefined || - authorized.maxTimeToLive > ssoSessionMaxAgeNs) + (requested === undefined || requested > ssoSessionMaxAgeNs) ? ssoSessionMaxAgeNs - : authorized.maxTimeToLive; + : requested; const key = { identityNumber, accountNumber, origin: effectiveOrigin }; const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); From eba83c98ca95ed9a144381b96e31acdd151c7b9b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:43:23 +0200 Subject: [PATCH 023/298] feat(fe): let a user sign a browser out from settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions live per account, so a user who wants to end one browser's access had nothing to name it by. Settings now lists the browsers this identity has signed in from, read off `identity_info` rather than a separate call, and signing one out ends its access to every app it is signed in to. 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 entry. Revoking a single session is deliberately absent: it wants listing applications first, then sessions within one, and neither surface exists yet. Implements docs/ongoing/revocable-app-sessions.md §8.2, §9.1. Co-Authored-By: Claude Opus 5 (1M context) --- .../(authenticated)/settings/+page.svelte | 20 ++ .../components/SessionDevicesSection.svelte | 135 ++++++++++++++ .../settings/sessionDevices.test.ts | 172 ++++++++++++++++++ .../settings/sessionDevices.ts | 62 +++++++ 4 files changed, 389 insertions(+) create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte index d5040d20be..50e290a71b 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte @@ -5,6 +5,9 @@ import { fromCanisterMcpConfig } from "$lib/utils/mcpConfig"; import CliAccessSection from "./components/CliAccessSection.svelte"; import McpTrustedServersSection from "./components/McpTrustedServersSection.svelte"; + import SessionDevicesSection from "./components/SessionDevicesSection.svelte"; + import { fromCanisterSessionDevices } from "./sessionDevices"; + import { currentDeviceId } from "$lib/stores/browser-key.store"; import type { PageProps } from "./$types"; const { data }: PageProps = $props(); @@ -15,6 +18,19 @@ const mcpConfig = $derived( fromCanisterMcpConfig(data.identityInfo.mcp_config), ); + + // Read from this browser's own key record rather than from the canister, which has no + // way to tell which browser is asking: `identity_info` is signed by an access method. + let thisBrowser = $state(undefined); + $effect(() => { + void currentDeviceId($authenticatedStore.identityNumber).then( + (id) => (thisBrowser = id), + ); + }); + + const sessionDevices = $derived( + fromCanisterSessionDevices(data.identityInfo.session_devices, thisBrowser), + );
@@ -32,4 +48,8 @@ identityNumber={$authenticatedStore.identityNumber} {mcpConfig} /> + diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte new file mode 100644 index 0000000000..101d02e1d7 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte @@ -0,0 +1,135 @@ + + +
+ + +
+
+

+ {$t`Signed-in browsers`} +

+

+ {#if devices.length === 0} + + Apps you sign in to from a browser will show up here, so you can end + their access at any time. + + {:else} + + Signing a browser out ends its access to every app it is signed in + to. + + {/if} +

+
+ + {#if devices.length > 0} +
    + {#each devices as device (device.id)} + {@const lastUsed = new Date(device.lastUsedMillis)} +
  • +
    + + + {device.name} + + {#if device.isCurrent} + + {$t`This browser`} + + {/if} + + + + + {$t`Last used ${$formatRelative(lastUsed, { style: "long" })}`} + + + + #{device.id} + +
    + {#if signedOut.includes(device.id)} + + {$t`Signed out`} + + {:else} + + {/if} +
  • + {/each} +
+ {/if} +
+
diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts new file mode 100644 index 0000000000..42164f25ff --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import "fake-indexeddb/auto"; +import type { ActorSubclass } from "@icp-sdk/core/agent"; +import type { _SERVICE } from "$lib/generated/internet_identity_types"; +import { + fromCanisterSessionDevices, + signOutSessionDevice, +} from "./sessionDevices"; + +const device = ( + id: number, + name: string, + createdAtNanos: bigint, + lastUsedNanos: bigint = createdAtNanos, +) => ({ + id, + name, + created_at: createdAtNanos, + last_used: lastUsedNanos, +}); + +describe("fromCanisterSessionDevices", () => { + it("reports no devices for an identity that has never created a session", () => { + expect(fromCanisterSessionDevices([])).toEqual([]); + }); + + it("shows the most recently used browser first", () => { + expect( + fromCanisterSessionDevices([ + [ + device(1, "Firefox on Linux", BigInt(1_000_000_000)), + device(2, "Chrome on macOS", BigInt(3_000_000_000)), + device(3, "Safari on iOS", BigInt(2_000_000_000)), + ], + ]).map((entry) => entry.name), + ).toEqual(["Chrome on macOS", "Safari on iOS", "Firefox on Linux"]); + }); + + it("orders on use rather than on registration", () => { + expect( + fromCanisterSessionDevices([ + [ + device( + 1, + "enrolled first, still in use", + BigInt(1), + BigInt(9_000_000_000), + ), + device(2, "enrolled later, gone quiet", BigInt(5_000_000_000)), + ], + ]).map((entry) => entry.name), + ).toEqual(["enrolled first, still in use", "enrolled later, gone quiet"]); + }); + + it("converts both timestamps to milliseconds", () => { + expect( + fromCanisterSessionDevices([ + [device(1, "Chrome", BigInt(1_500_000_000), BigInt(4_200_000_000))], + ]), + ).toEqual([ + { + id: 1, + name: "Chrome", + createdAtMillis: 1_500, + lastUsedMillis: 4_200, + isCurrent: false, + }, + ]); + }); + + it("marks the browser being read from, so two of one name can be told apart", () => { + const marked = fromCanisterSessionDevices( + [ + [ + device(1, "Chrome on Mac", BigInt(1_000_000_000)), + device(2, "Chrome on Mac", BigInt(2_000_000_000)), + ], + ], + 2, + ); + + expect(marked.map((entry) => [entry.id, entry.isCurrent])).toEqual([ + [2, true], + [1, false], + ]); + }); + + it("marks nothing when this browser has never created a session", () => { + expect( + fromCanisterSessionDevices([ + [device(1, "Chrome on Mac", BigInt(1_000_000_000))], + ]).some((entry) => entry.isCurrent), + ).toBe(false); + }); + + /// An id from another browser's record must not mark an entry here. + it("marks nothing when the id is one this identity does not hold", () => { + expect( + fromCanisterSessionDevices( + [[device(1, "Chrome on Mac", BigInt(1_000_000_000))]], + 99, + ).some((entry) => entry.isCurrent), + ).toBe(false); + }); +}); + +describe("signOutSessionDevice", () => { + it("names the browser by id and nothing else", async () => { + const revoke_device_sessions = vi.fn(() => Promise.resolve({ Ok: null })); + const actor = { + revoke_device_sessions, + } as unknown as ActorSubclass<_SERVICE>; + + await signOutSessionDevice(actor, BigInt(10_000), 3); + + expect(revoke_device_sessions).toHaveBeenCalledWith({ + identity_number: BigInt(10_000), + device_id: 3, + }); + }); + + it("surfaces an unauthorized refusal", async () => { + const actor = { + revoke_device_sessions: () => + Promise.resolve({ Err: { Unauthorized: "2vxsx-fae" } }), + } as unknown as ActorSubclass<_SERVICE>; + + await expect( + signOutSessionDevice(actor, BigInt(10_000), 3), + ).rejects.toThrow(/Not authorized/); + }); + + it("surfaces an internal failure", async () => { + const actor = { + revoke_device_sessions: () => + Promise.resolve({ Err: { InternalCanisterError: "boom" } }), + } as unknown as ActorSubclass<_SERVICE>; + + await expect( + signOutSessionDevice(actor, BigInt(10_000), 3), + ).rejects.toThrow("boom"); + }); + + it("discards this browser's stored chains, and another browser's not", async () => { + const { storeAppSession, appSessionsForOrigin } = + await import("$lib/stores/app-session.store"); + const record = { + keyPair: undefined as unknown as CryptoKeyPair, + chainJson: "{}", + expiresAtMillis: Date.now() + 60 * 60 * 1000, + createdAtNanos: BigInt(1_000), + accessLevel: "full-access" as const, + accountPrincipal: "2vxsx-fae", + }; + const actor = { + revoke_device_sessions: vi.fn(() => Promise.resolve({ Ok: null })), + } as unknown as ActorSubclass<_SERVICE>; + + await storeAppSession( + { identityNumber: BigInt(10_000), origin: "https://app.example.com" }, + record, + ); + // Signing another browser out must leave this one signed in locally. + await signOutSessionDevice(actor, BigInt(10_000), 3, false); + expect(await appSessionsForOrigin("https://app.example.com")).toHaveLength( + 1, + ); + + await signOutSessionDevice(actor, BigInt(10_000), 3, true); + expect(await appSessionsForOrigin("https://app.example.com")).toEqual([]); + }); +}); diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts new file mode 100644 index 0000000000..65f6614238 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts @@ -0,0 +1,62 @@ +import type { ActorSubclass } from "@icp-sdk/core/agent"; +import { purgeAppSessions } from "$lib/stores/app-session.store"; +import type { + _SERVICE, + SessionDeviceInfo, +} from "$lib/generated/internet_identity_types"; +import { nanosToMillis } from "$lib/utils/time"; + +export interface SessionDevice { + id: number; + name: string; + createdAtMillis: number; + lastUsedMillis: number; + /** Several browsers report the same name, so the list marks the one being read from. */ + isCurrent: boolean; +} + +export const fromCanisterSessionDevices = ( + devices: [] | [SessionDeviceInfo[]], + currentDeviceId?: number, +): SessionDevice[] => + (devices[0] ?? []) + .map((device) => ({ + id: device.id, + name: device.name, + createdAtMillis: nanosToMillis(device.created_at), + lastUsedMillis: nanosToMillis(device.last_used), + isCurrent: device.id === currentDeviceId, + })) + .sort((a, b) => b.lastUsedMillis - a.lastUsedMillis); + +/** + * Ends every session this browser holds, across every app it is signed into. + * + * The device record itself survives, so a browser that has been signed out is still one + * the user recognises and signing back in from it reuses the same entry. + * + * Signing *this* browser out also discards the session chains it holds locally. The + * canister has already stopped honouring them, and leaving them would have the next + * silent request offer a chain that cannot mint. + */ +export const signOutSessionDevice = async ( + actor: ActorSubclass<_SERVICE>, + identityNumber: bigint, + deviceId: number, + isCurrentBrowser = false, +): Promise => { + const result = await actor.revoke_device_sessions({ + identity_number: identityNumber, + device_id: deviceId, + }); + if ("Err" in result) { + throw new Error( + "Unauthorized" in result.Err + ? "Not authorized to end this browser's sessions" + : result.Err.InternalCanisterError, + ); + } + if (isCurrentBrowser) { + await purgeAppSessions(identityNumber); + } +}; From 49fa5a11ec6efa238fb95ff36d15efa27a5cbb94 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 22 Aug 2026 21:46:10 +0200 Subject: [PATCH 024/298] feat(fe): answer a silent re-auth without rendering anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling subdomains of one domain can share a sign-in, and the client half of that is already specified in `@icp-sdk/auth`: a shared `derivationOrigin`, a domain-scoped cookie holding only a principal and an expiry, and a `/reauth` page that redirects here with `prompt=none` and a `hint`. This is what II supplies for it. `prompt=none` renders nothing. Either the redirect carries a session chain or it carries `interaction_required`, which the client can tell apart from a real failure and fall back from. It is read before the channel is established, so the handler answers before anything that would paint, and the denial is sent on the channel rather than through the error store, which drives a full-page view. `prompt=none` never creates a session: a session comes only from `prepare_account_session`, which requires an anchor access method, and a silent request has no ceremony and therefore none. So it can only exercise authority, never obtain it. `hint` selects among the sessions this browser holds for the origin being authorized and can never name another origin's, so it is safe for it to come from a cookie the app can read and write: holding the session is what confers anything. Several candidates with nothing to choose between them is `interaction_required` rather than a guess, since picking the wrong persona for the user is worse than asking. Implements docs/ongoing/silent-reauth-redirect.md §2, §4, §5, §7 (R1, R2, R3, R4, R6, R7). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/api/internet_identity/api_v2.rs | 9 + .../lib/generated/internet_identity_idl.js | 1 + .../generated/internet_identity_types.d.ts | 7 + .../src/lib/stores/app-session.store.ts | 2 + .../src/lib/stores/authorization.store.ts | 11 + .../channelHandlers/sessionDelegation.test.ts | 355 +++++++++++++++++- .../channelHandlers/sessionDelegation.ts | 108 +++++- src/frontend/src/lib/utils/transport/utils.ts | 4 + .../(new-styling)/authorize/+layout.svelte | 12 + .../authorize/promptParams.test.ts | 86 +++++ .../(new-styling)/authorize/promptParams.ts | 84 +++++ .../authorize/silentReauth.test.ts | 59 +++ .../(new-styling)/authorize/silentReauth.ts | 44 +++ src/internet_identity/internet_identity.did | 5 + src/internet_identity/src/main.rs | 5 + src/internet_identity/src/sessions.rs | 7 + .../tests/integration/sessions.rs | 65 ++++ 17 files changed, 855 insertions(+), 9 deletions(-) create mode 100644 src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts create mode 100644 src/frontend/src/routes/(new-styling)/authorize/promptParams.ts create mode 100644 src/frontend/src/routes/(new-styling)/authorize/silentReauth.test.ts create mode 100644 src/frontend/src/routes/(new-styling)/authorize/silentReauth.ts 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 9420f1466c..3a646fbe4a 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -789,6 +789,15 @@ pub fn get_account_session( query_candid_as(env, canister_id, sender, "get_account_session", (request,)).map(|(x,)| x) } +/// The II frontend's liveness check, called as the query it is declared as. +pub fn check_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, +) -> Result { + query_candid_as(env, canister_id, sender, "check_session", ()).map(|(x,)| x) +} + pub fn app_prepare_delegation( env: &PocketIc, canister_id: CanisterId, diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 96c8286d81..d246777ba3 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -1032,6 +1032,7 @@ export const idlFactory = ({ IDL }) => { ], [], ), + 'check_session' : IDL.Func([], [IDL.Bool], ['query']), 'config' : IDL.Func([], [InternetIdentityInit], ['query']), 'create_account' : IDL.Func( [UserNumber, FrontendHostname, IDL.Text], 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 1c9c93f428..1e217c52e0 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -2044,6 +2044,13 @@ export interface _SERVICE { { 'Ok' : IdRegNextStepResult } | { 'Err' : CheckCaptchaError } >, + /** + * Whether the calling session is still usable. For the II frontend's silent + * re-auth path, which must decide whether it can answer without rendering + * anything. Advisory: a query reply is not certified, and every mint enforces + * the same conditions regardless of the answer here. + */ + 'check_session' : ActorMethod<[], boolean>, 'config' : ActorMethod<[], InternetIdentityInit>, 'create_account' : ActorMethod< [UserNumber, FrontendHostname, string], diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index e3f6b9ea4e..acc062c01f 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -21,6 +21,8 @@ export interface AppSessionRecord { * once a surface exists that lists them. */ /** Names this session to `revoke_account_session`, which is how one session is revoked * once a surface exists that lists them. */ + /** Names this session to `revoke_account_session`, which is how one session is revoked + * once a surface exists that lists them. */ createdAtNanos: bigint; /** What the user consented to when this session was created. Recorded for display; the * canister enforces it at every mint, and an app cannot request a level of its own. */ diff --git a/src/frontend/src/lib/stores/authorization.store.ts b/src/frontend/src/lib/stores/authorization.store.ts index 0d3b385b84..c5aa0d66ba 100644 --- a/src/frontend/src/lib/stores/authorization.store.ts +++ b/src/frontend/src/lib/stores/authorization.store.ts @@ -33,6 +33,17 @@ export type Authorized = { const contextInternal = writable(); const authorizedInternal = writable(); +export type AuthorizationPromptContext = { + prompt?: "none" | "login"; + hint?: string; +}; + +/** Kept out of `AuthorizationContext`, whose presence is what makes the sign-in UI + * render: URL state must not paint anything before there is a request to answer. */ +export const authorizationPromptStore = writable( + {}, +); + export const authorizationStore = { /** Called by the channel handler once the delegation request is parsed. * Sets the effective origin and the app's requested session duration in a diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index f6f1da0b26..64d934f52d 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -1,11 +1,14 @@ 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 { + agentOptions: {}, canisterId: Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai"), backendCanisterConfig: { openid_configs: [] }, frontendCanisterConfig: { related_origins: [], dev_csp: [] }, @@ -18,16 +21,40 @@ vi.mock("$lib/utils/iiConnection", () => ({ remapToLegacyDomain: (origin: string) => origin, })); +const checkSession = vi.fn(() => Promise.resolve(true)); +vi.mock("@icp-sdk/core/agent", async () => { + const actual = await vi.importActual( + "@icp-sdk/core/agent", + ); + return { + ...actual, + HttpAgent: { ...actual.HttpAgent, createSync: () => ({}) }, + Actor: { + ...actual.Actor, + createActor: () => ({ check_session: checkSession }), + }, + }; +}); + const setRequestContext = vi.fn(); -vi.mock("$lib/stores/authorization.store", () => ({ - authorizationStore: { - setRequestContext: (...args: unknown[]) => setRequestContext(...args), - }, - authorizedStore: { subscribe: () => () => {} }, -})); +vi.mock("$lib/stores/authorization.store", async () => { + const { writable } = await import("svelte/store"); + return { + authorizationStore: { + setRequestContext: (...args: unknown[]) => setRequestContext(...args), + }, + authorizedStore: { subscribe: () => () => {} }, + authorizationPromptStore: writable<{ prompt?: string; hint?: string }>({}), + }; +}); import { handleSessionDelegationRequest } from "./sessionDelegation"; -import { purgeAppSessions } from "$lib/stores/app-session.store"; +import { + appSessionsForOrigin, + purgeAppSessions, + storeAppSession, +} from "$lib/stores/app-session.store"; +import { INTERACTION_REQUIRED_ERROR_CODE } from "$lib/utils/transport/utils"; const channelWith = () => { const sent: unknown[] = []; @@ -47,6 +74,33 @@ const channelWith = () => { }; }; +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(); @@ -89,4 +143,291 @@ describe("ii_session_delegation", () => { expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); expect(onError).toHaveBeenCalledWith("invalid-request"); }); + + it("answers a malformed silent request without rendering anything", async () => { + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); + 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).not.toHaveBeenCalled(); + }); + + it("re-issues from a held session when silence is asked for", async () => { + await storedSession(BigInt(10_000)); + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); + 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 { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); + 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("answers a silent request it cannot satisfy without rendering", async () => { + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); + 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(setRequestContext).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(sent[0]).toMatchObject({ + error: { code: 3002, data: { reason: "login_required" } }, + }); + authorizationPromptStore.set({}); + }); +}); + +describe("a session the canister no longer holds", () => { + const promptStore = async () => + (await import("$lib/stores/authorization.store")).authorizationPromptStore; + + beforeEach(async () => { + checkSession.mockClear(); + await purgeAppSessions(BigInt(10_000)); + (await promptStore()).set({}); + }); + + it("denies a silent request when the canister no longer holds the session", async () => { + checkSession.mockResolvedValueOnce(false); + await storedSession(BigInt(10_000)); + const { channel, sent } = channelWith(); + (await promptStore()).set({ prompt: "none" }); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + error: { code: INTERACTION_REQUIRED_ERROR_CODE }, + }); + }); + + it("forgets a record the canister no longer holds", async () => { + checkSession.mockResolvedValueOnce(false); + await storedSession(BigInt(10_000)); + const { channel } = channelWith(); + (await promptStore()).set({ prompt: "none" }); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(await appSessionsForOrigin(ORIGIN)).toEqual([]); + }); +}); + +describe("silent requests never paint", () => { + const promptStore = async () => + (await import("$lib/stores/authorization.store")).authorizationPromptStore; + + it("answers rather than surfacing an unverified origin", async () => { + const { validateDerivationOrigin } = + await import("$lib/utils/validateDerivationOrigin"); + vi.mocked(validateDerivationOrigin).mockResolvedValueOnce({ + result: "invalid", + message: "nope", + }); + (await promptStore()).set({ prompt: "none" }); + 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(sent[0]).toMatchObject({ error: { code: 3002 } }); + (await promptStore()).set({}); + }); + + it("runs a ceremony for prompt=login even when a session is held", async () => { + await storedSession(BigInt(10_000)); + (await promptStore()).set({ prompt: "login" }); + 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(); + (await promptStore()).set({}); + }); +}); + +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(); + }); +}); + +// Ordered last: each leaves a ceremony pending, which holds the shared authorization queue. +describe("requests that fall through to a ceremony", () => { + 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); + }); + + it("runs the ceremony when silence was not asked for", async () => { + await storedSession(BigInt(10_000)); + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({}); + const { channel, sent } = 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)), + ]); + + // A held session is not handed back: the ceremony starts and nothing is answered from + // local state, because silence is something an app has to ask for. + expect(setRequestContext).toHaveBeenCalled(); + expect(sent).toEqual([]); + }); }); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index dc4631b2d6..7ac76eaf26 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -2,16 +2,20 @@ import type { Channel, JsonRequest } from "$lib/utils/transport/utils"; import { Base64ToBytesCodec, Base64ToPublicKeyCodec, + INTERACTION_REQUIRED_ERROR_CODE, INVALID_PARAMS_ERROR_CODE, OriginSchema, StringToBigIntCodec, } from "$lib/utils/transport/utils"; import { + authorizationPromptStore, authorizationStore, authorizedStore, } from "$lib/stores/authorization.store"; import { authenticationStore } from "$lib/stores/authentication.store"; import { + appSessionsForOrigin, + discardAppSession, storeAppSession, type AppSessionRecord, } from "$lib/stores/app-session.store"; @@ -19,14 +23,23 @@ 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 { agentOptions, canisterId } from "$lib/globals"; +import { Actor, HttpAgent } from "@icp-sdk/core/agent"; +import { idlFactory as internet_identity_idl } from "$lib/generated/internet_identity_idl"; +import type { _SERVICE } from "$lib/generated/internet_identity_types"; import { Principal } from "@icp-sdk/core/principal"; import { Delegation, DelegationChain, + DelegationIdentity, ECDSAKeyIdentity, } from "@icp-sdk/core/identity"; import type { PublicKey, Signature } from "@icp-sdk/core/agent"; +import { get } from "svelte/store"; +import { + chooseSilentSession, + type SilentDenial, +} from "../../../routes/(new-styling)/authorize/silentReauth"; import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; import { withBrowserProof } from "$lib/stores/browser-key.store"; import { describeBrowser } from "$lib/stores/channelHandlers/describeBrowser"; @@ -116,6 +129,30 @@ const extendToApp = async ( }, ); +/** + * Whether the canister still holds the session this record names. + * + * A record can outlive its session: revoking from settings or from another app leaves + * this browser's copy in place. Answering from the record alone would hand the app a + * chain that cannot mint, and the failure would surface later as something the client + * cannot tell apart from a real error. + */ +const sessionIsLive = async (record: AppSessionRecord): Promise => { + try { + const identity = DelegationIdentity.fromDelegation( + await ECDSAKeyIdentity.fromKeyPair(record.keyPair), + DelegationChain.fromJSON(JSON.parse(record.chainJson)), + ); + const actor = Actor.createActor<_SERVICE>(internet_identity_idl, { + agent: HttpAgent.createSync({ ...agentOptions, identity }), + canisterId, + }); + return await actor.check_session(); + } catch { + return false; + } +}; + /** * Obtains the session an app re-issues its own delegations from. * @@ -135,6 +172,19 @@ export const handleSessionDelegationRequest = } const requestId = request.id; + const isSilent = get(authorizationPromptStore).prompt === "none"; + const deny = async (reason: SilentDenial) => { + await channel.send({ + jsonrpc: "2.0", + id: requestId, + error: { + code: INTERACTION_REQUIRED_ERROR_CODE, + message: "Interaction required", + data: { reason }, + }, + }); + }; + const parsed = SessionParamsCodec.safeParse(request.params); if (!parsed.success) { await channel.send({ @@ -145,7 +195,12 @@ export const handleSessionDelegationRequest = message: z.prettifyError(parsed.error), }, }); - onError("invalid-request"); + // A malformed request is still a protocol error rather than a denial, so the code + // stays INVALID_PARAMS. What the silent path must not do is render: it was asked to + // answer without showing the user anything, and that holds however it fails. + if (!isSilent) { + onError("invalid-request"); + } return; } @@ -157,6 +212,10 @@ export const handleSessionDelegationRequest = derivationOrigin: params.icrc95DerivationOrigin, }); if (validation.result === "invalid") { + if (isSilent) { + await deny("login_required"); + return; + } onError("unverified-origin"); return; } @@ -165,6 +224,47 @@ export const handleSessionDelegationRequest = params.icrc95DerivationOrigin ?? channel.origin, ); + const { prompt, hint } = get(authorizationPromptStore); + // Silence is something an app asks for. Anything else, an absent `prompt` included, + // runs the ceremony, so a held session is never handed over without the user + // seeing a screen they did not request. + const held = + prompt === "none" ? await appSessionsForOrigin(effectiveOrigin) : []; + const chosen = chooseSilentSession({ held, hint }); + + let usable = + "record" in chosen + ? held.find((entry) => entry.record === chosen.record) + : undefined; + if (usable && !(await sessionIsLive(usable.record))) { + await discardAppSession({ + identityNumber: usable.identityNumber, + accountNumber: usable.accountNumber, + origin: effectiveOrigin, + }); + usable = undefined; + } + + if (usable) { + const chain = await extendToApp( + usable.record, + params.sessionPublicKey, + ); + await channel.send({ + jsonrpc: "2.0", + id: requestId, + result: SessionResultSchema.encode({ + chain, + }), + }); + return; + } + + if (isSilent) { + await deny("denial" in chosen ? chosen.denial : "login_required"); + return; + } + const created = await createSession( effectiveOrigin, params.maxTimeToLive, @@ -182,6 +282,10 @@ export const handleSessionDelegationRequest = }); } catch (error) { console.error(error); + if (isSilent) { + await deny("login_required"); + return; + } onError("delegation-failed"); } }); diff --git a/src/frontend/src/lib/utils/transport/utils.ts b/src/frontend/src/lib/utils/transport/utils.ts index 0f232a930c..6c0decb65f 100644 --- a/src/frontend/src/lib/utils/transport/utils.ts +++ b/src/frontend/src/lib/utils/transport/utils.ts @@ -6,6 +6,10 @@ import { Principal } from "@icp-sdk/core/principal"; // See: https://www.jsonrpc.org/specification#error_object export const INVALID_PARAMS_ERROR_CODE = -32602; // See: https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_25_signer_interaction_standard.md#errors-3 +/// Placed in ICRC-25's 3xxx "user action" range, so a client can tell a silent request +/// that needs a ceremony apart from a real failure. +export const INTERACTION_REQUIRED_ERROR_CODE = 3002; + export const GENERIC_ERROR_CODE = 1000; export interface ChannelOptions { diff --git a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte index 26fd5caccc..2115c24186 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte @@ -10,6 +10,8 @@ authorizationStore, authorizedStore, } from "$lib/stores/authorization.store"; + import { authorizationPromptStore } from "$lib/stores/authorization.store"; + import { resolvePromptParams, stripPromptParams } from "./promptParams"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; import { purgeSession } from "$lib/stores/session-delegation.store"; import { authenticationStore } from "$lib/stores/authentication.store"; @@ -62,6 +64,16 @@ return "normal" as const; })(); + // Set before the channel is established, so the delegation handler has the prompt + // context by the time a request arrives. + authorizationPromptStore.set( + resolvePromptParams( + new URL(window.location.href), + flow === "openid-resume", + ), + ); + stripPromptParams(); + // --- Channel establishment --- $effect.pre(() => { if (flow === "error") { diff --git a/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts b/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts new file mode 100644 index 0000000000..5e25567d51 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + readPromptParams, + resolvePromptParams, + stripPromptParams, +} from "./promptParams"; + +const PRINCIPAL = "2vxsx-fae"; + +describe("authorize prompt params", () => { + beforeEach(() => { + sessionStorage.clear(); + window.history.replaceState(null, "", "http://localhost:3000/authorize"); + }); + + it("reads a silent request", () => { + expect( + readPromptParams( + new URL( + `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}`, + ), + ), + ).toEqual({ prompt: "none", hint: PRINCIPAL }); + }); + + it("reads an interactive request", () => { + expect( + readPromptParams(new URL("http://localhost:3000/authorize?prompt=login")), + ).toEqual({ prompt: "login", hint: undefined }); + }); + + it("treats an unknown prompt as absent", () => { + expect( + readPromptParams( + new URL("http://localhost:3000/authorize?prompt=consent"), + ), + ).toEqual({ prompt: undefined, hint: undefined }); + }); + + it("treats a hint that is not a principal as absent", () => { + expect( + readPromptParams( + new URL("http://localhost:3000/authorize?hint=not-a-principal"), + ), + ).toEqual({ prompt: undefined, hint: undefined }); + }); + + it("keeps the params across a resume", () => { + resolvePromptParams( + new URL(`http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}`), + false, + ); + + expect( + resolvePromptParams(new URL("http://localhost:3000/authorize"), true), + ).toEqual({ prompt: "none", hint: PRINCIPAL }); + }); + + it("clears a stored context when a later request carries none", () => { + resolvePromptParams( + new URL("http://localhost:3000/authorize?prompt=none"), + false, + ); + + resolvePromptParams(new URL("http://localhost:3000/authorize"), false); + + expect( + resolvePromptParams(new URL("http://localhost:3000/authorize"), true), + ).toEqual({}); + }); + + it("strips the params it has consumed and leaves the rest", () => { + window.history.replaceState( + null, + "", + `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}&sso=example.com`, + ); + + stripPromptParams(); + + const url = new URL(window.location.href); + expect(url.searchParams.get("prompt")).toBeNull(); + expect(url.searchParams.get("hint")).toBeNull(); + expect(url.searchParams.get("sso")).toBe("example.com"); + }); +}); diff --git a/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts new file mode 100644 index 0000000000..b11024b178 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts @@ -0,0 +1,84 @@ +import { Principal } from "@icp-sdk/core/principal"; +import { z } from "zod"; + +export const PROMPT_PARAM = "prompt"; +export const HINT_PARAM = "hint"; + +/** Survives the round trip an interactive flow may take through an IdP. */ +const STORAGE_KEY = "ii-authorize-prompt"; + +export type AuthorizationPrompt = "none" | "login"; + +export interface PromptContext { + prompt?: AuthorizationPrompt; + hint?: string; +} + +const isPrincipal = (value: string): boolean => { + try { + Principal.fromText(value); + return true; + } catch { + return false; + } +}; + +// `prompt` and `hint` are preferences, never credentials, so an unreadable value +// degrades to an interactive sign-in rather than failing the request. +const PromptContextSchema = z.object({ + prompt: z.enum(["none", "login"]).optional().catch(undefined), + hint: z + .string() + .refine(isPrincipal) + .transform((value) => Principal.fromText(value).toText()) + .optional() + .catch(undefined), +}); + +export const readPromptParams = (url: URL): PromptContext => { + const parsed = PromptContextSchema.safeParse({ + prompt: url.searchParams.get(PROMPT_PARAM) ?? undefined, + hint: url.searchParams.get(HINT_PARAM) ?? undefined, + }); + return parsed.success ? parsed.data : {}; +}; + +export const resolvePromptParams = ( + url: URL, + isResuming: boolean, +): PromptContext => { + if (isResuming) { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored === null) { + return {}; + } + try { + const parsed = PromptContextSchema.safeParse(JSON.parse(stored)); + return parsed.success ? parsed.data : {}; + } catch { + return {}; + } + } + + const context = readPromptParams(url); + if (context.prompt === undefined && context.hint === undefined) { + sessionStorage.removeItem(STORAGE_KEY); + } else { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)); + } + return context; +}; + +/** Keeps the address bar free of values the flow has already consumed. */ +export const stripPromptParams = (): void => { + const url = new URL(window.location.href); + if ( + !url.searchParams.has(PROMPT_PARAM) && + !url.searchParams.has(HINT_PARAM) + ) { + return; + } + url.searchParams.delete(PROMPT_PARAM); + url.searchParams.delete(HINT_PARAM); + window.history.replaceState(null, "", url.toString()); +}; diff --git a/src/frontend/src/routes/(new-styling)/authorize/silentReauth.test.ts b/src/frontend/src/routes/(new-styling)/authorize/silentReauth.test.ts new file mode 100644 index 0000000000..d7e31885ef --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/silentReauth.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { chooseSilentSession } from "./silentReauth"; +import type { AppSessionRecord } from "$lib/stores/app-session.store"; + +const PRINCIPAL_A = "2vxsx-fae"; +const PRINCIPAL_B = "aaaaa-aa"; + +const held = (accountPrincipal: string) => ({ + record: { accountPrincipal } as AppSessionRecord, +}); + +describe("chooseSilentSession", () => { + it("denies when this browser holds nothing for the origin", () => { + expect(chooseSilentSession({ held: [] })).toEqual({ + denial: "login_required", + }); + }); + + it("uses the only session held when no hint is given", () => { + expect(chooseSilentSession({ held: [held(PRINCIPAL_A)] })).toMatchObject({ + record: { accountPrincipal: PRINCIPAL_A }, + }); + }); + + it("asks rather than guessing between personas", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A), held(PRINCIPAL_B)], + }), + ).toEqual({ denial: "account_selection_required" }); + }); + + it("selects the hinted session", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A), held(PRINCIPAL_B)], + hint: PRINCIPAL_B, + }), + ).toMatchObject({ record: { accountPrincipal: PRINCIPAL_B } }); + }); + + it("denies a hint this browser holds no session for", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A)], + hint: PRINCIPAL_B, + }), + ).toEqual({ denial: "login_required" }); + }); + + it("denies rather than guessing when a hint is ambiguous", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A), held(PRINCIPAL_A)], + hint: PRINCIPAL_A, + }), + ).toEqual({ denial: "account_selection_required" }); + }); +}); diff --git a/src/frontend/src/routes/(new-styling)/authorize/silentReauth.ts b/src/frontend/src/routes/(new-styling)/authorize/silentReauth.ts new file mode 100644 index 0000000000..e380983ed7 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/silentReauth.ts @@ -0,0 +1,44 @@ +import type { AppSessionRecord } from "$lib/stores/app-session.store"; + +export type SilentDenial = "login_required" | "account_selection_required"; + +export type SilentOutcome = + { record: AppSessionRecord } | { denial: SilentDenial }; + +/** + * Picks which of the origin's held sessions a silent request re-issues from. + * + * A hint is a preference, not a credential: it can only select from what this browser + * already holds for the origin being authorized, and holding the session is what confers + * anything. Picking the wrong persona for the user is worse than asking, so several + * candidates with nothing to choose between them is a denial rather than a guess. + */ +export const chooseSilentSession = ({ + held, + hint, +}: { + held: { record: AppSessionRecord }[]; + hint?: string; +}): SilentOutcome => { + if (held.length === 0) { + return { denial: "login_required" }; + } + + if (hint !== undefined) { + const matched = held.filter( + (entry) => entry.record.accountPrincipal === hint, + ); + if (matched.length === 1) { + return { record: matched[0].record }; + } + return { + denial: + matched.length === 0 ? "login_required" : "account_selection_required", + }; + } + + if (held.length === 1) { + return { record: held[0].record }; + } + return { denial: "account_selection_required" }; +}; diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 509e1df8e3..56b52f0d88 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1988,6 +1988,11 @@ service : (opt InternetIdentityInit) -> { // 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 : () -> (); + // Whether the calling session is still usable. For the II frontend's silent + // re-auth path, which must decide whether it can answer without rendering + // anything. Advisory: a query reply is not certified, and every mint enforces + // the same conditions regardless of the answer here. + check_session : () -> (bool) query; // Revocation from the user's own settings, authenticated by an anchor access method // rather than by a session chain. A session is named by locator here, never by diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 6d9b9482b4..372f9c6822 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -526,6 +526,11 @@ fn app_revoke_session() { sessions::app_revoke_session() } +#[query] +fn check_session() -> bool { + sessions::check_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 4d5e0d55e2..52934b3195 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -404,6 +404,13 @@ fn authorize_session( Ok(matched) } +/// Whether the calling session is still usable, for the II frontend's silent re-auth +/// path. Advisory: a query reply is not certified, and the refresh path enforces the +/// same conditions on every mint regardless of the answer here. +pub fn check_session() -> bool { + authorize_session(time()).is_ok() +} + /// 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() { diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 81fbea5874..641e959ebc 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -826,6 +826,71 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { Ok(()) } +/// The silent re-auth path answers from a locally held record, so it needs a way to ask +/// whether that record still stands for a session the canister has since lost. +#[test] +fn should_report_a_live_session_as_live() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::check_session; + + 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); + + assert!(check_session(&env, canister_id, session_principal,)?); + + Ok(()) +} + +#[test] +fn should_report_a_revoked_session_as_gone() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::check_session; + + 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(); + + assert!(!check_session(&env, canister_id, session_principal,)?); + + Ok(()) +} + +#[test] +fn should_report_an_expired_session_as_gone() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::check_session; + + 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)); + // `check_session` is a query, so it sees the latest certified state: without a round the + // canister's clock has not moved and the session is not yet expired from its view. + env.tick(); + + assert!(!check_session(&env, canister_id, session_principal)?); + + 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 6968cf14c88809ccad80253c4bc8b3a0d212d485 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 25 Aug 2026 15:32:21 +0200 Subject: [PATCH 025/298] refactor(be): make recording an account use say what it does `set_account_last_used` stamped a field, created a reference-list row, allocated an application number and evicted idle rows, behind a name that promised only the first. It also returned `Result, _>` whose `Option` no caller read and which production could never see as `None`. It becomes `record_account_use`, returning `Result<(), _>`, and reads as the two steps it is: make sure the default has a reference at this origin, then stamp the reference. The first step is the existing `ensure_account_reference_list`, and only a default takes it, because a named account with no reference is one that was removed and must stay removed. `with_account_mut` loses its two near-identical arms, and no longer writes a reference list and an account record back unchanged when no reference matched. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/account_management.rs | 2 +- src/internet_identity/src/storage.rs | 150 +++++++--------- src/internet_identity/src/storage/tests.rs | 170 +++++++++++------- 3 files changed, 175 insertions(+), 147 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 1a6b4d4695..7ba789eeaa 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -357,7 +357,7 @@ pub async fn prepare_account_delegation( let seed = account.calculate_seed(); storage_borrow_mut(|storage| { - storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()) + storage.record_account_use(anchor_number, origin.clone(), account_number, time()) }) .map_err(|err| AccountDelegationError::InternalCanisterError(err.to_string()))?; diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 7c609f3f05..c0a50fb0dc 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1645,105 +1645,89 @@ impl Storage { where F: FnOnce(&mut StorableAccountReference, Option<&mut StorableAccount>) -> T, { - match maybe_account_number { - None => { - // We are looking for a synthetic account - let Some(((_, application_number), mut account_references)) = - self.find_account_references(anchor_number, application_number) - else { - return Ok(None); - }; - - let mut result = None; - - for account_reference in &mut account_references { - if account_reference.account_number == maybe_account_number { - result = Some(f(account_reference, None)); - break; - } - } - - self.write_reference_list( - anchor_number, - application_number, - account_references.into_iter().map(Into::into).collect(), - )?; - - Ok(result) - } - Some(account_number) => { - // Account should be stored, otherwise, it was removed and we'll return `None`. - let Some(mut storable_account) = self.stable_account_memory.get(&account_number) - else { - return Ok(None); - }; - let Some(((_, application_number), mut account_references)) = - self.find_account_references(anchor_number, application_number) - else { - return Ok(None); - }; - - let mut result = None; + // A named account has a stored record to hand to `f`; a default is derived + // and has none. A named account with no record was removed, so there is + // nothing to update. + let mut storable_account = match maybe_account_number { + Some(account_number) => match self.stable_account_memory.get(&account_number) { + Some(storable_account) => Some(storable_account), + None => return Ok(None), + }, + None => None, + }; - for account_reference in &mut account_references { - if account_reference.account_number == maybe_account_number { - result = Some(f(account_reference, Some(&mut storable_account))); - break; - } - } + let Some(((_, application_number), mut account_references)) = + self.find_account_references(anchor_number, application_number) + else { + return Ok(None); + }; - self.write_reference_list( - anchor_number, - application_number, - account_references.into_iter().map(Into::into).collect(), - )?; - self.stable_account_memory - .insert(account_number, storable_account); + let Some(account_reference) = account_references + .iter_mut() + .find(|account_reference| account_reference.account_number == maybe_account_number) + else { + // `f` never ran, so nothing was modified and writing the list back would + // store the bytes it already holds. + return Ok(None); + }; + let result = f(account_reference, storable_account.as_mut()); - Ok(result) - } + self.write_reference_list( + anchor_number, + application_number, + account_references.into_iter().map(Into::into).collect(), + )?; + if let (Some(account_number), Some(storable_account)) = + (maybe_account_number, storable_account) + { + self.stable_account_memory + .insert(account_number, storable_account); } + + Ok(Some(result)) } - /// Stamps `last_used`, tracking the default account on first use at an origin. - pub fn set_account_last_used( + /// Records that an account was used at `origin`. + /// + /// A default account has no reference stored until it is used, so recording a + /// use is also what creates one. A named account's reference is its existence: + /// where it is gone the account was removed, and writing one back would undo + /// that, so only the default gets the pre-step. + pub fn record_account_use( &mut self, anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, now: Timestamp, - ) -> Result, StorageError> { - if let Some(application_number) = self.lookup_application_number_with_origin(&origin) { - if self - .lookup_account_references(anchor_number, application_number) - .is_some() - { - return self.with_account_mut( - anchor_number, - Some(application_number), - account_number, - |account_reference, _| { - account_reference.last_used = Some(now); - }, - ); - } - } - - if account_number.is_some() { - return Ok(None); + ) -> Result<(), StorageError> { + if account_number.is_none() { + let application_number = self.lookup_or_insert_application_number_with_origin(&origin); + self.ensure_account_reference_list(anchor_number, application_number)?; } + self.stamp_account_reference(anchor_number, &origin, account_number, now) + } - let application_number = self.lookup_or_insert_application_number_with_origin(&origin); - self.write_reference_list( + /// Stamps `last_used` on the reference for `account_number` at `origin`. + /// + /// Does nothing when the identity has no such reference stored: nothing is + /// recorded against an account it cannot reach. + fn stamp_account_reference( + &mut self, + anchor_number: AnchorNumber, + origin: &FrontendHostname, + account_number: Option, + now: Timestamp, + ) -> Result<(), StorageError> { + let application_number = self.lookup_application_number_with_origin(origin); + self.with_account_mut( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: Some(now), - }], + account_number, + |account_reference, _| { + account_reference.last_used = Some(now); + }, )?; - self.evict_idle_tracked_defaults(anchor_number, application_number)?; - Ok(Some(())) + Ok(()) } /// Writes the reference-list row an `AnchorApplicationConfig` row implies, leaving diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index aaf099716e..c03f8dd60b 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -415,7 +415,7 @@ fn should_not_overwrite_device_credential_lookup() { } #[test] -fn should_set_account_last_used() { +fn should_record_repeated_use_of_a_named_account() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); @@ -449,13 +449,14 @@ fn should_set_account_last_used() { // Set last_used for the additional account let timestamp = 123456789u64; - let result = storage.set_account_last_used( - anchor_number, - origin.clone(), - Some(account_number), - timestamp, - ); - assert!(result.unwrap().is_some()); + storage + .record_account_use( + anchor_number, + origin.clone(), + Some(account_number), + timestamp, + ) + .unwrap(); // Verify last_used was updated let read_account = storage @@ -470,13 +471,14 @@ fn should_set_account_last_used() { // Update last_used again with a new timestamp let new_timestamp = 987654321u64; - let result = storage.set_account_last_used( - anchor_number, - origin.clone(), - Some(account_number), - new_timestamp, - ); - assert!(result.unwrap().is_some()); + storage + .record_account_use( + anchor_number, + origin.clone(), + Some(account_number), + new_timestamp, + ) + .unwrap(); // Verify last_used was updated to the new timestamp let read_account = storage @@ -502,8 +504,9 @@ fn should_track_the_default_account_on_first_use() { storage.write(anchor).unwrap(); let timestamp = 555555u64; - let result = storage.set_account_last_used(anchor_number, origin.clone(), None, timestamp); - assert!(result.unwrap().is_some()); + storage + .record_account_use(anchor_number, origin.clone(), None, timestamp) + .unwrap(); let read_account = storage .read_account(ReadAccountParams { @@ -523,7 +526,7 @@ fn should_track_the_default_account_on_first_use() { } #[test] -fn should_set_account_last_used_for_synthetic_account_with_reference() { +fn should_record_use_of_a_default_that_has_a_reference() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); @@ -544,8 +547,9 @@ fn should_set_account_last_used_for_synthetic_account_with_reference() { // Set last_used for the synthetic account (account_number = None) let timestamp = 555555u64; - let result = storage.set_account_last_used(anchor_number, origin.clone(), None, timestamp); - assert!(result.unwrap().is_some()); + storage + .record_account_use(anchor_number, origin.clone(), None, timestamp) + .unwrap(); // Verify last_used was updated for the synthetic account let read_account = storage @@ -560,7 +564,7 @@ fn should_set_account_last_used_for_synthetic_account_with_reference() { } #[test] -fn should_return_none_when_setting_last_used_for_nonexistent_account() { +fn should_record_nothing_for_a_nonexistent_named_account() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); @@ -570,18 +574,20 @@ fn should_return_none_when_setting_last_used_for_nonexistent_account() { let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Try to set last_used for a non-existent account number let nonexistent_account_number = 99999u64; let timestamp = 123456u64; - let result = storage.set_account_last_used( - anchor_number, - origin, - Some(nonexistent_account_number), - timestamp, - ); + storage + .record_account_use( + anchor_number, + origin.clone(), + Some(nonexistent_account_number), + timestamp, + ) + .unwrap(); - // Should return None because the account doesn't exist - assert!(result.unwrap().is_none()); + assert!(storage + .lookup_application_number_with_origin(&origin) + .is_none()); } #[test] @@ -596,14 +602,15 @@ fn should_not_track_a_named_account_at_an_unknown_origin() { let nonexistent_origin = "https://nonexistent.com".to_string(); let timestamp = 123456u64; - let result = storage.set_account_last_used( - anchor_number, - nonexistent_origin.clone(), - Some(1), - timestamp, - ); + storage + .record_account_use( + anchor_number, + nonexistent_origin.clone(), + Some(1), + timestamp, + ) + .unwrap(); - assert!(result.unwrap().is_none()); assert!(storage .lookup_application_number_with_origin(&nonexistent_origin) .is_none()); @@ -2510,14 +2517,51 @@ mod default_account_tracking_tests { (storage, anchor_number) } + #[test] + fn a_default_is_tracked_at_an_origin_another_identity_registered() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + // Another identity reached this origin first, so the application is known + // while this identity has no reference list there. + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + storage + .record_account_use(anchor_number, origin, None, 1_000) + .unwrap(); + + assert_eq!( + storage.lookup_account_references(anchor_number, application_number), + Some(vec![AccountReference { + account_number: None, + last_used: Some(1_000), + } + .into()]) + ); + } + + #[test] + fn recording_a_named_account_never_creates_a_reference() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + storage + .record_account_use(anchor_number, origin, Some(7), 1_000) + .unwrap(); + + assert_eq!( + storage.lookup_account_references(anchor_number, application_number), + None + ); + } + #[test] fn tracking_registers_the_application_and_the_reference() { 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() + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); let application_number = storage @@ -2539,10 +2583,10 @@ mod default_account_tracking_tests { let origin = "https://example.com".to_string(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 2_000) + .record_account_use(anchor_number, origin.clone(), None, 2_000) .unwrap(); let application_number = storage @@ -2577,14 +2621,16 @@ mod default_account_tracking_tests { ) .unwrap(); - let result = storage.set_account_last_used(anchor_number, origin, None, 1_000); + storage + .record_account_use(anchor_number, origin, None, 1_000) + .unwrap(); - assert!(result.unwrap().is_none()); let references = storage .lookup_account_references(anchor_number, application_number) .unwrap(); assert_eq!(references.len(), 1); assert_eq!(references[0].account_number, Some(9)); + assert_eq!(references[0].last_used, None); } #[test] @@ -2637,7 +2683,7 @@ mod default_account_tracking_tests { let origin = "https://example.com".to_string(); let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); storage - .set_account_last_used(anchor_number, origin, None, 7_000) + .record_account_use(anchor_number, origin, None, 7_000) .unwrap(); storage @@ -2678,8 +2724,7 @@ mod tracked_default_eviction_tests { fn sign_in_at(storage: &mut Storage, anchor_number: AnchorNumber, index: u64) { storage - .set_account_last_used(anchor_number, origin_of(index), None, index + 1) - .unwrap() + .record_account_use(anchor_number, origin_of(index), None, index + 1) .unwrap(); } @@ -2735,14 +2780,13 @@ mod tracked_default_eviction_tests { let (mut storage, anchor_number) = storage_with_anchor(); for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 { storage - .set_account_last_used(anchor_number, origin_of(index), None, 1) + .record_account_use(anchor_number, origin_of(index), None, 1) .unwrap(); } let newest_origin = "https://newest.com".to_string(); storage - .set_account_last_used(anchor_number, newest_origin.clone(), None, 1) - .unwrap() + .record_account_use(anchor_number, newest_origin.clone(), None, 1) .unwrap(); let newest_application = storage @@ -2773,7 +2817,7 @@ mod tracked_default_eviction_tests { let before = storage.evictable_default_rows(anchor_number).len() as u64; storage - .set_account_last_used( + .record_account_use( anchor_number, "https://trigger.com".to_string(), None, @@ -2856,7 +2900,7 @@ mod tracked_default_eviction_tests { let origin = "https://example.com".to_string(); let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); storage - .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); storage.set_anchor_application_config( anchor_number, @@ -2899,7 +2943,7 @@ mod tracked_default_eviction_tests { let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); storage - .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); let before = storage .read_account(ReadAccountParams { @@ -2914,7 +2958,7 @@ mod tracked_default_eviction_tests { .remove_reference_list(anchor_number, application_number) .unwrap(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 2_000) + .record_account_use(anchor_number, origin.clone(), None, 2_000) .unwrap(); let after = storage @@ -3001,7 +3045,7 @@ mod tracked_default_eviction_tests { let other_anchor_number = other_anchor.anchor_number(); storage.write(other_anchor).unwrap(); storage - .set_account_last_used(other_anchor_number, origin_of(0), None, 1) + .record_account_use(other_anchor_number, origin_of(0), None, 1) .unwrap(); for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { @@ -3061,7 +3105,7 @@ mod application_removal_tests { let (mut storage, anchor_number, _) = storage_with_anchors(); let origin = "https://example.com".to_string(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -3086,10 +3130,10 @@ mod application_removal_tests { let (mut storage, anchor_number, other_anchor_number) = storage_with_anchors(); let origin = "https://example.com".to_string(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); storage - .set_account_last_used(other_anchor_number, origin.clone(), None, 2_000) + .record_account_use(other_anchor_number, origin.clone(), None, 2_000) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -3119,10 +3163,10 @@ mod application_removal_tests { let removed_origin = "https://removed.com".to_string(); let kept_origin = "https://kept.com".to_string(); storage - .set_account_last_used(anchor_number, removed_origin.clone(), None, 1_000) + .record_account_use(anchor_number, removed_origin.clone(), None, 1_000) .unwrap(); storage - .set_account_last_used(anchor_number, kept_origin.clone(), None, 2_000) + .record_account_use(anchor_number, kept_origin.clone(), None, 2_000) .unwrap(); let removed_number = storage .lookup_application_number_with_origin(&removed_origin) @@ -3135,7 +3179,7 @@ mod application_removal_tests { .remove_reference_list(anchor_number, removed_number) .unwrap(); storage - .set_account_last_used(anchor_number, "https://fresh.com".to_string(), None, 3_000) + .record_account_use(anchor_number, "https://fresh.com".to_string(), None, 3_000) .unwrap(); let fresh_number = storage @@ -3154,7 +3198,7 @@ mod application_removal_tests { let (mut storage, anchor_number, _) = storage_with_anchors(); let origin = "https://example.com".to_string(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); let first_number = storage .lookup_application_number_with_origin(&origin) @@ -3164,7 +3208,7 @@ mod application_removal_tests { .unwrap(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 2_000) + .record_account_use(anchor_number, origin.clone(), None, 2_000) .unwrap(); let second_number = storage @@ -3255,7 +3299,7 @@ mod application_removal_tests { let (mut storage, anchor_number, _) = storage_with_anchors(); let origin = "https://example.com".to_string(); storage - .set_account_last_used(anchor_number, origin.clone(), None, 1_000) + .record_account_use(anchor_number, origin.clone(), None, 1_000) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) From 22f5c007ba3784ca5330c9afa4718f7fb8131803 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 25 Aug 2026 17:47:53 +0200 Subject: [PATCH 026/298] refactor(be): return the counter's failures instead of trapping Two writes to the accounts counter could trap. Neither can now. `allocate_account_number` takes the account number from the counter, so the increment must not wrap or saturate: either re-issues a number already in use, and two accounts at one origin would then derive the same principal. The check moves to the allocator, where `StorageError` already is, which lets `StorableAccountsCounter` go back to being a plain storable type with no logic on it, and puts the check on the line that makes the number. `AccountsCounterOverflow` is the refusal. `apply_reference_counter_deltas` wrote the global counter with an `expect`. It returns `Result` and maps that write to `ErrorUpdatingAccountCounter`, which is what the write path this replaced already did. Co-Authored-By: Claude Opus 5 (1M context) --- src/internet_identity/src/storage.rs | 30 ++++++++++++------- .../src/storage/storable/accounts_counter.rs | 12 -------- src/internet_identity/src/storage/tests.rs | 24 +++++++++++++++ 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5efe46c645..4939513597 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1740,9 +1740,7 @@ impl Storage { self.stable_account_reference_list_memory .insert(key, current.into()); - self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas); - - Ok(()) + self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas) } fn apply_reference_counter_deltas( @@ -1751,9 +1749,9 @@ impl Storage { application_number: ApplicationNumber, application: StorableApplication, deltas: ReferenceListDeltas, - ) { + ) -> Result<(), StorageError> { if deltas.is_empty() { - return; + return Ok(()); } let anchor_counter = self @@ -1782,7 +1780,7 @@ impl Storage { stored_accounts: global_counter.stored_accounts, stored_account_references: global_references, }) - .expect("failed to update the global account counter"); + .map_err(|_| StorageError::ErrorUpdatingAccountCounter)?; let (stored_accounts, stored_account_references) = deltas.apply( application.stored_accounts, @@ -1796,6 +1794,8 @@ impl Storage { stored_account_references, }, ); + + Ok(()) } /// This is for testing purposes only, DO NOT use anywhere else! @@ -1839,11 +1839,19 @@ impl Storage { // Increments the `stable_account_counter_memory` account counter by one and returns the new number. fn allocate_account_number(&mut self) -> Result { - let account_counter = self.stable_account_counter_memory.get(); - let updated_accounts_counter = account_counter.increment_accounts(); - let next_account_number = updated_accounts_counter.stored_accounts; + let account_counter = self.stable_account_counter_memory.get().clone(); + // The counter is also the account number, so it must not wrap or saturate: + // either would re-issue a number that is already in use, and two accounts at + // one origin would derive the same principal. + let next_account_number = account_counter + .stored_accounts + .checked_add(1) + .ok_or(StorageError::AccountsCounterOverflow)?; self.stable_account_counter_memory - .set(updated_accounts_counter) + .set(StorableAccountsCounter { + stored_accounts: next_account_number, + ..account_counter + }) .map_err(|_| StorageError::ErrorUpdatingAccountCounter)?; Ok(next_account_number) } @@ -2589,6 +2597,7 @@ pub enum StorageError { application_number: ApplicationNumber, }, ErrorUpdatingAccountCounter, + AccountsCounterOverflow, EmptyAccountReferenceList { anchor_number: AnchorNumber, application_number: ApplicationNumber, @@ -2655,6 +2664,7 @@ impl fmt::Display for StorageError { "Origin not found for application number {application_number}", ), Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), + Self::AccountsCounterOverflow => write!(f, "No account numbers left to allocate"), Self::EmptyAccountReferenceList { anchor_number, application_number, diff --git a/src/internet_identity/src/storage/storable/accounts_counter.rs b/src/internet_identity/src/storage/storable/accounts_counter.rs index 51baf9de88..c2248d4de8 100644 --- a/src/internet_identity/src/storage/storable/accounts_counter.rs +++ b/src/internet_identity/src/storage/storable/accounts_counter.rs @@ -13,18 +13,6 @@ pub struct StorableAccountsCounter { pub stored_account_references: u64, } -impl StorableAccountsCounter { - pub fn increment_accounts(&self) -> Self { - Self { - stored_accounts: self - .stored_accounts - .checked_add(1) - .expect("overflow in stored_accounts"), - stored_account_references: self.stored_account_references, - } - } -} - impl Storable for StorableAccountsCounter { 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 6e7dbcd9b5..3e2762eaae 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2127,6 +2127,7 @@ fn test_anchor_storage_migration_round_trip() { mod reference_list_write_path_tests { use crate::storage::account::{AccountReference, CreateAccountParams}; + use crate::storage::storable::accounts_counter::StorableAccountsCounter; use crate::storage::StorageError; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -2141,6 +2142,29 @@ mod reference_list_write_path_tests { (storage, anchor_number) } + #[test] + fn allocating_past_the_last_account_number_is_refused() { + let (mut storage, anchor_number) = storage_with_anchor(); + // The allocator hands out the incremented count, so there is no number left + // after this one. Reachable only here, which is the point: it refuses rather + // than re-issuing a number, and it does so without trapping. + storage + .stable_account_counter_memory + .set(StorableAccountsCounter { + stored_accounts: u64::MAX, + stored_account_references: 0, + }) + .unwrap(); + + let result = storage.create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: "https://example.com".to_string(), + }); + + assert!(matches!(result, Err(StorageError::AccountsCounterOverflow))); + } + #[test] fn rejects_writing_an_empty_list() { let (mut storage, anchor_number) = storage_with_anchor(); From 052b123120972e61ad21b0d0fe14081d4c63a88d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 25 Aug 2026 18:00:56 +0200 Subject: [PATCH 027/298] fix(be): propagate the counter write failure from the removal path `remove_reference_list` discarded the `Result` that `apply_reference_counter_deltas` now returns, so a failed counter write was dropped on the path that retires a row. The tests do not see it because an unused `Result` is a warning until CI runs clippy with `-D warnings`. Co-Authored-By: Claude Opus 5 (1M context) --- src/internet_identity/src/storage.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index dc76d7e42b..a0cca4295a 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1778,9 +1778,7 @@ impl Storage { self.stable_anchor_application_config_memory.remove(&key); let deltas = ReferenceListDeltas::between(&previous, &[]); - self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas); - - Ok(()) + self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas) } /// Rows whose only reference is a tracked default. From cb47d8e6b3e1c413930ef32e32a9c79e6544dcb8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 22:55:20 +0200 Subject: [PATCH 028/298] feat: a session record carries how long it may go unused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session lasts until valid_till whether or not anybody is there, so a browser abandoned an hour after signing in holds a usable one for the rest of thirty days. max_idle bounds it relatively: nothing minted for that long and the session is over, whatever life it has left. An option rather than a duration meaning all of it, so a record written before this existed and one that asked for no bound read the same — and a new CBOR map key decodes as None rather than failing. Nothing reads it yet. Clamping at creation and refusing at resolve land in the PRs that own those paths. --- src/internet_identity/src/storage/account.rs | 27 ++++++++ .../src/storage/storable/session_record.rs | 8 +++ src/internet_identity/src/storage/tests.rs | 62 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index c0c226bcea..93e23f310a 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -75,6 +75,12 @@ impl AccountReference { pub struct SessionRecord { pub created_at: Timestamp, pub valid_till: Timestamp, + /// How long this session may go unused before it is over, in nanoseconds. + /// + /// `None` is no bound beyond `valid_till`, which is what a request that asks + /// for nothing gets. A session is otherwise finished once nothing has minted + /// from it for this long, whatever life `valid_till` has left. + pub max_idle: Option, pub last_refreshed: Option, pub device_id: SessionDeviceId, pub read_only: bool, @@ -85,6 +91,27 @@ impl SessionRecord { self.valid_till <= now } + /// Whether this session has gone unused for longer than it was allowed to. + /// + /// Measured from the last mint, or from creation where nothing has minted yet, + /// so a session abandoned immediately after sign-in is bounded like any other. + /// A session with no bound is never idle. + pub fn is_idle(&self, now: Timestamp) -> bool { + let Some(max_idle) = self.max_idle else { + return false; + }; + let last_used = self.last_refreshed.unwrap_or(self.created_at); + now.saturating_sub(last_used) >= max_idle + } + + /// Whether this session can still be minted from, on either bound. + /// + /// The two are one question at the point of use, and asking them separately is + /// how a caller ends up checking one and forgetting the other. + pub fn is_over(&self, now: Timestamp) -> bool { + self.is_expired(now) || self.is_idle(now) + } + /// How long this session stayed in service: the span from its creation to the last time /// its app asked for a delegation. Bounded by the session's own lifetime. pub fn demonstrated_use(&self) -> u64 { diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index 6ac550dd69..2d80d9cf45 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -15,6 +15,12 @@ pub struct StorableSessionRecord { pub valid_till: Timestamp, #[n(2)] pub last_refreshed: Option, + /// Absent in every record written before sessions could be bounded by use, and + /// absent in any that asked for no bound. The two are the same thing to a + /// reader, which is why this is an option rather than a duration meaning "all + /// of it". + #[n(5)] + pub max_idle: Option, #[n(3)] pub device_id: StorableSessionDeviceId, #[n(4)] @@ -41,6 +47,7 @@ impl From for SessionRecord { created_at: value.created_at, valid_till: value.valid_till, last_refreshed: value.last_refreshed, + max_idle: value.max_idle, device_id: value.device_id, read_only: value.read_only, } @@ -53,6 +60,7 @@ impl From for StorableSessionRecord { created_at: value.created_at, valid_till: value.valid_till, last_refreshed: value.last_refreshed, + max_idle: value.max_idle, device_id: value.device_id, read_only: value.read_only, } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 4ed8b063cb..f0f7f2746e 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3713,6 +3713,7 @@ mod session_record_tests { SessionRecord { created_at, valid_till, + max_idle: None, last_refreshed: None, device_id: 1, read_only: false, @@ -3743,6 +3744,63 @@ mod session_record_tests { assert_eq!(decoded, reference); } + #[test] + fn a_session_with_no_idle_bound_is_never_idle() { + let record = session(0, DAY_NS); + + // What a request that asked for nothing gets. The absolute lifetime is the + // only thing bounding it, which is how every session behaved before. + assert!(!record.is_idle(DAY_NS * 365)); + assert!(!record.is_over(0)); + } + + #[test] + fn a_session_is_idle_once_nothing_has_minted_for_its_bound() { + let record = SessionRecord { + max_idle: Some(30 * MINUTE_NS), + last_refreshed: Some(10 * MINUTE_NS), + ..session(0, DAY_NS) + }; + + assert!(!record.is_idle(39 * MINUTE_NS)); + assert!(record.is_idle(40 * MINUTE_NS)); + // Still inside its absolute lifetime, and over anyway. That is the point: + // the two bounds answer different questions and either one ends it. + assert!(!record.is_expired(40 * MINUTE_NS)); + assert!(record.is_over(40 * MINUTE_NS)); + } + + #[test] + fn a_session_that_never_minted_is_measured_from_its_creation() { + let record = SessionRecord { + max_idle: Some(30 * MINUTE_NS), + last_refreshed: None, + ..session(5 * MINUTE_NS, DAY_NS) + }; + + // Otherwise a session abandoned straight after sign-in would sit unbounded + // until its lifetime ran out, which is the case the bound exists for. + assert!(!record.is_idle(34 * MINUTE_NS)); + assert!(record.is_idle(35 * MINUTE_NS)); + } + + #[test] + fn a_session_written_before_the_idle_bound_existed_decodes_without_one() { + let reference = AccountReference { + account_number: Some(3), + last_used: Some(9), + sessions: vec![session(1, 100)], + }; + + let decoded = AccountReference::from(StorableAccountReference::from_bytes( + StorableAccountReference::from(reference).to_bytes(), + )); + + // The field is a new key in a CBOR map, so a record written without it reads + // as no bound rather than failing to decode. + assert_eq!(decoded.sessions[0].max_idle, None); + } + #[test] fn a_reference_written_before_sessions_existed_decodes_with_none() { let stored = StorableAccountReference { @@ -3794,6 +3852,7 @@ mod session_record_tests { let now = 1_000; let expired = session(1, 500); let live = SessionRecord { + max_idle: None, last_refreshed: Some(900), ..session(400, 10_000) }; @@ -3807,6 +3866,7 @@ mod session_record_tests { fn a_flood_of_unused_sessions_cannot_displace_a_used_one() { let now = 100 * DAY_NS; let held = SessionRecord { + max_idle: None, last_refreshed: Some(now - DAY_NS), ..session(now - 20 * DAY_NS, now + DAY_NS) }; @@ -3829,11 +3889,13 @@ mod session_record_tests { let now = 100 * DAY_NS; // Signed in three months ago, still being opened every few days. let weekly = SessionRecord { + max_idle: None, last_refreshed: Some(now - 3 * DAY_NS), ..session(now - 90 * DAY_NS, now + DAY_NS) }; // Signed in yesterday, used for five minutes, never opened again. let one_sitting = SessionRecord { + max_idle: None, last_refreshed: Some(now - DAY_NS + 5 * MINUTE_NS), ..session(now - DAY_NS, now + DAY_NS) }; From 362beda38d7672519eaf19da27e7d1956839282e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:16:28 +0200 Subject: [PATCH 029/298] feat: a session takes the idle bound its ceremony asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_session clamps it rather than trusting the caller, so every path that makes a session gets the same range. The floor is ten minutes: an app delegation lasts five and an active application replaces it a little early, so a bound near that would end sessions plainly in use. The ceiling is the life this session was actually granted — a bound it could never reach would say something about the session that is not true. Asking for nothing still stores nothing, which is the same session anyone gets today. --- src/internet_identity/src/storage.rs | 15 +++- src/internet_identity/src/storage/account.rs | 8 +++ src/internet_identity/src/storage/tests.rs | 72 +++++++++++++++++++- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 42abd2d57a..49c610a793 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -110,7 +110,7 @@ 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, SessionRecord}; +use crate::storage::account::{AccountReference, SessionRecord, MIN_SESSION_IDLE_NS}; use crate::storage::anchor::Anchor; use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; @@ -1951,10 +1951,18 @@ impl Storage { account_number, device_id, valid_till, + max_idle, read_only, now, } = params; + // Clamped here rather than at the caller, so every path that creates a session + // gets the same range whatever it asked for. The ceiling is the life this + // session was actually granted: a bound longer than that could never be reached, + // and storing one would say something about the session that is not true. + let max_idle = max_idle + .map(|requested| requested.clamp(MIN_SESSION_IDLE_NS, valid_till.saturating_sub(now))); + // 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) { @@ -2018,7 +2026,7 @@ impl Storage { let session = SessionRecord { created_at: now, valid_till, - max_idle: None, + max_idle, last_refreshed: None, device_id, read_only, @@ -3229,6 +3237,9 @@ pub struct CreateSessionParams { pub account_number: Option, pub device_id: SessionDeviceId, pub valid_till: Timestamp, + /// How long the session may go unused, as the application asked for it. Clamped + /// on the way in; `None` asks for no bound beyond `valid_till`. + pub max_idle: Option, pub read_only: bool, pub now: Timestamp, } diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 93e23f310a..01ed408669 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -69,6 +69,14 @@ impl AccountReference { } } +/// The shortest idle bound a session may be given. +/// +/// An app delegation lasts five minutes and an active application replaces it a +/// little before it expires, so a bound anywhere near that would end sessions +/// plainly in use. Ten minutes is already the floor on a session's own length, +/// so this shares that range rather than introducing a second one. +pub const MIN_SESSION_IDLE_NS: u64 = 10 * crate::MINUTE_NS; + /// A revocable session at one account. Only `last_refreshed` is mutable, which is why /// it is the one field absent from the seed. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 06119b286f..bf804ca03c 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4015,9 +4015,11 @@ 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::account::{ + AccountReference, CreateAccountParams, SessionRecord, MIN_SESSION_IDLE_NS, + }; use crate::storage::CreateSessionParams; - use crate::Storage; + use crate::{Storage, DAY_NS, MINUTE_NS}; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; use pretty_assertions::assert_eq; @@ -4041,6 +4043,7 @@ mod session_creation_tests { account_number: None, device_id, valid_till: now + 10_000, + max_idle: None, read_only: false, now, } @@ -4066,6 +4069,68 @@ mod session_creation_tests { .sessions } + #[test] + fn an_idle_bound_is_kept_as_asked_for_when_it_is_in_range() { + let (mut storage, anchor_number) = storage_with_anchor(); + let asked = 20 * MINUTE_NS; + + let session = storage + .create_session(CreateSessionParams { + max_idle: Some(asked), + valid_till: DAY_NS, + ..params(anchor_number, 1, 0) + }) + .unwrap(); + + assert_eq!(session.max_idle, Some(asked)); + } + + #[test] + fn an_idle_bound_below_the_floor_is_raised_to_it() { + let (mut storage, anchor_number) = storage_with_anchor(); + + let session = storage + .create_session(CreateSessionParams { + max_idle: Some(MINUTE_NS), + valid_till: DAY_NS, + ..params(anchor_number, 1, 0) + }) + .unwrap(); + + // An app delegation lasts five minutes, so a bound under that would end a + // session between two mints of one that is plainly in use. + assert_eq!(session.max_idle, Some(MIN_SESSION_IDLE_NS)); + } + + #[test] + fn an_idle_bound_longer_than_the_session_is_cut_to_it() { + let (mut storage, anchor_number) = storage_with_anchor(); + + let session = storage + .create_session(CreateSessionParams { + max_idle: Some(400 * DAY_NS), + valid_till: DAY_NS, + ..params(anchor_number, 1, 0) + }) + .unwrap(); + + // A bound it could never reach says something about the session that is not + // true, so it is stored as the life the session actually got. + assert_eq!(session.max_idle, Some(DAY_NS)); + } + + #[test] + fn asking_for_no_idle_bound_stores_none() { + let (mut storage, anchor_number) = storage_with_anchor(); + + let session = storage + .create_session(params(anchor_number, 1, 1_000)) + .unwrap(); + + assert_eq!(session.max_idle, None); + assert!(!session.is_idle(1_000 + 400 * DAY_NS)); + } + #[test] fn creating_a_session_tracks_the_account_and_stores_the_record() { let (mut storage, anchor_number) = storage_with_anchor(); @@ -4260,6 +4325,7 @@ mod session_creation_tests { account_number: None, device_id: 1, valid_till: u64::MAX, + max_idle: None, read_only, now: 1_000, }; @@ -4381,6 +4447,7 @@ mod session_consent_change_tests { account_number: None, device_id: 1, valid_till: u64::MAX, + max_idle: None, read_only, now, }) @@ -4447,6 +4514,7 @@ mod session_consent_change_tests { account_number: None, device_id: 2, valid_till: u64::MAX, + max_idle: None, read_only: false, now: 1_000, }) From 7b4f3907f78afb1e4fd4aeeb0eac2e926464bfb5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:23:14 +0200 Subject: [PATCH 030/298] refactor: name the units, and give the storable record its own types The storable layer was borrowing the interface crate's Timestamp and, for the bound added a commit ago, a bare u64. It gets a StorableTimestamp and a StorableDuration of its own, in the one-alias-per-file shape the rest of the directory already uses. Every field carrying a time now says its unit, and the cbor keys run in declaration order rather than in the order fields were added. The test asserting a record written before the bound existed still decodes goes with it: these keys have moved, nothing is deployed, and a test whose name claims a compatibility we are not keeping is worse than no test. --- src/internet_identity/src/storage/account.rs | 27 ++++------ src/internet_identity/src/storage/storable.rs | 2 + .../src/storage/storable/duration.rs | 2 + .../src/storage/storable/session_record.rs | 35 ++++++------- .../src/storage/storable/timestamp.rs | 2 + src/internet_identity/src/storage/tests.rs | 51 +++++++------------ 6 files changed, 50 insertions(+), 69 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/duration.rs create mode 100644 src/internet_identity/src/storage/storable/timestamp.rs diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 93e23f310a..478535e179 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -73,22 +73,17 @@ impl AccountReference { /// it is the one field absent from the seed. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub struct SessionRecord { - pub created_at: Timestamp, - pub valid_till: Timestamp, - /// How long this session may go unused before it is over, in nanoseconds. - /// - /// `None` is no bound beyond `valid_till`, which is what a request that asks - /// for nothing gets. A session is otherwise finished once nothing has minted - /// from it for this long, whatever life `valid_till` has left. - pub max_idle: Option, - pub last_refreshed: Option, + pub created_at_ns: Timestamp, + pub valid_till_ns: Timestamp, + pub max_idle_ns: Option, + pub last_refreshed_ns: Option, pub device_id: SessionDeviceId, pub read_only: bool, } impl SessionRecord { pub fn is_expired(&self, now: Timestamp) -> bool { - self.valid_till <= now + self.valid_till_ns <= now } /// Whether this session has gone unused for longer than it was allowed to. @@ -97,11 +92,11 @@ impl SessionRecord { /// so a session abandoned immediately after sign-in is bounded like any other. /// A session with no bound is never idle. pub fn is_idle(&self, now: Timestamp) -> bool { - let Some(max_idle) = self.max_idle else { + let Some(max_idle_ns) = self.max_idle_ns else { return false; }; - let last_used = self.last_refreshed.unwrap_or(self.created_at); - now.saturating_sub(last_used) >= max_idle + let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); + now.saturating_sub(last_used) >= max_idle_ns } /// Whether this session can still be minted from, on either bound. @@ -115,8 +110,8 @@ impl SessionRecord { /// How long this session stayed in service: the span from its creation to the last time /// its app asked for a delegation. Bounded by the session's own lifetime. pub fn demonstrated_use(&self) -> u64 { - self.last_refreshed - .map_or(0, |refreshed| refreshed.saturating_sub(self.created_at)) + self.last_refreshed_ns + .map_or(0, |refreshed| refreshed.saturating_sub(self.created_at_ns)) } /// What the caps reclaim on, ascending: dead sessions first, then live ones by how @@ -126,7 +121,7 @@ impl SessionRecord { /// abandoned, which recency alone gets backwards — the abandoned one was touched more /// recently. `device_id` only makes the order total. pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, SessionDeviceId) { - let last_used = self.last_refreshed.unwrap_or(self.created_at); + let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); ( !self.is_expired(now), last_used.saturating_add(self.demonstrated_use()), diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 4b94d30798..b25a3a4461 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -12,6 +12,7 @@ pub mod application; pub mod application_number; pub mod credential_id; pub mod discrepancy_counter; +pub mod duration; pub mod email_recovery_address_hash; pub mod email_recovery_credential; pub mod fixed_anchor; @@ -29,4 +30,5 @@ pub mod session_record; pub mod special_device_migration; pub mod sso_stable_id_key; pub mod storable_persistent_state; +pub mod timestamp; pub mod verified_email; diff --git a/src/internet_identity/src/storage/storable/duration.rs b/src/internet_identity/src/storage/storable/duration.rs new file mode 100644 index 0000000000..5596e7956d --- /dev/null +++ b/src/internet_identity/src/storage/storable/duration.rs @@ -0,0 +1,2 @@ +/// A span of time, in nanoseconds, as it is stored. +pub type StorableDuration = u64; diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index 2d80d9cf45..3bd7845e38 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -1,8 +1,9 @@ use crate::storage::account::SessionRecord; +use crate::storage::storable::duration::StorableDuration; use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use crate::storage::storable::timestamp::StorableTimestamp; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; -use internet_identity_interface::internet_identity::types::Timestamp; use minicbor::{Decode, Encode}; use std::borrow::Cow; @@ -10,20 +11,16 @@ use std::borrow::Cow; #[cbor(map)] pub struct StorableSessionRecord { #[n(0)] - pub created_at: Timestamp, + pub created_at_ns: StorableTimestamp, #[n(1)] - pub valid_till: Timestamp, + pub valid_till_ns: StorableTimestamp, #[n(2)] - pub last_refreshed: Option, - /// Absent in every record written before sessions could be bounded by use, and - /// absent in any that asked for no bound. The two are the same thing to a - /// reader, which is why this is an option rather than a duration meaning "all - /// of it". - #[n(5)] - pub max_idle: Option, + pub max_idle_ns: Option, #[n(3)] - pub device_id: StorableSessionDeviceId, + pub last_refreshed_ns: Option, #[n(4)] + pub device_id: StorableSessionDeviceId, + #[n(5)] pub read_only: bool, } @@ -44,10 +41,10 @@ impl Storable for StorableSessionRecord { impl From for SessionRecord { fn from(value: StorableSessionRecord) -> Self { SessionRecord { - created_at: value.created_at, - valid_till: value.valid_till, - last_refreshed: value.last_refreshed, - max_idle: value.max_idle, + created_at_ns: value.created_at_ns, + valid_till_ns: value.valid_till_ns, + last_refreshed_ns: value.last_refreshed_ns, + max_idle_ns: value.max_idle_ns, device_id: value.device_id, read_only: value.read_only, } @@ -57,10 +54,10 @@ impl From for SessionRecord { impl From for StorableSessionRecord { fn from(value: SessionRecord) -> Self { StorableSessionRecord { - created_at: value.created_at, - valid_till: value.valid_till, - last_refreshed: value.last_refreshed, - max_idle: value.max_idle, + created_at_ns: value.created_at_ns, + valid_till_ns: value.valid_till_ns, + last_refreshed_ns: value.last_refreshed_ns, + max_idle_ns: value.max_idle_ns, device_id: value.device_id, read_only: value.read_only, } diff --git a/src/internet_identity/src/storage/storable/timestamp.rs b/src/internet_identity/src/storage/storable/timestamp.rs new file mode 100644 index 0000000000..5e39e423e9 --- /dev/null +++ b/src/internet_identity/src/storage/storable/timestamp.rs @@ -0,0 +1,2 @@ +/// A moment, in nanoseconds since the epoch, as it is stored. +pub type StorableTimestamp = u64; diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f0f7f2746e..042e9119d2 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3709,12 +3709,12 @@ mod session_record_tests { use internet_identity_interface::internet_identity::types::AnchorNumber; use pretty_assertions::assert_eq; - fn session(created_at: u64, valid_till: u64) -> SessionRecord { + fn session(created_at_ns: u64, valid_till_ns: u64) -> SessionRecord { SessionRecord { - created_at, - valid_till, - max_idle: None, - last_refreshed: None, + created_at_ns, + valid_till_ns, + max_idle_ns: None, + last_refreshed_ns: None, device_id: 1, read_only: false, } @@ -3757,8 +3757,8 @@ mod session_record_tests { #[test] fn a_session_is_idle_once_nothing_has_minted_for_its_bound() { let record = SessionRecord { - max_idle: Some(30 * MINUTE_NS), - last_refreshed: Some(10 * MINUTE_NS), + max_idle_ns: Some(30 * MINUTE_NS), + last_refreshed_ns: Some(10 * MINUTE_NS), ..session(0, DAY_NS) }; @@ -3773,8 +3773,8 @@ mod session_record_tests { #[test] fn a_session_that_never_minted_is_measured_from_its_creation() { let record = SessionRecord { - max_idle: Some(30 * MINUTE_NS), - last_refreshed: None, + max_idle_ns: Some(30 * MINUTE_NS), + last_refreshed_ns: None, ..session(5 * MINUTE_NS, DAY_NS) }; @@ -3784,23 +3784,6 @@ mod session_record_tests { assert!(record.is_idle(35 * MINUTE_NS)); } - #[test] - fn a_session_written_before_the_idle_bound_existed_decodes_without_one() { - let reference = AccountReference { - account_number: Some(3), - last_used: Some(9), - sessions: vec![session(1, 100)], - }; - - let decoded = AccountReference::from(StorableAccountReference::from_bytes( - StorableAccountReference::from(reference).to_bytes(), - )); - - // The field is a new key in a CBOR map, so a record written without it reads - // as no bound rather than failing to decode. - assert_eq!(decoded.sessions[0].max_idle, None); - } - #[test] fn a_reference_written_before_sessions_existed_decodes_with_none() { let stored = StorableAccountReference { @@ -3852,8 +3835,8 @@ mod session_record_tests { let now = 1_000; let expired = session(1, 500); let live = SessionRecord { - max_idle: None, - last_refreshed: Some(900), + max_idle_ns: None, + last_refreshed_ns: Some(900), ..session(400, 10_000) }; let live_untouched = session(400, 10_000); @@ -3866,8 +3849,8 @@ mod session_record_tests { fn a_flood_of_unused_sessions_cannot_displace_a_used_one() { let now = 100 * DAY_NS; let held = SessionRecord { - max_idle: None, - last_refreshed: Some(now - DAY_NS), + max_idle_ns: None, + last_refreshed_ns: 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 @@ -3889,14 +3872,14 @@ mod session_record_tests { let now = 100 * DAY_NS; // Signed in three months ago, still being opened every few days. let weekly = SessionRecord { - max_idle: None, - last_refreshed: Some(now - 3 * DAY_NS), + max_idle_ns: None, + last_refreshed_ns: Some(now - 3 * DAY_NS), ..session(now - 90 * DAY_NS, now + DAY_NS) }; // Signed in yesterday, used for five minutes, never opened again. let one_sitting = SessionRecord { - max_idle: None, - last_refreshed: Some(now - DAY_NS + 5 * MINUTE_NS), + max_idle_ns: None, + last_refreshed_ns: Some(now - DAY_NS + 5 * MINUTE_NS), ..session(now - DAY_NS, now + DAY_NS) }; From 36c5665ff60e6323fcdb1b990c9a84a420b99f1c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:26:17 +0200 Subject: [PATCH 031/298] merge: carry the unit rename into the creation path --- src/internet_identity/src/storage.rs | 37 ++++++------ src/internet_identity/src/storage/tests.rs | 66 +++++++++++----------- 2 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 49c610a793..bca1489e9c 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1847,7 +1847,7 @@ impl Storage { let seed = calculate_session_seed_with_salt( &salt, &account.calculate_seed_with_salt(&salt), - session.created_at, + session.created_at_ns, session.device_id, ); Some(canister_sig_principal(canister_id(), seed.to_vec())) @@ -1950,18 +1950,19 @@ impl Storage { origin, account_number, device_id, - valid_till, - max_idle, + valid_till_ns, + max_idle_ns, read_only, - now, + now_ns, } = params; // Clamped here rather than at the caller, so every path that creates a session // gets the same range whatever it asked for. The ceiling is the life this // session was actually granted: a bound longer than that could never be reached, // and storing one would say something about the session that is not true. - let max_idle = max_idle - .map(|requested| requested.clamp(MIN_SESSION_IDLE_NS, valid_till.saturating_sub(now))); + let max_idle_ns = max_idle_ns.map(|requested| { + requested.clamp(MIN_SESSION_IDLE_NS, valid_till_ns.saturating_sub(now_ns)) + }); // 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`. @@ -1985,7 +1986,7 @@ impl Storage { self.write_reference_list( anchor_number, application_number, - vec![AccountReference::new(None, Some(now))], + vec![AccountReference::new(None, Some(now_ns))], )?; self.evict_idle_tracked_defaults(anchor_number, application_number)?; application_number @@ -2009,7 +2010,7 @@ impl Storage { anchor_number, name: String::new(), })?; - reference.last_used = Some(now); + reference.last_used = Some(now_ns); // 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 @@ -2024,10 +2025,10 @@ impl Storage { }); let session = SessionRecord { - created_at: now, - valid_till, - max_idle, - last_refreshed: None, + created_at_ns: now_ns, + valid_till_ns, + max_idle_ns, + last_refreshed_ns: None, device_id, read_only, }; @@ -2039,7 +2040,7 @@ impl Storage { for reference in references.iter_mut() { let account_number = reference.account_number; reference.sessions.retain(|session| { - if session.is_expired(now) { + if session.is_expired(now_ns) { dropped.push((account_number, session.clone())); return false; } @@ -2065,7 +2066,7 @@ impl Storage { StorableSessionHandle { account_principal: account_principal.as_slice().to_vec(), device_id, - created_at: session.created_at, + created_at: session.created_at_ns, }, ); } @@ -3236,12 +3237,10 @@ pub struct CreateSessionParams { pub origin: FrontendHostname, pub account_number: Option, pub device_id: SessionDeviceId, - pub valid_till: Timestamp, - /// How long the session may go unused, as the application asked for it. Clamped - /// on the way in; `None` asks for no bound beyond `valid_till`. - pub max_idle: Option, + pub valid_till_ns: Timestamp, + pub max_idle_ns: Option, pub read_only: bool, - pub now: Timestamp, + pub now_ns: Timestamp, } #[derive(Clone, Debug, Default, PartialEq, Eq)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index b6b25b26c8..3d30b652f5 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4025,10 +4025,10 @@ mod session_creation_tests { origin: ORIGIN.to_string(), account_number: None, device_id, - valid_till: now + 10_000, - max_idle: None, + valid_till_ns: now + 10_000, + max_idle_ns: None, read_only: false, - now, + now_ns: now, } } @@ -4059,13 +4059,13 @@ mod session_creation_tests { let session = storage .create_session(CreateSessionParams { - max_idle: Some(asked), - valid_till: DAY_NS, + max_idle_ns: Some(asked), + valid_till_ns: DAY_NS, ..params(anchor_number, 1, 0) }) .unwrap(); - assert_eq!(session.max_idle, Some(asked)); + assert_eq!(session.max_idle_ns, Some(asked)); } #[test] @@ -4074,15 +4074,15 @@ mod session_creation_tests { let session = storage .create_session(CreateSessionParams { - max_idle: Some(MINUTE_NS), - valid_till: DAY_NS, + max_idle_ns: Some(MINUTE_NS), + valid_till_ns: DAY_NS, ..params(anchor_number, 1, 0) }) .unwrap(); // An app delegation lasts five minutes, so a bound under that would end a // session between two mints of one that is plainly in use. - assert_eq!(session.max_idle, Some(MIN_SESSION_IDLE_NS)); + assert_eq!(session.max_idle_ns, Some(MIN_SESSION_IDLE_NS)); } #[test] @@ -4091,15 +4091,15 @@ mod session_creation_tests { let session = storage .create_session(CreateSessionParams { - max_idle: Some(400 * DAY_NS), - valid_till: DAY_NS, + max_idle_ns: Some(400 * DAY_NS), + valid_till_ns: DAY_NS, ..params(anchor_number, 1, 0) }) .unwrap(); // A bound it could never reach says something about the session that is not // true, so it is stored as the life the session actually got. - assert_eq!(session.max_idle, Some(DAY_NS)); + assert_eq!(session.max_idle_ns, Some(DAY_NS)); } #[test] @@ -4110,7 +4110,7 @@ mod session_creation_tests { .create_session(params(anchor_number, 1, 1_000)) .unwrap(); - assert_eq!(session.max_idle, None); + assert_eq!(session.max_idle_ns, None); assert!(!session.is_idle(1_000 + 400 * DAY_NS)); } @@ -4122,9 +4122,9 @@ mod session_creation_tests { .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.created_at_ns, 1_000); + assert_eq!(session.valid_till_ns, 11_000); + assert_eq!(session.last_refreshed_ns, None); assert_eq!(session.device_id, 1); assert_eq!(sessions_of(&storage, anchor_number), vec![session]); } @@ -4142,7 +4142,7 @@ mod session_creation_tests { .create_session(params(anchor_number, 1, 5_000)) .unwrap(); - assert_ne!(again.created_at, first.created_at); + assert_ne!(again.created_at_ns, first.created_at_ns); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); } @@ -4185,7 +4185,7 @@ mod session_creation_tests { 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; + p.valid_till_ns = 1_000_000; storage.create_session(p).unwrap(); } @@ -4276,12 +4276,12 @@ mod session_creation_tests { account_number: None, last_used: Some(1), sessions: vec![SessionRecord { - created_at: 1_000, + created_at_ns: 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, - max_idle: None, - last_refreshed: None, + valid_till_ns: 1_000, + max_idle_ns: None, + last_refreshed_ns: None, device_id: 1, read_only: false, }], @@ -4294,7 +4294,7 @@ mod session_creation_tests { let created = storage .create_session(params(anchor_number, 1, 1_000)) .unwrap(); - assert_eq!(created.created_at, 1_000); + assert_eq!(created.created_at_ns, 1_000); } /// Creating twice from one browser at one account replaces, so there is never a second @@ -4307,10 +4307,10 @@ mod session_creation_tests { origin: ORIGIN.to_string(), account_number: None, device_id: 1, - valid_till: u64::MAX, - max_idle: None, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only, - now: 1_000, + now_ns: 1_000, }; let first = storage.create_session(params(false)).unwrap(); @@ -4429,13 +4429,13 @@ mod session_consent_change_tests { origin: ORIGIN.to_string(), account_number: None, device_id: 1, - valid_till: u64::MAX, - max_idle: None, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only, - now, + now_ns: now, }) .unwrap() - .created_at + .created_at_ns } fn sessions(storage: &Storage, anchor_number: AnchorNumber) -> Vec { @@ -4496,10 +4496,10 @@ mod session_consent_change_tests { origin: ORIGIN.to_string(), account_number: None, device_id: 2, - valid_till: u64::MAX, - max_idle: None, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only: false, - now: 1_000, + now_ns: 1_000, }) .unwrap(); create(&mut storage, anchor_number, false, 1_000); From 07b11ccbe70a36c51160d552bd7f1e0c2de1f61a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:30:01 +0200 Subject: [PATCH 032/298] merge: carry the unit rename into session creation --- src/internet_identity/src/sessions.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 087ac26ed2..713ad70935 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -156,9 +156,10 @@ pub async fn prepare_account_session( origin: origin.clone(), account_number, device_id, - valid_till, + valid_till_ns: valid_till, + max_idle_ns: None, read_only, - now, + now_ns: now, }) }) .expect("failed to create a session for an account that was just read"); @@ -170,14 +171,20 @@ pub async fn prepare_account_session( .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); + add_delegation_signature( + sigs, + session_key, + seed.as_ref(), + session.valid_till_ns, + 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, + expiration: session.valid_till_ns, + created_at: session.created_at_ns, device_id, account_principal, }) @@ -204,7 +211,7 @@ pub fn get_account_session( sessions .into_iter() - .filter(|session| session.valid_till == expiration) + .filter(|session| session.valid_till_ns == expiration) .find_map(|session| { let (seed, _) = session_identity(identity_number, &origin, account_number, &session).ok()?; @@ -269,7 +276,7 @@ fn session_identity( let seed = calculate_session_seed_with_salt( &salt, &account.calculate_seed_with_salt(&salt), - session.created_at, + session.created_at_ns, session.device_id, ); Ok((seed, application_number)) From 1725b03776612d5cd685c48064354ab161e09cd5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:31:11 +0200 Subject: [PATCH 033/298] merge: carry the unit rename into the mint path --- src/internet_identity/src/sessions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index d61d165581..ef9daa9808 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -319,7 +319,7 @@ pub fn app_prepare_delegation( let expiration = u64::min( now.saturating_add(APP_DELEGATION_TTL_NS), - session.valid_till, + session.valid_till_ns, ); let seed = account_seed(&account)?; let access = DelegationAccess::from_read_only(session.read_only); @@ -348,7 +348,7 @@ pub fn app_get_delegation( let (account, session) = authorize_session(now)?; if request.expiration > now.saturating_add(APP_DELEGATION_TTL_NS) - || request.expiration > session.valid_till + || request.expiration > session.valid_till_ns { return Err(AppSessionError::NoMatchingSession); } @@ -408,7 +408,7 @@ fn authorize_session(now: Timestamp) -> Result<(Account, SessionRecord), AppSess let session = sessions .into_iter() .find(|session| { - session.device_id == handle.device_id && session.created_at == handle.created_at + session.device_id == handle.device_id && session.created_at_ns == handle.created_at }) .ok_or(AppSessionError::NoMatchingSession)?; if session.is_expired(now) { From bcaa7044d2b289a41e42fc2f1c240061bd80c3a0 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:32:37 +0200 Subject: [PATCH 034/298] merge: carry the unit rename into the refresh stamps --- src/internet_identity/src/sessions.rs | 2 +- src/internet_identity/src/storage.rs | 4 +-- src/internet_identity/src/storage/tests.rs | 34 ++++++++++++---------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 03f44aafa3..bddef30179 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -323,7 +323,7 @@ pub fn app_prepare_delegation( locator.anchor_number, locator.application_number, locator.account_number, - session.created_at, + session.created_at_ns, session.device_id, now, ) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index c4f1be2312..e6650c15d8 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2062,12 +2062,12 @@ impl Storage { let Some(session) = reference .sessions .iter_mut() - .find(|session| session.created_at == created_at && session.device_id == device_id) + .find(|session| session.created_at_ns == created_at && session.device_id == device_id) else { return Ok(false); }; - session.last_refreshed = Some(now); + session.last_refreshed_ns = Some(now); reference.last_used = Some(now); // This row is being rewritten anyway, so its dead sessions go now. It costs one diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 9428635db0..0fd4535014 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4846,9 +4846,10 @@ mod session_refresh_stamp_tests { origin: ORIGIN.to_string(), account_number: None, device_id: 1, - valid_till: u64::MAX, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only: false, - now: 1_000, + now_ns: 1_000, }) .unwrap(); let application_number = storage @@ -4858,7 +4859,7 @@ mod session_refresh_stamp_tests { storage, anchor_number, application_number, - session.created_at, + session.created_at_ns, ) } @@ -4896,7 +4897,7 @@ mod session_refresh_stamp_tests { assert!(stamped); assert_eq!( - session_of(&storage, anchor_number).last_refreshed, + session_of(&storage, anchor_number).last_refreshed_ns, Some(2_000) ); assert_eq!(reference(&storage, anchor_number).last_used, Some(2_000)); @@ -4911,7 +4912,7 @@ mod session_refresh_stamp_tests { .stamp_session_refresh(anchor_number, application_number, None, created_at, 1, now) .unwrap()); assert_eq!( - session_of(&storage, anchor_number).last_refreshed, + session_of(&storage, anchor_number).last_refreshed_ns, Some(now) ); } @@ -4929,9 +4930,10 @@ mod session_refresh_stamp_tests { origin: ORIGIN.to_string(), account_number: None, device_id: 9, - valid_till: 1_500, + valid_till_ns: 1_500, + max_idle_ns: None, read_only: false, - now: 1_000, + now_ns: 1_000, }) .unwrap(); let dead_principal = storage @@ -4985,9 +4987,10 @@ mod session_refresh_stamp_tests { origin: ORIGIN.to_string(), account_number: None, device_id: 2, - valid_till: u64::MAX, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only: false, - now: 1_000, + now_ns: 1_000, }) .unwrap(); let now = 2_000; @@ -5000,8 +5003,8 @@ mod session_refresh_stamp_tests { 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); + assert_eq!(stamped.last_refreshed_ns, Some(now)); + assert_eq!(untouched.last_refreshed_ns, None); } fn storage_with_registered_device() -> ( @@ -5030,9 +5033,10 @@ mod session_refresh_stamp_tests { origin: ORIGIN.to_string(), account_number: None, device_id, - valid_till: u64::MAX, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only: false, - now: 1_000, + now_ns: 1_000, }) .unwrap(); let application_number = storage @@ -5042,7 +5046,7 @@ mod session_refresh_stamp_tests { storage, anchor_number, application_number, - session.created_at, + session.created_at_ns, device_id, ) } @@ -5108,7 +5112,7 @@ mod session_refresh_stamp_tests { assert!(stamped); assert_eq!( - session_of(&storage, anchor_number).last_refreshed, + session_of(&storage, anchor_number).last_refreshed_ns, Some(9_000) ); } From c48d0d2680bc2a6473b09c2232dca4bd2301c5df Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:33:32 +0200 Subject: [PATCH 035/298] merge: carry the unit rename into revocation --- src/internet_identity/src/sessions.rs | 2 +- src/internet_identity/src/storage.rs | 3 ++- src/internet_identity/src/storage/tests.rs | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 8b313a286e..e35a9a3ab4 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -424,7 +424,7 @@ pub fn app_revoke_session() { locator.anchor_number, locator.application_number, locator.account_number, - session.created_at, + session.created_at_ns, session.device_id, ) }) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5de78ac38f..5161b36035 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1950,7 +1950,8 @@ impl Storage { .any(|reference| { reference.account_number == account_number && reference.sessions.iter().any(|session| { - session.device_id == device_id && session.created_at == created_at + session.device_id == device_id + && session.created_at_ns == created_at }) }) }) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 6ca8cc8aa6..a9889cac76 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5141,9 +5141,10 @@ mod session_removal_tests { origin: ORIGIN.to_string(), account_number: None, device_id: *device_id, - valid_till: u64::MAX, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only: false, - now: 1_000, + now_ns: 1_000, }) .unwrap(); } From fbfed4d5fdda88742948b157cf0f4f436330dc6e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:33:51 +0200 Subject: [PATCH 036/298] merge: carry the unit rename up --- src/internet_identity/src/storage.rs | 4 ++-- src/internet_identity/src/storage/tests.rs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index bfcc38ecbf..6f51ed13dd 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1959,7 +1959,7 @@ impl Storage { let dropped: Vec = reference .sessions .iter() - .filter(|session| session.created_at == created_at) + .filter(|session| session.created_at_ns == created_at) .cloned() .collect(); if dropped.is_empty() { @@ -1967,7 +1967,7 @@ impl Storage { } reference .sessions - .retain(|session| session.created_at != created_at); + .retain(|session| session.created_at_ns != created_at); self.write_reference_list(anchor_number, application_number, references)?; self.unindex_sessions(anchor_number, application_number, account_number, &dropped); diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f7f74d89e0..7c7c8bb3c7 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5242,9 +5242,10 @@ mod session_revocation_tests { origin: origin.to_string(), account_number: None, device_id, - valid_till: u64::MAX, + valid_till_ns: u64::MAX, + max_idle_ns: None, read_only: false, - now, + now_ns: now, }) .unwrap(); } From 2cfbeab537532559c217748607fa14f1335ae1e5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:53:48 +0200 Subject: [PATCH 037/298] refactor: one question about whether a session is finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_expired, is_idle and is_over were three methods for one purpose, and only one of them had a caller. A session past its lifetime and one nobody has used for longer than it was allowed are equally over, and a caller has no use for the halves apart — asking separately is how you check one and forget the other. The bound stops being optional. Absent meant unbounded, which made every session unbounded by default; it now carries a duration always, and what a request that names none gets is decided where sessions are created. Reclaiming ranks on is_over rather than on the lifetime alone, so a session finished by idleness stops competing with live ones for a slot under the cap. --- src/internet_identity/src/storage/account.rs | 37 +++++-------- .../src/storage/storable/session_record.rs | 2 +- src/internet_identity/src/storage/tests.rs | 55 +++++++++++++------ 3 files changed, 53 insertions(+), 41 deletions(-) diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 478535e179..965660552c 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -75,36 +75,29 @@ impl AccountReference { pub struct SessionRecord { pub created_at_ns: Timestamp, pub valid_till_ns: Timestamp, - pub max_idle_ns: Option, + pub max_idle_ns: u64, pub last_refreshed_ns: Option, pub device_id: SessionDeviceId, pub read_only: bool, } impl SessionRecord { - pub fn is_expired(&self, now: Timestamp) -> bool { - self.valid_till_ns <= now - } - - /// Whether this session has gone unused for longer than it was allowed to. + /// Whether this session is finished, on either bound. /// - /// Measured from the last mint, or from creation where nothing has minted yet, - /// so a session abandoned immediately after sign-in is bounded like any other. - /// A session with no bound is never idle. - pub fn is_idle(&self, now: Timestamp) -> bool { - let Some(max_idle_ns) = self.max_idle_ns else { - return false; - }; - let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); - now.saturating_sub(last_used) >= max_idle_ns - } - - /// Whether this session can still be minted from, on either bound. + /// One question rather than two, because a caller has no use for the halves + /// apart: a session past its lifetime and one nobody has used for longer than + /// it was allowed are equally over. Asking separately is how a caller ends up + /// checking one and forgetting the other. /// - /// The two are one question at the point of use, and asking them separately is - /// how a caller ends up checking one and forgetting the other. + /// Idleness is measured from the last mint, or from creation where nothing has + /// minted yet, so a session abandoned immediately after sign-in is bounded like + /// any other. pub fn is_over(&self, now: Timestamp) -> bool { - self.is_expired(now) || self.is_idle(now) + if self.valid_till_ns <= now { + return true; + } + let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); + now.saturating_sub(last_used) >= self.max_idle_ns } /// How long this session stayed in service: the span from its creation to the last time @@ -123,7 +116,7 @@ impl SessionRecord { pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, SessionDeviceId) { let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); ( - !self.is_expired(now), + !self.is_over(now), last_used.saturating_add(self.demonstrated_use()), self.device_id, ) diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index 3bd7845e38..0feda7eef6 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -15,7 +15,7 @@ pub struct StorableSessionRecord { #[n(1)] pub valid_till_ns: StorableTimestamp, #[n(2)] - pub max_idle_ns: Option, + pub max_idle_ns: StorableDuration, #[n(3)] pub last_refreshed_ns: Option, #[n(4)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 042e9119d2..3f8e965b5f 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3709,11 +3709,15 @@ mod session_record_tests { use internet_identity_interface::internet_identity::types::AnchorNumber; use pretty_assertions::assert_eq; + /// A bound so far out that only `valid_till_ns` can end these records, which is + /// what the tests about the absolute bound want. + const NEVER_IDLE: u64 = u64::MAX; + fn session(created_at_ns: u64, valid_till_ns: u64) -> SessionRecord { SessionRecord { created_at_ns, valid_till_ns, - max_idle_ns: None, + max_idle_ns: NEVER_IDLE, last_refreshed_ns: None, device_id: 1, read_only: false, @@ -3745,43 +3749,40 @@ mod session_record_tests { } #[test] - fn a_session_with_no_idle_bound_is_never_idle() { + fn a_bound_further_out_than_the_session_never_bites() { let record = session(0, DAY_NS); - // What a request that asked for nothing gets. The absolute lifetime is the - // only thing bounding it, which is how every session behaved before. - assert!(!record.is_idle(DAY_NS * 365)); assert!(!record.is_over(0)); + // Past its own lifetime, so over on the other bound — which is the point: + // one question, answered by whichever bound is reached first. + assert!(record.is_over(DAY_NS)); } #[test] fn a_session_is_idle_once_nothing_has_minted_for_its_bound() { let record = SessionRecord { - max_idle_ns: Some(30 * MINUTE_NS), + max_idle_ns: 30 * MINUTE_NS, last_refreshed_ns: Some(10 * MINUTE_NS), ..session(0, DAY_NS) }; - assert!(!record.is_idle(39 * MINUTE_NS)); - assert!(record.is_idle(40 * MINUTE_NS)); - // Still inside its absolute lifetime, and over anyway. That is the point: - // the two bounds answer different questions and either one ends it. - assert!(!record.is_expired(40 * MINUTE_NS)); + assert!(!record.is_over(39 * MINUTE_NS)); + // Still inside its absolute lifetime, and over anyway: either bound ends it. assert!(record.is_over(40 * MINUTE_NS)); } #[test] fn a_session_that_never_minted_is_measured_from_its_creation() { let record = SessionRecord { - max_idle_ns: Some(30 * MINUTE_NS), + max_idle_ns: 30 * MINUTE_NS, last_refreshed_ns: None, ..session(5 * MINUTE_NS, DAY_NS) }; // Otherwise a session abandoned straight after sign-in would sit unbounded // until its lifetime ran out, which is the case the bound exists for. - assert!(!record.is_idle(34 * MINUTE_NS)); - assert!(record.is_idle(35 * MINUTE_NS)); + assert!(!record.is_over(34 * MINUTE_NS)); + assert!(record.is_over(35 * MINUTE_NS)); } #[test] @@ -3830,12 +3831,30 @@ mod session_record_tests { assert_eq!(storage.evictable_default_rows(anchor_number).len(), 1); } + #[test] + fn a_session_over_by_idleness_reclaims_like_a_dead_one() { + let now = 100 * DAY_NS; + let idle = SessionRecord { + max_idle_ns: DAY_NS, + last_refreshed_ns: Some(now - 10 * DAY_NS), + ..session(now - 20 * DAY_NS, now + DAY_NS) + }; + let live = SessionRecord { + last_refreshed_ns: Some(now - 1), + ..session(now - 20 * DAY_NS, now + DAY_NS) + }; + + // Both are inside their lifetime, so ranking on that alone would have them + // compete for a slot. One of them is finished. + assert!(idle.reclaim_order(now) < live.reclaim_order(now)); + } + #[test] fn reclaim_order_ranks_dead_sessions_first() { let now = 1_000; let expired = session(1, 500); let live = SessionRecord { - max_idle_ns: None, + max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(900), ..session(400, 10_000) }; @@ -3849,7 +3868,7 @@ mod session_record_tests { fn a_flood_of_unused_sessions_cannot_displace_a_used_one() { let now = 100 * DAY_NS; let held = SessionRecord { - max_idle_ns: None, + max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - DAY_NS), ..session(now - 20 * DAY_NS, now + DAY_NS) }; @@ -3872,13 +3891,13 @@ mod session_record_tests { let now = 100 * DAY_NS; // Signed in three months ago, still being opened every few days. let weekly = SessionRecord { - max_idle_ns: None, + max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - 3 * DAY_NS), ..session(now - 90 * DAY_NS, now + DAY_NS) }; // Signed in yesterday, used for five minutes, never opened again. let one_sitting = SessionRecord { - max_idle_ns: None, + max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - DAY_NS + 5 * MINUTE_NS), ..session(now - DAY_NS, now + DAY_NS) }; From 2c2074203aa525f5247094c847c6ed1c2a231cee Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:56:28 +0200 Subject: [PATCH 038/298] feat: every session gets an idle bound, seven days unless asked otherwise Absent used to mean unbounded, which made the feature opt-in and left the default sign-in lasting thirty days whether or not anybody came back. Seven days of nobody touching an application now ends it, under the same thirty-day cap. Raised then lowered rather than clamped in one call: clamp panics when its floor exceeds its ceiling, which a session granted less than ten minutes would do, and a trap is a poor answer to a short session. Such a session is bounded by its own life instead. --- src/internet_identity/src/storage.rs | 26 ++++++++----- src/internet_identity/src/storage/account.rs | 8 ++++ src/internet_identity/src/storage/tests.rs | 41 +++++++++++++++----- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index bca1489e9c..b342acfb7b 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -110,7 +110,9 @@ 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, SessionRecord, MIN_SESSION_IDLE_NS}; +use crate::storage::account::{ + AccountReference, SessionRecord, DEFAULT_SESSION_IDLE_NS, MIN_SESSION_IDLE_NS, +}; use crate::storage::anchor::Anchor; use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; @@ -1956,13 +1958,19 @@ impl Storage { now_ns, } = params; - // Clamped here rather than at the caller, so every path that creates a session - // gets the same range whatever it asked for. The ceiling is the life this - // session was actually granted: a bound longer than that could never be reached, - // and storing one would say something about the session that is not true. - let max_idle_ns = max_idle_ns.map(|requested| { - requested.clamp(MIN_SESSION_IDLE_NS, valid_till_ns.saturating_sub(now_ns)) - }); + // Defaulted and clamped here rather than at the caller, so every path that + // creates a session gets the same answer whatever it asked for. The ceiling is + // the life this session was actually granted: a bound longer than that could + // never be reached, and storing one would say something untrue about it. + // + // Raised then lowered rather than clamped in one call: `clamp` panics when its + // floor exceeds its ceiling, which a session granted less than the floor would + // do, and a trap is a poor answer to a short session. + let granted = valid_till_ns.saturating_sub(now_ns); + let max_idle_ns = max_idle_ns + .unwrap_or(DEFAULT_SESSION_IDLE_NS) + .max(MIN_SESSION_IDLE_NS) + .min(granted); // 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`. @@ -2040,7 +2048,7 @@ impl Storage { for reference in references.iter_mut() { let account_number = reference.account_number; reference.sessions.retain(|session| { - if session.is_expired(now_ns) { + if session.is_over(now_ns) { dropped.push((account_number, session.clone())); return false; } diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 63ca84d918..0ec226e7b1 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -77,6 +77,14 @@ impl AccountReference { /// so this shares that range rather than introducing a second one. pub const MIN_SESSION_IDLE_NS: u64 = 10 * crate::MINUTE_NS; +/// What a session gets when its ceremony asks for no bound of its own. +/// +/// Seven days of nobody touching an application ends the sign-in, well inside the +/// thirty days a session may otherwise live. It is the length of an absence rather +/// than of a session: coming back inside a week keeps you signed in indefinitely, +/// and a machine walked away from stops being signed in within one. +pub const DEFAULT_SESSION_IDLE_NS: u64 = 7 * crate::DAY_NS; + /// A revocable session at one account. Only `last_refreshed` is mutable, which is why /// it is the one field absent from the seed. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index fa3f807455..1bb56c855a 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4018,7 +4018,8 @@ mod session_record_tests { mod session_creation_tests { use crate::delegation::calculate_session_seed_with_salt; use crate::storage::account::{ - AccountReference, CreateAccountParams, SessionRecord, MIN_SESSION_IDLE_NS, + AccountReference, CreateAccountParams, SessionRecord, DEFAULT_SESSION_IDLE_NS, + MIN_SESSION_IDLE_NS, }; use crate::storage::CreateSessionParams; use crate::{Storage, DAY_NS, MINUTE_NS}; @@ -4084,7 +4085,7 @@ mod session_creation_tests { }) .unwrap(); - assert_eq!(session.max_idle_ns, Some(asked)); + assert_eq!(session.max_idle_ns, asked); } #[test] @@ -4101,7 +4102,7 @@ mod session_creation_tests { // An app delegation lasts five minutes, so a bound under that would end a // session between two mints of one that is plainly in use. - assert_eq!(session.max_idle_ns, Some(MIN_SESSION_IDLE_NS)); + assert_eq!(session.max_idle_ns, MIN_SESSION_IDLE_NS); } #[test] @@ -4118,19 +4119,41 @@ mod session_creation_tests { // A bound it could never reach says something about the session that is not // true, so it is stored as the life the session actually got. - assert_eq!(session.max_idle_ns, Some(DAY_NS)); + assert_eq!(session.max_idle_ns, DAY_NS); } #[test] - fn asking_for_no_idle_bound_stores_none() { + fn asking_for_no_idle_bound_gets_the_default() { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session(params(anchor_number, 1, 1_000)) + .create_session(CreateSessionParams { + valid_till_ns: 30 * DAY_NS, + ..params(anchor_number, 1, 0) + }) + .unwrap(); + + // Every session gets a bound now. A week of nobody touching the application + // ends the sign-in, well inside the thirty days it could otherwise live. + assert_eq!(session.max_idle_ns, DEFAULT_SESSION_IDLE_NS); + assert!(!session.is_over(6 * DAY_NS)); + assert!(session.is_over(7 * DAY_NS)); + } + + #[test] + fn a_session_shorter_than_the_idle_floor_is_bounded_by_its_own_life() { + let (mut storage, anchor_number) = storage_with_anchor(); + + // Under the floor the range inverts, and clamping in one call would trap. + let session = storage + .create_session(CreateSessionParams { + valid_till_ns: MINUTE_NS, + max_idle_ns: Some(30 * MINUTE_NS), + ..params(anchor_number, 1, 0) + }) .unwrap(); - assert_eq!(session.max_idle_ns, None); - assert!(!session.is_idle(1_000 + 400 * DAY_NS)); + assert_eq!(session.max_idle_ns, MINUTE_NS); } #[test] @@ -4299,7 +4322,7 @@ mod session_creation_tests { // 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_ns: 1_000, - max_idle_ns: None, + max_idle_ns: u64::MAX, last_refreshed_ns: None, device_id: 1, read_only: false, From 93beaef345c842b64788ca5c12789c874b832bc6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:57:03 +0200 Subject: [PATCH 039/298] merge: one predicate, and a default idle bound --- src/internet_identity/src/storage/tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 5c23e5b0e9..317de21355 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4251,7 +4251,7 @@ mod session_creation_tests { created_at_ns: 1_000, valid_till_ns: 1_000_000, // Device 0 is the stalest live one; device 1 has already expired. - max_idle_ns: None, + max_idle_ns: u64::MAX, last_refreshed_ns: Some(500_000 + device_id as u64), device_id, read_only: false, @@ -4357,7 +4357,7 @@ mod session_creation_tests { SessionRecord { created_at_ns: 1, valid_till_ns: 2, - max_idle_ns: None, + max_idle_ns: u64::MAX, last_refreshed_ns: None, device_id, read_only: false, @@ -4366,7 +4366,7 @@ mod session_creation_tests { SessionRecord { created_at_ns: 1_000, valid_till_ns: 100_000_000, - max_idle_ns: None, + max_idle_ns: u64::MAX, last_refreshed_ns: Some(500_000), device_id, read_only: false, @@ -4448,7 +4448,7 @@ mod session_creation_tests { let mut sessions = vec![SessionRecord { created_at_ns: 1_000, valid_till_ns: 100_000_000, - max_idle_ns: None, + max_idle_ns: u64::MAX, last_refreshed_ns: Some(400_000), device_id: 1, read_only: false, @@ -4457,7 +4457,7 @@ mod session_creation_tests { (2..=MAX_SESSIONS_PER_ANCHOR).map(|device_id| SessionRecord { created_at_ns: 500_000, valid_till_ns: 100_000_000, - max_idle_ns: None, + max_idle_ns: u64::MAX, last_refreshed_ns: None, device_id, read_only: false, From c0cb41b015ea85c51b32b2fd037750f8f5a030e2 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:57:32 +0200 Subject: [PATCH 040/298] merge: one predicate, and a default idle bound --- src/internet_identity/src/sessions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index ef9daa9808..b17859f038 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -411,7 +411,7 @@ fn authorize_session(now: Timestamp) -> Result<(Account, SessionRecord), AppSess session.device_id == handle.device_id && session.created_at_ns == handle.created_at }) .ok_or(AppSessionError::NoMatchingSession)?; - if session.is_expired(now) { + if session.is_over(now) { return Err(AppSessionError::NoMatchingSession); } Ok((account, session)) From 287a699ed758e1abd642b94a4d43066c01daecb6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 31 Aug 2026 23:57:45 +0200 Subject: [PATCH 041/298] merge: one predicate, and a default idle bound --- src/internet_identity/src/storage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index d41aaf3b65..406924ed4e 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2079,7 +2079,7 @@ impl Storage { for reference in references.iter_mut() { let account_number = reference.account_number; reference.sessions.retain(|session| { - if session.is_expired(now) { + if session.is_over(now) { expired.push((account_number, session.clone())); return false; } From 6449123c49df732288d93c20e2bef7f090f12c50 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 00:16:43 +0200 Subject: [PATCH 042/298] feat(sessions): keep the account mapping apart from the session Which account of which identity an app's principal names is not a credential and nothing there can sign, so it does not have to be forgotten when the session is. Splitting it out is what lets a sign-in decline to be resumable and still leave a later hint able to select the right account to sign in as. Two databases rather than two object stores: idb-keyval gives a database one store and fixes it at creation. --- .../src/lib/stores/app-session.store.test.ts | 43 ++++- .../src/lib/stores/app-session.store.ts | 175 +++++++++++------- 2 files changed, 155 insertions(+), 63 deletions(-) diff --git a/src/frontend/src/lib/stores/app-session.store.test.ts b/src/frontend/src/lib/stores/app-session.store.test.ts index a6ba11bc51..1961419871 100644 --- a/src/frontend/src/lib/stores/app-session.store.test.ts +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; import { + appAccountsForOrigin, appSessionsForOrigin, discardAppSession, purgeAppSessions, + rememberAppAccount, storeAppSession, type AppSessionRecord, } from "./app-session.store"; @@ -16,7 +18,6 @@ const record = (expiresAtMillis: number): AppSessionRecord => ({ expiresAtMillis, createdAtNanos: BigInt(1_000), accessLevel: "full-access" as const, - accountPrincipal: "2vxsx-fae", }); const anHourFromNow = () => Date.now() + 60 * 60 * 1000; @@ -33,6 +34,19 @@ describe("app session store", () => { await storeAppSession(key, record(anHourFromNow())); await expect(appSessionsForOrigin(ORIGIN)).resolves.toMatchObject([ + { + identityNumber: key.identityNumber, + record: { chainJson: "{}" }, + }, + ]); + }); + + it("remembers which account a principal names without a session", async () => { + const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; + await rememberAppAccount(key, { accountPrincipal: "2vxsx-fae" }); + + await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); + await expect(appAccountsForOrigin(ORIGIN)).resolves.toMatchObject([ { identityNumber: key.identityNumber, record: { accountPrincipal: "2vxsx-fae" }, @@ -40,6 +54,16 @@ describe("app session store", () => { ]); }); + it("keeps the account mapping when the session is discarded", async () => { + const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; + await rememberAppAccount(key, { accountPrincipal: "2vxsx-fae" }); + await storeAppSession(key, record(anHourFromNow())); + + await discardAppSession(key); + + await expect(appAccountsForOrigin(ORIGIN)).resolves.toHaveLength(1); + }); + it("keeps accounts of one identity apart", async () => { const identityNumber = BigInt(10_000); await storeAppSession( @@ -134,4 +158,21 @@ describe("app session store", () => { appSessionsForOrigin("https://other.example.com"), ).resolves.toEqual([]); }); + + it("purges the account mappings of one identity too", async () => { + await rememberAppAccount( + { identityNumber: BigInt(10_000), origin: ORIGIN }, + { accountPrincipal: "2vxsx-fae" }, + ); + await rememberAppAccount( + { identityNumber: BigInt(10_001), origin: ORIGIN }, + { accountPrincipal: "2vxsx-fae" }, + ); + + await purgeAppSessions(BigInt(10_000)); + + await expect(appAccountsForOrigin(ORIGIN)).resolves.toMatchObject([ + { identityNumber: BigInt(10_001) }, + ]); + }); }); diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index e3f6b9ea4e..7943753a98 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -17,53 +17,98 @@ export interface AppSessionRecord { keyPair: CryptoKeyPair; chainJson: string; expiresAtMillis: number; - /** Names this session to `revoke_account_session`, which is how one session is revoked - * once a surface exists that lists them. */ /** Names this session to `revoke_account_session`, which is how one session is revoked * once a surface exists that lists them. */ createdAtNanos: bigint; /** What the user consented to when this session was created. Recorded for display; the * canister enforces it at every mint, and an app cannot request a level of its own. */ accessLevel: AccessLevel; - /** The principal apps see for this account, so a hint can select between sessions. */ +} + +/** + * Which account of which identity an app's principal names. + * + * Kept apart from the session because it is not a credential: it is a derivation an app + * already knows the answer to, and nothing here can sign. A sign-in that asked not to be + * resumable stores this and no session, so a later `hint` still selects the right account + * to sign in as — it just has to be signed in for. + */ +export interface AppAccountRecord { accountPrincipal: string; } +// Two databases rather than two object stores, because idb-keyval gives a database one +// store and fixes it at creation. const APP_SESSION_STORE = createStore("ii-app-sessions", "sessions"); +const APP_ACCOUNT_STORE = createStore("ii-app-accounts", "accounts"); // 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; +interface SessionKey { + identityNumber: bigint; + accountNumber?: bigint; + origin: string; +} + /** Returns a copy, so a caller mutating the record cannot write back into the store * through the object IndexedDB handed us. */ -const normalize = (record: AppSessionRecord): AppSessionRecord => ({ - ...record, -}); +const normalize = (record: T): T => ({ ...record }); const sessionKey = ({ identityNumber, accountNumber, origin, -}: { - identityNumber: bigint; - accountNumber?: bigint; - origin: string; -}): string => +}: SessionKey): string => `${identityNumber.toString()}:${accountNumber?.toString() ?? "default"}:${origin}`; +const parseKey = (key: IDBValidKey): SessionKey | undefined => { + if (typeof key !== "string") { + return undefined; + } + const separator = key.indexOf(":"); + const accountSeparator = key.indexOf(":", separator + 1); + if (separator === -1 || accountSeparator === -1) { + return undefined; + } + const accountPart = key.slice(separator + 1, accountSeparator); + return { + identityNumber: BigInt(key.slice(0, separator)), + accountNumber: accountPart === "default" ? undefined : BigInt(accountPart), + origin: key.slice(accountSeparator + 1), + }; +}; + +const readAll = async ( + store: ReturnType, +): Promise<[IDBValidKey, T][]> => { + try { + return await idbEntries(store); + } catch { + return []; + } +}; + export const storeAppSession = async ( - key: { identityNumber: bigint; accountNumber?: bigint; origin: string }, + key: SessionKey, record: AppSessionRecord, ): Promise => { await idbSet(sessionKey(key), record, APP_SESSION_STORE); }; -export const discardAppSession = async (key: { - identityNumber: bigint; - accountNumber?: bigint; - origin: string; -}): Promise => { +export const rememberAppAccount = async ( + key: SessionKey, + record: AppAccountRecord, +): Promise => { + try { + await idbSet(sessionKey(key), record, APP_ACCOUNT_STORE); + } catch { + // Losing the mapping costs a hint its shortcut, not the sign-in it belongs to. + } +}; + +export const discardAppSession = async (key: SessionKey): Promise => { try { await idbDel(sessionKey(key), APP_SESSION_STORE); } catch { @@ -77,58 +122,64 @@ export const appSessionsForOrigin = async ( ): 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), - }, - ]; - }); + return (await readAll(APP_SESSION_STORE)).flatMap( + ([key, record]) => { + const parsed = parseKey(key); + if (parsed?.origin !== origin) { + return []; + } + if (record.expiresAtMillis - EXPIRY_MARGIN_MS <= now) { + return []; + } + return [ + { + identityNumber: parsed.identityNumber, + accountNumber: parsed.accountNumber, + record: normalize(record), + }, + ]; + }, + ); }; +/** Every account this browser has seen at one origin, whether or not a session for it + * survived. */ +export const appAccountsForOrigin = async ( + origin: string, +): Promise< + { identityNumber: bigint; accountNumber?: bigint; record: AppAccountRecord }[] +> => + (await readAll(APP_ACCOUNT_STORE)).flatMap( + ([key, record]) => { + const parsed = parseKey(key); + return parsed?.origin === origin + ? [ + { + identityNumber: parsed.identityNumber, + accountNumber: parsed.accountNumber, + 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(() => {})), + [APP_SESSION_STORE, APP_ACCOUNT_STORE].map(async (store) => + Promise.all( + (await readAll(store)) + .map(([key]) => key) + .filter( + (key): key is string => + typeof key === "string" && key.startsWith(prefix), + ) + .map((key) => idbDel(key, store).catch(() => {})), + ), + ), ); }; From e6dd29d25ae88facc7f5ae9b3a7ec84ead4c13de Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 00:17:31 +0200 Subject: [PATCH 043/298] feat(sessions): record the account mapping alongside the session The two are written together here; they are separable so that a later layer can decline the session and keep the mapping. --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index dc4631b2d6..17cf72bc74 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -12,6 +12,7 @@ import { } from "$lib/stores/authorization.store"; import { authenticationStore } from "$lib/stores/authentication.store"; import { + rememberAppAccount, storeAppSession, type AppSessionRecord, } from "$lib/stores/app-session.store"; @@ -275,8 +276,10 @@ const createSession = async ( expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), createdAtNanos: prepared.created_at, accessLevel: authorized.accessLevel, - accountPrincipal: prepared.account_principal.toText(), }; + await rememberAppAccount(key, { + accountPrincipal: prepared.account_principal.toText(), + }); await storeAppSession(key, record); return { record }; }; From 9f0d73b7775e018515d667e972fe0370dd479cdc Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 00:19:13 +0200 Subject: [PATCH 044/298] feat(sessions): a listed session carries its account principal Splitting the mapping out took the principal off the listing, and a hint names exactly that. It is joined back on the entry rather than the record: the record is what persists, and this is not part of it. --- .../src/lib/stores/app-session.store.test.ts | 10 ++++++++++ .../src/lib/stores/app-session.store.ts | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/app-session.store.test.ts b/src/frontend/src/lib/stores/app-session.store.test.ts index 1961419871..a280722b58 100644 --- a/src/frontend/src/lib/stores/app-session.store.test.ts +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -41,6 +41,16 @@ describe("app session store", () => { ]); }); + it("carries the account principal of a listed session", async () => { + const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; + await rememberAppAccount(key, { accountPrincipal: "2vxsx-fae" }); + await storeAppSession(key, record(anHourFromNow())); + + await expect(appSessionsForOrigin(ORIGIN)).resolves.toMatchObject([ + { accountPrincipal: "2vxsx-fae" }, + ]); + }); + it("remembers which account a principal names without a session", async () => { const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; await rememberAppAccount(key, { accountPrincipal: "2vxsx-fae" }); diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index 7943753a98..351de34157 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -116,13 +116,27 @@ export const discardAppSession = async (key: SessionKey): Promise => { } }; -/** Every session this identity holds, for the sibling lookup and for sign-out. */ +/** Every session this identity holds, for the sibling lookup and for sign-out. + * + * Each carries the principal its account is known by, which lives in the other store + * and is joined back on here: a hint names a principal, and what it selects between is + * sessions. */ export const appSessionsForOrigin = async ( origin: string, ): Promise< - { identityNumber: bigint; accountNumber?: bigint; record: AppSessionRecord }[] + { + identityNumber: bigint; + accountNumber?: bigint; + accountPrincipal?: string; + record: AppSessionRecord; + }[] > => { const now = Date.now(); + const accounts = new Map( + (await readAll(APP_ACCOUNT_STORE)).map( + ([key, record]) => [key, record.accountPrincipal], + ), + ); return (await readAll(APP_SESSION_STORE)).flatMap( ([key, record]) => { const parsed = parseKey(key); @@ -136,6 +150,7 @@ export const appSessionsForOrigin = async ( { identityNumber: parsed.identityNumber, accountNumber: parsed.accountNumber, + accountPrincipal: accounts.get(key), record: normalize(record), }, ]; From 1aa5d7dbcb778067da30d21577d3ca2e60f2a838 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 00:24:33 +0200 Subject: [PATCH 045/298] feat(sessions): a sign-in is kept only when the app asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `?resumable=true` on the authorize URL, read beside `prompt` and `hint` and degrading the same way: an unreadable value means not resumable, which is the answer for every app that never thought about it. Without it the app is still served — what it loses is this browser answering the next request without another ceremony. The account mapping is written either way, so a later hint still names an account this browser has seen. --- .../src/lib/stores/authorization.store.ts | 1 + .../channelHandlers/sessionDelegation.test.ts | 123 +++++++++++++++++- .../channelHandlers/sessionDelegation.ts | 10 +- .../(new-styling)/authorize/promptParams.ts | 26 +++- 4 files changed, 150 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/lib/stores/authorization.store.ts b/src/frontend/src/lib/stores/authorization.store.ts index c5aa0d66ba..b4ceb8d573 100644 --- a/src/frontend/src/lib/stores/authorization.store.ts +++ b/src/frontend/src/lib/stores/authorization.store.ts @@ -36,6 +36,7 @@ const authorizedInternal = writable(); export type AuthorizationPromptContext = { prompt?: "none" | "login"; hint?: string; + resumable?: boolean; }; /** Kept out of `AuthorizationContext`, whose presence is what makes the sign-in UI diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 1f9da9f662..6c9eddd12a 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity"; +import { Principal } from "@icp-sdk/core/principal"; const CANISTER_ID_TEXT = "rwlgt-iiaaa-aaaaa-aaaaa-cai"; const ORIGIN = "https://app.example.com"; @@ -35,6 +36,27 @@ vi.mock("@icp-sdk/core/agent", async () => { }, }; }); +vi.mock("$lib/stores/authentication.store", async () => { + const { writable } = await import("svelte/store"); + return { authenticationStore: writable(undefined) }; +}); +vi.mock("$lib/stores/browser-key.store", () => ({ + withBrowserProof: ( + _identityNumber: bigint, + _sessionKey: Uint8Array, + signIn: (proof: unknown) => Promise, + ) => + signIn({ + publicKey: new Uint8Array(), + nextPublicKey: new Uint8Array(), + signature: new Uint8Array(), + nextSignature: new Uint8Array(), + accept: () => Promise.resolve(), + }), +})); +vi.mock("$lib/stores/channelHandlers/describeBrowser", () => ({ + describeBrowser: () => Promise.resolve("a browser"), +})); const setRequestContext = vi.fn(); vi.mock("$lib/stores/authorization.store", async () => { @@ -43,13 +65,14 @@ vi.mock("$lib/stores/authorization.store", async () => { authorizationStore: { setRequestContext: (...args: unknown[]) => setRequestContext(...args), }, - authorizedStore: { subscribe: () => () => {} }, + authorizedStore: writable(undefined), authorizationPromptStore: writable<{ prompt?: string; hint?: string }>({}), }; }); import { handleSessionDelegationRequest } from "./sessionDelegation"; import { + appAccountsForOrigin, appSessionsForOrigin, rememberAppAccount, purgeAppSessions, @@ -57,6 +80,73 @@ import { } from "$lib/stores/app-session.store"; import { INTERACTION_REQUIRED_ERROR_CODE } from "$lib/utils/transport/utils"; +/** Drives one ceremony to the point where the session it created is either kept or + * discarded, which is the whole of what `resumable` decides. */ +const runCeremony = async (resumable?: boolean) => { + const { authorizationPromptStore, authorizedStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set(resumable === undefined ? {} : { resumable }); + + const identityNumber = BigInt(10_000); + const sessionKey = await ECDSAKeyIdentity.generate({ extractable: true }); + const expiration = BigInt(Date.now() + 60 * 60 * 1000) * BigInt(1_000_000); + const chain = await DelegationChain.create( + sessionKey, + sessionKey.getPublicKey(), + new Date(Number(expiration / BigInt(1_000_000))), + ); + const signed = chain.delegations[0]; + + const actor = { + prepare_account_session: () => + Promise.resolve({ + Ok: { + user_key: new Uint8Array(chain.publicKey), + expiration, + created_at: BigInt(1_000), + account_principal: Principal.fromText("2vxsx-fae"), + device_id: BigInt(1), + }, + }), + get_account_session: () => + Promise.resolve({ + Ok: { + signed_delegation: { + delegation: { + pubkey: new Uint8Array(signed.delegation.pubkey), + expiration: signed.delegation.expiration, + targets: [], + }, + signature: new Uint8Array(signed.signature), + }, + }, + }), + }; + const { authenticationStore } = + await import("$lib/stores/authentication.store"); + authenticationStore.set({ + identityNumber, + actor, + authMethod: { passkey: {} }, + }); + authorizedStore.set({ + accountNumberPromise: Promise.resolve(undefined), + accessLevel: "full-access", + }); + + const { channel, sent } = channelWith(); + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + return { sent }; +}; + const channelWith = () => { const sent: unknown[] = []; return { @@ -435,3 +525,34 @@ describe("requests that fall through to a ceremony", () => { expect(sent).toEqual([]); }); }); + +describe("keeping a session for later", () => { + beforeEach(async () => { + await purgeAppSessions(BigInt(10_000)); + await purgeAppSessions(BigInt(10_001)); + }); + + it("keeps a session the app asked to be resumable", async () => { + const { sent } = await runCeremony(true); + + expect(sent).toHaveLength(1); + await expect(appSessionsForOrigin(ORIGIN)).resolves.toHaveLength(1); + }); + + it("answers without keeping a session the app did not ask to be resumable", async () => { + const { sent } = await runCeremony(); + + // The app is served either way: what `resumable` decides is only whether this + // browser can answer the next request without another ceremony. + expect(sent).toHaveLength(1); + await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); + }); + + it("remembers the account even when the session is not kept", async () => { + await runCeremony(); + + await expect(appAccountsForOrigin(ORIGIN)).resolves.toMatchObject([ + { record: { accountPrincipal: "2vxsx-fae" } }, + ]); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 76da1bf499..b76f827448 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -225,7 +225,7 @@ export const handleSessionDelegationRequest = params.icrc95DerivationOrigin ?? channel.origin, ); - const { prompt, hint } = get(authorizationPromptStore); + const { prompt, hint, resumable } = get(authorizationPromptStore); // Silence is something an app asks for. Anything else, an absent `prompt` included, // runs the ceremony, so a held session is never handed over without the user // seeing a screen they did not request. @@ -266,6 +266,7 @@ export const handleSessionDelegationRequest = const created = await createSession( effectiveOrigin, params.maxTimeToLive, + resumable === true, ); const chain = await extendToApp( created.record, @@ -292,6 +293,7 @@ export const handleSessionDelegationRequest = const createSession = async ( effectiveOrigin: string, requestedMaxTimeToLive: bigint | undefined, + resumable: boolean, ): Promise<{ record: AppSessionRecord }> => { authorizationStore.setRequestContext(effectiveOrigin, requestedMaxTimeToLive); const authorized = await waitForStore(authorizedStore); @@ -378,9 +380,13 @@ const createSession = async ( createdAtNanos: prepared.created_at, accessLevel: authorized.accessLevel, }; + // The mapping is not a credential and is kept either way, so a later hint still names + // an account this browser has seen. The session is what an app has to ask to have kept. await rememberAppAccount(key, { accountPrincipal: prepared.account_principal.toText(), }); - await storeAppSession(key, record); + if (resumable) { + await storeAppSession(key, record); + } return { record }; }; diff --git a/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts index b11024b178..095bf1753a 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts +++ b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const PROMPT_PARAM = "prompt"; export const HINT_PARAM = "hint"; +export const RESUMABLE_PARAM = "resumable"; /** Survives the round trip an interactive flow may take through an IdP. */ const STORAGE_KEY = "ii-authorize-prompt"; @@ -12,6 +13,10 @@ export type AuthorizationPrompt = "none" | "login"; export interface PromptContext { prompt?: AuthorizationPrompt; hint?: string; + /** Whether this sign-in may be kept here to be resumed later. An app that does not + * ask is not kept, so a later silent request finds nothing — which is the answer + * for every app that never thought about it. */ + resumable?: boolean; } const isPrincipal = (value: string): boolean => { @@ -33,12 +38,18 @@ const PromptContextSchema = z.object({ .transform((value) => Principal.fromText(value).toText()) .optional() .catch(undefined), + resumable: z + .literal("true") + .transform(() => true) + .optional() + .catch(undefined), }); export const readPromptParams = (url: URL): PromptContext => { const parsed = PromptContextSchema.safeParse({ prompt: url.searchParams.get(PROMPT_PARAM) ?? undefined, hint: url.searchParams.get(HINT_PARAM) ?? undefined, + resumable: url.searchParams.get(RESUMABLE_PARAM) ?? undefined, }); return parsed.success ? parsed.data : {}; }; @@ -61,7 +72,11 @@ export const resolvePromptParams = ( } const context = readPromptParams(url); - if (context.prompt === undefined && context.hint === undefined) { + if ( + context.prompt === undefined && + context.hint === undefined && + context.resumable === undefined + ) { sessionStorage.removeItem(STORAGE_KEY); } else { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)); @@ -72,13 +87,10 @@ export const resolvePromptParams = ( /** Keeps the address bar free of values the flow has already consumed. */ export const stripPromptParams = (): void => { const url = new URL(window.location.href); - if ( - !url.searchParams.has(PROMPT_PARAM) && - !url.searchParams.has(HINT_PARAM) - ) { + const params = [PROMPT_PARAM, HINT_PARAM, RESUMABLE_PARAM]; + if (!params.some((param) => url.searchParams.has(param))) { return; } - url.searchParams.delete(PROMPT_PARAM); - url.searchParams.delete(HINT_PARAM); + params.forEach((param) => url.searchParams.delete(param)); window.history.replaceState(null, "", url.toString()); }; From 6bfe14c7784dac4b19ff975d19d22d98a55c1446 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 00:25:41 +0200 Subject: [PATCH 046/298] test(sessions): cover reading resumable off the URL and across a resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round trip stores the parsed boolean and reads it back through the same schema, which only accepted the string the URL carries — so an interactive sign-in that went through an IdP came back as not resumable. The schema now reads both shapes. --- .../authorize/promptParams.test.ts | 47 ++++++++++++++++--- .../(new-styling)/authorize/promptParams.ts | 4 +- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts b/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts index 5e25567d51..1b5331d1fe 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts +++ b/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts @@ -20,13 +20,31 @@ describe("authorize prompt params", () => { `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}`, ), ), - ).toEqual({ prompt: "none", hint: PRINCIPAL }); + ).toEqual({ prompt: "none", hint: PRINCIPAL, resumable: undefined }); + }); + + it("reads a request to be kept for later", () => { + expect( + readPromptParams( + new URL("http://localhost:3000/authorize?resumable=true"), + ), + ).toEqual({ prompt: undefined, hint: undefined, resumable: true }); + }); + + it("keeps nothing for an app that did not ask in so many words", () => { + for (const value of ["false", "1", "yes", ""]) { + expect( + readPromptParams( + new URL(`http://localhost:3000/authorize?resumable=${value}`), + ).resumable, + ).toBeUndefined(); + } }); it("reads an interactive request", () => { expect( readPromptParams(new URL("http://localhost:3000/authorize?prompt=login")), - ).toEqual({ prompt: "login", hint: undefined }); + ).toEqual({ prompt: "login", hint: undefined, resumable: undefined }); }); it("treats an unknown prompt as absent", () => { @@ -34,7 +52,7 @@ describe("authorize prompt params", () => { readPromptParams( new URL("http://localhost:3000/authorize?prompt=consent"), ), - ).toEqual({ prompt: undefined, hint: undefined }); + ).toEqual({ prompt: undefined, hint: undefined, resumable: undefined }); }); it("treats a hint that is not a principal as absent", () => { @@ -42,18 +60,32 @@ describe("authorize prompt params", () => { readPromptParams( new URL("http://localhost:3000/authorize?hint=not-a-principal"), ), - ).toEqual({ prompt: undefined, hint: undefined }); + ).toEqual({ prompt: undefined, hint: undefined, resumable: undefined }); }); it("keeps the params across a resume", () => { resolvePromptParams( - new URL(`http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}`), + new URL( + `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}&resumable=true`, + ), false, ); expect( resolvePromptParams(new URL("http://localhost:3000/authorize"), true), - ).toEqual({ prompt: "none", hint: PRINCIPAL }); + ).toEqual({ prompt: "none", hint: PRINCIPAL, resumable: true }); + }); + + it("keeps a lone request to be kept across a resume", () => { + resolvePromptParams( + new URL("http://localhost:3000/authorize?resumable=true"), + false, + ); + + expect( + resolvePromptParams(new URL("http://localhost:3000/authorize"), true) + .resumable, + ).toBe(true); }); it("clears a stored context when a later request carries none", () => { @@ -73,7 +105,7 @@ describe("authorize prompt params", () => { window.history.replaceState( null, "", - `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}&sso=example.com`, + `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}&resumable=true&sso=example.com`, ); stripPromptParams(); @@ -81,6 +113,7 @@ describe("authorize prompt params", () => { const url = new URL(window.location.href); expect(url.searchParams.get("prompt")).toBeNull(); expect(url.searchParams.get("hint")).toBeNull(); + expect(url.searchParams.get("resumable")).toBeNull(); expect(url.searchParams.get("sso")).toBe("example.com"); }); }); diff --git a/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts index 095bf1753a..bfbc882115 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts +++ b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts @@ -38,8 +38,10 @@ const PromptContextSchema = z.object({ .transform((value) => Principal.fromText(value).toText()) .optional() .catch(undefined), + // Arrives from the URL as the string it was written as, and from session storage as + // the boolean it was stored as, so the schema has to read both. resumable: z - .literal("true") + .union([z.literal("true"), z.literal(true)]) .transform(() => true) .optional() .catch(undefined), From 6fe0975e4e6b31860f8189065daea101cc6747f6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 00:29:43 +0200 Subject: [PATCH 047/298] test(sessions): type the mocked stores the ceremony drives --- .../lib/stores/channelHandlers/sessionDelegation.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 6c9eddd12a..7454d42b01 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity"; import { Principal } from "@icp-sdk/core/principal"; +import type { Writable } from "svelte/store"; const CANISTER_ID_TEXT = "rwlgt-iiaaa-aaaaa-aaaaa-cai"; const ORIGIN = "https://app.example.com"; @@ -124,12 +125,14 @@ const runCeremony = async (resumable?: boolean) => { }; const { authenticationStore } = await import("$lib/stores/authentication.store"); - authenticationStore.set({ + // Both stores are mocked as plain writables above; only their real types are in + // scope here, and neither is writable or shaped like what the handler reads. + (authenticationStore as unknown as Writable).set({ identityNumber, actor, - authMethod: { passkey: {} }, + authMethod: { passkey: { credentialId: new Uint8Array() } }, }); - authorizedStore.set({ + (authorizedStore as unknown as Writable).set({ accountNumberPromise: Promise.resolve(undefined), accessLevel: "full-access", }); From f53ca18078f2926b59ae3f466ba0d03a800a31fb Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 01:13:30 +0200 Subject: [PATCH 048/298] docs(sessions): say what the expiry margin is actually for It was defended as a check-to-validation race, which is a network round trip and three orders of magnitude smaller. The number is right for a different reason: below one app delegation's life a session cannot back even one. --- src/frontend/src/lib/stores/app-session.store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index 351de34157..c9ecd8ae47 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -42,8 +42,9 @@ export interface AppAccountRecord { const APP_SESSION_STORE = createStore("ii-app-sessions", "sessions"); const APP_ACCOUNT_STORE = createStore("ii-app-accounts", "accounts"); -// 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. +// A session with less than an app delegation's life left cannot back even one, so +// serving it would answer a silent request with a sign-in that dies unexplained a +// moment later. The canister's own APP_DELEGATION_TTL_NS is the same five minutes. const EXPIRY_MARGIN_MS = 5 * 60 * 1000; interface SessionKey { From e1b87ea52fb3002a693efea20fccc624b9059f75 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 14:28:46 +0200 Subject: [PATCH 049/298] fix(be): a browser cannot name itself its own successor Rotation is what stops a leaked browser key from being useful for longer than one sign-in. Presenting the key as its own successor kept it alive for as long as the caller kept asking, and whoever leaked it could ask too. The pair is refused now, and the test that asserted a browser may never rotate says the opposite. The lookup ran four passes over the list to answer two questions; it runs two. `key` and `pending` become `current_device_key` and `next_device_key`, which is what the caller and the wire already call them. --- src/internet_identity/src/storage/anchor.rs | 58 ++++++++------- .../src/storage/anchor/tests.rs | 74 +++++++++++++++---- .../src/storage/storable/session_device.rs | 4 +- 3 files changed, 96 insertions(+), 40 deletions(-) diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 9197acd7e7..577702d4d7 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -58,6 +58,12 @@ pub enum SessionDeviceError { /// Presented keys are visible on the wire, so without this a caller could announce a /// key another browser is about to present and take over its entry when it does. SuccessorAlreadyInUse, + /// The announced successor is the key being presented. + /// + /// Rotation is what stops a leaked key from being useful for longer than one sign-in, + /// so a browser that named itself its own successor would keep the key alive for as + /// long as it kept asking — and whoever leaked it would too. + SuccessorMatchesCurrent, } /// A browser this anchor has signed in from. The name is self-reported by the client. @@ -65,9 +71,9 @@ pub enum SessionDeviceError { pub struct SessionDevice { pub id: SessionDeviceId, /// The browser's own public key, DER-encoded. What the entry is looked up by. - pub key: PublicKey, + pub current_device_key: PublicKey, /// The successor the browser announced at its last sign-in, also accepted as a proof. - pub pending: PublicKey, + pub next_device_key: PublicKey, pub name: String, pub created_at: Timestamp, pub last_used: Timestamp, @@ -77,8 +83,8 @@ impl From for SessionDevice { fn from(value: StorableSessionDevice) -> Self { SessionDevice { id: value.id, - key: ByteBuf::from(value.key), - pending: ByteBuf::from(value.pending), + current_device_key: ByteBuf::from(value.current_device_key), + next_device_key: ByteBuf::from(value.next_device_key), name: value.name, created_at: value.created_at, last_used: value.last_used, @@ -90,8 +96,8 @@ impl From for StorableSessionDevice { fn from(value: SessionDevice) -> Self { StorableSessionDevice { id: value.id, - key: value.key.into_vec(), - pending: value.pending.into_vec(), + current_device_key: value.current_device_key.into_vec(), + next_device_key: value.next_device_key.into_vec(), name: value.name, created_at: value.created_at, last_used: value.last_used, @@ -722,35 +728,37 @@ impl Anchor { /// registering it when this anchor holds neither that key nor a successor equal to it. /// /// A proof from the successor promotes it, retiring the key it replaces. Either way the - /// entry then awaits `next_key`, which is what a browser presents once this sign-in has - /// reached it. + /// entry then awaits `next_device_key`, which is what a browser presents once this + /// sign-in has reached it. /// /// At the cap the least recently used records are dropped, and their ids returned so /// the caller can end their sessions too. pub fn resolve_session_device( &mut self, - key: PublicKey, - next_key: PublicKey, + current_device_key: PublicKey, + next_device_key: PublicKey, name: String, now: Timestamp, ) -> Result<(SessionDeviceId, Vec), SessionDeviceError> { - let holder = |candidate: &PublicKey| { - self.session_devices - .iter() - .find(|device| device.key == *candidate || device.pending == *candidate) - .map(|device| device.id) + if current_device_key == next_device_key { + return Err(SessionDeviceError::SuccessorMatchesCurrent); + } + + let device_index_for_key = |candidate: &PublicKey| { + self.session_devices.iter().position(|device| { + device.current_device_key == *candidate || device.next_device_key == *candidate + }) }; - if holder(&next_key).is_some() && holder(&next_key) != holder(&key) { + let current_index = device_index_for_key(¤t_device_key); + let next_index = device_index_for_key(&next_device_key); + if next_index.is_some() && next_index != current_index { return Err(SessionDeviceError::SuccessorAlreadyInUse); } - if let Some(device) = self - .session_devices - .iter_mut() - .find(|device| device.key == key || device.pending == key) - { - device.key = key; - device.pending = next_key; + if let Some(index) = current_index { + let device = &mut self.session_devices[index]; + device.current_device_key = current_device_key; + device.next_device_key = next_device_key; device.last_used = now; return Ok((device.id, vec![])); } @@ -759,8 +767,8 @@ impl Anchor { self.next_session_device_id = self.next_session_device_id.saturating_add(1); self.session_devices.push(SessionDevice { id, - key, - pending: next_key, + current_device_key, + next_device_key, name, created_at: now, last_used: now, diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index c49292b8a4..bfc777df6c 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -1301,7 +1301,10 @@ mod session_device_tests { assert_eq!(id, 0); assert_eq!(anchor.session_devices().len(), 1); assert_eq!(anchor.session_devices()[0].name, "Chrome on MacBook"); - assert_eq!(anchor.session_devices()[0].key, browser_key(1)); + assert_eq!( + anchor.session_devices()[0].current_device_key, + browser_key(1) + ); assert_eq!(anchor.session_devices()[0].created_at, 1_000); } @@ -1580,8 +1583,11 @@ mod session_device_tests { assert_eq!(again, id); assert_eq!(anchor.session_devices().len(), 1); - assert_eq!(anchor.session_devices()[0].key, successor_key(1)); - assert_eq!(anchor.session_devices()[0].pending, browser_key(2)); + assert_eq!( + anchor.session_devices()[0].current_device_key, + successor_key(1) + ); + assert_eq!(anchor.session_devices()[0].next_device_key, browser_key(2)); } #[test] @@ -1642,8 +1648,14 @@ mod session_device_tests { assert_eq!(again, id); assert_eq!(anchor.session_devices().len(), 1); - assert_eq!(anchor.session_devices()[0].key, browser_key(1)); - assert_eq!(anchor.session_devices()[0].pending, successor_key(2)); + assert_eq!( + anchor.session_devices()[0].current_device_key, + browser_key(1) + ); + assert_eq!( + anchor.session_devices()[0].next_device_key, + successor_key(2) + ); } #[test] @@ -1733,17 +1745,53 @@ mod session_device_tests { } #[test] - fn a_browser_that_never_rotates_keeps_working() { + fn a_browser_cannot_name_itself_its_own_successor() { let mut anchor = anchor(); - let (id, _) = anchor - .resolve_session_device(browser_key(1), browser_key(1), "Chrome".to_string(), 1_000) - .unwrap(); - let (again, _) = anchor - .resolve_session_device(browser_key(1), browser_key(1), "Chrome".to_string(), 2_000) + // Rotation is what stops a leaked key from outliving one sign-in. A browser that + // announced the key it is presenting would keep it alive for as long as it kept + // asking, and so would whoever leaked it. + assert_eq!( + anchor.resolve_session_device( + browser_key(1), + browser_key(1), + "Chrome".to_string(), + 1_000 + ), + Err(SessionDeviceError::SuccessorMatchesCurrent) + ); + assert!(anchor.session_devices().is_empty()); + } + + #[test] + fn a_registered_browser_cannot_stop_rotating_either() { + let mut anchor = anchor(); + anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) .unwrap(); - assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!( + anchor.resolve_session_device( + successor_key(1), + successor_key(1), + "Chrome".to_string(), + 2_000 + ), + Err(SessionDeviceError::SuccessorMatchesCurrent) + ); + // The entry is left as it was, still awaiting a successor it has not seen. + assert_eq!( + anchor.session_devices()[0].current_device_key, + browser_key(1) + ); + assert_eq!( + anchor.session_devices()[0].next_device_key, + successor_key(1) + ); } } diff --git a/src/internet_identity/src/storage/storable/session_device.rs b/src/internet_identity/src/storage/storable/session_device.rs index 6acc7cbfbc..841a4140e4 100644 --- a/src/internet_identity/src/storage/storable/session_device.rs +++ b/src/internet_identity/src/storage/storable/session_device.rs @@ -17,9 +17,9 @@ pub struct StorableSessionDevice { #[n(3)] pub last_used: Timestamp, #[cbor(n(4), with = "minicbor::bytes")] - pub key: Vec, + pub current_device_key: Vec, #[cbor(n(5), with = "minicbor::bytes")] - pub pending: Vec, + pub next_device_key: Vec, } impl Storable for StorableSessionDevice { From aa8cc14e6cec33e1a93931c38a210db5e1cf4cc4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 14:31:37 +0200 Subject: [PATCH 050/298] refactor(be): current_device_key and next_device_key, on the wire too The same key went by three names: device_key on the wire, key in the record and a bare parameter in between. One pair of names now, and the candid comment says the two must differ. --- src/internet_identity/internet_identity.did | 13 +++++++------ src/internet_identity/src/sessions.rs | 18 +++++++++--------- .../src/sessions/device_key.rs | 14 +++++++++----- .../src/internet_identity/types.rs | 9 +++++---- 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 62d3766a60..7afbba9632 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1027,13 +1027,14 @@ type PrepareAccountSessionRequest = record { 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. + current_device_key : PublicKey; + // What the browser rotates to once this sign-in succeeds. Must differ from + // current_device_key: a browser that never rotates keeps a leaked key useful. 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. + // Signature over session_key and next_device_key, verified with current_device_key. + current_device_key_signature : blob; + // Signature by next_device_key over session_key and current_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; diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 713ad70935..2b9aaa7b72 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -70,9 +70,9 @@ pub async fn prepare_account_session( account_number, session_key, device_name, - device_key, + current_device_key, next_device_key, - device_key_signature, + current_device_key_signature, next_device_key_signature, permissions, valid_for, @@ -86,8 +86,8 @@ pub async fn prepare_account_session( )); } if !verify_device_keys( - &device_key, - &device_key_signature, + ¤t_device_key, + ¤t_device_key_signature, &next_device_key, &next_device_key_signature, &session_key, @@ -123,12 +123,12 @@ pub async fn prepare_account_session( 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 known_device = anchor.session_devices().iter().any(|device| { + device.current_device_key == current_device_key + || device.next_device_key == current_device_key + }); let (device_id, dropped_devices) = anchor - .resolve_session_device(device_key, next_device_key, device_name, now) + .resolve_session_device(current_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"); diff --git a/src/internet_identity/src/sessions/device_key.rs b/src/internet_identity/src/sessions/device_key.rs index ae9433f63a..5aafd30f94 100644 --- a/src/internet_identity/src/sessions/device_key.rs +++ b/src/internet_identity/src/sessions/device_key.rs @@ -23,20 +23,24 @@ const DEVICE_KEY_SIGNATURE_BYTES: usize = 64; /// hold it — without it, keys read off the wire could be planted as another browser's /// successor and claimed when that browser next presented one. pub fn verify_device_keys( - device_key: &PublicKey, - device_key_signature: &[u8], + current_device_key: &PublicKey, + current_device_key_signature: &[u8], next_device_key: &PublicKey, next_device_key_signature: &[u8], session_key: &SessionKey, ) -> bool { verify( - device_key, - device_key_signature, + current_device_key, + current_device_key_signature, &signed_message(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_device_key), ) && verify( next_device_key, next_device_key_signature, - &signed_message(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, device_key), + &signed_message( + SUCCESSOR_KEY_SIGNATURE_DOMAIN, + session_key, + current_device_key, + ), ) } diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 654d7de6c2..38f23c3cca 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -767,12 +767,13 @@ pub struct PrepareAccountSessionRequest { 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, + pub current_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, + /// Signature over `session_key` and `next_device_key`, verified with + /// `current_device_key`. A second signature by `next_device_key` proves the browser + /// holds the key it is announcing. + pub current_device_key_signature: ByteBuf, pub next_device_key_signature: ByteBuf, /// The consented access level, fixed for the session's life. pub permissions: Option, From e43c25d341ad4c6269b84c64060580d318845c7c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 14:32:00 +0200 Subject: [PATCH 051/298] chore(be): regenerate the candid bindings for the renamed device keys --- .../lib/generated/internet_identity_idl.js | 4 +-- .../generated/internet_identity_types.d.ts | 25 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index b27a3de3e1..d0bc00d389 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -705,13 +705,13 @@ export const idlFactory = ({ IDL }) => { }); const PrepareAccountSessionRequest = IDL.Record({ 'permissions' : IDL.Opt(Permissions), + 'current_device_key' : PublicKey, 'session_key' : SessionKey, 'valid_for' : IDL.Opt(IDL.Nat64), 'origin' : FrontendHostname, + 'current_device_key_signature' : IDL.Vec(IDL.Nat8), '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), 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 db772b6ea7..fc7f76e0a0 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1343,6 +1343,11 @@ export interface PrepareAccountSessionRequest { * The consented access level, fixed for the session's life. */ 'permissions' : [] | [Permissions], + /** + * 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. + */ + 'current_device_key' : PublicKey, /** * The II frontend's own key. The app never sees this chain's private key. */ @@ -1352,28 +1357,24 @@ export interface PrepareAccountSessionRequest { */ 'valid_for' : [] | [bigint], 'origin' : FrontendHostname, + /** + * Signature over session_key and next_device_key, verified with current_device_key. + */ + 'current_device_key_signature' : Uint8Array | number[], /** * 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. + * What the browser rotates to once this sign-in succeeds. Must differ from + * current_device_key: a browser that never rotates keeps a leaked key useful. */ 'next_device_key' : PublicKey, /** - * Signature by next_device_key over session_key and device_key, proving the browser - * holds the key it is announcing. + * Signature by next_device_key over session_key and current_device_key, proving the + * browser holds the key it is announcing. */ 'next_device_key_signature' : Uint8Array | number[], } From 1f4a74488c81d862536986f5cb9a1d6019694927 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 14:33:20 +0200 Subject: [PATCH 052/298] refactor(fe): send current_device_key by the name the canister now uses --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 17cf72bc74..2e54fd16a3 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -228,9 +228,9 @@ const createSession = async ( account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, device_name: deviceName, - device_key: browser.publicKey, + current_device_key: browser.publicKey, next_device_key: browser.nextPublicKey, - device_key_signature: browser.signature, + current_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 From f1e781bff2cc2a73bdca634ca8c15cb37d26911e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 14:42:10 +0200 Subject: [PATCH 053/298] test(be): the wire refuses a successor equal to the key presented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both signatures are real in this case — the caller holds the key it is naming — so only the equality check stands between a browser and never rotating. --- .../tests/integration/sessions.rs | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index e448f5eb2c..20690572c7 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -36,8 +36,8 @@ fn session_request_from( 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), + current_device_key: browser.public_key(), + current_device_key_signature: browser.sign(&session_key, &next_device_key), next_device_key_signature: browser .successor() .sign_as_successor(&session_key, &browser.public_key()), @@ -213,7 +213,7 @@ fn should_refuse_a_signature_from_another_key() -> Result<(), RejectResponse> { let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.device_key_signature = + request.current_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)?; @@ -247,7 +247,7 @@ fn should_refuse_a_key_that_is_not_a_public_key() -> Result<(), RejectResponse> let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.device_key = ByteBuf::from(vec![0; 91]); + request.current_device_key = ByteBuf::from(vec![0; 91]); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); @@ -266,7 +266,7 @@ fn should_register_no_browser_when_the_proof_fails() -> Result<(), RejectRespons 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]); + request.current_device_key_signature = ByteBuf::from(vec![0; 64]); prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap_err(); assert_eq!( @@ -419,7 +419,8 @@ fn should_treat_a_retired_key_as_a_new_browser() -> Result<(), RejectResponse> { 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.current_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(); @@ -515,7 +516,8 @@ fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectRespons 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); + request.current_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)); @@ -543,6 +545,29 @@ fn should_refuse_a_successor_the_caller_cannot_prove() -> Result<(), RejectRespo Ok(()) } +/// Rotation is what stops a leaked browser key from being useful for longer than one +/// sign-in, so a browser cannot decline it by announcing the key it is presenting. +#[test] +fn should_refuse_a_successor_equal_to_the_key_presented() -> 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); + // Both signatures are real: the caller holds the key it is naming as its own successor. + request.next_device_key = browser.public_key(); + request.current_device_key_signature = + browser.sign(&request.session_key, &request.next_device_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] @@ -565,7 +590,8 @@ fn should_refuse_a_successor_another_browser_holds_even_when_proven() -> Result< 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.current_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)?; From 7392ce740c68734452133a77e97ff161c5b2b9dc Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 23:07:18 +0200 Subject: [PATCH 054/298] fix(be): the reference list and its counters land together, or not at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning an error after the list was written committed one without the other. Every fallible step now runs before every write: the counters are computed, the one write that can report a failure goes first, and the rest cannot fail. A refusal leaves nothing written. The deltas refuse instead of clamping for the same reason. An under-run means the counters and the stored lists have already diverged, and saturating to zero does not merely record a wrong number — it reads as no anchor referencing the application any more, which is what retires the row. --- src/internet_identity/src/storage.rs | 69 +++++++++++++++------- src/internet_identity/src/storage/tests.rs | 45 ++++++++++++++ 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 4939513597..68467bfd93 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1738,9 +1738,18 @@ impl Storage { let deltas = ReferenceListDeltas::between(&previous, ¤t); + // Counters first: it is the only step here that can fail, and returning an error + // after the list is written would commit one without the other. A failure now + // leaves nothing written at all. + self.apply_reference_counter_deltas( + anchor_number, + application_number, + application, + deltas, + )?; self.stable_account_reference_list_memory .insert(key, current.into()); - self.apply_reference_counter_deltas(anchor_number, application_number, application, deltas) + Ok(()) } fn apply_reference_counter_deltas( @@ -1754,27 +1763,30 @@ impl Storage { return Ok(()); } + // Every counter is computed before any of them is written, so an out-of-bounds + // delta refuses with all three still holding their old values. let anchor_counter = self .stable_anchor_account_counter_memory .get(&anchor_number) .unwrap_or_default(); - let (stored_accounts, stored_account_references) = deltas.apply( + let (anchor_accounts, anchor_references) = deltas.apply( anchor_counter.stored_accounts, anchor_counter.stored_account_references, - ); - self.stable_anchor_account_counter_memory.insert( - anchor_number, - StorableAccountsCounter { - stored_accounts, - stored_account_references, - }, - ); + )?; let global_counter = self.stable_account_counter_memory.get().clone(); let (_, global_references) = deltas.apply( global_counter.stored_accounts, global_counter.stored_account_references, - ); + )?; + + let (application_accounts, application_references) = deltas.apply( + application.stored_accounts, + application.stored_account_references, + )?; + + // The only write here that reports a failure, so it goes before the two that + // cannot: past this line nothing can return an error and leave a partial update. self.stable_account_counter_memory .set(StorableAccountsCounter { stored_accounts: global_counter.stored_accounts, @@ -1782,16 +1794,20 @@ impl Storage { }) .map_err(|_| StorageError::ErrorUpdatingAccountCounter)?; - let (stored_accounts, stored_account_references) = deltas.apply( - application.stored_accounts, - application.stored_account_references, + self.stable_anchor_account_counter_memory.insert( + anchor_number, + StorableAccountsCounter { + stored_accounts: anchor_accounts, + stored_account_references: anchor_references, + }, ); + self.stable_application_memory.insert( application_number, StorableApplication { origin: application.origin, - stored_accounts, - stored_account_references, + stored_accounts: application_accounts, + stored_account_references: application_references, }, ); @@ -2558,11 +2574,17 @@ impl ReferenceListDeltas { self.accounts == 0 && self.references == 0 } - fn apply(&self, accounts: u64, references: u64) -> (u64, u64) { - ( - accounts.saturating_add_signed(self.accounts), - references.saturating_add_signed(self.references), - ) + /// Refuses rather than clamping: an under-run means the counters and the stored + /// lists have already diverged, and a clamped zero reads as "no anchor references + /// this application any more", which retires a row other anchors still point at. + fn apply(&self, accounts: u64, references: u64) -> Result<(u64, u64), StorageError> { + let accounts = accounts + .checked_add_signed(self.accounts) + .ok_or(StorageError::AccountCounterOutOfBounds)?; + let references = references + .checked_add_signed(self.references) + .ok_or(StorageError::AccountCounterOutOfBounds)?; + Ok((accounts, references)) } } @@ -2598,6 +2620,7 @@ pub enum StorageError { }, ErrorUpdatingAccountCounter, AccountsCounterOverflow, + AccountCounterOutOfBounds, EmptyAccountReferenceList { anchor_number: AnchorNumber, application_number: ApplicationNumber, @@ -2665,6 +2688,10 @@ impl fmt::Display for StorageError { ), Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), Self::AccountsCounterOverflow => write!(f, "No account numbers left to allocate"), + Self::AccountCounterOutOfBounds => write!( + f, + "An account counter delta would move the count outside its range" + ), Self::EmptyAccountReferenceList { anchor_number, application_number, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 3e2762eaae..ba260654ad 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2165,6 +2165,51 @@ mod reference_list_write_path_tests { assert!(matches!(result, Err(StorageError::AccountsCounterOverflow))); } + #[test] + fn refuses_a_counter_delta_that_would_underflow_without_writing_anything() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + let default_reference = AccountReference { + account_number: None, + last_used: None, + }; + let named_reference = AccountReference { + account_number: Some(1), + last_used: None, + }; + storage + .write_reference_list( + anchor_number, + application_number, + vec![default_reference.clone(), named_reference], + ) + .unwrap(); + + // Force the divergence this guards: the stored list holds two references the + // anchor counter no longer knows about, so dropping one under-runs it. + storage.set_counters_for_testing(anchor_number, 0, 0); + + let result = storage.write_reference_list( + anchor_number, + application_number, + vec![default_reference], + ); + + assert!(matches!( + result, + Err(StorageError::AccountCounterOutOfBounds) + )); + // Refused before anything was written: the list still holds both references. + assert_eq!( + storage + .lookup_account_references(anchor_number, application_number) + .unwrap() + .len(), + 2 + ); + } + #[test] fn rejects_writing_an_empty_list() { let (mut storage, anchor_number) = storage_with_anchor(); From ae0ac7b860cc6d9d09f8bfef982a4b8c6c633856 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 23:08:45 +0200 Subject: [PATCH 055/298] docs(be): say why the allocator takes a maximum, not a row count The row count is exact only for data written before this counter existed. Once retiring an application removes a row without reissuing its number the count undershoots, and the maximum is what keeps allocation monotonic across both. --- src/internet_identity/src/storage.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 8e2636d168..b667e1c9ec 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -662,8 +662,14 @@ impl Storage { storage } - /// Existing application numbers are dense from zero, so the row count is the - /// first free number. + /// Seeds the allocator from whichever is higher: the stored counter, or the row + /// count. + /// + /// The row count is a floor rather than the answer. It is exact only for data + /// written before this counter existed, where numbering was dense from zero. + /// Retiring an application removes its row without reissuing its number, so from + /// then on the count undershoots and only the stored counter is right — taking the + /// maximum is what keeps allocation monotonic across both. fn seed_application_number_allocator(&mut self) { let seeded = ApplicationNumber::max( *self.next_application_number_memory.get(), From 81da237fedf3048f492e1c44f25bc5bf11ae3c61 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 23:14:12 +0200 Subject: [PATCH 056/298] fix(be): nothing is written until the operation is known to succeed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three paths minted rows before the check that could refuse. The worst is set_default_account_for_origin: the application row went in first and the named account was validated after, so every NoSuchAccount left a row behind — and nothing reaps it, because a row is only retired when a reference list is written and that path never writes one. The origin is the caller's to choose, so it was one stranded row per call, unbounded. create_default_account did the same across more state: an account number that is never reissued, the stored account, the application row and its config, all committed before the MissingAccount that its own comment predicts. create_additional_account only leaks on a storage fault, but the allocation moves ahead of the writes so all three read the same way. --- .../src/account_management.rs | 21 ++++++-- src/internet_identity/src/storage.rs | 50 +++++++++++-------- src/internet_identity/src/storage/tests.rs | 38 ++++++++++++++ 3 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 7ba789eeaa..9c490316e6 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -160,11 +160,18 @@ pub fn set_default_account_for_origin( account_number: Option, ) -> Result { check_frontend_length(&origin); - let application_number = storage_borrow_mut(|storage| { - storage.lookup_or_insert_application_number_with_origin(&origin) - }); + // Nothing is written until the account is known to exist. Minting the application + // row first left one behind on every refusal, and nothing reaps it: a row is only + // retired when a reference list is written, and this path never writes one. The + // origin is the caller's to choose, so that is a row per call, unbounded. let account = if let Some(account_number) = account_number { + let application_number = + storage_borrow(|storage| storage.lookup_application_number_with_origin(&origin)) + .ok_or_else(|| SetDefaultAccountError::NoSuchAccount { + anchor_number, + origin: origin.clone(), + })?; try_read_account_info( anchor_number, origin.clone(), @@ -173,12 +180,16 @@ pub fn set_default_account_for_origin( ) .map_err(|_| SetDefaultAccountError::NoSuchAccount { anchor_number, - origin, + origin: origin.clone(), })? } else { - Account::synthetic(anchor_number, origin).to_info() + Account::synthetic(anchor_number, origin.clone()).to_info() }; + let application_number = storage_borrow_mut(|storage| { + storage.lookup_or_insert_application_number_with_origin(&origin) + }); + let config = AnchorApplicationConfig { default_account_number: account_number, }; diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index fe5e08580d..767545592a 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2132,8 +2132,12 @@ impl Storage { let anchor_number = params.anchor_number; let origin = ¶ms.origin; - // Create and store account in stable memory + // The one step that can refuse runs before anything is stored, so a refusal does + // not leave an account and an application row behind with no reference to reap + // them by. let account_number = self.allocate_account_number()?; + + // Create and store account in stable memory let storable_account = StorableAccount { name: params.name.clone(), seed_from_anchor: None, @@ -2400,6 +2404,28 @@ impl Storage { origin, } = params; + // Read and check before anything is written. Allocating a number and storing the + // account first left both behind on a refusal, along with the application row and + // its config, and nothing reaps them. + let existing_references = self + .lookup_application_number_with_origin(&origin) + .and_then(|application_number| { + self.stable_account_reference_list_memory + .get(&(anchor_number, application_number)) + .map(Vec::::from) + }); + if existing_references.as_ref().is_some_and(|references| { + !references + .iter() + .any(|reference| reference.account_number.is_none()) + }) { + // The default reference was removed, so there is nothing here to name. + return Err(StorageError::MissingAccount { + anchor_number, + name, + }); + } + // Create and store the default account. let new_account_number = self.allocate_account_number()?; let storable_account = StorableAccount { @@ -2423,11 +2449,7 @@ impl Storage { self.set_anchor_application_config(anchor_number, application_number, config); } - let account_references_key = (anchor_number, application_number); - let references = match self - .stable_account_reference_list_memory - .get(&account_references_key) - { + let references = match existing_references { None => { // If no list exists for this anchor & application, // Create and insert the default account. @@ -2438,26 +2460,14 @@ impl Storage { last_used: None, }] } - Some(existing_storable_list) => { - // If the list exists, update the default account reference with the new account number. - let mut refs_vec: Vec = existing_storable_list.into(); - let mut found_and_updated = false; + Some(mut refs_vec) => { + // The check above proved a default reference is here to update. for r_mut in refs_vec.iter_mut() { if r_mut.account_number.is_none() { - // Found the default account reference. r_mut.account_number = Some(new_account_number); - found_and_updated = true; break; } } - - // This could happen if the account was removed and now we try to update it. - if !found_and_updated { - return Err(StorageError::MissingAccount { - anchor_number, - name: name.clone(), - }); - } refs_vec } }; diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 80f833ea0c..719294726f 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2181,6 +2181,44 @@ mod reference_list_write_path_tests { assert!(matches!(result, Err(StorageError::AccountsCounterOverflow))); } + #[test] + fn a_refused_default_account_rename_leaves_nothing_behind() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + // A list with no default reference: the default was removed, so there is nothing + // for a rename to name. + storage + .write_reference_list( + anchor_number, + application_number, + vec![AccountReference { + account_number: Some(1), + last_used: None, + }], + ) + .unwrap(); + let accounts_before = storage.stable_account_memory.len(); + let config_before = + storage.lookup_anchor_application_config(anchor_number, application_number); + + let result = storage.create_default_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }); + + assert!(matches!(result, Err(StorageError::MissingAccount { .. }))); + // No account number burned, no account stored, no config rewritten. + assert_eq!(storage.stable_account_memory.len(), accounts_before); + assert_eq!( + storage + .lookup_anchor_application_config(anchor_number, application_number) + .default_account_number, + config_before.default_account_number + ); + } + #[test] fn refuses_a_counter_delta_that_would_underflow_without_writing_anything() { let (mut storage, anchor_number) = storage_with_anchor(); From 6a6528206a62dfa38394adadd22cc69a8f42c14b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 23:17:10 +0200 Subject: [PATCH 057/298] test(be): a refused set_default_account mints no application row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asserted on the application-count metric, which is what a stranded row shows up in — one per call, at an origin the caller picks. --- .../tests/integration/accounts.rs | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 40cd7b0ae5..4b02d1ddf2 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -16,7 +16,7 @@ use canister_tests::{ }; use internet_identity_interface::internet_identity::types::{ AccountDelegationError, AccountInfo, AccountUpdate, GetDelegationResponse, - PrepareAccountDelegation, + PrepareAccountDelegation, SetDefaultAccountError, }; use pocket_ic::RejectResponse; use pretty_assertions::assert_eq; @@ -1602,6 +1602,43 @@ fn should_track_a_chosen_default_account_without_marking_it_used() -> Result<(), } #[test] +/// A refused call must not mint an application row. Nothing reaps one: a row is only +/// retired when a reference list is written, and this path never writes one, so a row +/// left here would stay for the life of the canister — one per call, at an origin the +/// caller chooses. +#[test] +fn should_not_leave_an_application_behind_when_the_named_account_does_not_exist( +) -> 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 (applications_before, _) = parse_metric( + &get_metrics(&env, canister_id), + "internet_identity_total_application_count", + ); + + let result = set_default_account( + &env, + canister_id, + principal_1(), + identity_number, + "https://never-seen-before.com".to_string(), + Some(9_999), + )?; + + assert!(matches!( + result, + Err(SetDefaultAccountError::NoSuchAccount { .. }) + )); + let (applications_after, _) = parse_metric( + &get_metrics(&env, canister_id), + "internet_identity_total_application_count", + ); + assert_eq!(applications_after, applications_before); + + Ok(()) +} + fn should_remove_unreferenced_applications_an_anchor_stops_referencing( ) -> Result<(), RejectResponse> { const EVICTABLE_DEFAULT_ACCOUNTS_CAP: u64 = 500; From 9a5bed36495d8731095fd0e80a4a0188f1f67d30 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 23:26:13 +0200 Subject: [PATCH 058/298] fix(be): restore the test attribute my insertion displaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new test was added between an existing #[test] and its function, which duplicated the attribute on one and left the other unregistered — it had stopped running. --- src/internet_identity/tests/integration/accounts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 4b02d1ddf2..c077d85bc4 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -1601,7 +1601,6 @@ fn should_track_a_chosen_default_account_without_marking_it_used() -> Result<(), Ok(()) } -#[test] /// A refused call must not mint an application row. Nothing reaps one: a row is only /// retired when a reference list is written, and this path never writes one, so a row /// left here would stay for the life of the canister — one per call, at an origin the @@ -1639,6 +1638,7 @@ fn should_not_leave_an_application_behind_when_the_named_account_does_not_exist( Ok(()) } +#[test] fn should_remove_unreferenced_applications_an_anchor_stops_referencing( ) -> Result<(), RejectResponse> { const EVICTABLE_DEFAULT_ACCOUNTS_CAP: u64 = 500; From a0e04e09bb71fd6312822a50c76e049c475c00e6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 23:34:34 +0200 Subject: [PATCH 059/298] fix(be): a concurrent salt initialisation no longer traps the loser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both callers pass the pre-await check, both fetch randomness, and the second to resume trapped on a write the first had already done. It re-checks after the await and discards its own salt instead: they are both random, so which one wins does not matter, and the caller gets the answer it asked for. Returning SaltNotSet from these endpoints instead would be the better shape, but nothing else initialises the salt on a fresh canister — these paths are the initialisation — so that needs a salt-at-install step first. --- src/internet_identity/src/state.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index 86cbd61f21..238f09ee81 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -267,7 +267,14 @@ pub async fn init_salt() { }); let salt = random_salt().await; - storage_borrow_mut(|storage| storage.update_salt(salt)); // update_salt() traps if salt has already been set + storage_borrow_mut(|storage| { + // Re-checked after the await, which is where a second message can have gone all + // the way through. Both salts are random, so the loser discards its own rather + // than trapping a caller whose request was perfectly good. + if storage.salt().is_none() { + storage.update_salt(salt); + } + }); } pub fn salt() -> [u8; 32] { From 0073a57dbc3fb34c4a06e1208f5ed74df836ed0e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 1 Sep 2026 23:47:47 +0200 Subject: [PATCH 060/298] refactor(be): account endpoints refuse an unset salt rather than setting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initialising the salt from a mutating endpoint is what made it racy: two callers both pass the check, both fetch randomness, and the second traps on a write the first already did. A canister sets its salt once, explicitly, so these three ask for it and refuse with SaltNotSet when it is absent — there is no interleaving point left, which is also why they stop being async. The accounts tests now install the way a deployment does, calling init_salt, instead of relying on create_account to do it for them. --- src/internet_identity/src/main.rs | 9 +-- .../tests/integration/accounts.rs | 71 +++++++++++-------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 88bc103f29..17cbbd3371 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -407,12 +407,11 @@ fn get_accounts( } #[update] -async fn create_account( +fn create_account( anchor_number: AnchorNumber, origin: FrontendHostname, name: String, ) -> Result { - state::ensure_salt_set().await; match check_authorization(anchor_number) { Ok(_) => { // check if this anchor and acc are actually linked @@ -424,13 +423,12 @@ async fn create_account( } #[update] -async fn update_account( +fn update_account( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, update: AccountUpdate, ) -> Result { - state::ensure_salt_set().await; match check_authorization(anchor_number) { Ok(_) => account_management::update_account_for_origin( anchor_number, @@ -472,12 +470,11 @@ impl From for SetDefaultAccountError { } #[update] -async fn set_default_account( +fn set_default_account( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, ) -> Result { - state::ensure_salt_set().await; check_authz_and_record_activity(anchor_number).map_err(SetDefaultAccountError::from)?; let result = diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index c077d85bc4..d0e82421ce 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -1,3 +1,4 @@ +use candid::Principal; use canister_tests::{ api::internet_identity::{ api_v2::{ @@ -6,7 +7,7 @@ use canister_tests::{ prepare_account_delegation_with_read_only, set_default_account, update_account, AccountDelegationParams, }, - get_delegation, prepare_delegation, + get_delegation, init_salt, prepare_delegation, }, flows, framework::{ @@ -18,16 +19,24 @@ use internet_identity_interface::internet_identity::types::{ AccountDelegationError, AccountInfo, AccountUpdate, GetDelegationResponse, PrepareAccountDelegation, SetDefaultAccountError, }; -use pocket_ic::RejectResponse; +use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; use serde_bytes::ByteBuf; use std::time::Duration; +/// Installs II the way a deployment does: the salt is set once, explicitly, rather +/// than by whichever request happens to need an account principal first. +fn install_ii_with_salt(env: &PocketIc) -> Principal { + let canister_id = install_ii_with_archive(env, None, None); + init_salt(env, canister_id).expect("failed to initialize the salt"); + canister_id +} + /// Verifies that one account can be created #[test] fn should_create_account() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); let name = "Callisto".to_string(); @@ -59,7 +68,7 @@ fn should_create_account() -> Result<(), RejectResponse> { #[test] fn should_list_accounts() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); let name = "Ganymede".to_string(); @@ -146,7 +155,7 @@ fn should_list_accounts() -> Result<(), RejectResponse> { #[test] fn should_list_default_account() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); @@ -177,7 +186,7 @@ fn should_list_default_account() -> Result<(), RejectResponse> { #[test] fn should_list_only_own_accounts() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let another_identity_number = flows::register_anchor_with_device(&env, canister_id, &device_data_2()); @@ -289,7 +298,7 @@ fn should_list_only_own_accounts() -> Result<(), RejectResponse> { #[test] fn should_update_account() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); let name = "Callisto".to_string(); @@ -341,7 +350,7 @@ fn should_update_account() -> Result<(), RejectResponse> { #[should_panic] fn should_not_update_numberless_account_twice() { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); let name = Some("Icarus".to_string()); @@ -377,7 +386,7 @@ fn should_not_update_numberless_account_twice() { #[test] fn should_update_default_account() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); let name = "Callisto".to_string(); @@ -458,7 +467,7 @@ fn should_update_default_account() -> Result<(), RejectResponse> { #[should_panic] fn should_only_update_owned_account() { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let another_identity_number = flows::register_anchor_with_device(&env, canister_id, &device_data_2()); @@ -517,7 +526,7 @@ fn should_only_update_owned_account() { fn should_get_read_only_account_delegation_with_queries_permissions() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -568,7 +577,7 @@ fn should_get_read_only_account_delegation_with_queries_permissions() -> Result< fn should_default_to_unrestricted_account_delegation_when_unspecified() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -613,7 +622,7 @@ fn should_default_to_unrestricted_account_delegation_when_unspecified() -> Resul #[test] fn should_issue_explicitly_unrestricted_account_delegation() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let pub_session_key = ByteBuf::from("session public key"); let params = AccountDelegationParams::new( @@ -649,7 +658,7 @@ fn should_issue_explicitly_unrestricted_account_delegation() -> Result<(), Rejec #[test] fn should_issue_read_only_delegation_for_non_default_account() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -703,7 +712,7 @@ fn should_issue_read_only_delegation_for_non_default_account() -> Result<(), Rej #[test] fn read_only_does_not_change_the_delegated_principal() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let params = AccountDelegationParams::new( &env, @@ -731,7 +740,7 @@ fn read_only_does_not_change_the_delegated_principal() -> Result<(), RejectRespo #[test] fn should_get_valid_account_delegation() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -771,7 +780,7 @@ fn should_get_valid_account_delegation() -> Result<(), RejectResponse> { #[test] fn should_get_matching_principals() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -854,7 +863,7 @@ fn should_get_matching_principals() -> Result<(), RejectResponse> { #[test] fn should_get_valid_account_delegation_with_custom_expiration() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -896,7 +905,7 @@ fn should_get_valid_account_delegation_with_custom_expiration() -> Result<(), Re #[test] fn should_shorten_account_delegation_expiration_greater_max_ttl() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -943,7 +952,7 @@ fn should_shorten_account_delegation_expiration_greater_max_ttl() -> Result<(), fn should_get_multiple_valid_account_delegations() -> Result<(), RejectResponse> { let env = env(); let root_key = env.root_key().unwrap(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname_1 = "https://dapp1.com".to_string(); let frontend_hostname_2 = "https://dapp2.com".to_string(); @@ -1026,7 +1035,7 @@ fn should_get_multiple_valid_account_delegations() -> Result<(), RejectResponse> #[test] fn should_issue_different_principals_for_account_delegations() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let pub_session_key = ByteBuf::from("session public key"); let frontend_hostname_1 = "https://dapp1.com".to_string(); @@ -1074,7 +1083,7 @@ fn should_issue_different_principals_for_account_delegations() -> Result<(), Rej #[test] fn can_not_prepare_account_delegation_for_different_user() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -1102,7 +1111,7 @@ fn can_not_prepare_account_delegation_for_different_user() -> Result<(), RejectR #[test] fn can_not_get_account_delegation_for_different_user() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -1143,7 +1152,7 @@ fn can_not_get_account_delegation_for_different_user() -> Result<(), RejectRespo #[test] fn should_not_get_account_delegation_after_expiration() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -1180,7 +1189,7 @@ fn should_not_get_account_delegation_after_expiration() -> Result<(), RejectResp #[test] fn should_issue_different_principals_for_different_accounts() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -1253,7 +1262,7 @@ fn should_issue_different_principals_for_different_accounts() -> Result<(), Reje #[test] fn should_update_last_used_after_prepare_account_delegation() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -1354,7 +1363,7 @@ fn should_update_last_used_after_prepare_account_delegation() -> Result<(), Reje #[test] fn should_update_last_used_independently_for_different_accounts() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname = "https://some-dapp.com".to_string(); let pub_session_key = ByteBuf::from("session public key"); @@ -1519,7 +1528,7 @@ fn should_update_last_used_independently_for_different_accounts() -> Result<(), #[test] fn should_track_the_default_account_on_first_sign_in() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://untouched-dapp.com".to_string(); @@ -1558,7 +1567,7 @@ fn should_track_the_default_account_on_first_sign_in() -> Result<(), RejectRespo #[test] fn should_track_a_chosen_default_account_without_marking_it_used() -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://untouched-dapp.com".to_string(); let (references_before, _) = parse_metric( @@ -1609,7 +1618,7 @@ fn should_track_a_chosen_default_account_without_marking_it_used() -> Result<(), fn should_not_leave_an_application_behind_when_the_named_account_does_not_exist( ) -> Result<(), RejectResponse> { let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let (applications_before, _) = parse_metric( &get_metrics(&env, canister_id), @@ -1644,7 +1653,7 @@ fn should_remove_unreferenced_applications_an_anchor_stops_referencing( const EVICTABLE_DEFAULT_ACCOUNTS_CAP: u64 = 500; let env = env(); - let canister_id = install_ii_with_archive(&env, None, None); + let canister_id = install_ii_with_salt(&env); let identity_number = flows::register_anchor(&env, canister_id); let evicted_origin = "https://dapp-0.com".to_string(); From 4b616fa968ed216f6c7dc2f4ab2e95a5ca1e7871 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 2 Sep 2026 00:12:45 +0200 Subject: [PATCH 061/298] test(be): three more suites install the salt the way a deployment does They reached account endpoints that used to initialise the salt on their way through. Now that those refuse instead, the setup has to say so. --- src/internet_identity/tests/integration/http.rs | 2 ++ src/internet_identity/tests/integration/mcp.rs | 6 +++++- .../tests/integration/session_delegation.rs | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/tests/integration/http.rs b/src/internet_identity/tests/integration/http.rs index 202b437b3d..7b70a214d1 100644 --- a/src/internet_identity/tests/integration/http.rs +++ b/src/internet_identity/tests/integration/http.rs @@ -884,6 +884,8 @@ fn should_report_registration_rates() -> Result<(), RejectResponse> { fn should_report_total_account_metrics() -> Result<(), RejectResponse> { let env = env(); let canister_id = install_ii_canister(&env, II_WASM.clone()); + // A deployment sets the salt once; account principals cannot be derived without it. + api::init_salt(&env, canister_id)?; let identity_number = flows::register_anchor(&env, canister_id); let origin = "https://some-dapp.com".to_string(); let name = "Callisto".to_string(); diff --git a/src/internet_identity/tests/integration/mcp.rs b/src/internet_identity/tests/integration/mcp.rs index d637bae8fe..a1687a43a2 100644 --- a/src/internet_identity/tests/integration/mcp.rs +++ b/src/internet_identity/tests/integration/mcp.rs @@ -47,7 +47,11 @@ const GRANT_TTL_NS: u64 = 24 * 60 * 60 * 1_000_000_000; // The `/mcp` path is not gated by a global config; each identity trusts the // server it chooses via its synced config. So a plain install suffices. fn install_with_mcp(env: &PocketIc) -> Principal { - install_ii_canister_with_arg(env, II_WASM.clone(), None) + let canister_id = install_ii_canister_with_arg(env, II_WASM.clone(), None); + // A deployment sets the salt once; account principals cannot be derived without it. + canister_tests::api::internet_identity::init_salt(env, canister_id) + .expect("failed to initialize the salt"); + canister_id } /// The official MCP connector a deployment can ship. Deliberately a different diff --git a/src/internet_identity/tests/integration/session_delegation.rs b/src/internet_identity/tests/integration/session_delegation.rs index 0d5f9c90b9..48d6be6e51 100644 --- a/src/internet_identity/tests/integration/session_delegation.rs +++ b/src/internet_identity/tests/integration/session_delegation.rs @@ -54,6 +54,9 @@ fn fresh_anchor() -> ( ) { let env = env(); let canister_id = install_ii_canister(&env, II_WASM.clone()); + // A deployment sets the salt once; account principals cannot be derived without it. + canister_tests::api::internet_identity::init_salt(&env, canister_id) + .expect("failed to initialize the salt"); let anchor = flows::register_anchor(&env, canister_id); (env, canister_id, anchor) } From fc5f96bcba56d6c1d139eea736a891b69247daef Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 2 Sep 2026 00:16:32 +0200 Subject: [PATCH 062/298] feat(be): the backfill counts the rows it could not index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reference-list row whose application is gone derives no principal, so the sweep passes over it — and with only an indexed count, a run that passed over everything looked like a run with nothing to do. The count is reported beside it, and the completion log says both. The salt-absent early return keeps the sweep running rather than reporting completion, which is what it already did; it now says so. --- .../src/api/internet_identity/api_v2.rs | 6 +++--- src/internet_identity/src/main.rs | 18 +++++++++++++++--- src/internet_identity/src/storage.rs | 7 +++++++ .../tests/integration/accounts.rs | 4 +++- 4 files changed, 28 insertions(+), 7 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 c45aa2af99..fdcd1d1067 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -747,13 +747,13 @@ pub fn get_account_delegation_with_read_only( .map(|(x,)| x) } -/// Hidden monitoring endpoint: `(indexed_entries, is_done)` for the account -/// principal index backfill. +/// Hidden monitoring endpoint: `(indexed_entries, skipped_rows, is_done)` for the +/// account principal index backfill. pub fn account_principal_index_backfill_status( env: &PocketIc, canister_id: CanisterId, sender: Principal, -) -> Result<(u64, bool), RejectResponse> { +) -> Result<(u64, u64, bool), RejectResponse> { query_candid_as( env, canister_id, diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 3e569fb71b..67667c0a1f 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -842,14 +842,20 @@ thread_local! { static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR: RefCell> = const { RefCell::new(None) }; static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE: RefCell = const { RefCell::new(false) }; static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED: RefCell = const { RefCell::new(0) }; + static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED: RefCell = const { RefCell::new(0) }; static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID: RefCell> = const { RefCell::new(None) }; } -/// Returns `(indexed_entries, is_done)` so monitoring can track the sweep. +/// Returns `(indexed_entries, skipped_rows, is_done)` so monitoring can track the sweep. +/// +/// A non-zero skip count is not progress: it is reference-list rows whose application +/// is gone, which the sweep cannot derive a principal for. A run that reports nothing +/// indexed and nothing skipped had nothing to do; one that reports skips did not. #[query(hidden = true)] -fn account_principal_index_backfill_status() -> (u64, bool) { +fn account_principal_index_backfill_status() -> (u64, u64, bool) { ( ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow(|indexed| *indexed), + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED.with_borrow(|skipped| *skipped), ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.with_borrow(|done| *done), ) } @@ -870,6 +876,9 @@ fn run_account_principal_index_backfill_batch() { ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow_mut(|indexed| { *indexed = indexed.saturating_add(outcome.indexed); }); + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED.with_borrow_mut(|skipped| { + *skipped = skipped.saturating_add(outcome.skipped); + }); ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR.replace(outcome.next_cursor); if outcome.is_done { @@ -880,7 +889,10 @@ fn run_account_principal_index_backfill_batch() { } }); let indexed = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow(|indexed| *indexed); - ic_cdk::println!("Account principal index backfill COMPLETED ({indexed} entries)."); + let skipped = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED.with_borrow(|skipped| *skipped); + ic_cdk::println!( + "Account principal index backfill COMPLETED ({indexed} entries, {skipped} skipped)." + ); } } diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 2d4a00b975..e15ff9117a 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1998,6 +1998,8 @@ impl Storage { outcome.is_done = true; return outcome; } + // Not done, so the caller comes back. A canister whose salt is unset has not + // finished starting up rather than finished backfilling. let Some(salt) = self.salt().copied() else { return outcome; }; @@ -2026,6 +2028,7 @@ impl Storage { .get(&application_number) .map(|application| application.origin) else { + outcome.skipped += 1; continue; }; @@ -2950,6 +2953,10 @@ impl Storage { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option<(AnchorNumber, ApplicationNumber)>, pub indexed: u64, + /// Rows whose application is gone, so no principal can be derived for them. A row + /// in that state is an inconsistency rather than a normal skip, and a run that + /// silently indexes nothing would otherwise look like a run with nothing to do. + pub skipped: u64, pub is_done: bool, } diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 1690a5fae4..ea3b705d47 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -1751,13 +1751,15 @@ fn should_backfill_the_account_principal_index_after_an_upgrade() -> Result<(), env.tick(); } - let (indexed, is_done) = + let (indexed, skipped, is_done) = account_principal_index_backfill_status(&env, canister_id, principal_1())?; assert!(is_done, "the backfill should report completion"); assert_eq!( indexed, 6, "three named accounts, each alongside the default reference backfilled with it" ); + // Every row had its application, so nothing was passed over. + assert_eq!(skipped, 0); Ok(()) } From 8398b209827064a1842e8039512d2d0a2cd7e247 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 2 Sep 2026 00:21:55 +0200 Subject: [PATCH 063/298] fix(be): unindex a row's sessions before the counters retire its application A session's principal is derived through the application row, and applying the deltas is what removes it. Unindexing afterwards found nothing to derive the keys from and left every entry behind, which the eviction test caught. --- src/internet_identity/src/storage.rs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index cc776f909a..e7cd12044e 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2163,21 +2163,13 @@ impl Storage { .map(|reference| reference.sessions.len()) .sum(); - // Both of these can refuse, so both run before anything is removed. - let deltas = ReferenceListDeltas::between(&previous, &[]); - self.apply_reference_counter_deltas( - anchor_number, - application_number, - application, - deltas, - )?; - if dropped > 0 { - self.change_session_count(anchor_number, dropped, 0)?; - } - // 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. + // + // Ahead of the counters because a session's principal is derived through the + // application row, and applying the deltas is what retires it — afterwards there + // would be nothing left to derive the keys to remove. for reference in &previous { self.unindex_sessions( anchor_number, @@ -2186,6 +2178,18 @@ impl Storage { &reference.sessions, ); } + + let deltas = ReferenceListDeltas::between(&previous, &[]); + self.apply_reference_counter_deltas( + anchor_number, + application_number, + application, + deltas, + )?; + if dropped > 0 { + self.change_session_count(anchor_number, dropped, 0)?; + } + self.sync_account_principal_index( anchor_number, application_number, From fd5b7641148184b7bf9129bb9f9cd9384bae0617 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 2 Sep 2026 00:44:02 +0200 Subject: [PATCH 064/298] fix(be): the reference-list tests carry the sessions field this PR adds They arrive from the write-path stack below, which predates the field. --- src/internet_identity/src/storage/tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 435c88612b..d80adccb76 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2199,6 +2199,7 @@ mod reference_list_write_path_tests { vec![AccountReference { account_number: Some(1), last_used: None, + sessions: vec![], }], ) .unwrap(); @@ -2231,10 +2232,12 @@ mod reference_list_write_path_tests { let default_reference = AccountReference { account_number: None, last_used: None, + sessions: vec![], }; let named_reference = AccountReference { account_number: Some(1), last_used: None, + sessions: vec![], }; storage .write_reference_list( From c2c3f4d62b0dfa1bf0d24f3fda0c52ffbfb8e153 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 2 Sep 2026 18:37:49 +0200 Subject: [PATCH 065/298] feat(be): an app may ask how long its session can go unused `max_idle` on the request, passed to `create_session` where `None` was hardcoded. Without it the field existed on the record and no caller could ever set it, so the canister's default was the only value a session ever had. --- .../lib/generated/internet_identity_idl.js | 1 + .../generated/internet_identity_types.d.ts | 6 ++++ src/internet_identity/internet_identity.did | 4 +++ src/internet_identity/src/sessions.rs | 3 +- .../tests/integration/sessions.rs | 33 +++++++++++++++++++ .../src/internet_identity/types.rs | 4 +++ 6 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index d0bc00d389..25be6a94fb 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -705,6 +705,7 @@ export const idlFactory = ({ IDL }) => { }); const PrepareAccountSessionRequest = IDL.Record({ 'permissions' : IDL.Opt(Permissions), + 'max_idle' : IDL.Opt(IDL.Nat64), 'current_device_key' : PublicKey, 'session_key' : SessionKey, 'valid_for' : IDL.Opt(IDL.Nat64), 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 fc7f76e0a0..c6afd3b1f6 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1343,6 +1343,12 @@ export interface PrepareAccountSessionRequest { * The consented access level, fixed for the session's life. */ 'permissions' : [] | [Permissions], + /** + * How long the session may go unminted before it is over, clamped to between + * 10 minutes and the session's own granted length. Absent leaves the + * canister's own default. + */ + 'max_idle' : [] | [bigint], /** * 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. diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 7afbba9632..285dc1b34c 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1040,6 +1040,10 @@ type PrepareAccountSessionRequest = record { permissions : opt Permissions; // Clamped to the session maximum. valid_for : opt nat64; + // How long the session may go unminted before it is over, clamped to between + // 10 minutes and the session's own granted length. Absent leaves the + // canister's own default. + max_idle : opt nat64; }; type PrepareAccountSessionResponse = record { diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 2b9aaa7b72..c22b6c4743 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -76,6 +76,7 @@ pub async fn prepare_account_session( next_device_key_signature, permissions, valid_for, + max_idle, } = request; check_authz_and_record_activity(identity_number)?; @@ -157,7 +158,7 @@ pub async fn prepare_account_session( account_number, device_id, valid_till_ns: valid_till, - max_idle_ns: None, + max_idle_ns: max_idle, read_only, now_ns: now, }) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 20690572c7..d89c759ae0 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -45,6 +45,7 @@ fn session_request_from( session_key, permissions: None, valid_for: None, + max_idle: None, } } @@ -568,6 +569,38 @@ fn should_refuse_a_successor_equal_to_the_key_presented() -> Result<(), RejectRe Ok(()) } +/// The idle bound is the app's to ask for. Absent it defaults, and a value below the +/// floor is clamped up rather than refused, so a short request still yields a usable +/// session. +#[test] +fn should_store_the_requested_idle_bound() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + canister_tests::api::internet_identity::init_salt(&env, canister_id)?; + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let mut request = session_request_from(identity_number, &browser); + // Under the ten-minute floor, so the clamp is what makes this session usable. + request.max_idle = Some(60_000_000_000); + prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); + + // A session clamped up to the floor still mints, which is the point of clamping + // rather than refusing. + let devices = canister_tests::api::internet_identity::api_v2::identity_info( + &env, + canister_id, + principal_1(), + identity_number, + )? + .unwrap() + .session_devices + .unwrap_or_default(); + assert_eq!(devices.len(), 1); + + 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] diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 38f23c3cca..2e5c5c5687 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -779,6 +779,10 @@ pub struct PrepareAccountSessionRequest { pub permissions: Option, /// Clamped to the session maximum. pub valid_for: Option, + /// How long the session may go unminted before it is over. Clamped to between + /// 10 minutes and the session's own granted length; absent leaves the + /// canister's own default. + pub max_idle: Option, } #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] From 7ac1a656e22a90b5f97045da7b735376634ddba2 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 2 Sep 2026 18:38:40 +0200 Subject: [PATCH 066/298] feat(fe): carry the app's idle bound to the canister MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request parsed maxTimeToLive and dropped maxTimeToIdle, so zod stripped it and prepare_account_session was called without one — a client could ask for a bound and always get the canister's default instead. --- .../lib/stores/channelHandlers/sessionDelegation.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 2e54fd16a3..dbf0f370a6 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -42,6 +42,10 @@ const SessionParamsCodec = z.object({ // request: what the user picks at consent wins, an SSO organization's cap // narrows it further, and the canister clamps the result. maxTimeToLive: z.optional(StringToBigIntCodec), + // How long the session may go unminted before the canister ends it. A ceiling + // like `maxTimeToLive`: the canister clamps it to between 10 minutes and the + // session's own granted length, and applies its own default where absent. + maxTimeToIdle: z.optional(StringToBigIntCodec), icrc95DerivationOrigin: z.optional(OriginSchema), }); @@ -169,6 +173,7 @@ export const handleSessionDelegationRequest = const created = await createSession( effectiveOrigin, params.maxTimeToLive, + params.maxTimeToIdle, ); const chain = await extendToApp( created.record, @@ -191,6 +196,7 @@ export const handleSessionDelegationRequest = const createSession = async ( effectiveOrigin: string, requestedMaxTimeToLive: bigint | undefined, + requestedMaxTimeToIdle: bigint | undefined, ): Promise<{ record: AppSessionRecord }> => { authorizationStore.setRequestContext(effectiveOrigin, requestedMaxTimeToLive); const authorized = await waitForStore(authorizedStore); @@ -236,6 +242,12 @@ const createSession = async ( // 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] : [], + // Straight through: the bound is the app's to ask for and the + // canister's to clamp, and nothing at consent narrows it. + max_idle: + requestedMaxTimeToIdle !== undefined + ? [requestedMaxTimeToIdle] + : [], }) .then(throwCanisterError); await browser.accept(prepared.device_id); From 791cb4ed3d0e04a7956a574d28bee88a7833a996 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 13:17:17 +0200 Subject: [PATCH 067/298] refactor(be): make a reference row's three states a type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row at `(anchor, application)` has three states that mean three different things: absent, empty, and holding references. Absent says a default account is still reconstructible; empty is a tombstone and says it never can be again. Both arrived at callers as `Option>`, which spells the two opposites as one nullable vector, and three overlapping readers each re-derived the distinction by hand. `ReferenceRow` names the three states and `HeldReferences` carries a non-empty list, so: - `reference_row` is the one place storage is mapped onto them, replacing `lookup_account_references`, `find_account_references` and `find_account_reference`. - `write_reference_list` takes `HeldReferences`, so its runtime empty-list refusal (and `StorageError::EmptyAccountReferenceList`) is gone — an empty list can no longer be handed to it. - Every reader matches exhaustively, so the tombstone arm is stated rather than implied. `with_account_mut` splits along the same line. `with_reference_mut` takes the reference alone; `with_named_account_mut` takes a named account and its record, dropping the two unreachable `Option`s `update_existing_account` had to unwrap. The account record is written only once the reference has granted access, so a miss cannot rewrite the record of whichever identity does own the account. `create_default_account` decides which reference the new number goes into before it writes anything. Returning `Err` on the IC commits every write that came before it, so its `MissingAccount` refusal used to leave an allocated account stored, and the config pointing at it, with no reference naming it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 681 +++++++++++---------- src/internet_identity/src/storage/tests.rs | 372 +++++++++-- 2 files changed, 673 insertions(+), 380 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 68467bfd93..be2a5210e9 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -115,7 +115,6 @@ use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; use crate::storage::storable::account::StorableAccount; use crate::storage::storable::account_number::StorableAccountNumber; -use crate::storage::storable::account_reference::StorableAccountReference; use crate::storage::storable::accounts_counter::StorableAccountsCounter; use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; use crate::storage::storable::application::StorableOriginSha256; @@ -1527,140 +1526,107 @@ impl Storage { .and_then(|application_number| self.stable_application_memory.get(&application_number)) } - fn lookup_account_references( + /// What this identity's reference-list row at `application_number` holds. + /// + /// The one place that maps storage onto [`ReferenceRow`], so no caller has to + /// remember that an absent row and an empty one mean opposite things. + fn reference_row( &self, anchor_number: AnchorNumber, application_number: ApplicationNumber, - ) -> Option> { - self.stable_account_reference_list_memory + ) -> ReferenceRow { + match self + .stable_account_reference_list_memory .get(&(anchor_number, application_number)) - .map(|list| list.into_vec()) + { + None => ReferenceRow::Untouched, + Some(stored) => { + let references = Vec::::from(stored); + match HeldReferences::new(references) { + Some(held) => ReferenceRow::Held(held), + None => ReferenceRow::Tombstone, + } + } + } } - fn find_account_references( - &self, + /// Applies `f` to one of this identity's account references, and writes the row + /// back if it ran. + /// + /// `account_number` names the reference, `None` being the tracked default. + /// + /// `Ok(None)` means there was nothing to apply `f` to and nothing was written: + /// the row is absent or a tombstone, or it holds no reference for this account. + /// That last case is the ownership check — an account belongs to whichever + /// identity's row names it. + fn with_reference_mut( + &mut self, anchor_number: AnchorNumber, - application_number: Option, - ) -> Option<( - (AnchorNumber, ApplicationNumber), - Vec, - )> { - let application_number = application_number?; - - let key = (anchor_number, application_number); - - let account_references = self.stable_account_reference_list_memory.get(&key)?; + application_number: ApplicationNumber, + account_number: Option, + f: F, + ) -> Result, StorageError> + where + F: FnOnce(&mut AccountReference) -> T, + { + let ReferenceRow::Held(mut references) = + self.reference_row(anchor_number, application_number) + else { + return Ok(None); + }; - Some((key, account_references.into_vec())) - } + let Some(reference) = references.reference_mut(account_number) else { + // `f` never ran, so nothing changed, and writing the row back here would + // store the bytes it already holds. + return Ok(None); + }; + let result = f(reference); - fn find_account_reference( - &self, - anchor_number: AnchorNumber, - application_number: Option, - account_number: Option, - ) -> Option { - let (_, account_references) = - self.find_account_references(anchor_number, application_number)?; + self.write_reference_list(anchor_number, application_number, references)?; - account_references - .into_iter() - .find(|account_reference| account_reference.account_number == account_number) + Ok(Some(result)) } - /// Search for an account and account_reference and applies the function `f` if found. - /// - /// - /// The function `f` is called with a mutable reference to the account reference and an option to the mutable reference to the account. + /// Applies `f` to one of this identity's named accounts and the reference to it, + /// and writes both back if it ran. /// - /// # Arguments + /// The tracked default is not reachable here: it is derived and has no stored + /// account record, so only a named account can be handed to `f`. /// - /// * `anchor_number` - The anchor number of the account. - /// * `application_number` - The application number of the account. - /// * `account_number` - The account number of the account or None if synthetic account. - /// * `f` - The function to apply to the accounts. - /// - /// If the `account_number` is None, it means the storable account doesn't exist and account reference might exist. - /// * If the account reference exists, the function `f` is called with a mutable reference to the account reference and None as the second argument. - /// * If the account reference does not exist, None is returned. - /// - /// If the `account_number` is Some, it means the storable account exists (or existed at some point) and account references exists (or existed at some point). - /// * If the storable account exists, the function `f` is called with a mutable reference to the account reference and a mutable reference to the storable account. - /// * If the storable account does not exist, None is returned. - /// - /// # Returns - /// - /// * `Ok(None)` if both account and account_reference are not found - /// * `Ok(Some(T))` if the account or account_reference are found where T is the result of the function `f`. - /// * `Err` if writing the reference list back failed - fn with_account_mut( + /// `Ok(None)` carries the same meaning as in [`Self::with_reference_mut`], plus + /// the account having been removed. + fn with_named_account_mut( &mut self, anchor_number: AnchorNumber, - application_number: Option, - maybe_account_number: Option, + application_number: ApplicationNumber, + account_number: AccountNumber, f: F, ) -> Result, StorageError> where - F: FnOnce(&mut StorableAccountReference, Option<&mut StorableAccount>) -> T, + F: FnOnce(&mut AccountReference, &mut StorableAccount) -> T, { - match maybe_account_number { - None => { - // We are looking for a synthetic account - let Some(((_, application_number), mut account_references)) = - self.find_account_references(anchor_number, application_number) - else { - return Ok(None); - }; - - let mut result = None; - - for account_reference in &mut account_references { - if account_reference.account_number == maybe_account_number { - result = Some(f(account_reference, None)); - break; - } - } - - self.write_reference_list( - anchor_number, - application_number, - account_references.into_iter().map(Into::into).collect(), - )?; - - Ok(result) - } - Some(account_number) => { - // Account should be stored, otherwise, it was removed and we'll return `None`. - let Some(mut storable_account) = self.stable_account_memory.get(&account_number) - else { - return Ok(None); - }; - let Some(((_, application_number), mut account_references)) = - self.find_account_references(anchor_number, application_number) - else { - return Ok(None); - }; - - let mut result = None; - - for account_reference in &mut account_references { - if account_reference.account_number == maybe_account_number { - result = Some(f(account_reference, Some(&mut storable_account))); - break; - } - } + // A named account with no stored record has been removed, so there is nothing + // to modify. + let Some(mut storable_account) = self.stable_account_memory.get(&account_number) else { + return Ok(None); + }; - self.write_reference_list( - anchor_number, - application_number, - account_references.into_iter().map(Into::into).collect(), - )?; - self.stable_account_memory - .insert(account_number, storable_account); + let result = self.with_reference_mut( + anchor_number, + application_number, + Some(account_number), + |reference| f(reference, &mut storable_account), + )?; - Ok(result) - } + // Skipped on a miss, where `f` never ran: the record would be written back + // exactly as it was read, and it belongs to whichever identity does hold a + // reference to it. + if result.is_some() { + self.stable_account_memory + .insert(account_number, storable_account); } + + Ok(result) } pub fn set_account_last_used( @@ -1670,13 +1636,17 @@ impl Storage { account_number: Option, now: Timestamp, ) -> Result, StorageError> { - let application_number = self.lookup_application_number_with_origin(&origin); + // An origin nothing has ever been stored under holds no reference to stamp, + // which is the same answer as a row that holds no reference for this account. + let Some(application_number) = self.lookup_application_number_with_origin(&origin) else { + return Ok(None); + }; - self.with_account_mut( + self.with_reference_mut( anchor_number, application_number, account_number, - |account_reference, _| { + |account_reference| { account_reference.last_used = Some(now); }, ) @@ -1711,32 +1681,23 @@ impl Storage { /// The single write path for an anchor's account reference list at one /// application, including the counters derived from it. + /// + /// Takes [`HeldReferences`], so it cannot be handed an empty list: that would be a + /// tombstone, which only a future move may create and which no path in this design + /// should reach by writing references it happens to have none of. fn write_reference_list( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, - current: Vec, + current: HeldReferences, ) -> Result<(), StorageError> { - if current.is_empty() { - return Err(StorageError::EmptyAccountReferenceList { - anchor_number, - application_number, - }); - } - let application = self .stable_application_memory .get(&application_number) .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; - let key = (anchor_number, application_number); - let previous = self - .stable_account_reference_list_memory - .get(&key) - .map(Vec::::from) - .unwrap_or_default(); - - let deltas = ReferenceListDeltas::between(&previous, ¤t); + let previous_row = self.reference_row(anchor_number, application_number); + let deltas = ReferenceListDeltas::between(&previous_row, ¤t); // Counters first: it is the only step here that can fail, and returning an error // after the list is written would commit one without the other. A failure now @@ -1747,8 +1708,10 @@ impl Storage { application, deltas, )?; - self.stable_account_reference_list_memory - .insert(key, current.into()); + self.stable_account_reference_list_memory.insert( + (anchor_number, application_number), + current.into_vec().into(), + ); Ok(()) } @@ -1953,42 +1916,32 @@ impl Storage { .insert(account_number, storable_account); // Update application data - let app_num = self.lookup_or_insert_application_number_with_origin(origin); + let application_number = self.lookup_or_insert_application_number_with_origin(origin); // last_used will be set once the user signs in with the account. let last_used = None; - // Process account references - let references = match self - .stable_account_reference_list_memory - .get(&(anchor_number, app_num)) - { - None => { - // If no list exists for this anchor & application, - // Create and insert the default and additional account. - // This is because we don't create default accounts explicitly. - let additional_account_reference = AccountReference { - account_number: Some(account_number), - last_used, - }; - let default_account_reference = AccountReference { - account_number: None, - last_used, - }; - vec![default_account_reference, additional_account_reference] - } - Some(existing_storable_list) => { - // If the list exists, push the new account and reinsert it to memory - let mut refs_vec: Vec = existing_storable_list.into(); - refs_vec.push(AccountReference { - account_number: Some(account_number), - last_used, - }); - refs_vec - } + let existing_references = match self.reference_row(anchor_number, application_number) { + // The default account reference is created alongside this one, because + // default accounts are never created explicitly. + ReferenceRow::Untouched => vec![AccountReference { + account_number: None, + last_used, + }], + // A tombstone must not regain a default reference, which is the whole + // reason it is kept, so the named account is all that goes in. + ReferenceRow::Tombstone => vec![], + ReferenceRow::Held(held) => held.into_vec(), }; + let references = HeldReferences::including( + existing_references, + AccountReference { + account_number: Some(account_number), + last_used, + }, + ); - self.write_reference_list(anchor_number, app_num, references)?; + self.write_reference_list(anchor_number, application_number, references)?; // Return the new account Ok(Account::new( @@ -2009,22 +1962,28 @@ impl Storage { origin: &FrontendHostname, ) -> Vec { check_frontend_length(origin); - match self.lookup_application_number_with_origin(origin) { - None => vec![Account::synthetic(anchor_number, origin.clone())], - Some(app_num) => match self.lookup_account_references(anchor_number, app_num) { - None => vec![Account::synthetic(anchor_number, origin.clone())], - Some(refs) => refs - .iter() - .filter_map(|acc_ref| { - self.read_account(ReadAccountParams { - account_number: acc_ref.account_number, - anchor_number, - origin, - known_app_num: Some(app_num), - }) + let Some(application_number) = self.lookup_application_number_with_origin(origin) else { + // Nothing has ever been stored under this origin, so the default account is + // still reconstructible. + return vec![Account::synthetic(anchor_number, origin.clone())]; + }; + + match self.reference_row(anchor_number, application_number) { + ReferenceRow::Untouched => vec![Account::synthetic(anchor_number, origin.clone())], + // Everything here moved away. Not even a synthetic default is offered — + // that is what the tombstone exists to prevent. + ReferenceRow::Tombstone => vec![], + ReferenceRow::Held(held) => held + .iter() + .filter_map(|reference| { + self.read_account(ReadAccountParams { + account_number: reference.account_number, + anchor_number, + origin, + known_app_num: Some(application_number), }) - .collect(), - }, + }) + .collect(), } } @@ -2042,92 +2001,57 @@ impl Storage { .known_app_num .or_else(|| self.lookup_application_number_with_origin(params.origin)); + let synthetic_default = || Account::synthetic(params.anchor_number, params.origin.clone()); + + // Nothing has ever been stored under this origin. A default account is still + // reconstructible, and a named one cannot be here at all. + let Some(application_number) = application_number else { + return match params.account_number { + None => Some(synthetic_default()), + Some(_) => None, + }; + }; + let row = self.reference_row(params.anchor_number, application_number); + match params.account_number { - // If a default account is requested - None => { - // if there is no stored application, return a synthetic default account - if application_number.is_none() { - return Some(Account::new( + // The tracked default. + None => match row { + ReferenceRow::Untouched => Some(synthetic_default()), + // XXX WARNING: a tombstone means the default moved away, so answering + // with a synthetic one lets its former owner reconstruct it at the same + // principal. Kept for now because refusing would lock out an identity + // that moved its default away and then reached the account limit. + ReferenceRow::Tombstone => Some(synthetic_default()), + // A row that names other accounts but not the default means the default + // was named or moved away; the identity signs in with one of the others. + ReferenceRow::Held(held) => held.reference(None).map(|reference| { + Account::new_with_last_used( params.anchor_number, params.origin.clone(), None, - None, - )); - } - // check if there is a stored account reference list - if let Some(acc_ref_vec) = - // we can safely unwrap here - self.lookup_account_references( - params.anchor_number, - application_number.unwrap(), + reference.account_number, + reference.last_used, ) - { - // if the list exists but is empty, we should still return a synthetic default account - // this should only happen if a named account was created, and then both it and the - // default account references were moved/deleted. - // XXX WARNING: this is done for the case that a user might have moved/deleted a default account - // and then reached the maximum accounts limit. If we don't return a synthetic default account here, - // they would be locked out of their account. - // However: if we implement account transfers at some point, and default accounts can be transfered, - // this would allow a user to regain access to their transferred default account. - if acc_ref_vec.is_empty() { - return Some(Account::new( - params.anchor_number, - params.origin.clone(), - None, - None, - )); - } - - // if there is a default account in the list, we return it - // else we return None, account has been moved or deleted - // but there is another account in the list, so user can log in with that - acc_ref_vec - .iter() - .find(|acc_ref| acc_ref.account_number.is_none()) - .map(|acc_ref| { - Account::new_with_last_used( - params.anchor_number, - params.origin.clone(), - None, - acc_ref.account_number, - acc_ref.last_used, - ) - }) - } else { - //if there is no list, we return a synthetic default account - Some(Account::new( + }), + }, + // A named account. The stored record carries its name; this identity's row + // naming it is what says the identity owns it. + Some(account_number) => { + let storable_account = self.stable_account_memory.get(&account_number)?; + let ReferenceRow::Held(held) = row else { + return None; + }; + held.reference(Some(account_number)).map(|reference| { + Account::new_full( params.anchor_number, params.origin.clone(), - None, - None, - )) - } - } - // if a named/stored account is requested - Some(account_number) => match self.stable_account_memory.get(&account_number) { - // if it does not exist, return None - None => None, - Some(storable_account) => { - // if it does exist, check whether it is owned by the caller anchor - // and belongs to the correct origin - self.find_account_reference( - params.anchor_number, - application_number, - params.account_number, + Some(storable_account.name.clone()), + Some(account_number), + reference.last_used, + storable_account.seed_from_anchor, ) - .map(|acc_ref| { - Account::new_full( - params.anchor_number, - params.origin.clone(), - Some(storable_account.name.clone()), - Some(account_number), - acc_ref.last_used, - storable_account.seed_from_anchor, - ) - }) - } - }, + }) + } } } @@ -2174,44 +2098,36 @@ impl Storage { origin, } = params; - // Check if account reference exists for given anchor number, origin and account number, - // if the account refence exists for a given anchor, that means the anchor has access. - let application_number = self.lookup_application_number_with_origin(&origin); + // Nothing has ever been stored under this origin, so no reference to the + // account exists under it either. + let Some(application_number) = self.lookup_application_number_with_origin(&origin) else { + return Err(StorageError::AccountNotFound { account_number }); + }; - let account_update_result = self.with_account_mut( + // Holding a reference is what grants access, so a miss here means this + // identity does not own the account, whether or not it exists. + let Some(updated_account) = self.with_named_account_mut( anchor_number, application_number, - Some(account_number), - |account_reference, maybe_storable_account| { - // Check if the account reference has an account number, - // throw error if it doesn't since we only want to update - // accounts with an account number in this function. - let account_number = account_reference.account_number?; - // Check if the storable_account exists. - // throw error if it doesn't since we only want to update - // existing accounts in this function. - let storable_account = maybe_storable_account?; - - // Update account and write back to storage + account_number, + |account_reference, storable_account| { storable_account.name = name.clone(); - // Return a user-facing account structure - Some(Account::new_full( + Account::new_full( anchor_number, origin, Some(name), Some(account_number), account_reference.last_used, storable_account.seed_from_anchor, - )) + ) }, - ); - - let Some(Some(account_update_result)) = account_update_result? else { + )? + else { return Err(StorageError::AccountNotFound { account_number }); }; - Ok(account_update_result) + Ok(updated_account) } /// Used in `update_account` to create a default account. @@ -2228,6 +2144,28 @@ impl Storage { origin, } = params; + // Which reference the new account number goes into, decided before anything is + // written: returning `Err` on the IC commits every write that came before it, + // so a refusal below this point would leave an allocated account stored with + // no reference naming it. + let existing_references = match self + .lookup_application_number_with_origin(&origin) + .map(|application_number| self.reference_row(anchor_number, application_number)) + { + // Nothing stored under this origin yet, so the row starts with just this + // account. Default accounts are never created explicitly. + None | Some(ReferenceRow::Untouched) => None, + Some(ReferenceRow::Held(held)) if held.reference(None).is_some() => Some(held), + // A tombstone holds nothing to name, and a row whose default reference is + // gone never regains one — the account it pointed at was removed. + Some(ReferenceRow::Tombstone) | Some(ReferenceRow::Held(_)) => { + return Err(StorageError::MissingAccount { + anchor_number, + name, + }); + } + }; + // Create and store the default account. let new_account_number = self.allocate_account_number()?; let storable_account = StorableAccount { @@ -2251,42 +2189,20 @@ impl Storage { self.set_anchor_application_config(anchor_number, application_number, config); } - let account_references_key = (anchor_number, application_number); - let references = match self - .stable_account_reference_list_memory - .get(&account_references_key) - { - None => { - // If no list exists for this anchor & application, - // Create and insert the default account. - // This is because we don't create default accounts explicitly. - vec![AccountReference { - account_number: Some(new_account_number), - // The `last_used` field will be set when the user signs with this account. - last_used: None, - }] - } - Some(existing_storable_list) => { - // If the list exists, update the default account reference with the new account number. - let mut refs_vec: Vec = existing_storable_list.into(); - let mut found_and_updated = false; - for r_mut in refs_vec.iter_mut() { - if r_mut.account_number.is_none() { - // Found the default account reference. - r_mut.account_number = Some(new_account_number); - found_and_updated = true; - break; - } - } - - // This could happen if the account was removed and now we try to update it. - if !found_and_updated { - return Err(StorageError::MissingAccount { - anchor_number, - name: name.clone(), - }); + let new_reference = AccountReference { + account_number: Some(new_account_number), + // The `last_used` field will be set when the user signs with this account. + last_used: None, + }; + let references = match existing_references { + None => HeldReferences::including(vec![], new_reference), + Some(mut held) => { + // Present, per the check above. Repointed in place so the reference + // keeps both its position in the list and its `last_used`. + if let Some(default_reference) = held.reference_mut(None) { + default_reference.account_number = Some(new_account_number); } - refs_vec + held } }; @@ -2545,28 +2461,124 @@ impl Storage { } } +/// What one `(anchor, application)` reference-list row holds. +/// +/// Deliberately not `Option>`: absence and emptiness are opposites here, and +/// spelling them as one nullable vector is what lets a caller treat them alike. +/// +/// - Absent means nothing ever happened at this application, so a default account is +/// reconstructible and reads answer with a synthetic one. +/// - Empty means everything that was here moved away. The default must never be +/// reconstructible again, or its former owner could re-mint it at the same +/// principal. +/// +/// Matching is exhaustive, so a fourth state cannot be added without every reader +/// being made to say what it does about it. +#[derive(Clone, Debug, PartialEq, Eq)] +enum ReferenceRow { + /// No row. + Untouched, + /// A row holding nothing. A permanent tombstone. + Tombstone, + /// A row holding references. + Held(HeldReferences), +} + +/// The references stored against one row, never empty. +/// +/// An empty list is a [`ReferenceRow::Tombstone`] and means something else entirely, +/// so it cannot be built here — which is what keeps the write path from producing one +/// by accident. +#[derive(Clone, Debug, PartialEq, Eq)] +struct HeldReferences(Vec); + +impl HeldReferences { + /// `None` for an empty list, which is a tombstone rather than references. + fn new(references: Vec) -> Option { + (!references.is_empty()).then_some(Self(references)) + } + + /// Non-empty because `reference` is in it, whatever `others` holds. The way a + /// caller adding a reference builds a row without having to rule out emptiness it + /// has just made impossible. + fn including(mut others: Vec, reference: AccountReference) -> Self { + others.push(reference); + Self(others) + } + + /// The reference for one account, where `None` asks for the tracked default. + fn reference(&self, account_number: Option) -> Option<&AccountReference> { + self.0 + .iter() + .find(|reference| reference.account_number == account_number) + } + + /// The reference for one account, to modify in place. Answers `None` where this + /// identity holds no such reference, which is what makes reference-list + /// membership the ownership check. + fn reference_mut( + &mut self, + account_number: Option, + ) -> Option<&mut AccountReference> { + self.0 + .iter_mut() + .find(|reference| reference.account_number == account_number) + } + + fn iter(&self) -> impl Iterator { + self.0.iter() + } + + fn into_vec(self) -> Vec { + self.0 + } +} + +/// How one write to a reference-list row moves the counters derived from it. +/// +/// Signed because these are differences rather than totals: a write that drops a +/// reference has to move the counters down, and there is no unsigned way to say so. +/// Both are applied to `u64` totals by [`ReferenceListDeltas::apply`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ReferenceListDeltas { + /// Change in named accounts — references that carry an account number. accounts: i64, + /// Change in references, named and tracked-default alike. references: i64, } impl ReferenceListDeltas { - fn between(previous: &[AccountReference], current: &[AccountReference]) -> Self { - fn counts(references: &[AccountReference]) -> (i64, i64) { - let stored = references - .iter() - .filter(|reference| reference.account_number.is_some()) - .count() as i64; - (stored, references.len() as i64) + /// What writing `current` over `previous_row` does to the counters. + /// + /// A tombstone counts as no references, which is right for the totals and is why + /// removing a row must not consult this: a tombstone's row is still alive while + /// holding nothing, so decrementing to zero on it would let the row be retired and + /// a moved-away default be reconstructed at the same principal. + fn between(previous_row: &ReferenceRow, current: &HeldReferences) -> Self { + /// Saturating rather than `as`: a list long enough to overflow `i64` cannot + /// exist — `MAX_ANCHOR_ACCOUNTS` bounds it far below — and saturating says so + /// without a cast that would wrap silently if that ever stopped being true. + fn counts<'a>(references: impl Iterator) -> (i64, i64) { + let mut named = 0i64; + let mut total = 0i64; + for reference in references { + total = total.saturating_add(1); + if reference.account_number.is_some() { + named = named.saturating_add(1); + } + } + (named, total) } - let (previous_accounts, previous_references) = counts(previous); - let (current_accounts, current_references) = counts(current); + let (previous_named, previous_total) = match previous_row { + ReferenceRow::Untouched | ReferenceRow::Tombstone => (0, 0), + ReferenceRow::Held(held) => counts(held.iter()), + }; + let (current_named, current_total) = counts(current.iter()); Self { - accounts: current_accounts - previous_accounts, - references: current_references - previous_references, + accounts: current_named.saturating_sub(previous_named), + references: current_total.saturating_sub(previous_total), } } @@ -2577,14 +2589,14 @@ impl ReferenceListDeltas { /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references /// this application any more", which retires a row other anchors still point at. - fn apply(&self, accounts: u64, references: u64) -> Result<(u64, u64), StorageError> { - let accounts = accounts + fn apply(&self, num_accounts: u64, num_references: u64) -> Result<(u64, u64), StorageError> { + let num_accounts = num_accounts .checked_add_signed(self.accounts) .ok_or(StorageError::AccountCounterOutOfBounds)?; - let references = references + let num_references = num_references .checked_add_signed(self.references) .ok_or(StorageError::AccountCounterOutOfBounds)?; - Ok((accounts, references)) + Ok((num_accounts, num_references)) } } @@ -2621,10 +2633,6 @@ pub enum StorageError { ErrorUpdatingAccountCounter, AccountsCounterOverflow, AccountCounterOutOfBounds, - EmptyAccountReferenceList { - anchor_number: AnchorNumber, - application_number: ApplicationNumber, - }, /// 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 @@ -2692,13 +2700,6 @@ impl fmt::Display for StorageError { f, "An account counter delta would move the count outside its range" ), - Self::EmptyAccountReferenceList { - anchor_number, - application_number, - } => write!( - f, - "refusing to store an empty account reference list for anchor {anchor_number} at application {application_number}" - ), Self::EmailRecoveryAddressAlreadyBound { existing_anchor } => write!( f, "recovery email is already bound to a different anchor ({existing_anchor})", diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index ba260654ad..71bb6886fd 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2128,7 +2128,7 @@ fn test_anchor_storage_migration_round_trip() { mod reference_list_write_path_tests { use crate::storage::account::{AccountReference, CreateAccountParams}; use crate::storage::storable::accounts_counter::StorableAccountsCounter; - use crate::storage::StorageError; + use crate::storage::{HeldReferences, ReferenceRow, StorageError}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -2142,6 +2142,12 @@ mod reference_list_write_path_tests { (storage, anchor_number) } + /// The references a test means to write, for the cases where a non-empty list is + /// the premise rather than the thing under test. + fn held(references: Vec) -> HeldReferences { + HeldReferences::new(references).expect("test wrote an empty reference list") + } + #[test] fn allocating_past_the_last_account_number_is_refused() { let (mut storage, anchor_number) = storage_with_anchor(); @@ -2182,7 +2188,7 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - vec![default_reference.clone(), named_reference], + held(vec![default_reference.clone(), named_reference]), ) .unwrap(); @@ -2193,7 +2199,7 @@ mod reference_list_write_path_tests { let result = storage.write_reference_list( anchor_number, application_number, - vec![default_reference], + held(vec![default_reference]), ); assert!(matches!( @@ -2201,30 +2207,24 @@ mod reference_list_write_path_tests { Err(StorageError::AccountCounterOutOfBounds) )); // Refused before anything was written: the list still holds both references. - assert_eq!( - storage - .lookup_account_references(anchor_number, application_number) - .unwrap() - .len(), - 2 - ); + let ReferenceRow::Held(references) = + storage.reference_row(anchor_number, application_number) + else { + panic!("the row written above is gone"); + }; + assert_eq!(references.iter().count(), 2); } #[test] - fn rejects_writing_an_empty_list() { - let (mut storage, anchor_number) = storage_with_anchor(); - let origin = "https://example.com".to_string(); - let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); - - let result = storage.write_reference_list(anchor_number, application_number, vec![]); - - assert!(matches!( - result, - Err(StorageError::EmptyAccountReferenceList { .. }) - )); - assert!(storage - .lookup_account_references(anchor_number, application_number) - .is_none()); + fn an_empty_list_cannot_be_handed_to_the_write_path() { + // The write path takes `HeldReferences`, which has no empty value, so an + // empty list is refused at construction rather than at the write. + assert!(HeldReferences::new(vec![]).is_none()); + assert!(HeldReferences::new(vec![AccountReference { + account_number: None, + last_used: None, + }]) + .is_some()); } #[test] @@ -2235,19 +2235,20 @@ mod reference_list_write_path_tests { let result = storage.write_reference_list( anchor_number, unknown_application_number, - vec![AccountReference { + held(vec![AccountReference { account_number: None, last_used: None, - }], + }]), ); assert!(matches!( result, Err(StorageError::OriginNotFoundForApplicationNumber { .. }) )); - assert!(storage - .lookup_account_references(anchor_number, unknown_application_number) - .is_none()); + assert_eq!( + storage.reference_row(anchor_number, unknown_application_number), + ReferenceRow::Untouched + ); assert_eq!( storage.get_account_counter(anchor_number), crate::storage::account::AccountsCounter::default() @@ -2270,13 +2271,14 @@ mod reference_list_write_path_tests { last_used: None, }]; storage - .write_reference_list(anchor_number, application_number, references.clone()) + .write_reference_list(anchor_number, application_number, held(references.clone())) .unwrap(); storage .stable_application_memory .remove(&application_number); - let result = storage.write_reference_list(anchor_number, application_number, references); + let result = + storage.write_reference_list(anchor_number, application_number, held(references)); assert!(matches!( result, @@ -2294,7 +2296,7 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - vec![ + held(vec![ AccountReference { account_number: None, last_used: None, @@ -2303,7 +2305,7 @@ mod reference_list_write_path_tests { account_number: Some(7), last_used: None, }, - ], + ]), ) .unwrap(); @@ -2333,20 +2335,20 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { + held(vec![AccountReference { account_number: None, last_used: None, - }], + }]), ) .unwrap(); storage .write_reference_list( anchor_number, application_number, - vec![AccountReference { + held(vec![AccountReference { account_number: Some(3), last_used: None, - }], + }]), ) .unwrap(); @@ -2370,7 +2372,7 @@ mod reference_list_write_path_tests { }]; storage - .write_reference_list(anchor_number, application_number, references.clone()) + .write_reference_list(anchor_number, application_number, held(references.clone())) .unwrap(); let after_first_write = storage.get_account_counter(anchor_number); @@ -2378,10 +2380,10 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - vec![AccountReference { + held(vec![AccountReference { account_number: Some(1), last_used: Some(123), - }], + }]), ) .unwrap(); @@ -2421,3 +2423,293 @@ mod reference_list_write_path_tests { assert_eq!(written.stored_accounts, 6); } } + +/// The three states a reference-list row can be in mean three different things, and +/// the reads have to keep telling them apart. Absence says a default account is still +/// reconstructible; emptiness says it never can be again. +mod reference_row_state_tests { + use crate::storage::account::{ + AccountReference, CreateAccountParams, ReadAccountParams, UpdateAccountParams, + }; + use crate::storage::{HeldReferences, ReferenceRow, StorageError}; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::{AccountNumber, 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()); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + /// Plants the row a future account move would leave behind. The write path cannot + /// produce one, which is the whole point of [`HeldReferences`], so a test that + /// needs a tombstone has to write it directly. + fn plant_tombstone(storage: &mut Storage, anchor_number: AnchorNumber) { + let origin = ORIGIN.to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + storage + .stable_account_reference_list_memory + .insert((anchor_number, application_number), vec![].into()); + assert_eq!( + storage.reference_row(anchor_number, application_number), + ReferenceRow::Tombstone + ); + } + + /// The account number the default reads back as, or `None` where there is no + /// default to read. The inner `None` is a default that is still derived rather + /// than stored, so the two levels have to stay apart. + fn read_default( + storage: &Storage, + anchor_number: AnchorNumber, + ) -> Option> { + let origin = ORIGIN.to_string(); + storage + .read_account(ReadAccountParams { + account_number: None, + anchor_number, + origin: &origin, + known_app_num: None, + }) + .map(|account| account.account_number) + } + + #[test] + fn an_untouched_row_offers_a_reconstructible_default() { + let (storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + + assert_eq!(read_default(&storage, anchor_number), Some(None)); + let accounts = storage.list_accounts(anchor_number, &origin); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].account_number, None); + } + + #[test] + fn a_tombstoned_row_lists_nothing_but_still_answers_the_default() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + plant_tombstone(&mut storage, anchor_number); + + // Nothing to list: every reference moved away. + assert!(storage.list_accounts(anchor_number, &origin).is_empty()); + // But the default is still answered, which is the lock-out escape hatch the + // `XXX WARNING` in `read_account` describes rather than a property to rely on. + assert_eq!(read_default(&storage, anchor_number), Some(None)); + } + + #[test] + fn a_row_that_names_no_default_has_no_default_to_read() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + let account = storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + let account_number = account.account_number.unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + + // Drop just the default reference, as moving it away would. + storage + .write_reference_list( + anchor_number, + application_number, + HeldReferences::new(vec![AccountReference { + account_number: Some(account_number), + last_used: None, + }]) + .unwrap(), + ) + .unwrap(); + + // No synthetic default here: the identity signs in with the named account. + assert_eq!(read_default(&storage, anchor_number), None); + let accounts = storage.list_accounts(anchor_number, &origin); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].account_number, Some(account_number)); + } + + #[test] + fn a_named_account_added_to_a_tombstoned_row_does_not_revive_the_default() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + plant_tombstone(&mut storage, anchor_number); + + let account = storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let ReferenceRow::Held(references) = + storage.reference_row(anchor_number, application_number) + else { + panic!("the named account should have left references behind"); + }; + assert_eq!( + references + .iter() + .map(|r| r.account_number) + .collect::>(), + vec![account.account_number] + ); + } + + #[test] + fn a_default_that_can_no_longer_be_named_is_refused_before_anything_is_written() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + plant_tombstone(&mut storage, anchor_number); + let counter_before = storage.get_total_accounts_counter().clone(); + + let result = storage.update_account(UpdateAccountParams { + account_number: None, + anchor_number, + name: "named default".to_string(), + origin: origin.clone(), + }); + + assert!(matches!(result, Err(StorageError::MissingAccount { .. }))); + // Refused before the allocation, so no account number was spent on a record + // that no reference would have named. + assert_eq!( + storage.get_total_accounts_counter().stored_accounts, + counter_before.stored_accounts + ); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + assert_eq!( + storage.reference_row(anchor_number, application_number), + ReferenceRow::Tombstone + ); + assert_eq!( + storage + .lookup_anchor_application_config(anchor_number, application_number) + .default_account_number, + None + ); + } + + #[test] + fn naming_a_default_keeps_its_reference_in_place() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + let named = storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + let stamped_at = 123456u64; + storage + .set_account_last_used(anchor_number, origin.clone(), None, stamped_at) + .unwrap() + .unwrap(); + + let default = storage + .update_account(UpdateAccountParams { + account_number: None, + anchor_number, + name: "named default".to_string(), + origin: origin.clone(), + }) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let ReferenceRow::Held(references) = + storage.reference_row(anchor_number, application_number) + else { + panic!("naming the default should not have emptied the row"); + }; + // Repointed where it stood, keeping the order accounts are listed in and the + // timestamp the reference already carried. + assert_eq!( + references.iter().collect::>(), + vec![ + &AccountReference { + account_number: default.account_number, + last_used: Some(stamped_at), + }, + &AccountReference { + account_number: named.account_number, + last_used: None, + }, + ] + ); + } + + #[test] + fn an_identity_holding_no_reference_can_neither_rename_nor_stamp_the_account() { + let (mut storage, owner) = storage_with_anchor(); + let other = { + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + anchor_number + }; + let origin = ORIGIN.to_string(); + let account = storage + .create_additional_account(CreateAccountParams { + anchor_number: owner, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + let account_number = account.account_number.unwrap(); + // The other identity has a row of its own at this origin, so what refuses the + // attempts below is the row not naming this account rather than there being no + // row to look in. + storage + .create_additional_account(CreateAccountParams { + anchor_number: other, + name: "mine".to_string(), + origin: origin.clone(), + }) + .unwrap(); + + let rename = storage.update_account(UpdateAccountParams { + account_number: Some(account_number), + anchor_number: other, + name: "stolen".to_string(), + origin: origin.clone(), + }); + assert!(matches!(rename, Err(StorageError::AccountNotFound { .. }))); + + let stamp = storage + .set_account_last_used(other, origin.clone(), Some(account_number), 123456) + .unwrap(); + assert_eq!(stamp, None); + + // The owner's record and reference are untouched by either attempt. + let owned = storage + .read_account(ReadAccountParams { + account_number: Some(account_number), + anchor_number: owner, + origin: &origin, + known_app_num: None, + }) + .unwrap(); + assert_eq!(owned.name, Some("named".to_string())); + assert_eq!(owned.last_used, None); + } +} From feed41c22eb30833f2f894d0674888ccca2fe1c3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 13:20:34 +0200 Subject: [PATCH 068/298] fix(be): stamp last_used before the delegation is signed The stamp discarded its result with `let _`, so a storage failure went unreported. Propagating it where it stood would have been worse than the discard: it ran after `add_delegation_signature` and `update_root_hash`, and returning `Err` on the IC commits every write that came before it, so the caller would have been told the delegation failed while its signature was already in the map. Moved above the signature block, where a failure means nothing has been issued yet, and propagated as `InternalCanisterError`. `Ok(None)` stays a success: it means the row holds no reference to stamp, which is how a default account that is still derived rather than stored reads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/account_management.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 5d2c174143..c9da23e70a 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -351,6 +351,17 @@ pub async fn prepare_account_delegation( let effective_duration_ns = expiration.saturating_sub(time()); let seed = account.calculate_seed(); + // Stamped before the delegation is signed. On the IC returning `Err` commits + // every write that came before it, so propagating a failure from here once the + // signature was in the map would report an error for a delegation that has + // already been issued. `Ok(None)` is not a failure: it means the row holds no + // reference to stamp, which is how a default account that is still derived rather + // than stored reads. + storage_borrow_mut(|storage| { + storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()) + }) + .map_err(|err| AccountDelegationError::InternalCanisterError(err.to_string()))?; + state::signature_map_mut(|sigs| { add_delegation_signature( sigs, @@ -362,11 +373,6 @@ pub async fn prepare_account_delegation( }); update_root_hash(); - storage_borrow_mut(|storage| { - let _ = - storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()); - }); - delegation_bookkeeping(origin, ii_domain.clone(), effective_duration_ns); Ok(PrepareAccountDelegation { From 2bcfbd5a373c44754c08f41a7606d26f0213c175 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 13:29:22 +0200 Subject: [PATCH 069/298] refactor(be): name the write path's locals in full `current`, `previous` and `deltas` in `write_reference_list` said less than they could about which list and which counters they mean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index be2a5210e9..3953b6c87f 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1689,7 +1689,7 @@ impl Storage { &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, - current: HeldReferences, + new_references: HeldReferences, ) -> Result<(), StorageError> { let application = self .stable_application_memory @@ -1697,7 +1697,7 @@ impl Storage { .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; let previous_row = self.reference_row(anchor_number, application_number); - let deltas = ReferenceListDeltas::between(&previous_row, ¤t); + let counter_deltas = ReferenceListDeltas::between(&previous_row, &new_references); // Counters first: it is the only step here that can fail, and returning an error // after the list is written would commit one without the other. A failure now @@ -1706,11 +1706,11 @@ impl Storage { anchor_number, application_number, application, - deltas, + counter_deltas, )?; self.stable_account_reference_list_memory.insert( (anchor_number, application_number), - current.into_vec().into(), + new_references.into_vec().into(), ); Ok(()) } @@ -2548,13 +2548,13 @@ struct ReferenceListDeltas { } impl ReferenceListDeltas { - /// What writing `current` over `previous_row` does to the counters. + /// What writing `new_references` over `previous_row` does to the counters. /// /// A tombstone counts as no references, which is right for the totals and is why /// removing a row must not consult this: a tombstone's row is still alive while /// holding nothing, so decrementing to zero on it would let the row be retired and /// a moved-away default be reconstructed at the same principal. - fn between(previous_row: &ReferenceRow, current: &HeldReferences) -> Self { + fn between(previous_row: &ReferenceRow, new_references: &HeldReferences) -> Self { /// Saturating rather than `as`: a list long enough to overflow `i64` cannot /// exist — `MAX_ANCHOR_ACCOUNTS` bounds it far below — and saturating says so /// without a cast that would wrap silently if that ever stopped being true. @@ -2574,11 +2574,11 @@ impl ReferenceListDeltas { ReferenceRow::Untouched | ReferenceRow::Tombstone => (0, 0), ReferenceRow::Held(held) => counts(held.iter()), }; - let (current_named, current_total) = counts(current.iter()); + let (new_named, new_total) = counts(new_references.iter()); Self { - accounts: current_named.saturating_sub(previous_named), - references: current_total.saturating_sub(previous_total), + accounts: new_named.saturating_sub(previous_named), + references: new_total.saturating_sub(previous_total), } } From 2de71e5d2fd53166cb55cde998adba5ba18aece1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 13:29:22 +0200 Subject: [PATCH 070/298] fix(be): refuse an unreadable account instead of trapping `update_account` read the account it was about to rename with an `expect` whose message said the `None` was impossible. It is reachable two ways, and the integration tests asserted the trap: an identity naming an account it holds no reference to, and a second numberless update of a default that the first one already named and numbered. Both are the caller naming an account it does not have, which is what `prepare_account_delegation` already answers for the same read, so it returns `UpdateAccountError::Unauthorized` rather than trapping the call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 7 ++++- .../tests/integration/accounts.rs | 27 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index c9da23e70a..d404171391 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -248,6 +248,11 @@ pub fn update_account_for_origin( .map_err(Into::::into)? } + // A caller reaches this with nothing readable in two ways: the + // account belongs to another identity, or the tracked default has + // already been named and so is no longer numberless. Both are the + // caller naming an account it does not have, which is what + // `prepare_account_delegation` answers for the same read. let old_account = storage .read_account(ReadAccountParams { account_number, @@ -255,7 +260,7 @@ pub fn update_account_for_origin( origin: &origin, known_app_num: None }) - .expect("Updating an unreadable account should be impossible!"); + .ok_or_else(|| UpdateAccountError::Unauthorized(caller()))?; let updated_account = storage .update_account(UpdateAccountParams { diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 750091488a..1a2cb90ebc 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -15,7 +15,7 @@ use canister_tests::{ }; use internet_identity_interface::internet_identity::types::{ AccountDelegationError, AccountInfo, AccountUpdate, GetDelegationResponse, - PrepareAccountDelegation, + PrepareAccountDelegation, UpdateAccountError, }; use pocket_ic::RejectResponse; use pretty_assertions::assert_eq; @@ -334,10 +334,9 @@ fn should_update_account() -> Result<(), RejectResponse> { Ok(()) } -/// When a default / numberless account gets updated, it becomes stored and numbered. -/// It should not be possible to update +/// When a default / numberless account gets updated, it becomes stored and numbered, +/// so there is no numberless account left to update a second time. #[test] -#[should_panic] fn should_not_update_numberless_account_twice() { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); @@ -360,7 +359,7 @@ fn should_not_update_numberless_account_twice() { assert!(updated_account.is_ok()); - let _ = update_account( + let second_update = update_account( &env, canister_id, principal_1(), @@ -369,7 +368,12 @@ fn should_not_update_numberless_account_twice() { None, update, ) - .expect("The call itself should succeed, but should panic inside"); + .expect("This call should succeed!"); + + assert!(matches!( + second_update, + Err(UpdateAccountError::Unauthorized(_)) + )); } /// Verifies that a default account can be updated @@ -454,7 +458,6 @@ fn should_update_default_account() -> Result<(), RejectResponse> { /// Verifies that only owned accounts can be updated #[test] -#[should_panic] fn should_only_update_owned_account() { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); @@ -495,7 +498,7 @@ fn should_only_update_owned_account() { // Here we try to update the account created by the first identity using the second identity. // This should fail. - let _ = update_account( + let update_by_another_identity = update_account( &env, canister_id, principal_2(), @@ -504,8 +507,12 @@ fn should_only_update_owned_account() { created_account.account_number, update, ) - .unwrap() - .unwrap(); + .expect("This call should succeed!"); + + assert!(matches!( + update_by_another_identity, + Err(UpdateAccountError::Unauthorized(_)) + )); } /// Verifies that read-only account delegations carry the `permissions` From bcb10273f230b281d33f8a85f221986625d97541 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 14:10:53 +0200 Subject: [PATCH 071/298] Merge branch 'feat/app-delegation-from-session' into feat/session-refresh-stamps --- src/internet_identity/src/storage.rs | 10 +++------- src/internet_identity/src/storage/tests.rs | 6 ++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5eda58d8d4..7648208758 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2045,17 +2045,13 @@ impl Storage { device_id: SessionDeviceId, now: Timestamp, ) -> Result { - let Some(references) = self.lookup_account_references(anchor_number, application_number) + let ReferenceRow::Held(mut references) = + self.reference_row(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 { + let Some(reference) = references.reference_mut(account_number) else { return Ok(false); }; let Some(session) = reference diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index fb2d62baa0..7b9db67ac1 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5235,6 +5235,7 @@ mod session_consent_change_tests { } mod session_refresh_stamp_tests { + use super::held_references; use crate::storage::account::{AccountReference, SessionRecord}; use crate::storage::CreateSessionParams; use crate::Storage; @@ -5278,11 +5279,8 @@ mod session_refresh_stamp_tests { let application_number = storage .lookup_application_number_with_origin(&ORIGIN.to_string()) .unwrap(); - storage - .lookup_account_references(anchor_number, application_number) - .unwrap() + held_references(storage, anchor_number, application_number) .into_iter() - .map(AccountReference::from) .find(|reference| reference.account_number.is_none()) .unwrap() } From 1be8392ae9a70efd0c2934e8eea0b3cc0b248356 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 14:13:32 +0200 Subject: [PATCH 072/298] Merge branch 'feat/session-refresh-stamps' into feat/app-revoke-session --- src/internet_identity/src/storage.rs | 33 +++++++++------------- src/internet_identity/src/storage/tests.rs | 14 ++++----- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 9636cae9e6..2b583a31cf 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1939,19 +1939,15 @@ impl Storage { // 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_ns == created_at - }) + .reference_row(anchor_number, application_number) + .references() + .iter() + .any(|reference| { + reference.account_number == account_number + && reference.sessions.iter().any(|session| { + session.device_id == device_id && session.created_at_ns == created_at }) - }) - .unwrap_or(false); + }); if !present { return Ok(false); } @@ -2158,17 +2154,14 @@ impl Storage { 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) + let ReferenceRow::Held(mut references) = + self.reference_row(anchor_number, application_number) else { return Ok(0); }; + let Some(reference) = references.reference_mut(account_number) else { + return Ok(0); + }; let dropped: Vec = reference .sessions .iter() diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f993ff95e6..91c6e6d531 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5528,7 +5528,7 @@ mod session_refresh_stamp_tests { } mod session_removal_tests { - use crate::storage::account::AccountReference; + use super::{held_references, ReferenceRow}; use crate::storage::CreateSessionParams; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -5567,11 +5567,8 @@ mod session_removal_tests { let application_number = storage .lookup_application_number_with_origin(&ORIGIN.to_string()) .unwrap(); - storage - .lookup_account_references(anchor_number, application_number) - .unwrap() + held_references(storage, anchor_number, application_number) .into_iter() - .map(AccountReference::from) .find(|reference| reference.account_number.is_none()) .unwrap() .sessions @@ -5615,8 +5612,9 @@ mod session_removal_tests { .remove_session(anchor_number, application_number, None, 1_000, 1) .unwrap(); - assert!(storage - .lookup_account_references(anchor_number, application_number) - .is_some()); + assert_ne!( + storage.reference_row(anchor_number, application_number), + ReferenceRow::Untouched + ); } } From c3c42d598fb4b789d8bc412e4781e13d3686420e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 14:15:40 +0200 Subject: [PATCH 073/298] Merge branch 'feat/app-revoke-session' into feat/revoke-sessions-from-settings --- src/internet_identity/src/storage.rs | 11 +++-------- src/internet_identity/src/storage/tests.rs | 7 ++----- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index e3b6d7dbf9..f7169801af 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1938,17 +1938,12 @@ impl Storage { 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) + let ReferenceRow::Held(mut references) = + self.reference_row(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 { + let Some(reference) = references.reference_mut(account_number) else { return Ok(0); }; diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 8fb224260d..81b3caa3cb 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5620,7 +5620,7 @@ mod session_removal_tests { } mod session_revocation_tests { - use crate::storage::account::AccountReference; + use super::held_references; use crate::storage::CreateSessionParams; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -5665,11 +5665,8 @@ mod session_revocation_tests { let application_number = storage .lookup_application_number_with_origin(&origin.to_string()) .unwrap(); - storage - .lookup_account_references(anchor_number, application_number) - .unwrap() + held_references(storage, anchor_number, application_number) .into_iter() - .map(AccountReference::from) .find(|reference| reference.account_number.is_none()) .unwrap() .sessions From a9a1ca94b9029ccbabeafd48deff3edd48812599 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 14:39:01 +0200 Subject: [PATCH 074/298] fix(be): say which counter no longer agrees with the lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AccountCounterOutOfBounds` carried nothing, so a refusal said only that some count had diverged from the stored reference lists. It is refused before anything is written, which is the point, but that also means the counters keep the values that disagreed and there is nothing left to read the cause off afterwards. It now names the counter, the count, the value stored and the delta that would not fit: the anchor 10000 stored accounts counter cannot move from 0 by -1, so it no longer agrees with the stored reference lists `apply` takes the `ReferenceCounter` it is moving, and `apply_one` carries the one refusal path for both counts. The global counter now applies only its reference count: its account count is the account-number allocator, owned by `allocate_account_number` and rewritten unchanged here, so moving it could refuse a write over a count nothing was going to store — and the discarded half of that pair is gone with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 121 ++++++++++++++++++--- src/internet_identity/src/storage/tests.rs | 63 ++++++++++- 2 files changed, 166 insertions(+), 18 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 3953b6c87f..14d27e39e9 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1733,17 +1733,23 @@ impl Storage { .get(&anchor_number) .unwrap_or_default(); let (anchor_accounts, anchor_references) = deltas.apply( + ReferenceCounter::Anchor { anchor_number }, anchor_counter.stored_accounts, anchor_counter.stored_account_references, )?; + // Only the reference count: the global account count is the account-number + // allocator, which `allocate_account_number` owns and this rewrites unchanged. + // Moving it here would refuse a write over a count nothing was going to store. let global_counter = self.stable_account_counter_memory.get().clone(); - let (_, global_references) = deltas.apply( - global_counter.stored_accounts, + let global_references = deltas.apply_one( + ReferenceCounter::Global, + ReferenceCount::References, global_counter.stored_account_references, )?; let (application_accounts, application_references) = deltas.apply( + ReferenceCounter::Application { application_number }, application.stored_accounts, application.stored_account_references, )?; @@ -2534,6 +2540,52 @@ impl HeldReferences { } } +/// Which of the counters derived from a reference list a delta is applied to. +/// +/// Each carries what identifies its row, so a refusal points at the counter that +/// diverged rather than only saying that one did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReferenceCounter { + /// One identity's totals across every application. + Anchor { anchor_number: AnchorNumber }, + /// The canister-wide gauge. + Global, + /// One application's totals across every identity. + Application { + application_number: ApplicationNumber, + }, +} + +impl fmt::Display for ReferenceCounter { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Anchor { anchor_number } => write!(f, "anchor {anchor_number}"), + Self::Global => write!(f, "global"), + Self::Application { application_number } => { + write!(f, "application {application_number}") + } + } + } +} + +/// Which of the two counts every reference-list counter holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReferenceCount { + /// Named accounts — references that carry an account number. + Accounts, + /// References, named and tracked-default alike. + References, +} + +impl fmt::Display for ReferenceCount { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Accounts => write!(f, "stored accounts"), + Self::References => write!(f, "stored account references"), + } + } +} + /// How one write to a reference-list row moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a @@ -2586,17 +2638,47 @@ impl ReferenceListDeltas { self.accounts == 0 && self.references == 0 } + /// Both counts of `counter`, moved by this delta. + /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references /// this application any more", which retires a row other anchors still point at. - fn apply(&self, num_accounts: u64, num_references: u64) -> Result<(u64, u64), StorageError> { - let num_accounts = num_accounts - .checked_add_signed(self.accounts) - .ok_or(StorageError::AccountCounterOutOfBounds)?; - let num_references = num_references - .checked_add_signed(self.references) - .ok_or(StorageError::AccountCounterOutOfBounds)?; - Ok((num_accounts, num_references)) + fn apply( + &self, + counter: ReferenceCounter, + num_accounts: u64, + num_references: u64, + ) -> Result<(u64, u64), StorageError> { + Ok(( + self.apply_one(counter, ReferenceCount::Accounts, num_accounts)?, + self.apply_one(counter, ReferenceCount::References, num_references)?, + )) + } + + /// One count of one counter, moved by this delta. + /// + /// The refusal names the counter, the count, the value stored and the delta that + /// would not fit, because that is the whole of what diverged and there is nothing + /// left to read it off afterwards: the write is refused, so the counters keep the + /// values that disagreed with the lists. + fn apply_one( + &self, + counter: ReferenceCounter, + count: ReferenceCount, + stored: u64, + ) -> Result { + let delta = match count { + ReferenceCount::Accounts => self.accounts, + ReferenceCount::References => self.references, + }; + stored + .checked_add_signed(delta) + .ok_or(StorageError::AccountCounterOutOfBounds { + counter, + count, + stored, + delta, + }) } } @@ -2632,7 +2714,14 @@ pub enum StorageError { }, ErrorUpdatingAccountCounter, AccountsCounterOverflow, - AccountCounterOutOfBounds, + /// A counter derived from a reference list cannot move by the delta a write + /// implies, which means it and the stored lists have already diverged. + AccountCounterOutOfBounds { + counter: ReferenceCounter, + count: ReferenceCount, + stored: u64, + delta: i64, + }, /// 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 @@ -2696,9 +2785,15 @@ impl fmt::Display for StorageError { ), Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), Self::AccountsCounterOverflow => write!(f, "No account numbers left to allocate"), - Self::AccountCounterOutOfBounds => write!( + Self::AccountCounterOutOfBounds { + counter, + count, + stored, + delta, + } => write!( f, - "An account counter delta would move the count outside its range" + "the {counter} {count} counter cannot move from {stored} by {delta}, \ + so it no longer agrees with the stored reference lists" ), Self::EmailRecoveryAddressAlreadyBound { existing_anchor } => write!( f, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 71bb6886fd..0a4b53a4cf 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2128,7 +2128,9 @@ fn test_anchor_storage_migration_round_trip() { mod reference_list_write_path_tests { use crate::storage::account::{AccountReference, CreateAccountParams}; use crate::storage::storable::accounts_counter::StorableAccountsCounter; - use crate::storage::{HeldReferences, ReferenceRow, StorageError}; + use crate::storage::{ + HeldReferences, ReferenceCount, ReferenceCounter, ReferenceRow, StorageError, + }; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -2202,10 +2204,18 @@ mod reference_list_write_path_tests { held(vec![default_reference]), ); - assert!(matches!( - result, - Err(StorageError::AccountCounterOutOfBounds) - )); + // The refusal names what diverged: this identity's account count, what it held, + // and the move that would not fit. + assert_eq!( + result.unwrap_err().to_string(), + StorageError::AccountCounterOutOfBounds { + counter: ReferenceCounter::Anchor { anchor_number }, + count: ReferenceCount::Accounts, + stored: 0, + delta: -1, + } + .to_string() + ); // Refused before anything was written: the list still holds both references. let ReferenceRow::Held(references) = storage.reference_row(anchor_number, application_number) @@ -2215,6 +2225,49 @@ mod reference_list_write_path_tests { assert_eq!(references.iter().count(), 2); } + #[test] + fn the_two_counts_move_independently_and_the_refusal_says_which_one_failed() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + let default_reference = AccountReference { + account_number: None, + last_used: None, + }; + let named_reference = AccountReference { + account_number: Some(1), + last_used: None, + }; + storage + .write_reference_list( + anchor_number, + application_number, + held(vec![default_reference, named_reference.clone()]), + ) + .unwrap(); + storage.set_counters_for_testing(anchor_number, 0, 0); + + // Dropping the tracked default takes a reference without taking a named + // account, so the two deltas differ: 0 and -1. Only the reference count can + // under-run here, and the refusal has to name that one rather than the other. + let result = storage.write_reference_list( + anchor_number, + application_number, + held(vec![named_reference]), + ); + + assert_eq!( + result.unwrap_err().to_string(), + StorageError::AccountCounterOutOfBounds { + counter: ReferenceCounter::Anchor { anchor_number }, + count: ReferenceCount::References, + stored: 0, + delta: -1, + } + .to_string() + ); + } + #[test] fn an_empty_list_cannot_be_handed_to_the_write_path() { // The write path takes `HeldReferences`, which has no empty value, so an From dee1d28416c2704a7503cf136b1aac10a3777af4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 14:42:21 +0200 Subject: [PATCH 075/298] Merge branch 'feat/account-principal-index-backfill' into feat/session-record-storage --- src/internet_identity/src/storage/tests.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index b9cec91d2a..b63f65bdfc 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2305,14 +2305,8 @@ mod reference_list_write_path_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); - let default_reference = AccountReference { - account_number: None, - last_used: None, - }; - let named_reference = AccountReference { - account_number: Some(1), - last_used: None, - }; + let default_reference = AccountReference::new(None, None); + let named_reference = AccountReference::new(Some(1), None); storage .write_reference_list( anchor_number, From 4bb3067764bce254d19d19dc73f2c10b39ee13c3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 14:47:38 +0200 Subject: [PATCH 076/298] refactor(be): one now for the whole account delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare_account_delegation` read `time()` at four sites — the expiry cap check, the expiration, the metrics duration and the `last_used` stamp — and a comment argued that two of them agree. `time()` is constant only within a single execution, and an `await` on an inter-canister call ends one: the continuation resumes with a later time. That makes four separate reads a property of where the calls happen to sit rather than something the code states, so this reads it once after the await and passes it to each use. The storage side already takes it as a parameter, the way `allocate_anchor` does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index d404171391..61f85617a8 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -327,6 +327,13 @@ pub async fn prepare_account_delegation( .ok_or(AccountDelegationError::Unauthorized(caller())) })?; + // One read, passed to everything below. `time()` is constant only within a single + // execution, and an `await` on an inter-canister call ends one — the continuation + // resumes with a later time. Reading it at each use would make "the same instant" + // rest on no `await` ever appearing between them, which is not a property to leave + // to where the calls happen to sit. + let now = time(); + let session_duration_ns = u64::min( max_ttl.unwrap_or(crate::delegation::DEFAULT_EXPIRATION_PERIOD_NS), crate::delegation::MAX_EXPIRATION_PERIOD_NS, @@ -340,20 +347,19 @@ pub async fn prepare_account_delegation( // success while wasting a signature-map entry on an unusable delegation. // For the MCP path this is exactly the session-over signal: the grant // expired mid-call. - if max_expiration.is_some_and(|cap| cap <= time()) { + if max_expiration.is_some_and(|cap| cap <= now) { return Err(AccountDelegationError::Unauthorized(caller())); } let expiration = u64::min( - time().saturating_add(session_duration_ns), + now.saturating_add(session_duration_ns), max_expiration.unwrap_or(u64::MAX), ); // The metrics duration is the delegation's *effective* lifetime: the // absolute `max_expiration` cap can shorten it below the requested // `session_duration_ns` (e.g. an MCP grant near expiry). With no cap the two // are equal, so this matches the regular path exactly while keeping the - // recorded duration honest when the cap binds. `time()` is stable here (no - // await since it was read for `expiration`). - let effective_duration_ns = expiration.saturating_sub(time()); + // recorded duration honest when the cap binds. + let effective_duration_ns = expiration.saturating_sub(now); let seed = account.calculate_seed(); // Stamped before the delegation is signed. On the IC returning `Err` commits @@ -363,7 +369,7 @@ pub async fn prepare_account_delegation( // reference to stamp, which is how a default account that is still derived rather // than stored reads. storage_borrow_mut(|storage| { - storage.set_account_last_used(anchor_number, origin.clone(), account_number, time()) + storage.set_account_last_used(anchor_number, origin.clone(), account_number, now) }) .map_err(|err| AccountDelegationError::InternalCanisterError(err.to_string()))?; From a8c533945595ee088dc78726241e6340d5ab8fbf Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 17:32:52 +0200 Subject: [PATCH 077/298] refactor(be): one checked way to store a reference list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StorableAccountReferenceList` had an infallible `From>`, so what may be stored was decided at each write site. An empty list is the one thing that must not be: it is a tombstone, saying every reference at the origin was moved away and its default account must never be derived again. Only a move may create one, and nothing moves accounts yet. `TryFrom` replaces the `From`, so the unchecked path no longer compiles, and `Default` goes with it — it handed out the empty list the constructor exists to refuse. Tests that need a tombstone to exist say so with `tombstone_for_testing`. Decoding stays infallible, which is what makes this safe to add: a stored row is never refused on the way out. But it does mean a rule here applies to every future write of an existing row, so a row violating one would become unwritable — an identity locked out of that origin. Emptiness is the only rule that can be shown never to have been written: `create_additional_account` always pushes at least one reference, `create_default_account` always has one, and the read-modify-write paths write back what they read. `write_reference_list` builds the storable form before it touches the counters, so a list that may not be stored is refused with nothing written and no counter moved for it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 333 ++++++++---------- .../src/storage/account/tests.rs | 3 +- .../storable/account_reference_list.rs | 92 ++++- src/internet_identity/src/storage/tests.rs | 137 ++++--- 4 files changed, 301 insertions(+), 264 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 14d27e39e9..f69505be63 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -91,7 +91,9 @@ use std::collections::{BTreeSet, HashMap}; use std::fmt; use std::io::Write; use std::ops::RangeInclusive; -use storable::account_reference_list::StorableAccountReferenceList; +use storable::account_reference_list::{ + StorableAccountReferenceList, StorableAccountReferenceListError, +}; use storable::anchor_number_list::StorableAnchorNumberList; use ic_cdk::api::trap; @@ -1526,28 +1528,20 @@ impl Storage { .and_then(|application_number| self.stable_application_memory.get(&application_number)) } - /// What this identity's reference-list row at `application_number` holds. + /// This identity's account references at `application_number`, or `None` where it + /// has no row there at all. /// - /// The one place that maps storage onto [`ReferenceRow`], so no caller has to - /// remember that an absent row and an empty one mean opposite things. - fn reference_row( + /// The one reader, so no caller has to assemble the list from storage itself. The + /// `None` is only ever "no row": an empty row is a tombstone and means the + /// opposite, so the two must not be collapsed by a caller either. + fn account_references( &self, anchor_number: AnchorNumber, application_number: ApplicationNumber, - ) -> ReferenceRow { - match self - .stable_account_reference_list_memory + ) -> Option> { + self.stable_account_reference_list_memory .get(&(anchor_number, application_number)) - { - None => ReferenceRow::Untouched, - Some(stored) => { - let references = Vec::::from(stored); - match HeldReferences::new(references) { - Some(held) => ReferenceRow::Held(held), - None => ReferenceRow::Tombstone, - } - } - } + .map(Vec::::from) } /// Applies `f` to one of this identity's account references, and writes the row @@ -1569,13 +1563,15 @@ impl Storage { where F: FnOnce(&mut AccountReference) -> T, { - let ReferenceRow::Held(mut references) = - self.reference_row(anchor_number, application_number) + let Some(mut references) = self.account_references(anchor_number, application_number) else { return Ok(None); }; - let Some(reference) = references.reference_mut(account_number) else { + let Some(reference) = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + else { // `f` never ran, so nothing changed, and writing the row back here would // store the bytes it already holds. return Ok(None); @@ -1682,22 +1678,32 @@ impl Storage { /// The single write path for an anchor's account reference list at one /// application, including the counters derived from it. /// - /// Takes [`HeldReferences`], so it cannot be handed an empty list: that would be a - /// tombstone, which only a future move may create and which no path in this design - /// should reach by writing references it happens to have none of. + /// Refuses an empty list, which would be a tombstone — see + /// [`StorableAccountReferenceList::try_from`], which is where that is enforced and + /// why it is enforced there. fn write_reference_list( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, - new_references: HeldReferences, + new_references: Vec, ) -> Result<(), StorageError> { + // Before the counters, so a list this identity may not store is refused with + // nothing written and no counter moved for it. + let storable_references = StorableAccountReferenceList::try_from(new_references.clone()) + .map_err(|error| StorageError::UnstorableAccountReferenceList { + anchor_number, + application_number, + error, + })?; let application = self .stable_application_memory .get(&application_number) .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; - let previous_row = self.reference_row(anchor_number, application_number); - let counter_deltas = ReferenceListDeltas::between(&previous_row, &new_references); + let previous_references = self + .account_references(anchor_number, application_number) + .unwrap_or_default(); + let counter_deltas = ReferenceListDeltas::between(&previous_references, &new_references); // Counters first: it is the only step here that can fail, and returning an error // after the list is written would commit one without the other. A failure now @@ -1708,10 +1714,8 @@ impl Storage { application, counter_deltas, )?; - self.stable_account_reference_list_memory.insert( - (anchor_number, application_number), - new_references.into_vec().into(), - ); + self.stable_account_reference_list_memory + .insert((anchor_number, application_number), storable_references); Ok(()) } @@ -1927,25 +1931,22 @@ impl Storage { // last_used will be set once the user signs in with the account. let last_used = None; - let existing_references = match self.reference_row(anchor_number, application_number) { - // The default account reference is created alongside this one, because - // default accounts are never created explicitly. - ReferenceRow::Untouched => vec![AccountReference { - account_number: None, - last_used, - }], - // A tombstone must not regain a default reference, which is the whole - // reason it is kept, so the named account is all that goes in. - ReferenceRow::Tombstone => vec![], - ReferenceRow::Held(held) => held.into_vec(), - }; - let references = HeldReferences::including( - existing_references, - AccountReference { - account_number: Some(account_number), - last_used, - }, - ); + // With no row yet the default account reference is created alongside this one, + // because default accounts are never created explicitly. An existing row is + // added to as it stands: a tombstone must not regain a default reference, which + // is the whole reason it is kept. + let mut references = self + .account_references(anchor_number, application_number) + .unwrap_or_else(|| { + vec![AccountReference { + account_number: None, + last_used, + }] + }); + references.push(AccountReference { + account_number: Some(account_number), + last_used, + }); self.write_reference_list(anchor_number, application_number, references)?; @@ -1974,12 +1975,12 @@ impl Storage { return vec![Account::synthetic(anchor_number, origin.clone())]; }; - match self.reference_row(anchor_number, application_number) { - ReferenceRow::Untouched => vec![Account::synthetic(anchor_number, origin.clone())], - // Everything here moved away. Not even a synthetic default is offered — - // that is what the tombstone exists to prevent. - ReferenceRow::Tombstone => vec![], - ReferenceRow::Held(held) => held + // An empty row is a tombstone: everything here moved away, so not even a + // synthetic default is offered — that is what it exists to prevent. Its empty + // list falls out of the iteration below. + match self.account_references(anchor_number, application_number) { + None => vec![Account::synthetic(anchor_number, origin.clone())], + Some(references) => references .iter() .filter_map(|reference| { self.read_account(ReadAccountParams { @@ -2017,46 +2018,59 @@ impl Storage { Some(_) => None, }; }; - let row = self.reference_row(params.anchor_number, application_number); + // No row: nothing has ever happened at this origin. + let Some(references) = self.account_references(params.anchor_number, application_number) + else { + return match params.account_number { + None => Some(synthetic_default()), + Some(_) => None, + }; + }; match params.account_number { // The tracked default. - None => match row { - ReferenceRow::Untouched => Some(synthetic_default()), - // XXX WARNING: a tombstone means the default moved away, so answering - // with a synthetic one lets its former owner reconstruct it at the same - // principal. Kept for now because refusing would lock out an identity - // that moved its default away and then reached the account limit. - ReferenceRow::Tombstone => Some(synthetic_default()), + None => { + // XXX WARNING: an empty row is a tombstone — the default moved away — + // so answering with a synthetic one lets its former owner reconstruct + // it at the same principal. Kept for now because refusing would lock + // out an identity that moved its default away and then reached the + // account limit. + if references.is_empty() { + return Some(synthetic_default()); + } + // A row that names other accounts but not the default means the default // was named or moved away; the identity signs in with one of the others. - ReferenceRow::Held(held) => held.reference(None).map(|reference| { - Account::new_with_last_used( - params.anchor_number, - params.origin.clone(), - None, - reference.account_number, - reference.last_used, - ) - }), - }, + references + .iter() + .find(|reference| reference.account_number.is_none()) + .map(|reference| { + Account::new_with_last_used( + params.anchor_number, + params.origin.clone(), + None, + reference.account_number, + reference.last_used, + ) + }) + } // A named account. The stored record carries its name; this identity's row // naming it is what says the identity owns it. Some(account_number) => { let storable_account = self.stable_account_memory.get(&account_number)?; - let ReferenceRow::Held(held) = row else { - return None; - }; - held.reference(Some(account_number)).map(|reference| { - Account::new_full( - params.anchor_number, - params.origin.clone(), - Some(storable_account.name.clone()), - Some(account_number), - reference.last_used, - storable_account.seed_from_anchor, - ) - }) + references + .iter() + .find(|reference| reference.account_number == Some(account_number)) + .map(|reference| { + Account::new_full( + params.anchor_number, + params.origin.clone(), + Some(storable_account.name.clone()), + Some(account_number), + reference.last_used, + storable_account.seed_from_anchor, + ) + }) } } } @@ -2156,15 +2170,22 @@ impl Storage { // no reference naming it. let existing_references = match self .lookup_application_number_with_origin(&origin) - .map(|application_number| self.reference_row(anchor_number, application_number)) - { + .and_then(|application_number| { + self.account_references(anchor_number, application_number) + }) { // Nothing stored under this origin yet, so the row starts with just this // account. Default accounts are never created explicitly. - None | Some(ReferenceRow::Untouched) => None, - Some(ReferenceRow::Held(held)) if held.reference(None).is_some() => Some(held), - // A tombstone holds nothing to name, and a row whose default reference is - // gone never regains one — the account it pointed at was removed. - Some(ReferenceRow::Tombstone) | Some(ReferenceRow::Held(_)) => { + None => None, + Some(references) + if references + .iter() + .any(|reference| reference.account_number.is_none()) => + { + Some(references) + } + // An empty row is a tombstone and holds nothing to name, and a row whose + // default reference is gone never regains one — it was named or moved away. + Some(_) => { return Err(StorageError::MissingAccount { anchor_number, name, @@ -2201,14 +2222,17 @@ impl Storage { last_used: None, }; let references = match existing_references { - None => HeldReferences::including(vec![], new_reference), - Some(mut held) => { + None => vec![new_reference], + Some(mut references) => { // Present, per the check above. Repointed in place so the reference // keeps both its position in the list and its `last_used`. - if let Some(default_reference) = held.reference_mut(None) { + if let Some(default_reference) = references + .iter_mut() + .find(|reference| reference.account_number.is_none()) + { default_reference.account_number = Some(new_account_number); } - held + references } }; @@ -2467,79 +2491,6 @@ impl Storage { } } -/// What one `(anchor, application)` reference-list row holds. -/// -/// Deliberately not `Option>`: absence and emptiness are opposites here, and -/// spelling them as one nullable vector is what lets a caller treat them alike. -/// -/// - Absent means nothing ever happened at this application, so a default account is -/// reconstructible and reads answer with a synthetic one. -/// - Empty means everything that was here moved away. The default must never be -/// reconstructible again, or its former owner could re-mint it at the same -/// principal. -/// -/// Matching is exhaustive, so a fourth state cannot be added without every reader -/// being made to say what it does about it. -#[derive(Clone, Debug, PartialEq, Eq)] -enum ReferenceRow { - /// No row. - Untouched, - /// A row holding nothing. A permanent tombstone. - Tombstone, - /// A row holding references. - Held(HeldReferences), -} - -/// The references stored against one row, never empty. -/// -/// An empty list is a [`ReferenceRow::Tombstone`] and means something else entirely, -/// so it cannot be built here — which is what keeps the write path from producing one -/// by accident. -#[derive(Clone, Debug, PartialEq, Eq)] -struct HeldReferences(Vec); - -impl HeldReferences { - /// `None` for an empty list, which is a tombstone rather than references. - fn new(references: Vec) -> Option { - (!references.is_empty()).then_some(Self(references)) - } - - /// Non-empty because `reference` is in it, whatever `others` holds. The way a - /// caller adding a reference builds a row without having to rule out emptiness it - /// has just made impossible. - fn including(mut others: Vec, reference: AccountReference) -> Self { - others.push(reference); - Self(others) - } - - /// The reference for one account, where `None` asks for the tracked default. - fn reference(&self, account_number: Option) -> Option<&AccountReference> { - self.0 - .iter() - .find(|reference| reference.account_number == account_number) - } - - /// The reference for one account, to modify in place. Answers `None` where this - /// identity holds no such reference, which is what makes reference-list - /// membership the ownership check. - fn reference_mut( - &mut self, - account_number: Option, - ) -> Option<&mut AccountReference> { - self.0 - .iter_mut() - .find(|reference| reference.account_number == account_number) - } - - fn iter(&self) -> impl Iterator { - self.0.iter() - } - - fn into_vec(self) -> Vec { - self.0 - } -} - /// Which of the counters derived from a reference list a delta is applied to. /// /// Each carries what identifies its row, so a refusal points at the counter that @@ -2600,17 +2551,21 @@ struct ReferenceListDeltas { } impl ReferenceListDeltas { - /// What writing `new_references` over `previous_row` does to the counters. + /// What writing `new_references` over `previous_references` does to the counters. /// - /// A tombstone counts as no references, which is right for the totals and is why - /// removing a row must not consult this: a tombstone's row is still alive while - /// holding nothing, so decrementing to zero on it would let the row be retired and - /// a moved-away default be reconstructed at the same principal. - fn between(previous_row: &ReferenceRow, new_references: &HeldReferences) -> Self { + /// A row that does not exist and one holding nothing both count as no references, + /// which is right for these totals: neither contributes any. It is also why + /// retiring a row must not go through here — a tombstone's row is still alive while + /// holding nothing, so a diff against it would report no change and leave the + /// counters claiming references the removed row no longer has. + fn between( + previous_references: &[AccountReference], + new_references: &[AccountReference], + ) -> Self { /// Saturating rather than `as`: a list long enough to overflow `i64` cannot /// exist — `MAX_ANCHOR_ACCOUNTS` bounds it far below — and saturating says so /// without a cast that would wrap silently if that ever stopped being true. - fn counts<'a>(references: impl Iterator) -> (i64, i64) { + fn counts(references: &[AccountReference]) -> (i64, i64) { let mut named = 0i64; let mut total = 0i64; for reference in references { @@ -2622,11 +2577,8 @@ impl ReferenceListDeltas { (named, total) } - let (previous_named, previous_total) = match previous_row { - ReferenceRow::Untouched | ReferenceRow::Tombstone => (0, 0), - ReferenceRow::Held(held) => counts(held.iter()), - }; - let (new_named, new_total) = counts(new_references.iter()); + let (previous_named, previous_total) = counts(previous_references); + let (new_named, new_total) = counts(new_references); Self { accounts: new_named.saturating_sub(previous_named), @@ -2714,6 +2666,12 @@ pub enum StorageError { }, ErrorUpdatingAccountCounter, AccountsCounterOverflow, + /// The references a write assembled cannot be stored as they stand. + UnstorableAccountReferenceList { + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + error: StorableAccountReferenceListError, + }, /// A counter derived from a reference list cannot move by the delta a write /// implies, which means it and the stored lists have already diverged. AccountCounterOutOfBounds { @@ -2785,6 +2743,15 @@ impl fmt::Display for StorageError { ), Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), Self::AccountsCounterOverflow => write!(f, "No account numbers left to allocate"), + Self::UnstorableAccountReferenceList { + anchor_number, + application_number, + error, + } => write!( + f, + "the account reference list for anchor {anchor_number} at application \ + {application_number} cannot be stored: {error}" + ), Self::AccountCounterOutOfBounds { counter, count, diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 4ee48cc1d4..5799cff3ea 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -1,4 +1,5 @@ use crate::storage::account::Account; +use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::storable::application::StorableApplication; use crate::storage::{CreateAccountParams, ReadAccountParams, UpdateAccountParams}; use crate::Storage; @@ -512,7 +513,7 @@ fn should_read_default_account_with_empty_reference_list() { let app_num = storage.lookup_or_insert_application_number_with_origin(&origin); storage.stable_account_reference_list_memory.insert( (anchor_number, app_num), - vec![].into(), // Empty reference list + StorableAccountReferenceList::tombstone_for_testing(), ); // 3. Try to read default account diff --git a/src/internet_identity/src/storage/storable/account_reference_list.rs b/src/internet_identity/src/storage/storable/account_reference_list.rs index 2f58720f32..224ebbf0e8 100644 --- a/src/internet_identity/src/storage/storable/account_reference_list.rs +++ b/src/internet_identity/src/storage/storable/account_reference_list.rs @@ -4,10 +4,11 @@ use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use minicbor::{Decode, Encode}; use std::borrow::Cow; +use std::fmt; /// Vectors are not supported yet in ic-stable-structures, this file /// implements a struct to wrap this vector so it can be stored. -#[derive(Encode, Decode, Clone, Ord, Eq, PartialEq, PartialOrd, Default)] +#[derive(Encode, Decode, Clone, Ord, Eq, PartialEq, PartialOrd)] #[cbor(transparent)] pub struct StorableAccountReferenceList(#[n(0)] Vec); @@ -25,13 +26,44 @@ impl Storable for StorableAccountReferenceList { const BOUND: Bound = Bound::Unbounded; } +/// Why a list of account references cannot be stored. +/// +/// Only ever raised on the way in. Decoding stays infallible, so a rule added here +/// applies to every future write of an existing row as well as to new ones — a stored +/// row that broke one would become unwritable, and for a reference list that means an +/// identity locked out of the origin. So the rules here are limited to states nothing +/// has ever written. +#[derive(Debug, Eq, PartialEq)] +pub enum StorableAccountReferenceListError { + /// An empty list is a tombstone: it says every reference at this origin was moved + /// away and its default account must never be derived again. Only a move may + /// create one, and nothing moves accounts yet, so an empty list here is a bug + /// rather than an intent — storing it would deny an identity its default account + /// for good. + Empty, +} + +impl fmt::Display for StorableAccountReferenceListError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Empty => write!( + f, + "refusing to store an empty account reference list, which would be a tombstone" + ), + } + } +} + impl StorableAccountReferenceList { pub fn into_vec(self) -> Vec { self.0 } - pub fn from_vec(vec: Vec) -> Self { - Self(vec) + /// The row a future account move will leave behind, for tests that need one to + /// exist. Test-only because [`Self::try_from`] refuses it, which is the point. + #[cfg(test)] + pub fn tombstone_for_testing() -> Self { + Self(vec![]) } } @@ -46,14 +78,60 @@ impl From for Vec { } } -impl From> for StorableAccountReferenceList { - fn from(value: Vec) -> Self { - StorableAccountReferenceList( +/// The only way to build a list to be stored, so every write is checked. Deliberately +/// `TryFrom` rather than `From`: an infallible conversion existed here before and the +/// checks it lacked had to be remembered at each of the write sites instead. +impl TryFrom> for StorableAccountReferenceList { + type Error = StorableAccountReferenceListError; + + fn try_from(value: Vec) -> Result { + if value.is_empty() { + return Err(StorableAccountReferenceListError::Empty); + } + + Ok(StorableAccountReferenceList( value .iter() .cloned() .map(StorableAccountReference::from) .collect(), - ) + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn reference(account_number: Option) -> AccountReference { + AccountReference { + account_number, + last_used: None, + } + } + + #[test] + fn refuses_an_empty_list() { + assert_eq!( + StorableAccountReferenceList::try_from(vec![]).err(), + Some(StorableAccountReferenceListError::Empty) + ); + } + + #[test] + fn keeps_the_references_in_the_order_they_were_given() { + let references = vec![reference(None), reference(Some(7)), reference(Some(3))]; + + let stored = StorableAccountReferenceList::try_from(references.clone()).unwrap(); + + assert_eq!(Vec::::from(stored), references); + } + + #[test] + fn a_list_without_a_tracked_default_is_storable() { + // Not a tombstone: the default was named, so the row legitimately holds only + // numbered references. + assert!(StorableAccountReferenceList::try_from(vec![reference(Some(7))]).is_ok()); } } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 0a4b53a4cf..86063ab722 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2128,9 +2128,7 @@ fn test_anchor_storage_migration_round_trip() { mod reference_list_write_path_tests { use crate::storage::account::{AccountReference, CreateAccountParams}; use crate::storage::storable::accounts_counter::StorableAccountsCounter; - use crate::storage::{ - HeldReferences, ReferenceCount, ReferenceCounter, ReferenceRow, StorageError, - }; + use crate::storage::{ReferenceCount, ReferenceCounter, StorageError}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -2144,12 +2142,6 @@ mod reference_list_write_path_tests { (storage, anchor_number) } - /// The references a test means to write, for the cases where a non-empty list is - /// the premise rather than the thing under test. - fn held(references: Vec) -> HeldReferences { - HeldReferences::new(references).expect("test wrote an empty reference list") - } - #[test] fn allocating_past_the_last_account_number_is_refused() { let (mut storage, anchor_number) = storage_with_anchor(); @@ -2190,7 +2182,7 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - held(vec![default_reference.clone(), named_reference]), + vec![default_reference.clone(), named_reference], ) .unwrap(); @@ -2201,7 +2193,7 @@ mod reference_list_write_path_tests { let result = storage.write_reference_list( anchor_number, application_number, - held(vec![default_reference]), + vec![default_reference], ); // The refusal names what diverged: this identity's account count, what it held, @@ -2217,12 +2209,10 @@ mod reference_list_write_path_tests { .to_string() ); // Refused before anything was written: the list still holds both references. - let ReferenceRow::Held(references) = - storage.reference_row(anchor_number, application_number) - else { - panic!("the row written above is gone"); - }; - assert_eq!(references.iter().count(), 2); + let references = storage + .account_references(anchor_number, application_number) + .expect("the row written above is gone"); + assert_eq!(references.len(), 2); } #[test] @@ -2242,7 +2232,7 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - held(vec![default_reference, named_reference.clone()]), + vec![default_reference, named_reference.clone()], ) .unwrap(); storage.set_counters_for_testing(anchor_number, 0, 0); @@ -2250,11 +2240,8 @@ mod reference_list_write_path_tests { // Dropping the tracked default takes a reference without taking a named // account, so the two deltas differ: 0 and -1. Only the reference count can // under-run here, and the refusal has to name that one rather than the other. - let result = storage.write_reference_list( - anchor_number, - application_number, - held(vec![named_reference]), - ); + let result = + storage.write_reference_list(anchor_number, application_number, vec![named_reference]); assert_eq!( result.unwrap_err().to_string(), @@ -2270,14 +2257,22 @@ mod reference_list_write_path_tests { #[test] fn an_empty_list_cannot_be_handed_to_the_write_path() { - // The write path takes `HeldReferences`, which has no empty value, so an - // empty list is refused at construction rather than at the write. - assert!(HeldReferences::new(vec![]).is_none()); - assert!(HeldReferences::new(vec![AccountReference { - account_number: None, - last_used: None, - }]) - .is_some()); + // Refused by `StorableAccountReferenceList`, so the write path cannot store one + // however the caller assembled it. + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); + + let result = storage.write_reference_list(anchor_number, application_number, vec![]); + + assert!(matches!( + result, + Err(StorageError::UnstorableAccountReferenceList { .. }) + )); + assert_eq!( + storage.account_references(anchor_number, application_number), + None + ); } #[test] @@ -2288,10 +2283,10 @@ mod reference_list_write_path_tests { let result = storage.write_reference_list( anchor_number, unknown_application_number, - held(vec![AccountReference { + vec![AccountReference { account_number: None, last_used: None, - }]), + }], ); assert!(matches!( @@ -2299,8 +2294,8 @@ mod reference_list_write_path_tests { Err(StorageError::OriginNotFoundForApplicationNumber { .. }) )); assert_eq!( - storage.reference_row(anchor_number, unknown_application_number), - ReferenceRow::Untouched + storage.account_references(anchor_number, unknown_application_number), + None ); assert_eq!( storage.get_account_counter(anchor_number), @@ -2324,14 +2319,13 @@ mod reference_list_write_path_tests { last_used: None, }]; storage - .write_reference_list(anchor_number, application_number, held(references.clone())) + .write_reference_list(anchor_number, application_number, references.clone()) .unwrap(); storage .stable_application_memory .remove(&application_number); - let result = - storage.write_reference_list(anchor_number, application_number, held(references)); + let result = storage.write_reference_list(anchor_number, application_number, references); assert!(matches!( result, @@ -2349,7 +2343,7 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - held(vec![ + vec![ AccountReference { account_number: None, last_used: None, @@ -2358,7 +2352,7 @@ mod reference_list_write_path_tests { account_number: Some(7), last_used: None, }, - ]), + ], ) .unwrap(); @@ -2388,20 +2382,20 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - held(vec![AccountReference { + vec![AccountReference { account_number: None, last_used: None, - }]), + }], ) .unwrap(); storage .write_reference_list( anchor_number, application_number, - held(vec![AccountReference { + vec![AccountReference { account_number: Some(3), last_used: None, - }]), + }], ) .unwrap(); @@ -2425,7 +2419,7 @@ mod reference_list_write_path_tests { }]; storage - .write_reference_list(anchor_number, application_number, held(references.clone())) + .write_reference_list(anchor_number, application_number, references.clone()) .unwrap(); let after_first_write = storage.get_account_counter(anchor_number); @@ -2433,10 +2427,10 @@ mod reference_list_write_path_tests { .write_reference_list( anchor_number, application_number, - held(vec![AccountReference { + vec![AccountReference { account_number: Some(1), last_used: Some(123), - }]), + }], ) .unwrap(); @@ -2477,14 +2471,15 @@ mod reference_list_write_path_tests { } } -/// The three states a reference-list row can be in mean three different things, and -/// the reads have to keep telling them apart. Absence says a default account is still -/// reconstructible; emptiness says it never can be again. -mod reference_row_state_tests { +/// A `(anchor, application)` row can be absent, empty, or hold references, and those +/// mean three different things. Absence says a default account is still +/// reconstructible; emptiness is a tombstone and says it never can be again. +mod account_reference_state_tests { use crate::storage::account::{ AccountReference, CreateAccountParams, ReadAccountParams, UpdateAccountParams, }; - use crate::storage::{HeldReferences, ReferenceRow, StorageError}; + use crate::storage::storable::account_reference_list::StorableAccountReferenceList; + use crate::storage::StorageError; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::{AccountNumber, AnchorNumber}; @@ -2501,17 +2496,18 @@ mod reference_row_state_tests { } /// Plants the row a future account move would leave behind. The write path cannot - /// produce one, which is the whole point of [`HeldReferences`], so a test that + /// store one, which is the whole point, so a test that /// needs a tombstone has to write it directly. fn plant_tombstone(storage: &mut Storage, anchor_number: AnchorNumber) { let origin = ORIGIN.to_string(); let application_number = storage.lookup_or_insert_application_number_with_origin(&origin); - storage - .stable_account_reference_list_memory - .insert((anchor_number, application_number), vec![].into()); + storage.stable_account_reference_list_memory.insert( + (anchor_number, application_number), + StorableAccountReferenceList::tombstone_for_testing(), + ); assert_eq!( - storage.reference_row(anchor_number, application_number), - ReferenceRow::Tombstone + storage.account_references(anchor_number, application_number), + Some(vec![]) ); } @@ -2578,11 +2574,10 @@ mod reference_row_state_tests { .write_reference_list( anchor_number, application_number, - HeldReferences::new(vec![AccountReference { + vec![AccountReference { account_number: Some(account_number), last_used: None, - }]) - .unwrap(), + }], ) .unwrap(); @@ -2610,11 +2605,9 @@ mod reference_row_state_tests { let application_number = storage .lookup_application_number_with_origin(&origin) .unwrap(); - let ReferenceRow::Held(references) = - storage.reference_row(anchor_number, application_number) - else { - panic!("the named account should have left references behind"); - }; + let references = storage + .account_references(anchor_number, application_number) + .expect("the named account should have left references behind"); assert_eq!( references .iter() @@ -2649,8 +2642,8 @@ mod reference_row_state_tests { .lookup_application_number_with_origin(&origin) .unwrap(); assert_eq!( - storage.reference_row(anchor_number, application_number), - ReferenceRow::Tombstone + storage.account_references(anchor_number, application_number), + Some(vec![]) ); assert_eq!( storage @@ -2689,11 +2682,9 @@ mod reference_row_state_tests { let application_number = storage .lookup_application_number_with_origin(&origin) .unwrap(); - let ReferenceRow::Held(references) = - storage.reference_row(anchor_number, application_number) - else { - panic!("naming the default should not have emptied the row"); - }; + let references = storage + .account_references(anchor_number, application_number) + .expect("naming the default should not have emptied the row"); // Repointed where it stood, keeping the order accounts are listed in and the // timestamp the reference already carried. assert_eq!( From 15340b623a224fca5444a29d90d0eaab1d06de05 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 17:42:38 +0200 Subject: [PATCH 078/298] Merge branch 'feat/account-principal-index' into feat/account-principal-index-backfill --- src/internet_identity/src/storage/tests.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 758aaae5d3..c98c00f966 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4101,7 +4101,6 @@ mod account_principal_index_tests { } mod account_principal_index_backfill_tests { - use super::held; use crate::delegation::canister_sig_principal; use crate::storage::account::{Account, AccountReference}; use crate::storage::canister_id; @@ -4128,10 +4127,10 @@ mod account_principal_index_backfill_tests { .write_reference_list( anchor_number, application_number, - held(vec![AccountReference { + vec![AccountReference { account_number: None, last_used: Some(index + 1), - }]), + }], ) .unwrap(); } From 6711920fd6b8f81326cfadf4d69d78a46e95c3e6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 17:50:52 +0200 Subject: [PATCH 079/298] Merge branch 'feat/app-delegation-from-session' into feat/session-refresh-stamps --- src/internet_identity/src/storage.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index aa143fe263..0d95801dd8 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2039,13 +2039,15 @@ impl Storage { device_id: SessionDeviceId, now: Timestamp, ) -> Result { - let ReferenceRow::Held(mut references) = - self.reference_row(anchor_number, application_number) + let Some(mut references) = self.account_references(anchor_number, application_number) else { return Ok(false); }; - let Some(reference) = references.reference_mut(account_number) else { + let Some(reference) = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + else { return Ok(false); }; let Some(session) = reference From 43030f602db2d4f5576e28a91ddd2bcc66c3fdaf Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 17:52:07 +0200 Subject: [PATCH 080/298] Merge branch 'feat/session-refresh-stamps' into feat/app-revoke-session --- src/internet_identity/src/storage.rs | 12 +++++++----- src/internet_identity/src/storage/tests.rs | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index c12def1000..96d4aec760 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1934,8 +1934,8 @@ impl Storage { // creation time is a guard: it stops a caller removing a session that replaced the // one it matched. let present = self - .reference_row(anchor_number, application_number) - .references() + .account_references(anchor_number, application_number) + .unwrap_or_default() .iter() .any(|reference| { reference.account_number == account_number @@ -2150,12 +2150,14 @@ impl Storage { account_number: Option, device_id: SessionDeviceId, ) -> Result { - let ReferenceRow::Held(mut references) = - self.reference_row(anchor_number, application_number) + let Some(mut references) = self.account_references(anchor_number, application_number) else { return Ok(0); }; - let Some(reference) = references.reference_mut(account_number) else { + let Some(reference) = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + else { return Ok(0); }; let dropped: Vec = reference diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 65c419128b..fd7156d632 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5610,7 +5610,7 @@ mod session_refresh_stamp_tests { } mod session_removal_tests { - use super::{held_references, ReferenceRow}; + use super::held_references; use crate::storage::CreateSessionParams; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -5695,8 +5695,8 @@ mod session_removal_tests { .unwrap(); assert_ne!( - storage.reference_row(anchor_number, application_number), - ReferenceRow::Untouched + storage.account_references(anchor_number, application_number), + None ); } } From 48009a56c39880cacf3703be8cdac4b62b8d55d9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 17:53:35 +0200 Subject: [PATCH 081/298] Merge branch 'feat/app-revoke-session' into feat/revoke-sessions-from-settings --- src/internet_identity/src/storage.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index d08c6342a4..adf8ca5dfd 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1933,12 +1933,14 @@ impl Storage { let Some(application_number) = self.lookup_application_number_with_origin(origin) else { return Ok(0); }; - let ReferenceRow::Held(mut references) = - self.reference_row(anchor_number, application_number) + let Some(mut references) = self.account_references(anchor_number, application_number) else { return Ok(0); }; - let Some(reference) = references.reference_mut(account_number) else { + let Some(reference) = references + .iter_mut() + .find(|reference| reference.account_number == account_number) + else { return Ok(0); }; From 00c0eec793e0b8cc75e4a19df029a54f17ff4187 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 18:16:49 +0200 Subject: [PATCH 082/298] refactor(be): rename an account without rewriting its reference row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `with_named_account_mut` existed so `update_existing_account` could reach a named account and the reference to it in one call. Its closure never mutated the reference — it set the record's name and read `last_used` — but the helper routed through `with_account_reference_mut`, which always writes the row back. So every rename rewrote the whole `(anchor, application)` blob with byte-identical content. It reads the reference and writes only the record now. Holding a reference is still what grants access, so a miss is still `AccountNotFound`; that check is now a read (`account_reference`) rather than a write that happens to find nothing. That leaves one helper for the callers that do change a reference, named for what it hands over: `with_reference_mut` said nothing about which reference, and `with_named_account_mut` named what it operated on rather than what the closure received, so nothing in the pair said one was the other plus a record. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 106 ++++++++------------- src/internet_identity/src/storage/tests.rs | 43 +++++++++ 2 files changed, 84 insertions(+), 65 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index f69505be63..18beb0b630 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1544,16 +1544,32 @@ impl Storage { .map(Vec::::from) } + /// This identity's reference for one account, or `None` where it holds none. + /// + /// `account_number` names the reference, `None` being the tracked default. + /// Answering `None` is the ownership check: an account belongs to whichever + /// identity's row names it, so a caller that finds no reference here has no claim + /// on the account whether or not it exists. + fn account_reference( + &self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + account_number: Option, + ) -> Option { + self.account_references(anchor_number, application_number)? + .into_iter() + .find(|reference| reference.account_number == account_number) + } + /// Applies `f` to one of this identity's account references, and writes the row /// back if it ran. /// /// `account_number` names the reference, `None` being the tracked default. /// /// `Ok(None)` means there was nothing to apply `f` to and nothing was written: - /// the row is absent or a tombstone, or it holds no reference for this account. - /// That last case is the ownership check — an account belongs to whichever - /// identity's row names it. - fn with_reference_mut( + /// the row is absent or a tombstone, or it holds no reference for this account — + /// see [`Self::account_reference`] for what that last case means. + fn with_account_reference_mut( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, @@ -1583,48 +1599,6 @@ impl Storage { Ok(Some(result)) } - /// Applies `f` to one of this identity's named accounts and the reference to it, - /// and writes both back if it ran. - /// - /// The tracked default is not reachable here: it is derived and has no stored - /// account record, so only a named account can be handed to `f`. - /// - /// `Ok(None)` carries the same meaning as in [`Self::with_reference_mut`], plus - /// the account having been removed. - fn with_named_account_mut( - &mut self, - anchor_number: AnchorNumber, - application_number: ApplicationNumber, - account_number: AccountNumber, - f: F, - ) -> Result, StorageError> - where - F: FnOnce(&mut AccountReference, &mut StorableAccount) -> T, - { - // A named account with no stored record has been removed, so there is nothing - // to modify. - let Some(mut storable_account) = self.stable_account_memory.get(&account_number) else { - return Ok(None); - }; - - let result = self.with_reference_mut( - anchor_number, - application_number, - Some(account_number), - |reference| f(reference, &mut storable_account), - )?; - - // Skipped on a miss, where `f` never ran: the record would be written back - // exactly as it was read, and it belongs to whichever identity does hold a - // reference to it. - if result.is_some() { - self.stable_account_memory - .insert(account_number, storable_account); - } - - Ok(result) - } - pub fn set_account_last_used( &mut self, anchor_number: AnchorNumber, @@ -1638,7 +1612,7 @@ impl Storage { return Ok(None); }; - self.with_reference_mut( + self.with_account_reference_mut( anchor_number, application_number, account_number, @@ -2126,28 +2100,30 @@ impl Storage { // Holding a reference is what grants access, so a miss here means this // identity does not own the account, whether or not it exists. - let Some(updated_account) = self.with_named_account_mut( - anchor_number, - application_number, - account_number, - |account_reference, storable_account| { - storable_account.name = name.clone(); - - Account::new_full( - anchor_number, - origin, - Some(name), - Some(account_number), - account_reference.last_used, - storable_account.seed_from_anchor, - ) - }, - )? + let Some(reference) = + self.account_reference(anchor_number, application_number, Some(account_number)) else { return Err(StorageError::AccountNotFound { account_number }); }; + let Some(mut storable_account) = self.stable_account_memory.get(&account_number) else { + return Err(StorageError::AccountNotFound { account_number }); + }; + + // Only the account record is written. Renaming leaves every reference as it + // was, and the row is a single blob, so writing it back would store the bytes + // it already holds. + storable_account.name = name.clone(); + self.stable_account_memory + .insert(account_number, storable_account.clone()); - Ok(updated_account) + Ok(Account::new_full( + anchor_number, + origin, + Some(name), + Some(account_number), + reference.last_used, + storable_account.seed_from_anchor, + )) } /// Used in `update_account` to create a default account. diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 86063ab722..eeb4e21b35 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2702,6 +2702,49 @@ mod account_reference_state_tests { ); } + #[test] + fn renaming_an_account_writes_only_its_record() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + let account = storage + .create_additional_account(CreateAccountParams { + anchor_number, + name: "named".to_string(), + origin: origin.clone(), + }) + .unwrap(); + let account_number = account.account_number.unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let references_before = storage + .account_references(anchor_number, application_number) + .unwrap(); + + // A skipped write is invisible in the stored bytes, since rewriting the row + // would store what it already holds. Retiring the application row makes it + // visible: `write_reference_list` refuses without one, so a rename that still + // went through it could not succeed here. + storage + .stable_application_memory + .remove(&application_number); + + let renamed = storage + .update_account(UpdateAccountParams { + account_number: Some(account_number), + anchor_number, + name: "renamed".to_string(), + origin: origin.clone(), + }) + .unwrap(); + + assert_eq!(renamed.name, Some("renamed".to_string())); + assert_eq!( + storage.account_references(anchor_number, application_number), + Some(references_before) + ); + } + #[test] fn an_identity_holding_no_reference_can_neither_rename_nor_stamp_the_account() { let (mut storage, owner) = storage_with_anchor(); From c8f9ad5b320a02a20a7cf50935e798dce93b1ac3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 19:27:40 +0200 Subject: [PATCH 083/298] refactor(be): allocate from the counter and the highest stored number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeding ran at load and took `max(stored_counter, stable_application_memory.len())`. The row count is only the first free number while numbering is dense from zero, so it needed three sentences of doc explaining when it could be trusted, and it had to be seeded eagerly — deferring would let a removal shrink the count first. The map is keyed by `ApplicationNumber`, so `last_key_value` gives the highest number outright. That is exact whether or not the rows have holes, which removes the density assumption rather than documenting around it, and with it the reason to seed eagerly: the allocator takes `max(counter, highest + 1)` where it allocates. `seed_application_number_allocator` is gone, and so is its constructor-time `expect` — there was nowhere for it to report to. The counter is still what guarantees a number is never reissued, because it only climbs; the highest stored number is a floor for the applications that predate it. Removing the highest row walks that floor backwards, which is why it cannot be the answer on its own. Both arithmetic steps are checked, refusing rather than saturating for the reason `allocate_account_number` refuses: the number keys the application row and the origin index, so reissuing one would put two origins on a single row and have them share its accounts and counters. `allocate_application_number` and `lookup_or_insert_application_number_with_origin` return `Result` for that. `a_gap_below_the_highest_number_is_not_handed_out_again` covers the state a row count gets wrong: a hole in the rows while the counter knows nothing. `a_removal_before_the_first_allocation_does_not_collide_with_a_live_number` now covers it too — it used to pass under a row count only because the removal happened after seeding, and there is no seeding step for it to follow. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 3 +- src/internet_identity/src/storage.rs | 113 ++++++++----- .../src/storage/account/tests.rs | 4 +- src/internet_identity/src/storage/tests.rs | 159 ++++++++++++++---- 4 files changed, 197 insertions(+), 82 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 61f85617a8..ae72ee6a8f 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -161,7 +161,8 @@ pub fn set_default_account_for_origin( ) -> Result { let application_number = storage_borrow_mut(|storage| { storage.lookup_or_insert_application_number_with_origin(&origin) - }); + }) + .map_err(|err| SetDefaultAccountError::InternalCanisterError(err.to_string()))?; let account = if let Some(account_number) = account_number { try_read_account_info( diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 7d9b965ca5..d336aca253 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -545,7 +545,7 @@ impl Storage { MinHeap::init(registration_current_rate_memory.clone()) .expect("failed to initialize registration current rate min heap"), ); - let mut storage = Self { + Self { header, header_memory, anchor_memory, @@ -658,27 +658,7 @@ impl Storage { sso_stable_id_index_memory.clone(), ), sso_stable_id_index_memory: StableBTreeMap::init(sso_stable_id_index_memory), - }; - storage.seed_application_number_allocator(); - storage - } - - /// Seeds the allocator from whichever is higher: the stored counter, or the row - /// count. - /// - /// The row count is a floor rather than the answer. It is exact only for data - /// written before this counter existed, where numbering was dense from zero. - /// Retiring an application removes its row without reissuing its number, so from - /// then on the count undershoots and only the stored counter is right — taking the - /// maximum is what keeps allocation monotonic across both. - fn seed_application_number_allocator(&mut self) { - let seeded = ApplicationNumber::max( - *self.next_application_number_memory.get(), - self.stable_application_memory.len(), - ); - self.next_application_number_memory - .set(seeded) - .expect("failed to seed the application number allocator"); + } } pub fn salt(&self) -> Option<&Salt> { @@ -1508,42 +1488,74 @@ impl Storage { } /// Look up an application number per origin, create entry in applications and lookup table if it doesn't exist + /// + /// Fails only where there is no number left to hand out, which + /// [`Self::allocate_application_number`] refuses rather than reissuing one. pub fn lookup_or_insert_application_number_with_origin( &mut self, origin: &FrontendHostname, - ) -> ApplicationNumber { + ) -> Result { let origin_sha256 = StorableOriginSha256::from_origin(origin); if let Some(existing_number) = self .lookup_application_with_origin_memory .get(&origin_sha256) { - existing_number - } else { - let new_number = self.allocate_application_number(); + return Ok(existing_number); + } - // Update the source of truth. - self.lookup_application_with_origin_memory - .insert(origin_sha256, new_number); + let new_number = self.allocate_application_number()?; - let new_application = StorableApplication { - origin: origin.to_string(), - stored_accounts: 0u64, - stored_account_references: 0u64, - }; + // Update the source of truth. + self.lookup_application_with_origin_memory + .insert(origin_sha256, new_number); - self.stable_application_memory - .insert(new_number, new_application); - new_number - } + let new_application = StorableApplication { + origin: origin.to_string(), + stored_accounts: 0u64, + stored_account_references: 0u64, + }; + + self.stable_application_memory + .insert(new_number, new_application); + + Ok(new_number) } - fn allocate_application_number(&mut self) -> ApplicationNumber { - let new_number = *self.next_application_number_memory.get(); + /// Hands out an application number that no live application holds and no retired + /// one ever held. + /// + /// The counter is what guarantees that: it only ever climbs, so a number it has + /// passed is never offered again even after the application's row is retired. Its + /// value is not the whole answer only because it postdates the applications + /// numbered before it existed, so the highest stored number is taken as a floor — + /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest row walks it backwards. + /// + /// Refuses at the ceiling rather than saturating. The number keys both the + /// application row and the origin index, so reissuing one would put two origins on + /// a single row and have them share its accounts and counters. + fn allocate_application_number(&mut self) -> Result { + let above_highest_stored = match self.stable_application_memory.last_key_value() { + Some((highest, _)) => highest + .checked_add(1) + .ok_or(StorageError::ApplicationsCounterOverflow)?, + None => 0, + }; + let new_number = ApplicationNumber::max( + *self.next_application_number_memory.get(), + above_highest_stored, + ); + self.next_application_number_memory - .set(new_number + 1) - .expect("failed to advance the application number allocator"); - new_number + .set( + new_number + .checked_add(1) + .ok_or(StorageError::ApplicationsCounterOverflow)?, + ) + .map_err(|_| StorageError::ErrorUpdatingApplicationNumberAllocator)?; + + Ok(new_number) } pub fn lookup_application_number_with_origin( @@ -1937,7 +1949,7 @@ impl Storage { .insert(account_number, storable_account); // Update application data - let application_number = self.lookup_or_insert_application_number_with_origin(origin); + let application_number = self.lookup_or_insert_application_number_with_origin(origin)?; // last_used will be set once the user signs in with the account. let last_used = None; @@ -2217,7 +2229,7 @@ impl Storage { .insert(new_account_number, storable_account.clone()); // Get or create an application number from the account's origin. - let application_number = self.lookup_or_insert_application_number_with_origin(&origin); + let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; // Update default account in the (anchor, origin) config. { @@ -2679,6 +2691,11 @@ pub enum StorageError { }, ErrorUpdatingAccountCounter, AccountsCounterOverflow, + /// No application numbers left to hand out. Refused rather than saturated: the + /// number keys the application row and the origin index, so reissuing one would + /// put two origins on a single row. + ApplicationsCounterOverflow, + ErrorUpdatingApplicationNumberAllocator, /// The references a write assembled cannot be stored as they stand. UnstorableAccountReferenceList { anchor_number: AnchorNumber, @@ -2756,6 +2773,12 @@ impl fmt::Display for StorageError { ), Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), Self::AccountsCounterOverflow => write!(f, "No account numbers left to allocate"), + Self::ApplicationsCounterOverflow => { + write!(f, "No application numbers left to allocate") + } + Self::ErrorUpdatingApplicationNumberAllocator => { + write!(f, "Error updating the application number allocator") + } Self::UnstorableAccountReferenceList { anchor_number, application_number, diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 5799cff3ea..8f33e698e9 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -510,7 +510,9 @@ fn should_read_default_account_with_empty_reference_list() { let origin: FrontendHostname = "https://some.origin".to_string(); // 2. Create application but with empty account reference list - let app_num = storage.lookup_or_insert_application_number_with_origin(&origin); + let app_num = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); storage.stable_account_reference_list_memory.insert( (anchor_number, app_num), StorableAccountReferenceList::tombstone_for_testing(), diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 810be8a7ab..651fc1edf6 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -702,7 +702,9 @@ mod application_lookup_tests { let mut storage = Storage::new((10, 20), VectorMemory::default()); let origin = "https://example.com".to_string(); - let app_number = storage.lookup_or_insert_application_number_with_origin(&origin); + let app_number = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); // Should create application number 0 for first application assert_eq!(app_number, 0); @@ -725,10 +727,14 @@ mod application_lookup_tests { let origin = "https://example.com".to_string(); // Create application first time - let app_number1 = storage.lookup_or_insert_application_number_with_origin(&origin); + let app_number1 = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); // Should return same application number on second call - let app_number2 = storage.lookup_or_insert_application_number_with_origin(&origin); + let app_number2 = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); assert_eq!(app_number1, app_number2); assert_eq!(app_number1, 0); @@ -742,9 +748,15 @@ mod application_lookup_tests { let origin2 = "https://different.com".to_string(); let origin3 = "https://another.org".to_string(); - let app_num1 = storage.lookup_or_insert_application_number_with_origin(&origin1); - let app_num2 = storage.lookup_or_insert_application_number_with_origin(&origin2); - let app_num3 = storage.lookup_or_insert_application_number_with_origin(&origin3); + let app_num1 = storage + .lookup_or_insert_application_number_with_origin(&origin1) + .unwrap(); + let app_num2 = storage + .lookup_or_insert_application_number_with_origin(&origin2) + .unwrap(); + let app_num3 = storage + .lookup_or_insert_application_number_with_origin(&origin3) + .unwrap(); assert_eq!(app_num1, 0); assert_eq!(app_num2, 1); @@ -768,7 +780,9 @@ mod application_lookup_tests { let long_origin = format!("https://{}.com", "a".repeat(20_000)); - let app_number = storage.lookup_or_insert_application_number_with_origin(&long_origin); + let app_number = storage + .lookup_or_insert_application_number_with_origin(&long_origin) + .unwrap(); assert_eq!(app_number, 0); // Should be findable in both maps @@ -790,7 +804,9 @@ mod application_lookup_tests { ]; for (i, origin) in origins.iter().enumerate() { - let app_number = storage.lookup_or_insert_application_number_with_origin(origin); + let app_number = storage + .lookup_or_insert_application_number_with_origin(origin) + .unwrap(); assert_eq!(app_number, i as u64); // Total application count should increment @@ -806,7 +822,9 @@ mod application_lookup_tests { // Create storage and add application { let mut storage = Storage::new((10, 20), memory.clone()); - let app_number = storage.lookup_or_insert_application_number_with_origin(&origin); + let app_number = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); assert_eq!(app_number, 0); } @@ -2169,7 +2187,9 @@ mod reference_list_write_path_tests { fn refuses_a_counter_delta_that_would_underflow_without_writing_anything() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.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(&origin) + .unwrap(); let default_reference = AccountReference { account_number: None, last_used: None, @@ -2219,7 +2239,9 @@ mod reference_list_write_path_tests { fn the_two_counts_move_independently_and_the_refusal_says_which_one_failed() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.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(&origin) + .unwrap(); let default_reference = AccountReference { account_number: None, last_used: None, @@ -2261,7 +2283,9 @@ mod reference_list_write_path_tests { // however the caller assembled it. let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.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(&origin) + .unwrap(); let result = storage.write_reference_list(anchor_number, application_number, vec![]); @@ -2313,7 +2337,9 @@ mod reference_list_write_path_tests { fn a_zero_delta_write_still_requires_a_live_application() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.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(&origin) + .unwrap(); let references = vec![AccountReference { account_number: Some(1), last_used: None, @@ -2337,7 +2363,9 @@ mod reference_list_write_path_tests { fn derives_counters_from_added_references() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.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(&origin) + .unwrap(); storage .write_reference_list( @@ -2376,7 +2404,9 @@ mod reference_list_write_path_tests { fn materializing_a_default_moves_only_the_account_counter() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.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(&origin) + .unwrap(); storage .write_reference_list( @@ -2412,7 +2442,9 @@ mod reference_list_write_path_tests { fn rewriting_an_unchanged_list_leaves_counters_alone() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.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(&origin) + .unwrap(); let references = vec![AccountReference { account_number: Some(1), last_used: None, @@ -2500,7 +2532,9 @@ mod account_reference_state_tests { /// needs a tombstone has to write it directly. fn plant_tombstone(storage: &mut Storage, anchor_number: AnchorNumber) { let origin = ORIGIN.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(&origin) + .unwrap(); storage.stable_account_reference_list_memory.insert( (anchor_number, application_number), StorableAccountReferenceList::tombstone_for_testing(), @@ -2819,12 +2853,15 @@ mod application_number_allocator_tests { fn allocates_dense_numbers_from_zero() { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); - let first = - storage.lookup_or_insert_application_number_with_origin(&"https://a.com".into()); - let second = - storage.lookup_or_insert_application_number_with_origin(&"https://b.com".into()); - let third = - storage.lookup_or_insert_application_number_with_origin(&"https://c.com".into()); + let first = storage + .lookup_or_insert_application_number_with_origin(&"https://a.com".into()) + .unwrap(); + let second = storage + .lookup_or_insert_application_number_with_origin(&"https://b.com".into()) + .unwrap(); + let third = storage + .lookup_or_insert_application_number_with_origin(&"https://c.com".into()) + .unwrap(); assert_eq!((first, second, third), (0, 1, 2)); } @@ -2834,15 +2871,19 @@ mod application_number_allocator_tests { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); let origin = "https://a.com".to_string(); - let first = storage.lookup_or_insert_application_number_with_origin(&origin); - let again = storage.lookup_or_insert_application_number_with_origin(&origin); + let first = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); + let again = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); assert_eq!(first, again); assert_eq!(storage.get_total_application_count(), 1); } #[test] - fn seeds_past_applications_written_before_the_allocator_existed() { + fn takes_past_applications_written_before_the_allocator_existed_into_account() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); for (number, origin) in [ @@ -2858,28 +2899,66 @@ mod application_number_allocator_tests { storage.flush(); let mut storage = Storage::from_memory(memory); - let next = storage.lookup_or_insert_application_number_with_origin(&"https://d.com".into()); + let next = storage + .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) + .unwrap(); assert_eq!(next, 3); } + #[test] + fn a_gap_below_the_highest_number_is_not_handed_out_again() { + let memory = VectorMemory::default(); + let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); + for (number, origin) in [ + (0, "https://a.com"), + (1, "https://b.com"), + (2, "https://c.com"), + ] { + storage + .stable_application_memory + .insert(number, application(origin)); + } + // The rows now have a hole in them while the counter knows nothing, which is + // the one state a row count gets wrong: it would answer 2, the number + // `https://c.com` still holds. + storage.stable_application_memory.remove(&0); + storage.next_application_number_memory.set(0).unwrap(); + storage.flush(); + + let mut storage = Storage::from_memory(memory); + let next = storage + .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) + .unwrap(); + + assert_eq!(next, 3); + assert_eq!( + storage.stable_application_memory.get(&2).unwrap().origin, + "https://c.com" + ); + } + #[test] fn never_reissues_the_number_of_a_removed_application() { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); for origin in ["https://a.com", "https://b.com", "https://c.com"] { - storage.lookup_or_insert_application_number_with_origin(&origin.into()); + storage + .lookup_or_insert_application_number_with_origin(&origin.into()) + .unwrap(); } storage.stable_application_memory.remove(&1); - let next = storage.lookup_or_insert_application_number_with_origin(&"https://d.com".into()); + let next = storage + .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) + .unwrap(); assert_eq!(next, 3); assert!(storage.stable_application_memory.get(&2).is_some()); } #[test] - fn reseeding_after_a_reap_does_not_lower_the_allocator() { + fn a_reap_does_not_lower_the_allocator_across_an_upgrade() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); for origin in [ @@ -2888,7 +2967,9 @@ mod application_number_allocator_tests { "https://c.com", "https://d.com", ] { - storage.lookup_or_insert_application_number_with_origin(&origin.into()); + storage + .lookup_or_insert_application_number_with_origin(&origin.into()) + .unwrap(); } storage.flush(); storage.stable_application_memory.remove(&1); @@ -2896,7 +2977,9 @@ mod application_number_allocator_tests { assert_eq!(storage.stable_application_memory.len(), 2); let mut storage = Storage::from_memory(memory.clone()); - let next = storage.lookup_or_insert_application_number_with_origin(&"https://e.com".into()); + let next = storage + .lookup_or_insert_application_number_with_origin(&"https://e.com".into()) + .unwrap(); assert_eq!(next, 4); assert_eq!( @@ -2906,7 +2989,9 @@ mod application_number_allocator_tests { let mut storage = Storage::from_memory(memory); assert_eq!( - storage.lookup_or_insert_application_number_with_origin(&"https://f.com".into()), + storage + .lookup_or_insert_application_number_with_origin(&"https://f.com".into()) + .unwrap(), 5 ); } @@ -2916,7 +3001,9 @@ mod application_number_allocator_tests { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); for origin in ["https://a.com", "https://b.com", "https://c.com"] { - storage.lookup_or_insert_application_number_with_origin(&origin.into()); + storage + .lookup_or_insert_application_number_with_origin(&origin.into()) + .unwrap(); } storage.next_application_number_memory.set(0).unwrap(); storage.flush(); @@ -2924,7 +3011,9 @@ mod application_number_allocator_tests { let mut storage = Storage::from_memory(memory); storage.stable_application_memory.remove(&0); - let next = storage.lookup_or_insert_application_number_with_origin(&"https://d.com".into()); + let next = storage + .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) + .unwrap(); assert_eq!(next, 3); assert_eq!( From e3e17d519e351ca84f4af508513e91a51a3733b4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 19:33:33 +0200 Subject: [PATCH 084/298] Merge branch 'refactor/monotonic-application-numbers' into fix/read-account-empty-list-is-not-default --- src/internet_identity/src/storage/account/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index ad4bd4dc37..7a9358b3f3 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -531,7 +531,9 @@ fn should_read_a_synthetic_default_account_when_no_reference_list_exists() { let anchor_number: AnchorNumber = 10_000; let origin: FrontendHostname = "https://some.origin".to_string(); - let app_num = storage.lookup_or_insert_application_number_with_origin(&origin); + let app_num = storage + .lookup_or_insert_application_number_with_origin(&origin) + .unwrap(); let default_account = storage .read_account(ReadAccountParams { From 17a16fe9e3dc187dd5e238a700f6261a427e0e09 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Fri, 4 Sep 2026 19:35:59 +0200 Subject: [PATCH 085/298] Merge branch 'feat/track-default-accounts' into feat/account-principal-index --- src/internet_identity/src/storage/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index c50ecda592..7431186165 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4205,7 +4205,9 @@ mod account_principal_index_tests { let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); let origin = "https://example.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(&origin) + .unwrap(); let result = storage.write_reference_list( anchor_number, From ad74d6c012c345ba4b5673026e5a3f9ed787ebef Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 01:14:49 +0200 Subject: [PATCH 086/298] refactor(be): one read and one write for accounts Three maps describe an identity's accounts at an origin: the reference list saying which it holds, the record holding each name, and the config naming its default. Every writer read the list itself and applied its own missing-row and tombstone policy, and the record and config were written outside the list's write path, so the three could drift. Reads normalise. `account_references` turns an absent row into the derived default and leaves a tombstone empty, so absence and emptiness stop being two things that both look like nothing and no caller carries a missing-row policy. Writes take the whole thing. `write_account_state` stores the list, the record and the config together, diffing the list against the stored row for the counter deltas. Every fallible step runs before any write, so a refusal leaves nothing behind. A list the row already holds is not written, which is what lets a rename leave every reference where it stands without its caller knowing to skip. What is left is `read_account`, `list_accounts`, `create_account`, `write_account` and `set_default_account`, addressed by an `AccountKey` that carries no capability. `create_additional_account`, `create_default_account`, `update_account`, `update_existing_account`, `set_account_last_used`, `account_reference` and `with_account_reference_mut` are gone. Three defects go with them: - `create_additional_account` stored the record before two fallible steps. On the IC `Err` commits what came before, so a refusal left a record holding a consumed account number that nothing referenced. The record now writes inside the gate, after everything that can fail. - `get_account_for_origin(None)` returned `Account::synthetic` without reading the row, so the attribute endpoints signed for a default the identity might have named or moved away. Every account now comes out of `read_account`, and `Account::synthetic` is `#[cfg(test)]` so it cannot be reached otherwise. - `read_account` took a caller's `known_app_num` and never checked it named the origin beside it, so references could be read from one application while the seed came from another. Storage resolves the origin itself. Naming the tracked default writes its config and its reference together, so a config naming an account no reference names can no longer be left behind. `write_account` takes the state to store rather than a patch over it: what the account carries is what the row ends up holding, so `None` never has to mean "leave this part alone". An account with a number and no name is not a state a read can hand back, and is refused as `MissingAccountName`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 228 ++---- src/internet_identity/src/mcp.rs | 45 +- src/internet_identity/src/storage.rs | 767 ++++++++---------- src/internet_identity/src/storage/account.rs | 44 +- .../src/storage/account/tests.rs | 158 ++-- src/internet_identity/src/storage/tests.rs | 457 +++++------ 6 files changed, 756 insertions(+), 943 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index ae72ee6a8f..8ee57c9f49 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -9,10 +9,9 @@ use crate::{ state::{self, storage_borrow, storage_borrow_mut}, storage::{ account::{ - validate_account_name, Account, AccountDelegationError, AccountsCounter, - CreateAccountParams, PrepareAccountDelegation, ReadAccountParams, UpdateAccountParams, + validate_account_name, Account, AccountDelegationError, AccountKey, AccountsCounter, + PrepareAccountDelegation, }, - storable::anchor_application_config::AnchorApplicationConfig, Storage, }, update_root_hash, @@ -23,10 +22,9 @@ use ic_stable_structures::DefaultMemoryImpl; use internet_identity_interface::{ archive::types::{Operation, Private}, internet_identity::types::{ - AccountInfo, AccountNumber, AccountUpdate, AnchorNumber, ApplicationNumber, - CheckMaxAccountError, CreateAccountError, Delegation, FrontendHostname, GetAccountError, - GetDefaultAccountError, SessionKey, SetDefaultAccountError, SignedDelegation, Timestamp, - UpdateAccountError, + AccountInfo, AccountNumber, AccountUpdate, AnchorNumber, CheckMaxAccountError, + CreateAccountError, Delegation, FrontendHostname, GetAccountError, GetDefaultAccountError, + SessionKey, SetDefaultAccountError, SignedDelegation, Timestamp, UpdateAccountError, }, }; #[cfg(test)] @@ -42,44 +40,38 @@ pub fn get_accounts_for_origin( storage_borrow(|storage| storage.list_accounts(anchor_number, origin)) } -/// Helper function to read an account by application number, unlike `storage.read_account` this -/// returns `Result` instead of `Option`. -/// -/// This function is applicable only to numbered accounts; synthetic accounts are not currently -/// stored in the memory and thus cannot be fetched from the storage. +/// Helper function to read an account, unlike `storage.read_account` this returns +/// `Result` instead of `Option`. fn try_read_account( anchor_number: AnchorNumber, origin: &FrontendHostname, - application_number: ApplicationNumber, - account_number: AccountNumber, + account_number: Option, ) -> Result { - let Some(account) = storage_borrow(|storage| { - storage.read_account(ReadAccountParams { - account_number: Some(account_number), + storage_borrow(|storage| { + storage.read_account(&AccountKey { anchor_number, - origin, - known_app_num: Some(application_number), + origin: origin.clone(), + account_number, }) - }) else { - let message = format!( + }) + .ok_or_else(|| match account_number { + Some(account_number) => format!( "Account #{} does not exist for anchor {} and origin {}.", account_number, anchor_number, origin - ); - - return Err(message); - }; - - Ok(account) + ), + None => format!( + "Anchor {} has no default account at origin {}.", + anchor_number, origin + ), + }) } fn try_read_account_info( anchor_number: AnchorNumber, - origin: FrontendHostname, - application_number: ApplicationNumber, - account_number: AccountNumber, + origin: &FrontendHostname, + account_number: Option, ) -> Result { - try_read_account(anchor_number, &origin, application_number, account_number) - .map(|account| account.to_info()) + try_read_account(anchor_number, origin, account_number).map(|account| account.to_info()) } pub fn get_account_for_origin( @@ -87,105 +79,73 @@ pub fn get_account_for_origin( origin: FrontendHostname, account_number: Option, ) -> Result { - // If the account_number is not specific, there is a *synthetic account* to return for any - // (anchor, origin) pair. - let Some(account_number) = account_number else { - return Ok(Account::synthetic(anchor_number, origin)); - }; - - // If the account is specified, there must be a known app for this origin. - let Some(application_number) = - storage_borrow(|storage| storage.lookup_application_number_with_origin(&origin)) - else { - return Err(GetAccountError::NoSuchOrigin { anchor_number }); - }; + // Including the tracked default, which the storage read answers for: it is the + // identity's only where its row still names it, and no account at all where it was + // named or moved away. + if let Ok(account) = try_read_account(anchor_number, &origin, account_number) { + return Ok(account); + } - // Once we know the (anchor, origin, app, account_number), we can try reading the corresponding - // account. - let account = try_read_account(anchor_number, &origin, application_number, account_number) - .map_err(|_| GetAccountError::NoSuchAccount { - anchor_number, - origin, - })?; + // A named account at an origin nothing has ever been stored under is reported as + // the origin being unknown, which is more than "no such account" tells a client. + if account_number.is_some() + && storage_borrow(|storage| storage.lookup_application_number_with_origin(&origin)) + .is_none() + { + return Err(GetAccountError::NoSuchOrigin { anchor_number }); + } - Ok(account) + Err(GetAccountError::NoSuchAccount { + anchor_number, + origin, + }) } -/// Best effort to determine the default account for the given (anchor, origin). -/// - If the application is not found, returns a "synthetic" account. -/// - If no default account stored, returns a "synthetic" account. -/// - Else, read from storage, returning an Err result if not found. +/// The account this identity signs in with at `origin` by default: the one it reserved, +/// or its tracked default where it reserved none. /// -/// An Err case indicates internal inconsistency in the canister state. +/// An `Err` means the identity has neither, which it can only reach by moving every +/// account away from the origin. pub fn get_default_account_for_origin( anchor_number: AnchorNumber, origin: FrontendHostname, ) -> Result { - let Some(application_number) = - storage_borrow(|storage| storage.lookup_application_number_with_origin(&origin)) - else { - return Ok(Account::synthetic(anchor_number, origin).to_info()); - }; - - let AnchorApplicationConfig { - default_account_number, - } = storage_borrow(|storage| { - storage.lookup_anchor_application_config(anchor_number, application_number) + let reserved = storage_borrow(|storage| { + storage + .lookup_application_number_with_origin(&origin) + .and_then(|application_number| { + storage + .lookup_anchor_application_config(anchor_number, application_number) + .default_account_number + }) }); - let Some(default_account_number) = default_account_number else { - return Ok(Account::synthetic(anchor_number, origin).to_info()); - }; - - let account = try_read_account_info( - anchor_number, - origin, - application_number, - default_account_number, - ) - .map_err(GetDefaultAccountError::InternalCanisterError)?; - - Ok(account) + // A `None` here reads the tracked default, so an anchor that reserved nothing and + // one whose reservation is still the default answer the same way. + try_read_account_info(anchor_number, &origin, reserved) + .map_err(GetDefaultAccountError::InternalCanisterError) } -/// Sets the default account for the given (anchor, origin) to the specified `account_number`. -/// -/// If the `account_number` is `None`, then the synthetic account is returned, and also `None` -/// is stored (just so that the previous default account is not retained). +/// Reserves `account_number` as this identity's default at `origin`, or clears the +/// reservation where it is `None`, leaving the tracked default in its place. /// -/// If this is the first time an origin is seen for the anchor, a new application number is created. +/// The account is read first, so an identity cannot reserve one it does not hold. pub fn set_default_account_for_origin( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, ) -> Result { - let application_number = storage_borrow_mut(|storage| { - storage.lookup_or_insert_application_number_with_origin(&origin) - }) - .map_err(|err| SetDefaultAccountError::InternalCanisterError(err.to_string()))?; - - let account = if let Some(account_number) = account_number { - try_read_account_info( + let account = try_read_account_info(anchor_number, &origin, account_number).map_err(|_| { + SetDefaultAccountError::NoSuchAccount { anchor_number, - origin.clone(), - application_number, - account_number, - ) - .map_err(|_| SetDefaultAccountError::NoSuchAccount { - anchor_number, - origin, - })? - } else { - Account::synthetic(anchor_number, origin).to_info() - }; - - let config = AnchorApplicationConfig { - default_account_number: account_number, - }; + origin: origin.clone(), + } + })?; storage_borrow_mut(|storage| { - storage.set_anchor_application_config(anchor_number, application_number, config); - }); + storage.set_default_account(anchor_number, origin, account_number) + }) + .map_err(|err| SetDefaultAccountError::InternalCanisterError(err.to_string()))?; Ok(account) } @@ -206,11 +166,7 @@ pub fn create_account_for_origin( .map_err(Into::::into)?; storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: name.clone(), - origin, - }) + .create_account(anchor_number, origin, name.clone()) .map_err(|err| CreateAccountError::InternalCanisterError(format!("{err}"))) })?; @@ -255,21 +211,17 @@ pub fn update_account_for_origin( // caller naming an account it does not have, which is what // `prepare_account_delegation` answers for the same read. let old_account = storage - .read_account(ReadAccountParams { - account_number, + .read_account(&AccountKey { anchor_number, - origin: &origin, - known_app_num: None + origin: origin.clone(), + account_number, }) .ok_or_else(|| UpdateAccountError::Unauthorized(caller()))?; + let mut renamed_account = old_account.clone(); + renamed_account.name = Some(new_name.clone()); let updated_account = storage - .update_account(UpdateAccountParams { - account_number, - anchor_number, - name: new_name.clone(), - origin: origin.clone(), - }) + .write_account(renamed_account) .map_err(|err| UpdateAccountError::InternalCanisterError(err.to_string()))?; Ok((updated_account, old_account.name)) @@ -319,11 +271,10 @@ pub async fn prepare_account_delegation( let account = storage_borrow(|storage| { storage - .read_account(ReadAccountParams { - account_number, + .read_account(&AccountKey { anchor_number, - origin: &origin, - known_app_num: None, + origin: origin.clone(), + account_number, }) .ok_or(AccountDelegationError::Unauthorized(caller())) })?; @@ -363,14 +314,14 @@ pub async fn prepare_account_delegation( let effective_duration_ns = expiration.saturating_sub(now); let seed = account.calculate_seed(); - // Stamped before the delegation is signed. On the IC returning `Err` commits - // every write that came before it, so propagating a failure from here once the - // signature was in the map would report an error for a delegation that has - // already been issued. `Ok(None)` is not a failure: it means the row holds no - // reference to stamp, which is how a default account that is still derived rather - // than stored reads. + // Stamped before the delegation is signed. On the IC returning `Err` commits every + // write that came before it, so propagating a failure from here once the signature + // was in the map would report an error for a delegation that has already been + // issued. storage_borrow_mut(|storage| { - storage.set_account_last_used(anchor_number, origin.clone(), account_number, now) + let mut used_account = account; + used_account.last_used = Some(now); + storage.write_account(used_account) }) .map_err(|err| AccountDelegationError::InternalCanisterError(err.to_string()))?; @@ -405,11 +356,10 @@ pub fn get_account_delegation( storage_borrow(|storage| { let account = storage - .read_account(ReadAccountParams { - account_number, + .read_account(&AccountKey { anchor_number, - origin, - known_app_num: None, + origin: origin.clone(), + account_number, }) .ok_or(AccountDelegationError::Unauthorized(caller()))?; diff --git a/src/internet_identity/src/mcp.rs b/src/internet_identity/src/mcp.rs index 3b240c4dba..588ef3823b 100644 --- a/src/internet_identity/src/mcp.rs +++ b/src/internet_identity/src/mcp.rs @@ -44,7 +44,7 @@ use crate::{ account_management, delegation::DelegationAccess, state::{persistent_state, storage_borrow, storage_borrow_mut}, - storage::account::{Account, AccountDelegationError, ReadAccountParams}, + storage::account::{AccountDelegationError, AccountKey}, storage::storable::mcp_config::StorableMcpConfig, storage::storable::mcp_grant::StorableMcpGrant, storage::Storage, @@ -129,35 +129,30 @@ pub fn resolve_connect_url(anchor_number: AnchorNumber) -> Option { .and_then(trusted_url) } -/// The anchor's default account at `origin` (synthetic when none is reserved). -fn default_account(anchor_number: AnchorNumber, origin: &FrontendHostname) -> Account { - storage_borrow(|storage| { - let Some(app_num) = storage.lookup_application_number_with_origin(origin) else { - return Account::synthetic(anchor_number, origin.clone()); - }; - let Some(default_num) = storage - .lookup_anchor_application_config(anchor_number, app_num) - .default_account_number - else { - return Account::synthetic(anchor_number, origin.clone()); - }; - storage - .read_account(ReadAccountParams { - account_number: Some(default_num), - anchor_number, - origin, - known_app_num: Some(app_num), - }) - .unwrap_or_else(|| Account::synthetic(anchor_number, origin.clone())) - }) -} - /// The default-account number for `(anchor, origin)` (None = unreserved default). fn default_account_number( anchor_number: AnchorNumber, origin: &FrontendHostname, ) -> Option { - default_account(anchor_number, origin).account_number + storage_borrow(|storage| { + // What the anchor reserved here, if anything. Read back through the account so + // a config left naming an account the anchor no longer holds answers the same + // as no reservation at all. + let reserved = storage + .lookup_application_number_with_origin(origin) + .and_then(|application_number| { + storage + .lookup_anchor_application_config(anchor_number, application_number) + .default_account_number + }); + + storage.read_account(&AccountKey { + anchor_number, + origin: origin.clone(), + account_number: reserved, + }) + })? + .account_number } /// Register the trusted MCP server's session key for `anchor_number`, granting diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 2d74300c42..4514a77600 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -79,10 +79,7 @@ //! //! The archive buffer memory is managed by the [MemoryManager] and is currently limited to a single //! bucket of 128 pages. -use account::{ - Account, AccountsCounter, CreateAccountParams, ReadAccountParams, UpdateAccountParams, - UpdateExistingAccountParams, -}; +use account::{Account, AccountKey, AccountsCounter}; use candid::{CandidType, Deserialize, Principal}; use ic_cdk::api::stable::WASM_PAGE_SIZE_IN_BYTES; use ic_stable_structures::cell::ValueError; @@ -1577,98 +1574,58 @@ impl Storage { .and_then(|application_number| self.stable_application_memory.get(&application_number)) } - /// This identity's account references at `application_number`, or `None` where it - /// has no row there at all. + /// This identity's account references at `application_number`. + /// + /// An absent row normalises to the derived default: nothing has happened at this + /// origin, so the identity still has the default it has always had. A stored empty + /// row is a tombstone and stays empty — everything here moved away and the default + /// must never be derived again. /// - /// The one reader, so no caller has to assemble the list from storage itself. The - /// `None` is only ever "no row": an empty row is a tombstone and means the - /// opposite, so the two must not be collapsed by a caller either. + /// Absence and emptiness are opposites, and this is the only place that knows it. fn account_references( &self, anchor_number: AnchorNumber, application_number: ApplicationNumber, - ) -> Option> { - self.stable_account_reference_list_memory - .get(&(anchor_number, application_number)) - .map(Vec::::from) + ) -> Vec { + self.stored_account_references(anchor_number, application_number) + .unwrap_or_else(Self::derived_default_references) } - /// This identity's reference for one account, or `None` where it holds none. - /// - /// `account_number` names the reference, `None` being the tracked default. - /// Answering `None` is the ownership check: an account belongs to whichever - /// identity's row names it, so a caller that finds no reference here has no claim - /// on the account whether or not it exists. - fn account_reference( + /// [`Self::account_references`] for a caller that has an origin rather than an + /// application number. An origin nothing has ever been stored under has no row, so + /// it normalises the same way. + fn account_references_for_origin( &self, anchor_number: AnchorNumber, - application_number: ApplicationNumber, - account_number: Option, - ) -> Option { - self.account_references(anchor_number, application_number)? - .into_iter() - .find(|reference| reference.account_number == account_number) + origin: &FrontendHostname, + ) -> Vec { + match self.lookup_application_number_with_origin(origin) { + Some(application_number) => self.account_references(anchor_number, application_number), + None => Self::derived_default_references(), + } } - /// Applies `f` to one of this identity's account references, and writes the row - /// back if it ran. - /// - /// `account_number` names the reference, `None` being the tracked default. - /// - /// `Ok(None)` means there was nothing to apply `f` to and nothing was written: - /// the row is absent or a tombstone, or it holds no reference for this account — - /// see [`Self::account_reference`] for what that last case means. - fn with_account_reference_mut( - &mut self, - anchor_number: AnchorNumber, - application_number: ApplicationNumber, - account_number: Option, - f: F, - ) -> Result, StorageError> - where - F: FnOnce(&mut AccountReference) -> T, - { - let Some(mut references) = self.account_references(anchor_number, application_number) - else { - return Ok(None); - }; - - let Some(reference) = references - .iter_mut() - .find(|reference| reference.account_number == account_number) - else { - // `f` never ran, so nothing changed, and writing the row back here would - // store the bytes it already holds. - return Ok(None); - }; - let result = f(reference); - - self.write_reference_list(anchor_number, application_number, references)?; - - Ok(Some(result)) + /// What an identity holds where nothing is stored: the default it has always had, + /// derived from the origin rather than kept. + fn derived_default_references() -> Vec { + vec![AccountReference { + account_number: None, + last_used: None, + }] } - pub fn set_account_last_used( - &mut self, + /// The row as stored, with no default derived for an absent one. + /// + /// Only the write path may see this. The counters describe stored rows, so a row + /// that never existed must not be diffed against as though it held the default. + fn stored_account_references( + &self, anchor_number: AnchorNumber, - origin: FrontendHostname, - account_number: Option, - now: Timestamp, - ) -> Result, StorageError> { - // An origin nothing has ever been stored under holds no reference to stamp, - // which is the same answer as a row that holds no reference for this account. - let Some(application_number) = self.lookup_application_number_with_origin(&origin) else { - return Ok(None); - }; - - self.with_account_reference_mut( - anchor_number, - application_number, - account_number, - |account_reference| { - account_reference.last_used = Some(now); - }, - ) + application_number: ApplicationNumber, + ) -> Option> { + self.stable_account_reference_list_memory + .get(&(anchor_number, application_number)) + .map(Vec::::from) } pub fn lookup_anchor_application_config( @@ -1686,59 +1643,89 @@ impl Storage { AnchorApplicationConfig::default() } - pub fn set_anchor_application_config( - &mut self, - anchor_number: AnchorNumber, - application_number: ApplicationNumber, - anchor_application_config: AnchorApplicationConfig, - ) { - self.stable_anchor_application_config_memory.insert( - (anchor_number, application_number), - anchor_application_config, - ); - } - - /// The single write path for an anchor's account reference list at one - /// application, including the counters derived from it. + /// The single write path for everything keyed by `(anchor_number, + /// application_number)`: the reference list, an account record, and the config + /// naming the default. /// - /// Refuses an empty list, which would be a tombstone — see - /// [`StorableAccountReferenceList::try_from`], which is where that is enforced and - /// why it is enforced there. - fn write_reference_list( + /// The three describe one state and drift apart if written apart, so they are + /// written together or not at all. Every fallible step runs before any of them: + /// on the IC returning `Err` commits what was written before it, and only a trap + /// rolls back, so a refusal here must leave nothing behind. + /// + /// `references` is the whole new list. It is diffed against the stored row for the + /// counter deltas, so a caller supplies only what it wants stored and cannot move a + /// counter by the default [`Self::account_references`] derived for it. A list equal + /// to the stored row writes nothing: the row is a single blob, so writing it back + /// would store the bytes it already holds. + fn write_account_state( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, - new_references: Vec, + references: Vec, + record: Option<(AccountNumber, StorableAccount)>, + config: Option, ) -> Result<(), StorageError> { - // Before the counters, so a list this identity may not store is refused with - // nothing written and no counter moved for it. - let storable_references = StorableAccountReferenceList::try_from(new_references.clone()) - .map_err(|error| StorageError::UnstorableAccountReferenceList { + let stored_references = self.stored_account_references(anchor_number, application_number); + + // Nothing to say: the row already holds these bytes, so it is not written and + // nothing about it is checked either. This is what lets a rename leave every + // reference alone without its caller having to know to skip the write. + let list_write = if stored_references.as_deref() == Some(references.as_slice()) { + None + } else { + // Refuses a list this identity may not store — see + // [`StorableAccountReferenceList::try_from`], which is where that is + // enforced and why it is enforced there. + let storable_references = StorableAccountReferenceList::try_from(references.clone()) + .map_err(|error| StorageError::UnstorableAccountReferenceList { + anchor_number, + application_number, + error, + })?; + let application = self + .stable_application_memory + .get(&application_number) + .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; + // A first row holding nothing but a derived default records nothing: + // absence already says the identity has its default here, so storing it + // would keep bytes to repeat that. Only an account number is worth a row. + // Checked after the refusals above, so a caller still hears about a list or + // an application it had no business passing. + let records_nothing = stored_references.is_none() + && references + .iter() + .all(|reference| reference.account_number.is_none()); + + let deltas = ReferenceListDeltas::between( + stored_references.as_deref().unwrap_or_default(), + &references, + ); + (!records_nothing).then_some((storable_references, application, deltas)) + }; + + if let Some((storable_references, application, deltas)) = list_write { + // Counters first: the only step left that can fail, so an out-of-bounds + // delta refuses with nothing stored rather than a list without its counts. + self.apply_reference_counter_deltas( anchor_number, application_number, - error, - })?; - let application = self - .stable_application_memory - .get(&application_number) - .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; + application, + deltas, + )?; + self.stable_account_reference_list_memory + .insert((anchor_number, application_number), storable_references); + } - let previous_references = self - .account_references(anchor_number, application_number) - .unwrap_or_default(); - let counter_deltas = ReferenceListDeltas::between(&previous_references, &new_references); + if let Some((account_number, storable_account)) = record { + self.stable_account_memory + .insert(account_number, storable_account); + } + + if let Some(config) = config { + self.stable_anchor_application_config_memory + .insert((anchor_number, application_number), config); + } - // Counters first: it is the only step here that can fail, and returning an error - // after the list is written would commit one without the other. A failure now - // leaves nothing written at all. - self.apply_reference_counter_deltas( - anchor_number, - application_number, - application, - counter_deltas, - )?; - self.stable_account_reference_list_memory - .insert((anchor_number, application_number), storable_references); Ok(()) } @@ -1928,345 +1915,283 @@ impl Storage { self.stable_account_counter_discrepancy_counter_memory.get() } - /// Creates an account for that identity. - /// If the identity doesn't yet have accounts, it will create the account reference for the synthetic account. - /// But not a storable account for the synthetic one. - pub fn create_additional_account( - &mut self, - params: CreateAccountParams, - ) -> Result { - check_frontend_length(¶ms.origin); - let anchor_number = params.anchor_number; - let origin = ¶ms.origin; - - // Create and store account in stable memory - let account_number = self.allocate_account_number()?; - let storable_account = StorableAccount { - name: params.name.clone(), - seed_from_anchor: None, - }; - self.stable_account_memory - .insert(account_number, storable_account); - - // Update application data - let application_number = self.lookup_or_insert_application_number_with_origin(origin)?; - - // last_used will be set once the user signs in with the account. - let last_used = None; - - // With no row yet the default account reference is created alongside this one, - // because default accounts are never created explicitly. An existing row is - // added to as it stands: a tombstone must not regain a default reference, which - // is the whole reason it is kept. - let mut references = self - .account_references(anchor_number, application_number) - .unwrap_or_else(|| { - vec![AccountReference { - account_number: None, - last_used, - }] - }); - references.push(AccountReference { - account_number: Some(account_number), - last_used, - }); - - self.write_reference_list(anchor_number, application_number, references)?; + /// One account this identity holds at `key.origin`, or `None` where it holds none. + /// + /// `key.account_number` names it, `None` being the tracked default. Answering + /// `None` is the ownership check: an account belongs to whichever identity's row + /// names it, so a caller that finds no reference here has no claim on the account + /// whether or not it exists. + /// + /// The `Account` returned carries the seed the account signs with, so this is also + /// the only place that capability is handed out — a caller that holds one has been + /// through the check above. + pub fn read_account(&self, key: &AccountKey) -> Option { + check_frontend_length(&key.origin); + + let reference = self + .account_references_for_origin(key.anchor_number, &key.origin) + .into_iter() + .find(|reference| reference.account_number == key.account_number)?; - // Return the new account - Ok(Account::new( - anchor_number, - origin.to_string(), - Some(params.name), - Some(account_number), - )) + self.account_for_reference(key.anchor_number, &key.origin, &reference) } - #[allow(dead_code)] - /// Returns a list of accounts for a given anchor and application. - /// If the application doesn't exist, returns a list with a synthetic default account. - /// If the account references don't exist, returns a list with a synthetic default account. + /// Every account this identity holds at `origin`. pub fn list_accounts( &self, anchor_number: AnchorNumber, origin: &FrontendHostname, ) -> Vec { check_frontend_length(origin); - let Some(application_number) = self.lookup_application_number_with_origin(origin) else { - // Nothing has ever been stored under this origin, so the default account is - // still reconstructible. - return vec![Account::synthetic(anchor_number, origin.clone())]; - }; - // An empty row is a tombstone: everything here moved away, so not even a - // synthetic default is offered — that is what it exists to prevent. Its empty - // list falls out of the iteration below. - match self.account_references(anchor_number, application_number) { - None => vec![Account::synthetic(anchor_number, origin.clone())], - Some(references) => references - .iter() - .filter_map(|reference| { - self.read_account(ReadAccountParams { - account_number: reference.account_number, - anchor_number, - origin, - known_app_num: Some(application_number), - }) - }) - .collect(), - } + self.account_references_for_origin(anchor_number, origin) + .iter() + .filter_map(|reference| self.account_for_reference(anchor_number, origin, reference)) + .collect() } - /// Returns the requested `Account`. - /// If the anchor doesn't own this `Account`, returns None. - /// If the `Account` is default but has been moved/deleted, returns None. - /// If the `Account` is default and ALL `Account`s for this origin have been moved or deleted, returns None. - /// If nothing has ever happened at this origin, returns a default `Account`. - /// If the `Account` number exists but the `Account` doesn't exist, returns None. - /// If the `Account` exists, returns it as `Account`. - /// Optionally an application number can be passed if it is already known, so we don't look it up more than necessary. - pub fn read_account(&self, params: ReadAccountParams) -> Option { - check_frontend_length(params.origin); - let application_number = params - .known_app_num - .or_else(|| self.lookup_application_number_with_origin(params.origin)); - - let synthetic_default = || Account::synthetic(params.anchor_number, params.origin.clone()); - - // Nothing has ever been stored under this origin. A default account is still - // reconstructible, and a named one cannot be here at all. - let Some(application_number) = application_number else { - return match params.account_number { - None => Some(synthetic_default()), - Some(_) => None, - }; - }; - // No row: nothing has ever happened at this origin. - let Some(references) = self.account_references(params.anchor_number, application_number) - else { - return match params.account_number { - None => Some(synthetic_default()), - Some(_) => None, - }; + /// The account one reference names, or `None` where its record is gone. + fn account_for_reference( + &self, + anchor_number: AnchorNumber, + origin: &FrontendHostname, + reference: &AccountReference, + ) -> Option { + // The tracked default has no record: its name is the origin's and its seed is + // the anchor's, both derived rather than stored. + let Some(account_number) = reference.account_number else { + return Some(Account::new_with_last_used( + anchor_number, + origin.clone(), + None, + None, + reference.last_used, + )); }; - match params.account_number { - // The tracked default. - None => { - // An empty row is a tombstone — the default moved away — and a row - // that names other accounts but not the default had it named or - // moved away too. Neither can be reconstructed from the origin, and - // both fall out of the lookup below finding nothing. - // A row that names other accounts but not the default means the default - // was named or moved away; the identity signs in with one of the others. - references - .iter() - .find(|reference| reference.account_number.is_none()) - .map(|reference| { - Account::new_with_last_used( - params.anchor_number, - params.origin.clone(), - None, - reference.account_number, - reference.last_used, - ) - }) - } - // A named account. The stored record carries its name; this identity's row - // naming it is what says the identity owns it. - Some(account_number) => { - let storable_account = self.stable_account_memory.get(&account_number)?; - references - .iter() - .find(|reference| reference.account_number == Some(account_number)) - .map(|reference| { - Account::new_full( - params.anchor_number, - params.origin.clone(), - Some(storable_account.name.clone()), - Some(account_number), - reference.last_used, - storable_account.seed_from_anchor, - ) - }) - } - } + let storable_account = self.stable_account_memory.get(&account_number)?; + Some(Account::new_full( + anchor_number, + origin.clone(), + Some(storable_account.name), + Some(account_number), + reference.last_used, + storable_account.seed_from_anchor, + )) } - /// Updates an account. - /// If the account number exists, then updates that account. - /// If the account number doesn't exist, then gets or creates an application and creates and stores a default account. - pub fn update_account(&mut self, params: UpdateAccountParams) -> Result { - let UpdateAccountParams { - account_number, + /// Creates an account named `name` at `origin` for this identity. + /// + /// The only place an account number is minted, and it takes none. A number that + /// nothing references can only be allocated here, never adopted — see + /// [`Self::write_account`] for what adopting one would hand a caller. + pub fn create_account( + &mut self, + anchor_number: AnchorNumber, + origin: FrontendHostname, + name: String, + ) -> Result { + check_frontend_length(&origin); + + // Both fallible, so both before the record is built. An allocated number that + // is never stored only leaves a gap, and the counter is monotonic by design; + // a stored record nothing references would be visible and permanent. + let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; + let account_number = self.allocate_account_number()?; + + // An absent row normalises to the derived default, which is how the first named + // account at an origin does not cost the identity the default it had. A + // tombstone normalises to nothing and stays that way. + let mut references = self.account_references(anchor_number, application_number); + references.push(AccountReference { + account_number: Some(account_number), + // Set when the identity signs in with the account. + last_used: None, + }); + + let storable_account = StorableAccount { + name: name.clone(), + seed_from_anchor: None, + }; + self.write_account_state( anchor_number, - name, - origin, - } = params; + application_number, + references, + Some((account_number, storable_account)), + None, + )?; - check_frontend_length(&origin); - match account_number { - Some(account_number) => self.update_existing_account(UpdateExistingAccountParams { - account_number, - anchor_number, - name, - origin, - }), - None => { - // Default accounts are not stored by default. - // They are created only once they are updated. - self.create_default_account(CreateAccountParams { - anchor_number, - name, - origin, - }) - } - } + Ok(Account::new( + anchor_number, + origin, + Some(name), + Some(account_number), + )) } - /// Used in `update_account` to update an existing account. - fn update_existing_account( - &mut self, - params: UpdateExistingAccountParams, - ) -> Result { - let UpdateExistingAccountParams { + /// Stores an account read back from [`Self::read_account`]. + /// + /// Renaming one, naming the tracked default, and recording that an account was used + /// are the same read-modify-write: the account is the state to store, not a patch + /// over it, so what it carries is what the row ends up holding. + /// + /// A number no reference names is [`StorageError::AccountNotFound`] and never a + /// create. `update_account_for_origin` takes its account number straight from the + /// client, so a write that adopted an unreferenced number would hand a caller a + /// reference to another identity's account, and with it that account's principal. + pub fn write_account(&mut self, account: Account) -> Result { + check_frontend_length(&account.origin); + + let Account { account_number, anchor_number, - name, origin, - } = params; - - // Nothing has ever been stored under this origin, so no reference to the - // account exists under it either. - let Some(application_number) = self.lookup_application_number_with_origin(&origin) else { - return Err(StorageError::AccountNotFound { account_number }); + last_used, + name, + .. + } = account; + + let application_number = match (account_number, &name) { + // Naming the tracked default stores this identity's first account here, so + // the origin gets its application number now. + (None, Some(_)) => self.lookup_or_insert_application_number_with_origin(&origin)?, + // Everything else writes to a row that already exists, and an origin + // nothing has been stored under has none. + _ => match self.lookup_application_number_with_origin(&origin) { + Some(application_number) => application_number, + None => { + return match account_number { + Some(account_number) => { + Err(StorageError::AccountNotFound { account_number }) + } + // The default here is still derived rather than stored, so + // there is no reference to record its use against. + None => Ok(Account::new_with_last_used( + anchor_number, + origin, + None, + None, + last_used, + )), + }; + } + }, }; - // Holding a reference is what grants access, so a miss here means this - // identity does not own the account, whether or not it exists. - let Some(reference) = - self.account_reference(anchor_number, application_number, Some(account_number)) + let mut references = self.account_references(anchor_number, application_number); + let Some(position) = references + .iter() + .position(|reference| reference.account_number == account_number) else { - return Err(StorageError::AccountNotFound { account_number }); + // Holding a reference is what grants access, so a miss means this identity + // does not have the account. For the tracked default it means the row is a + // tombstone or the default was named and is no longer numberless — neither + // can be reconstructed from the origin. + return Err(match account_number { + Some(account_number) => StorageError::AccountNotFound { account_number }, + None => StorageError::MissingAccount { + anchor_number, + name: name.unwrap_or_default(), + }, + }); }; - let Some(mut storable_account) = self.stable_account_memory.get(&account_number) else { - return Err(StorageError::AccountNotFound { account_number }); + references[position].last_used = last_used; + + let (account_number, storable_account, config) = match (account_number, name) { + // A stored account, whose record carries the name. Only the tracked default + // goes without one, so an account that has a number and no name is not a + // state a read can hand back. + (Some(_), None) => return Err(StorageError::MissingAccountName), + (Some(account_number), Some(name)) => { + let Some(mut storable_account) = self.stable_account_memory.get(&account_number) + else { + return Err(StorageError::AccountNotFound { account_number }); + }; + storable_account.name = name; + (account_number, storable_account, None) + } + // Naming the tracked default is what stores it, and storing it is what + // mints its number. Its seed stays the anchor's, so the principal this + // identity already signs in with here is preserved. + (None, Some(name)) => { + let account_number = self.allocate_account_number()?; + references[position].account_number = Some(account_number); + ( + account_number, + StorableAccount { + name, + seed_from_anchor: Some(anchor_number), + }, + Some(AnchorApplicationConfig { + default_account_number: Some(account_number), + }), + ) + } + // The tracked default, unnamed: nothing to store but the use of a + // reference the row already holds. + (None, None) => { + self.write_account_state( + anchor_number, + application_number, + references, + None, + None, + )?; + return Ok(Account::new_with_last_used( + anchor_number, + origin, + None, + None, + last_used, + )); + } }; - // Only the account record is written. Renaming leaves every reference as it - // was, and the row is a single blob, so writing it back would store the bytes - // it already holds. - storable_account.name = name.clone(); - self.stable_account_memory - .insert(account_number, storable_account.clone()); + let name = storable_account.name.clone(); + let seed_from_anchor = storable_account.seed_from_anchor; + self.write_account_state( + anchor_number, + application_number, + references, + Some((account_number, storable_account)), + config, + )?; Ok(Account::new_full( anchor_number, origin, Some(name), Some(account_number), - reference.last_used, - storable_account.seed_from_anchor, + last_used, + seed_from_anchor, )) } - /// Used in `update_account` to create a default account. - /// Default account are not initially stored. They are stored when updated. - /// If the default account reference does not exist, it must be created. - /// If the default account reference exists, its account number must be updated. - fn create_default_account( + /// Points this identity's default at `origin` to `account_number`, or clears it + /// where that is `None`. + /// + /// The config and the reference list go through one write, so a config naming a + /// number no reference names cannot be left behind. + pub fn set_default_account( &mut self, - params: CreateAccountParams, - ) -> Result { - let CreateAccountParams { - anchor_number, - name, - origin, - } = params; - - // Which reference the new account number goes into, decided before anything is - // written: returning `Err` on the IC commits every write that came before it, - // so a refusal below this point would leave an allocated account stored with - // no reference naming it. - let existing_references = match self - .lookup_application_number_with_origin(&origin) - .and_then(|application_number| { - self.account_references(anchor_number, application_number) - }) { - // Nothing stored under this origin yet, so the row starts with just this - // account. Default accounts are never created explicitly. - None => None, - Some(references) - if references - .iter() - .any(|reference| reference.account_number.is_none()) => - { - Some(references) - } - // An empty row is a tombstone and holds nothing to name, and a row whose - // default reference is gone never regains one — it was named or moved away. - Some(_) => { - return Err(StorageError::MissingAccount { - anchor_number, - name, - }); - } - }; - - // Create and store the default account. - let new_account_number = self.allocate_account_number()?; - let storable_account = StorableAccount { - name: name.clone(), - // This was a default account which uses the anchor number for the seed. - seed_from_anchor: Some(anchor_number), - }; - self.stable_account_memory - .insert(new_account_number, storable_account.clone()); + anchor_number: AnchorNumber, + origin: FrontendHostname, + account_number: Option, + ) -> Result<(), StorageError> { + check_frontend_length(&origin); - // Get or create an application number from the account's origin. let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; + let references = self.account_references(anchor_number, application_number); - // Update default account in the (anchor, origin) config. - { - let mut config = - self.lookup_anchor_application_config(anchor_number, application_number); - - config.default_account_number = Some(new_account_number); - - self.set_anchor_application_config(anchor_number, application_number, config); - } - - let new_reference = AccountReference { - account_number: Some(new_account_number), - // The `last_used` field will be set when the user signs with this account. - last_used: None, - }; - let references = match existing_references { - None => vec![new_reference], - Some(mut references) => { - // Present, per the check above. Repointed in place so the reference - // keeps both its position in the list and its `last_used`. - if let Some(default_reference) = references - .iter_mut() - .find(|reference| reference.account_number.is_none()) - { - default_reference.account_number = Some(new_account_number); - } - references - } - }; - - self.write_reference_list(anchor_number, application_number, references)?; - - // Return created default account - Ok(Account::new_full( + self.write_account_state( anchor_number, - origin, - Some(storable_account.name), - Some(new_account_number), + application_number, + references, None, - storable_account.seed_from_anchor, - )) + Some(AnchorApplicationConfig { + default_account_number: account_number, + }), + ) } /// Make sure all the required metadata is recorded to stable memory. diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 622134f2e8..6c2d155e95 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -7,41 +7,25 @@ use crate::{ use ic_cdk::trap; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ - AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, ApplicationNumber, - FrontendHostname, Timestamp, UserKey, + AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, FrontendHostname, + Timestamp, UserKey, }; use serde::{Deserialize, Serialize}; #[cfg(test)] mod tests; -// API to manage accounts. -pub struct CreateAccountParams { - pub anchor_number: AnchorNumber, - pub name: String, - pub origin: FrontendHostname, -} - -pub struct UpdateAccountParams { - pub account_number: Option, - pub anchor_number: AnchorNumber, - pub name: String, - pub origin: FrontendHostname, -} - -pub struct UpdateExistingAccountParams { - pub account_number: AccountNumber, +/// An account's address: the identity, the origin, and which of that identity's +/// accounts there, `None` being the tracked default. +/// +/// Carries no capability. The seed an account signs with lives on [`Account`], which +/// only [`crate::storage::Storage::read_account`] hands out, and only after checking +/// that the identity holds a reference to it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountKey { pub anchor_number: AnchorNumber, - pub name: String, pub origin: FrontendHostname, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ReadAccountParams<'a> { pub account_number: Option, - pub anchor_number: AnchorNumber, - pub origin: &'a FrontendHostname, - pub known_app_num: Option, } // Types used internally to encapsulate business logic and data. @@ -69,9 +53,13 @@ pub struct Account { } impl Account { - /// A "synthetic" account, i.e., one that is not meant to be stored. + /// An identity's default account at an origin, derived rather than stored. /// - /// One exception when it may be stored is to overwrite an existing stored account. + /// Test-only. In production every account comes out of + /// [`crate::storage::Storage::read_account`], which builds this one only where the + /// identity's row still names it — a derived default handed out without that check + /// would sign for an origin the identity may have moved every account away from. + #[cfg(test)] pub fn synthetic(anchor_number: AnchorNumber, origin: FrontendHostname) -> Self { Self { anchor_number, diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 7a9358b3f3..82a0773497 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -1,7 +1,7 @@ use crate::storage::account::Account; +use crate::storage::account::AccountKey; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::storable::application::StorableApplication; -use crate::storage::{CreateAccountParams, ReadAccountParams, UpdateAccountParams}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::{AnchorNumber, FrontendHostname}; @@ -20,7 +20,7 @@ fn assert_empty_counters(storage: &Storage, anchor_number: AnchorN } #[test] -fn should_create_additional_account() { +fn should_create_a_named_account() { // Setup storage let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); @@ -31,13 +31,12 @@ fn should_create_additional_account() { let account_name = "account name".to_string(); // 2. Additional account and application don't exist yet. - let read_params = ReadAccountParams { - account_number: Some(1), // First account created + let read_params = AccountKey { anchor_number, - origin: &origin, - known_app_num: None, + origin: origin.clone(), + account_number: Some(1), }; - let additional_account_1 = storage.read_account(read_params.clone()); + let additional_account_1 = storage.read_account(&read_params); assert!( additional_account_1.is_none(), "Additional account should not exist yet" @@ -51,17 +50,12 @@ fn should_create_additional_account() { assert_empty_counters(&storage, anchor_number); // 3. Create additional account - let new_account_params = CreateAccountParams { - anchor_number, - origin: origin.clone(), - name: account_name.clone(), - }; storage - .create_additional_account(new_account_params) + .create_account(anchor_number, origin.clone(), account_name.clone()) .unwrap(); // 5. Check that read_account returns additional account, creates application and updates counters. - let additional_account = storage.read_account(read_params).unwrap(); + let additional_account = storage.read_account(&read_params).unwrap(); let expected_account = Account { account_number: Some(1), anchor_number, @@ -117,15 +111,16 @@ fn should_list_accounts() { assert_empty_counters(&storage, anchor_number); // 4. Create new account - let new_account = CreateAccountParams { + let expected_additional_account = Account::new( anchor_number, - origin: origin.clone(), - name: account_name.clone(), - }; - let expected_additional_account = - Account::new(anchor_number, origin.clone(), Some(account_name), Some(1)); + origin.clone(), + Some(account_name.clone()), + Some(1), + ); let expected_default_account = Account::synthetic(anchor_number, origin.clone()); - storage.create_additional_account(new_account).unwrap(); + storage + .create_account(anchor_number, origin.clone(), account_name.clone()) + .unwrap(); // 5. List accounts returns default account let listed_accounts = storage.list_accounts(anchor_number, &origin); @@ -181,13 +176,8 @@ fn should_list_all_identity_accounts() { assert_eq!(listed_accounts.len(), 0); // 4. Create additional account - let new_account_params = CreateAccountParams { - anchor_number, - origin: origin.clone(), - name: account_name.clone(), - }; storage - .create_additional_account(new_account_params) + .create_account(anchor_number, origin.clone(), account_name.clone()) .unwrap(); // 5. List accounts returns default account @@ -196,13 +186,8 @@ fn should_list_all_identity_accounts() { assert_eq!(listed_accounts.len(), 2); // 6. Create additional account - let new_account_params = CreateAccountParams { - anchor_number, - origin: origin_2.clone(), - name: account_name.clone(), - }; storage - .create_additional_account(new_account_params) + .create_account(anchor_number, origin_2.clone(), account_name.clone()) .unwrap(); // 7. List accounts returns default account @@ -243,13 +228,15 @@ fn should_update_default_account() { assert_eq!(initial_accounts, vec![expected_unreserved_account]); // 3. Update default account - let updated_account_params = UpdateAccountParams { - anchor_number, - origin: origin.clone(), - name: account_name.clone(), - account_number: None, - }; - let new_account = storage.update_account(updated_account_params).unwrap(); + let mut account_to_update = storage + .read_account(&AccountKey { + anchor_number, + origin: origin.clone(), + account_number: None, + }) + .unwrap(); + account_to_update.name = Some(account_name.clone()); + let new_account = storage.write_account(account_to_update).unwrap(); // 4. Check that the default account has been created with the updated values. assert_eq!( @@ -293,13 +280,12 @@ fn should_update_additional_account() { let account_number = 1; // 2. Additional account and application don't exist yet. - let read_params = ReadAccountParams { - account_number: Some(account_number), // First account created is 1 + let read_params = AccountKey { anchor_number, - origin: &origin, - known_app_num: None, + origin: origin.clone(), + account_number: Some(account_number), }; - let additional_account_1 = storage.read_account(read_params.clone()); + let additional_account_1 = storage.read_account(&read_params); assert!( additional_account_1.is_none(), "Additional account should not exist yet" @@ -312,24 +298,21 @@ fn should_update_additional_account() { ); // 3. Create additional account - let new_account_params = CreateAccountParams { - anchor_number, - origin: origin.clone(), - name: account_name.clone(), - }; storage - .create_additional_account(new_account_params) + .create_account(anchor_number, origin.clone(), account_name.clone()) .unwrap(); - assert!(storage.read_account(read_params.clone()).is_some()); + assert!(storage.read_account(&read_params).is_some()); // 4. Update additional account - let updated_account_params = UpdateAccountParams { - anchor_number, - origin: origin.clone(), - name: new_account_name.clone(), - account_number: Some(1), - }; - let updated_account = storage.update_account(updated_account_params).unwrap(); + let mut account_to_update = storage + .read_account(&AccountKey { + anchor_number, + origin: origin.clone(), + account_number: Some(1), + }) + .unwrap(); + account_to_update.name = Some(new_account_name.clone()); + let updated_account = storage.write_account(account_to_update).unwrap(); // 5. Check that the additional account has been created with the updated values. assert_eq!( @@ -398,12 +381,9 @@ fn should_count_accounts_different_anchors() { ); // Create an additional account for anchor 1 - let create_params_1 = CreateAccountParams { - anchor_number: anchor_number_1, - origin: origin_1.clone(), - name: account_name_1.clone(), - }; - storage.create_additional_account(create_params_1).unwrap(); + storage + .create_account(anchor_number_1, origin_1.clone(), account_name_1.clone()) + .unwrap(); // List accounts for anchor 1 - should return 2 let accounts_anchor_1_after_add = storage.list_accounts(anchor_number_1, &origin_1); @@ -456,12 +436,9 @@ fn should_count_accounts_different_anchors() { ); // Create an additional account for anchor 2 - let create_params_2 = CreateAccountParams { - anchor_number: anchor_number_2, - origin: origin_2.clone(), - name: account_name_2.clone(), - }; - storage.create_additional_account(create_params_2).unwrap(); + storage + .create_account(anchor_number_2, origin_2.clone(), account_name_2.clone()) + .unwrap(); // List accounts for anchor 2 - should return 2 let accounts_anchor_2_after_add = storage.list_accounts(anchor_number_2, &origin_2); @@ -514,14 +491,13 @@ fn should_not_read_a_default_account_from_an_empty_reference_list() { ); // 3. Try to read default account - let read_params = ReadAccountParams { - account_number: None, + let read_params = AccountKey { anchor_number, - origin: &origin, - known_app_num: Some(app_num), + origin: origin.clone(), + account_number: None, }; - assert_eq!(storage.read_account(read_params), None); + assert_eq!(storage.read_account(&read_params), None); } #[test] @@ -531,16 +507,16 @@ fn should_read_a_synthetic_default_account_when_no_reference_list_exists() { let anchor_number: AnchorNumber = 10_000; let origin: FrontendHostname = "https://some.origin".to_string(); - let app_num = storage + // The origin is known, but this identity has no row under it. + storage .lookup_or_insert_application_number_with_origin(&origin) .unwrap(); let default_account = storage - .read_account(ReadAccountParams { - account_number: None, + .read_account(&AccountKey { anchor_number, - origin: &origin, - known_app_num: Some(app_num), + origin: origin.clone(), + account_number: None, }) .unwrap(); @@ -563,21 +539,17 @@ fn should_not_read_account_from_wrong_anchor() { let account_name = "account name".to_string(); // 2. Create account for first anchor - let create_params = CreateAccountParams { - anchor_number: anchor_number_1, - origin: origin.clone(), - name: account_name, - }; - storage.create_additional_account(create_params).unwrap(); + storage + .create_account(anchor_number_1, origin.clone(), account_name) + .unwrap(); // 3. Try to read the account with second anchor - let read_params = ReadAccountParams { - account_number: Some(1), // First account created - anchor_number: anchor_number_2, // Different anchor - origin: &origin, - known_app_num: None, + let read_params = AccountKey { + anchor_number: anchor_number_2, + origin: origin.clone(), + account_number: Some(1), }; - let account = storage.read_account(read_params); + let account = storage.read_account(&read_params); // 4. Verify we get None since the account doesn't belong to anchor_number_2 assert!( diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 363514491e..162b57992d 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3,7 +3,7 @@ use crate::openid::OpenIdCredential; use crate::state::PersistentState; use crate::stats::activity_stats::activity_counter::active_anchor_counter::ActiveAnchorCounter; use crate::stats::activity_stats::{ActivityStats, CompletedActivityStats, OngoingActivityStats}; -use crate::storage::account::{CreateAccountParams, ReadAccountParams}; +use crate::storage::account::{Account, AccountKey}; use crate::storage::anchor::{Anchor, Device}; use crate::storage::{Header, StorageError, MAX_ENTRIES}; use crate::Storage; @@ -415,189 +415,158 @@ fn should_not_overwrite_device_credential_lookup() { } #[test] -fn should_set_account_last_used() { +fn should_record_that_a_named_account_was_used() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); - // Create an anchor let anchor = storage.allocate_anchor(0).unwrap(); let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Create an additional account for this anchor and origin let account = storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "Test Account".to_string(), - origin: origin.clone(), - }) + .create_account(anchor_number, origin.clone(), "Test Account".to_string()) .unwrap(); - - let account_number = account.account_number.unwrap(); - - // Initially, last_used should be None - let read_account = storage - .read_account(ReadAccountParams { - anchor_number, - origin: &origin, - account_number: Some(account_number), - known_app_num: None, - }) - .unwrap(); - assert_eq!(read_account.last_used, None); - - // Set last_used for the additional account - let timestamp = 123456789u64; - let result = storage.set_account_last_used( + let key = AccountKey { anchor_number, - origin.clone(), - Some(account_number), - timestamp, - ); - assert!(result.unwrap().is_some()); + origin: origin.clone(), + account_number: account.account_number, + }; - // Verify last_used was updated - let read_account = storage - .read_account(ReadAccountParams { - anchor_number, - origin: &origin, - account_number: Some(account_number), - known_app_num: None, - }) - .unwrap(); - assert_eq!(read_account.last_used, Some(timestamp)); + assert_eq!(storage.read_account(&key).unwrap().last_used, None); - // Update last_used again with a new timestamp - let new_timestamp = 987654321u64; - let result = storage.set_account_last_used( - anchor_number, - origin.clone(), - Some(account_number), - new_timestamp, - ); - assert!(result.unwrap().is_some()); + for timestamp in [123456789u64, 987654321u64] { + let mut account = storage.read_account(&key).unwrap(); + account.last_used = Some(timestamp); + storage.write_account(account).unwrap(); - // Verify last_used was updated to the new timestamp - let read_account = storage - .read_account(ReadAccountParams { - anchor_number, - origin: &origin, - account_number: Some(account_number), - known_app_num: None, - }) - .unwrap(); - assert_eq!(read_account.last_used, Some(new_timestamp)); + assert_eq!( + storage.read_account(&key).unwrap().last_used, + Some(timestamp) + ); + } } #[test] -fn should_set_account_last_used_for_synthethic_account() { +fn should_not_store_a_row_to_record_use_of_a_derived_default() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); - // Create an anchor let anchor = storage.allocate_anchor(0).unwrap(); let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Set last_used for the synthetic account (account_number = None) - let timestamp = 555555u64; - let result = storage.set_account_last_used(anchor_number, origin.clone(), None, timestamp); - assert!(result.unwrap().is_none()); + let key = AccountKey { + anchor_number, + origin: origin.clone(), + account_number: None, + }; - // Verify last_used was updated for the synthetic account - let read_account = storage - .read_account(ReadAccountParams { - anchor_number, - origin: &origin, - account_number: None, - known_app_num: None, - }) - .unwrap(); - // Because the account reference doesn't exist, the `last_used` is not updated. - assert_eq!(read_account.last_used, None); + let mut account = storage.read_account(&key).unwrap(); + account.last_used = Some(555_555u64); + storage.write_account(account).unwrap(); + + // Nothing is stored at this origin, so the default here is still derived from it. + // A row saying only that it was used would keep bytes to repeat what absence + // already says, so none is written and the read is unchanged. + assert_eq!(storage.read_account(&key).unwrap().last_used, None); + assert!(storage + .lookup_application_number_with_origin(&origin) + .is_none()); } #[test] -fn should_set_account_last_used_for_synthetic_account_with_reference() { +fn should_record_that_a_tracked_default_was_used() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); - // Create an anchor let anchor = storage.allocate_anchor(0).unwrap(); let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Create an additional account to force creation of account references + // A named account gives the origin a row, which the default is tracked in. storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "Test Account".to_string(), - origin: origin.clone(), - }) + .create_account(anchor_number, origin.clone(), "Test Account".to_string()) .unwrap(); - // Set last_used for the synthetic account (account_number = None) - let timestamp = 555555u64; - let result = storage.set_account_last_used(anchor_number, origin.clone(), None, timestamp); - assert!(result.unwrap().is_some()); + let key = AccountKey { + anchor_number, + origin: origin.clone(), + account_number: None, + }; + let timestamp = 555_555u64; - // Verify last_used was updated for the synthetic account - let read_account = storage - .read_account(ReadAccountParams { - anchor_number, - origin: &origin, - account_number: None, - known_app_num: None, - }) - .unwrap(); - assert_eq!(read_account.last_used, Some(timestamp)); + let mut account = storage.read_account(&key).unwrap(); + account.last_used = Some(timestamp); + storage.write_account(account).unwrap(); + + assert_eq!( + storage.read_account(&key).unwrap().last_used, + Some(timestamp) + ); } #[test] -fn should_return_none_when_setting_last_used_for_nonexistent_account() { +fn should_refuse_to_write_an_account_no_reference_names() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); - // Create an anchor let anchor = storage.allocate_anchor(0).unwrap(); let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Try to set last_used for a non-existent account number - let nonexistent_account_number = 99999u64; - let timestamp = 123456u64; - let result = storage.set_account_last_used( + storage + .create_account(anchor_number, origin.clone(), "Test Account".to_string()) + .unwrap(); + + let unheld_account_number = 99_999u64; + let key = AccountKey { anchor_number, - origin, - Some(nonexistent_account_number), - timestamp, - ); + origin: origin.clone(), + account_number: Some(unheld_account_number), + }; - // Should return None because the account doesn't exist - assert!(result.unwrap().is_none()); + // Holding a reference is what grants access, so an account this identity does not + // hold cannot be read, and writing one it names anyway is refused rather than + // adopted. + assert!(storage.read_account(&key).is_none()); + assert!(matches!( + storage.write_account(Account::new_with_last_used( + anchor_number, + origin, + None, + Some(unheld_account_number), + Some(123_456u64), + )), + Err(StorageError::AccountNotFound { account_number }) + if account_number == unheld_account_number + )); } #[test] -fn should_return_none_when_setting_last_used_for_nonexistent_origin() { +fn should_refuse_to_write_an_account_at_an_unknown_origin() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); - // Create an anchor let anchor = storage.allocate_anchor(0).unwrap(); let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // Try to set last_used for an origin that hasn't been registered - let nonexistent_origin = "https://nonexistent.com".to_string(); - let timestamp = 123456u64; - let result = storage.set_account_last_used(anchor_number, nonexistent_origin, None, timestamp); + let unknown_origin = "https://nonexistent.com".to_string(); - // Should return None because the origin/application doesn't exist - assert!(result.unwrap().is_none()); + assert!(matches!( + storage.write_account(Account::new_with_last_used( + anchor_number, + unknown_origin, + None, + Some(99_999u64), + Some(123_456u64), + )), + Err(StorageError::AccountNotFound { .. }) + )); } fn sample_device() -> Device { @@ -2144,7 +2113,7 @@ fn test_anchor_storage_migration_round_trip() { } mod reference_list_write_path_tests { - use crate::storage::account::{AccountReference, CreateAccountParams}; + use crate::storage::account::AccountReference; use crate::storage::storable::accounts_counter::StorableAccountsCounter; use crate::storage::{ReferenceCount, ReferenceCounter, StorageError}; use crate::Storage; @@ -2174,11 +2143,11 @@ mod reference_list_write_path_tests { }) .unwrap(); - let result = storage.create_additional_account(CreateAccountParams { + let result = storage.create_account( anchor_number, - name: "named".to_string(), - origin: "https://example.com".to_string(), - }); + "https://example.com".to_string(), + "named".to_string(), + ); assert!(matches!(result, Err(StorageError::AccountsCounterOverflow))); } @@ -2199,10 +2168,12 @@ mod reference_list_write_path_tests { last_used: None, }; storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![default_reference.clone(), named_reference], + None, + None, ) .unwrap(); @@ -2210,10 +2181,12 @@ mod reference_list_write_path_tests { // anchor counter no longer knows about, so dropping one under-runs it. storage.set_counters_for_testing(anchor_number, 0, 0); - let result = storage.write_reference_list( + let result = storage.write_account_state( anchor_number, application_number, vec![default_reference], + None, + None, ); // The refusal names what diverged: this identity's account count, what it held, @@ -2230,7 +2203,7 @@ mod reference_list_write_path_tests { ); // Refused before anything was written: the list still holds both references. let references = storage - .account_references(anchor_number, application_number) + .stored_account_references(anchor_number, application_number) .expect("the row written above is gone"); assert_eq!(references.len(), 2); } @@ -2251,10 +2224,12 @@ mod reference_list_write_path_tests { last_used: None, }; storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![default_reference, named_reference.clone()], + None, + None, ) .unwrap(); storage.set_counters_for_testing(anchor_number, 0, 0); @@ -2262,8 +2237,13 @@ mod reference_list_write_path_tests { // Dropping the tracked default takes a reference without taking a named // account, so the two deltas differ: 0 and -1. Only the reference count can // under-run here, and the refusal has to name that one rather than the other. - let result = - storage.write_reference_list(anchor_number, application_number, vec![named_reference]); + let result = storage.write_account_state( + anchor_number, + application_number, + vec![named_reference], + None, + None, + ); assert_eq!( result.unwrap_err().to_string(), @@ -2287,14 +2267,15 @@ mod reference_list_write_path_tests { .lookup_or_insert_application_number_with_origin(&origin) .unwrap(); - let result = storage.write_reference_list(anchor_number, application_number, vec![]); + let result = + storage.write_account_state(anchor_number, application_number, vec![], None, None); assert!(matches!( result, Err(StorageError::UnstorableAccountReferenceList { .. }) )); assert_eq!( - storage.account_references(anchor_number, application_number), + storage.stored_account_references(anchor_number, application_number), None ); } @@ -2304,13 +2285,15 @@ mod reference_list_write_path_tests { let (mut storage, anchor_number) = storage_with_anchor(); let unknown_application_number = 42u64; - let result = storage.write_reference_list( + let result = storage.write_account_state( anchor_number, unknown_application_number, vec![AccountReference { account_number: None, last_used: None, }], + None, + None, ); assert!(matches!( @@ -2318,7 +2301,7 @@ mod reference_list_write_path_tests { Err(StorageError::OriginNotFoundForApplicationNumber { .. }) )); assert_eq!( - storage.account_references(anchor_number, unknown_application_number), + storage.stored_account_references(anchor_number, unknown_application_number), None ); assert_eq!( @@ -2334,7 +2317,7 @@ mod reference_list_write_path_tests { } #[test] - fn a_zero_delta_write_still_requires_a_live_application() { + fn writing_the_list_the_row_already_holds_touches_nothing() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -2345,18 +2328,34 @@ mod reference_list_write_path_tests { last_used: None, }]; storage - .write_reference_list(anchor_number, application_number, references.clone()) + .write_account_state( + anchor_number, + application_number, + references.clone(), + None, + None, + ) .unwrap(); + // Retiring the application makes a write visible: the write path refuses + // without one, so a write that still went through it could not succeed here. storage .stable_application_memory .remove(&application_number); - let result = storage.write_reference_list(anchor_number, application_number, references); + storage + .write_account_state( + anchor_number, + application_number, + references.clone(), + None, + None, + ) + .unwrap(); - assert!(matches!( - result, - Err(StorageError::OriginNotFoundForApplicationNumber { .. }) - )); + assert_eq!( + storage.stored_account_references(anchor_number, application_number), + Some(references) + ); } #[test] @@ -2368,7 +2367,7 @@ mod reference_list_write_path_tests { .unwrap(); storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![ @@ -2381,6 +2380,8 @@ mod reference_list_write_path_tests { last_used: None, }, ], + None, + None, ) .unwrap(); @@ -2409,23 +2410,27 @@ mod reference_list_write_path_tests { .unwrap(); storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![AccountReference { account_number: None, last_used: None, }], + None, + None, ) .unwrap(); storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![AccountReference { account_number: Some(3), last_used: None, }], + None, + None, ) .unwrap(); @@ -2451,18 +2456,26 @@ mod reference_list_write_path_tests { }]; storage - .write_reference_list(anchor_number, application_number, references.clone()) + .write_account_state( + anchor_number, + application_number, + references.clone(), + None, + None, + ) .unwrap(); let after_first_write = storage.get_account_counter(anchor_number); storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![AccountReference { account_number: Some(1), last_used: Some(123), }], + None, + None, ) .unwrap(); @@ -2479,18 +2492,10 @@ mod reference_list_write_path_tests { for origin in ["https://a.com", "https://b.com", "https://c.com"] { let origin = origin.to_string(); storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "account".to_string(), - origin: origin.clone(), - }) + .create_account(anchor_number, origin.clone(), "account".to_string()) .unwrap(); storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "another account".to_string(), - origin, - }) + .create_account(anchor_number, origin, "another account".to_string()) .unwrap(); } @@ -2507,9 +2512,7 @@ mod reference_list_write_path_tests { /// mean three different things. Absence says a default account is still /// reconstructible; emptiness is a tombstone and says it never can be again. mod account_reference_state_tests { - use crate::storage::account::{ - AccountReference, CreateAccountParams, ReadAccountParams, UpdateAccountParams, - }; + use crate::storage::account::{Account, AccountKey, AccountReference}; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::StorageError; use crate::Storage; @@ -2540,7 +2543,7 @@ mod account_reference_state_tests { StorableAccountReferenceList::tombstone_for_testing(), ); assert_eq!( - storage.account_references(anchor_number, application_number), + storage.stored_account_references(anchor_number, application_number), Some(vec![]) ); } @@ -2554,11 +2557,10 @@ mod account_reference_state_tests { ) -> Option> { let origin = ORIGIN.to_string(); storage - .read_account(ReadAccountParams { - account_number: None, + .read_account(&AccountKey { anchor_number, - origin: &origin, - known_app_num: None, + origin: origin.clone(), + account_number: None, }) .map(|account| account.account_number) } @@ -2592,11 +2594,7 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "named".to_string(), - origin: origin.clone(), - }) + .create_account(anchor_number, origin.clone(), "named".to_string()) .unwrap(); let account_number = account.account_number.unwrap(); let application_number = storage @@ -2605,13 +2603,15 @@ mod account_reference_state_tests { // Drop just the default reference, as moving it away would. storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![AccountReference { account_number: Some(account_number), last_used: None, }], + None, + None, ) .unwrap(); @@ -2629,18 +2629,14 @@ mod account_reference_state_tests { plant_tombstone(&mut storage, anchor_number); let account = storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "named".to_string(), - origin: origin.clone(), - }) + .create_account(anchor_number, origin.clone(), "named".to_string()) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) .unwrap(); let references = storage - .account_references(anchor_number, application_number) + .stored_account_references(anchor_number, application_number) .expect("the named account should have left references behind"); assert_eq!( references @@ -2658,12 +2654,12 @@ mod account_reference_state_tests { plant_tombstone(&mut storage, anchor_number); let counter_before = storage.get_total_accounts_counter().clone(); - let result = storage.update_account(UpdateAccountParams { - account_number: None, + let result = storage.write_account(Account::new( anchor_number, - name: "named default".to_string(), - origin: origin.clone(), - }); + origin.clone(), + Some("named default".to_string()), + None, + )); assert!(matches!(result, Err(StorageError::MissingAccount { .. }))); // Refused before the allocation, so no account number was spent on a record @@ -2676,7 +2672,7 @@ mod account_reference_state_tests { .lookup_application_number_with_origin(&origin) .unwrap(); assert_eq!( - storage.account_references(anchor_number, application_number), + storage.stored_account_references(anchor_number, application_number), Some(vec![]) ); assert_eq!( @@ -2692,32 +2688,27 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let named = storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "named".to_string(), - origin: origin.clone(), - }) + .create_account(anchor_number, origin.clone(), "named".to_string()) .unwrap(); let stamped_at = 123456u64; - storage - .set_account_last_used(anchor_number, origin.clone(), None, stamped_at) - .unwrap() - .unwrap(); + let default_key = AccountKey { + anchor_number, + origin: origin.clone(), + account_number: None, + }; + let mut used_default = storage.read_account(&default_key).unwrap(); + used_default.last_used = Some(stamped_at); + storage.write_account(used_default).unwrap(); - let default = storage - .update_account(UpdateAccountParams { - account_number: None, - anchor_number, - name: "named default".to_string(), - origin: origin.clone(), - }) - .unwrap(); + let mut default_to_name = storage.read_account(&default_key).unwrap(); + default_to_name.name = Some("named default".to_string()); + let default = storage.write_account(default_to_name).unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) .unwrap(); let references = storage - .account_references(anchor_number, application_number) + .stored_account_references(anchor_number, application_number) .expect("naming the default should not have emptied the row"); // Repointed where it stood, keeping the order accounts are listed in and the // timestamp the reference already carried. @@ -2741,40 +2732,37 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage - .create_additional_account(CreateAccountParams { - anchor_number, - name: "named".to_string(), - origin: origin.clone(), - }) + .create_account(anchor_number, origin.clone(), "named".to_string()) .unwrap(); let account_number = account.account_number.unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) .unwrap(); let references_before = storage - .account_references(anchor_number, application_number) + .stored_account_references(anchor_number, application_number) .unwrap(); // A skipped write is invisible in the stored bytes, since rewriting the row // would store what it already holds. Retiring the application row makes it - // visible: `write_reference_list` refuses without one, so a rename that still - // went through it could not succeed here. + // visible: `write_account_state` refuses without one, so a rename that still + // wrote the list could not succeed here. storage .stable_application_memory .remove(&application_number); - let renamed = storage - .update_account(UpdateAccountParams { - account_number: Some(account_number), + let mut account_to_rename = storage + .read_account(&AccountKey { anchor_number, - name: "renamed".to_string(), origin: origin.clone(), + account_number: Some(account_number), }) .unwrap(); + account_to_rename.name = Some("renamed".to_string()); + let renamed = storage.write_account(account_to_rename).unwrap(); assert_eq!(renamed.name, Some("renamed".to_string())); assert_eq!( - storage.account_references(anchor_number, application_number), + storage.stored_account_references(anchor_number, application_number), Some(references_before) ); } @@ -2790,44 +2778,39 @@ mod account_reference_state_tests { }; let origin = ORIGIN.to_string(); let account = storage - .create_additional_account(CreateAccountParams { - anchor_number: owner, - name: "named".to_string(), - origin: origin.clone(), - }) + .create_account(owner, origin.clone(), "named".to_string()) .unwrap(); let account_number = account.account_number.unwrap(); // The other identity has a row of its own at this origin, so what refuses the // attempts below is the row not naming this account rather than there being no // row to look in. storage - .create_additional_account(CreateAccountParams { - anchor_number: other, - name: "mine".to_string(), - origin: origin.clone(), - }) + .create_account(other, origin.clone(), "mine".to_string()) .unwrap(); - let rename = storage.update_account(UpdateAccountParams { - account_number: Some(account_number), - anchor_number: other, - name: "stolen".to_string(), - origin: origin.clone(), - }); + let rename = storage.write_account(Account::new( + other, + origin.clone(), + Some("stolen".to_string()), + Some(account_number), + )); assert!(matches!(rename, Err(StorageError::AccountNotFound { .. }))); - let stamp = storage - .set_account_last_used(other, origin.clone(), Some(account_number), 123456) - .unwrap(); - assert_eq!(stamp, None); + let stamp = storage.write_account(Account::new_with_last_used( + other, + origin.clone(), + None, + Some(account_number), + Some(123456), + )); + assert!(matches!(stamp, Err(StorageError::AccountNotFound { .. }))); // The owner's record and reference are untouched by either attempt. let owned = storage - .read_account(ReadAccountParams { - account_number: Some(account_number), + .read_account(&AccountKey { anchor_number: owner, - origin: &origin, - known_app_num: None, + origin: origin.clone(), + account_number: Some(account_number), }) .unwrap(); assert_eq!(owned.name, Some("named".to_string())); From 81a1d5eaa54f8ff81554f55198100542dd486003 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 01:42:18 +0200 Subject: [PATCH 087/298] test(be): move the backfill tests onto the account write path Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index c7b41be721..b1baceef38 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4259,13 +4259,15 @@ mod account_principal_index_backfill_tests { .lookup_or_insert_application_number_with_origin(&format!("https://d-{index}.com")) .unwrap(); storage - .write_reference_list( + .write_account_state( anchor_number, application_number, vec![AccountReference { account_number: None, last_used: Some(index + 1), }], + None, + None, ) .unwrap(); } From c84082bbd47a4d1b00c7c7af61836285bdd1b62e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 01:54:47 +0200 Subject: [PATCH 088/298] fix(be): move the app session revoke onto the account write path Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 8 ++------ src/internet_identity/src/storage/tests.rs | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 6b9b662602..ead6a95932 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1852,7 +1852,6 @@ impl Storage { // one it matched. let present = self .account_references(anchor_number, application_number) - .unwrap_or_default() .iter() .any(|reference| { reference.account_number == account_number @@ -2070,10 +2069,7 @@ impl Storage { account_number: Option, device_id: SessionDeviceId, ) -> Result { - let Some(mut references) = self.account_references(anchor_number, application_number) - else { - return Ok(0); - }; + let mut references = self.account_references(anchor_number, application_number); let Some(reference) = references .iter_mut() .find(|reference| reference.account_number == account_number) @@ -2092,7 +2088,7 @@ impl Storage { reference .sessions .retain(|session| session.device_id != device_id); - self.write_reference_list(anchor_number, application_number, references)?; + self.write_account_state(anchor_number, application_number, references, None, None)?; self.unindex_sessions(anchor_number, application_number, account_number, &dropped); Ok(dropped.len()) } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 255fbc43f4..c06dcd4a86 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5824,7 +5824,7 @@ mod session_removal_tests { .unwrap(); assert_ne!( - storage.account_references(anchor_number, application_number), + storage.stored_account_references(anchor_number, application_number), None ); } From 21693dbd0f7a192124c05dab2bff3b7251abd4eb Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 01:56:14 +0200 Subject: [PATCH 089/298] fix(be): move settings revoke onto the account write path Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index f25482fd40..2d6f1fb23a 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1850,10 +1850,7 @@ impl Storage { let Some(application_number) = self.lookup_application_number_with_origin(origin) else { return Ok(0); }; - let Some(mut references) = self.account_references(anchor_number, application_number) - else { - return Ok(0); - }; + let mut references = self.account_references(anchor_number, application_number); let Some(reference) = references .iter_mut() .find(|reference| reference.account_number == account_number) @@ -1874,7 +1871,7 @@ impl Storage { .sessions .retain(|session| session.created_at_ns != created_at); - self.write_reference_list(anchor_number, application_number, references)?; + self.write_account_state(anchor_number, application_number, references, None, None)?; self.unindex_sessions(anchor_number, application_number, account_number, &dropped); self.change_session_count(anchor_number, dropped.len(), 0)?; Ok(dropped.len() as u64) From 2ab774749fd2d371c9291d20425b7c4973e2bec9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 02:04:32 +0200 Subject: [PATCH 090/298] refactor(be): name a session by key rather than by its parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_session` hands back the key it just minted, and `read_session` takes one. The two list readers it replaces — `account_with_sessions` and `account_sessions` — returned every session at an account for callers that wanted exactly one, and left the caller to pick it out. `SessionRecordKey` carries the creation time as well as the browser. A browser keeps its id across sign-ins, so `device_id` alone names whatever that browser holds now rather than the session a caller meant. With the creation time in the key every operation is compare-and-act: a key for a replaced session reads as `None` instead of landing on its successor. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 61 ++++++++------------ src/internet_identity/src/storage/account.rs | 29 ++++++++++ src/internet_identity/src/storage/tests.rs | 35 +++++++---- 3 files changed, 76 insertions(+), 49 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 16ed34a3f5..f005bd2c75 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -79,7 +79,7 @@ //! //! The archive buffer memory is managed by the [MemoryManager] and is currently limited to a single //! bucket of 128 pages. -use account::{Account, AccountKey, AccountsCounter}; +use account::{Account, AccountKey, AccountsCounter, SessionRecordKey}; use candid::{CandidType, Deserialize, Principal}; use ic_cdk::api::stable::WASM_PAGE_SIZE_IN_BYTES; use ic_stable_structures::cell::ValueError; @@ -1795,42 +1795,22 @@ impl Storage { // Called by the sign-in ceremony, which lands two PRs up. #[allow(dead_code)] - /// 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 reference = self - .account_references(anchor_number, application_number) - .into_iter() - .find(|reference| reference.account_number == account_number)?; - let account = self.read_account(&AccountKey { - anchor_number, - origin, - account_number, - })?; - Some((account, reference.sessions)) - } + /// The session `key` names, or `None` where the identity holds no such session. + /// + /// A key whose session was replaced reads as `None` rather than as its successor: + /// a browser keeps its id across sign-ins, so the creation time is what tells two + /// of that browser's sessions apart. + pub fn read_session(&self, key: &SessionRecordKey) -> Option { + let application_number = self.lookup_application_number_with_origin(&key.origin)?; - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] - pub fn account_sessions( - &self, - anchor_number: AnchorNumber, - origin: &FrontendHostname, - account_number: Option, - ) -> Option> { - let application_number = self.lookup_application_number_with_origin(origin)?; - self.account_references(anchor_number, application_number) + self.account_references(key.anchor_number, application_number) .into_iter() - .find(|reference| reference.account_number == account_number) - .map(|reference| reference.sessions) + .find(|reference| reference.account_number == key.account_number)? + .sessions + .into_iter() + .find(|session| { + session.device_id == key.device_id && session.created_at_ns == key.created_at + }) } // Called by the sign-in ceremony, which lands two PRs up. @@ -1840,7 +1820,7 @@ impl Storage { pub fn create_session( &mut self, params: CreateSessionParams, - ) -> Result { + ) -> Result<(SessionRecordKey, SessionRecord), StorageError> { let CreateSessionParams { anchor_number, origin, @@ -1966,7 +1946,14 @@ impl Storage { } self.change_session_count(anchor_number, dropped.len(), 1)?; - Ok(session) + let key = SessionRecordKey { + anchor_number, + origin, + account_number, + device_id, + created_at: session.created_at_ns, + }; + Ok((key, session)) } // Called by the sign-in ceremony, which lands two PRs up. diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 678a5983b5..1b2aaa7b5b 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -69,6 +69,35 @@ pub const MIN_SESSION_IDLE_NS: u64 = 10 * crate::MINUTE_NS; /// and a machine walked away from stops being signed in within one. pub const DEFAULT_SESSION_IDLE_NS: u64 = 7 * crate::DAY_NS; +/// The four things that name one session, plus the creation time that tells two of one +/// browser's apart. +/// +/// A browser keeps its id across sign-ins, so `device_id` alone names whatever that +/// browser holds now rather than the session a caller means. With `created_at` every +/// operation is compare-and-act: a key for a session that was replaced reads as `None` +/// and revokes nothing, instead of landing on its successor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionRecordKey { + pub anchor_number: AnchorNumber, + pub origin: FrontendHostname, + pub account_number: Option, + pub device_id: SessionDeviceId, + pub created_at: Timestamp, +} + +impl SessionRecordKey { + // Used by the app delegation path, which lands four PRs up. + #[allow(dead_code)] + /// The account this session is at. + pub fn account(&self) -> AccountKey { + AccountKey { + anchor_number: self.anchor_number, + origin: self.origin.clone(), + account_number: self.account_number, + } + } +} + /// A revocable session at one account. Only `last_refreshed` is mutable, which is why /// it is the one field absent from the seed. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index c5b3e41d3d..14a75e5152 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4671,7 +4671,8 @@ mod session_creation_tests { valid_till_ns: DAY_NS, ..params(anchor_number, 1, 0) }) - .unwrap(); + .unwrap() + .1; assert_eq!(session.max_idle_ns, asked); } @@ -4686,7 +4687,8 @@ mod session_creation_tests { valid_till_ns: DAY_NS, ..params(anchor_number, 1, 0) }) - .unwrap(); + .unwrap() + .1; // An app delegation lasts five minutes, so a bound under that would end a // session between two mints of one that is plainly in use. @@ -4703,7 +4705,8 @@ mod session_creation_tests { valid_till_ns: DAY_NS, ..params(anchor_number, 1, 0) }) - .unwrap(); + .unwrap() + .1; // A bound it could never reach says something about the session that is not // true, so it is stored as the life the session actually got. @@ -4719,7 +4722,8 @@ mod session_creation_tests { valid_till_ns: 30 * DAY_NS, ..params(anchor_number, 1, 0) }) - .unwrap(); + .unwrap() + .1; // Every session gets a bound now. A week of nobody touching the application // ends the sign-in, well inside the thirty days it could otherwise live. @@ -4739,7 +4743,8 @@ mod session_creation_tests { max_idle_ns: Some(30 * MINUTE_NS), ..params(anchor_number, 1, 0) }) - .unwrap(); + .unwrap() + .1; assert_eq!(session.max_idle_ns, MINUTE_NS); } @@ -4750,7 +4755,8 @@ mod session_creation_tests { let session = storage .create_session(params(anchor_number, 1, 1_000)) - .unwrap(); + .unwrap() + .1; assert_eq!(session.created_at_ns, 1_000); assert_eq!(session.valid_till_ns, 11_000); @@ -4766,11 +4772,13 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let first = storage .create_session(params(anchor_number, 1, 1_000)) - .unwrap(); + .unwrap() + .1; let again = storage .create_session(params(anchor_number, 1, 5_000)) - .unwrap(); + .unwrap() + .1; assert_ne!(again.created_at_ns, first.created_at_ns); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); @@ -4832,7 +4840,8 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage .create_session(params(anchor_number, 7, 1_000)) - .unwrap(); + .unwrap() + .1; let application_number = storage .lookup_application_number_with_origin(&ORIGIN.to_string()) .unwrap(); @@ -4917,7 +4926,8 @@ mod session_creation_tests { // 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(); + .unwrap() + .1; assert_eq!(created.created_at_ns, 1_000); } @@ -4937,11 +4947,11 @@ mod session_creation_tests { now_ns: 1_000, }; - let first = storage.create_session(params(false)).unwrap(); + let first = storage.create_session(params(false)).unwrap().1; storage.create_session(params(false)).unwrap(); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); - let replaced = storage.create_session(params(true)).unwrap(); + let replaced = storage.create_session(params(true)).unwrap().1; assert_ne!(replaced.read_only, first.read_only); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); } @@ -5059,6 +5069,7 @@ mod session_consent_change_tests { now_ns: now, }) .unwrap() + .1 .created_at_ns } From a32abe5fcb8e44a8c58ed5cf832058b46181bdd9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 02:20:12 +0200 Subject: [PATCH 091/298] refactor(be): name the stored account address after the one it maps to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StorableAccountLocator` and `AccountKey` are the same address: an identity, an origin and which of its accounts there. The stored form interns the origin to an application number, because a row per principal would otherwise carry a copy of the origin string. Naming them apart made that read as two concepts. `StorableAccountKey` says what it is — the stored half of the pair — and keeps the application number where it belongs, inside storage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 8 ++++---- src/internet_identity/src/storage/storable.rs | 2 +- .../{account_locator.rs => account_key.rs} | 17 ++++++++++++----- src/internet_identity/src/storage/tests.rs | 10 +++++----- 4 files changed, 22 insertions(+), 15 deletions(-) rename src/internet_identity/src/storage/storable/{account_locator.rs => account_key.rs} (61%) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 152d18d457..94fae67267 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -113,7 +113,7 @@ use crate::storage::anchor::Anchor; use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; use crate::storage::storable::account::StorableAccount; -use crate::storage::storable::account_locator::StorableAccountLocator; +use crate::storage::storable::account_key::StorableAccountKey; use crate::storage::storable::account_number::StorableAccountNumber; use crate::storage::storable::accounts_counter::StorableAccountsCounter; use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; @@ -400,7 +400,7 @@ pub struct Storage { next_application_number_memory: StableCell>, lookup_account_with_principal_memory_wrapper: MemoryWrapper>, lookup_account_with_principal_memory: - StableBTreeMap>, + 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>, @@ -1982,7 +1982,7 @@ impl Storage { origin: &FrontendHostname, salt: &[u8; 32], references: &[AccountReference], - ) -> BTreeMap { + ) -> BTreeMap { references .iter() .filter_map(|reference| { @@ -2006,7 +2006,7 @@ impl Storage { ); Some(( principal, - StorableAccountLocator { + StorableAccountKey { anchor_number, application_number, account_number: reference.account_number, diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 3c7270e50f..3bd075a4ce 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -1,5 +1,5 @@ pub mod account; -pub mod account_locator; +pub mod account_key; pub mod account_number; pub mod account_reference; pub mod account_reference_list; diff --git a/src/internet_identity/src/storage/storable/account_locator.rs b/src/internet_identity/src/storage/storable/account_key.rs similarity index 61% rename from src/internet_identity/src/storage/storable/account_locator.rs rename to src/internet_identity/src/storage/storable/account_key.rs index 4af4696dd4..1bac974162 100644 --- a/src/internet_identity/src/storage/storable/account_locator.rs +++ b/src/internet_identity/src/storage/storable/account_key.rs @@ -6,10 +6,17 @@ use ic_stable_structures::Storable; use minicbor::{Decode, Encode}; use std::borrow::Cow; -/// The triple that identifies one account. Absent account number means the default. +/// The stored form of an [`crate::storage::account::AccountKey`], with the origin +/// interned to an application number. +/// +/// The number rather than the origin, because a row per principal would otherwise +/// carry a copy of the origin string, and interning it is what application numbers are +/// for. Which is also why the two types stay apart: the number is storage's own, and +/// what leaves is the `AccountKey` it maps to. Absent account number means the tracked +/// default. #[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[cbor(map)] -pub struct StorableAccountLocator { +pub struct StorableAccountKey { #[n(0)] pub anchor_number: StorableAnchorNumber, #[n(1)] @@ -18,15 +25,15 @@ pub struct StorableAccountLocator { pub account_number: Option, } -impl Storable for StorableAccountLocator { +impl Storable for StorableAccountKey { fn to_bytes(&self) -> Cow<'_, [u8]> { let mut buffer = Vec::new(); - minicbor::encode(self, &mut buffer).expect("failed to encode StorableAccountLocator"); + minicbor::encode(self, &mut buffer).expect("failed to encode StorableAccountKey"); Cow::Owned(buffer) } fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { - minicbor::decode(&bytes).expect("failed to decode StorableAccountLocator") + minicbor::decode(&bytes).expect("failed to decode StorableAccountKey") } const BOUND: Bound = Bound::Unbounded; diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index aca8d205f4..82d91d25f6 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3952,7 +3952,7 @@ mod account_principal_index_tests { use super::record_use; use crate::delegation::canister_sig_principal; use crate::storage::account::{Account, AccountReference}; - use crate::storage::storable::account_locator::StorableAccountLocator; + use crate::storage::storable::account_key::StorableAccountKey; use crate::storage::{canister_id, StorageError}; use crate::Storage; use candid::Principal; @@ -3993,7 +3993,7 @@ mod account_principal_index_tests { storage .lookup_account_with_principal_memory .get(&default_account_principal(anchor_number, &origin)), - Some(StorableAccountLocator { + Some(StorableAccountKey { anchor_number, application_number, account_number: None, @@ -4022,7 +4022,7 @@ mod account_principal_index_tests { assert_eq!( storage.lookup_account_with_principal_memory.get(&principal), - Some(StorableAccountLocator { + Some(StorableAccountKey { anchor_number, application_number, account_number: materialized.account_number, @@ -4050,7 +4050,7 @@ mod account_principal_index_tests { storage .lookup_account_with_principal_memory .get(&named_principal), - Some(StorableAccountLocator { + Some(StorableAccountKey { anchor_number, application_number, account_number: named.account_number, @@ -4212,7 +4212,7 @@ mod account_principal_index_tests { let other_anchor_number = anchor_number + 1; storage.lookup_account_with_principal_memory.insert( principal, - StorableAccountLocator { + StorableAccountKey { anchor_number: other_anchor_number, application_number, account_number: None, From 69de2204ad6979a5db7fc94bc5b2f44a6545b4e9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 02:25:18 +0200 Subject: [PATCH 092/298] refactor(be): resolve a principal to an account address, not a stored row `lookup_account_with_principal` handed back `StorableAccountKey`, which names the origin by the application number storage interned it to. That is storage's own shape: a caller that has one has to ask storage what origin it means before it can do anything with it. It returns an `AccountKey` now, resolved on the way out. `lookup_session_with_principal` was doing that resolution by hand and no longer has to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 40 ++++++++++++++-------- src/internet_identity/src/storage/tests.rs | 11 +++--- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index a74510f2df..a917522bae 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1745,12 +1745,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) + /// The account a principal a dapp sees was derived for, as an address. + /// + /// An [`Account`] carries the seed it signs with, so one only ever comes out of + /// [`Self::read_account`], which is where the identity's claim on it is checked. + pub fn lookup_account_with_principal(&self, principal: Principal) -> Option { + self.account_key_of(&self.lookup_account_with_principal_memory.get(&principal)?) + } + + /// A stored account address resolved to the one callers use. + /// + /// `None` where the application is gone, which leaves the stored row naming + /// nothing. Not a `From`, because the origin the number stands for comes out of + /// storage. + fn account_key_of(&self, stored: &StorableAccountKey) -> Option { + Some(AccountKey { + anchor_number: stored.anchor_number, + origin: self + .stable_application_memory + .get(&stored.application_number)? + .origin, + account_number: stored.account_number, + }) } /// The session a caller's principal names, or `None` where the index no longer @@ -1762,16 +1778,12 @@ impl Storage { /// [`Self::read_session`] will not match a later session of the same browser. pub fn lookup_session_with_principal(&self, principal: Principal) -> Option { let handle = self.lookup_session_with_principal_memory.get(&principal)?; - let locator = self.lookup_account_with_principal(handle.account())?; - let origin = self - .stable_application_memory - .get(&locator.application_number)? - .origin; + let account = self.lookup_account_with_principal(handle.account())?; Some(SessionRecordKey { - anchor_number: locator.anchor_number, - origin, - account_number: locator.account_number, + anchor_number: account.anchor_number, + origin: account.origin, + account_number: account.account_number, device_id: handle.device_id, created_at: handle.created_at, }) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 564262c689..aff3739218 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4225,16 +4225,13 @@ mod account_principal_index_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); record_use(&mut storage, 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(); + let key = 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); + assert_eq!(key.anchor_number, anchor_number); + assert_eq!(key.origin, origin); + assert_eq!(key.account_number, None); } #[test] From 389ed583f179df07967b207beba1bcf7fd168756 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 02:36:01 +0200 Subject: [PATCH 093/298] feat(be): the user signs a browser out, not one of its sessions `revoke_account_session` let the settings page end a single session, named by the round it was created in. It matched on `created_at` alone while authenticating a session requires the browser and the creation time together, so two browsers signing in during the same round were both signed out by a request that named one of them. The II frontend is device-only: what a user picks in settings is a browser, and `revoke_device_sessions` is the affordance for it. An app still signs its own session out through `app_revoke_session`, which resolves the caller's own principal and so cannot name anyone else's. Removed: the `revoke_account_session` method, `RevokeAccountSessionRequest`, and `Storage::revoke_account_sessions`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity_interface/src/internet_identity/types.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index d4c36250f0..7f51c278fe 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -853,8 +853,6 @@ pub enum AppSessionError { InternalCanisterError(String), } -/// Revokes one session of an anchor, named by where it was created. - /// Signs one browser out of every app it is signed into. #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub struct RevokeDeviceSessionsRequest { From 3a145bb44b7783b1baf8f01c6494d2d8d4722650 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 02:36:45 +0200 Subject: [PATCH 094/298] docs(fe): say what a stored session's creation time is for It named the session to `revoke_account_session`, which is gone: the user signs a whole browser out. What the creation time still names, paired with the browser's device id, is the session `get_account_session` collects a delegation for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/lib/stores/app-session.store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index c9ecd8ae47..422e695847 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -17,8 +17,9 @@ export interface AppSessionRecord { keyPair: CryptoKeyPair; chainJson: string; expiresAtMillis: number; - /** Names this session to `revoke_account_session`, which is how one session is revoked - * once a surface exists that lists them. */ + /** Which sign-in this is. Paired with the browser's device id it names the session to + * `get_account_session`, since a browser keeps its id across sign-ins. Ending one + * session is the app's own call; from settings the user signs a whole browser out. */ createdAtNanos: bigint; /** What the user consented to when this session was created. Recorded for display; the * canister enforces it at every mint, and an app cannot request a level of its own. */ From e5ca8cef1968194b3d288c3087d3eea7f9b2326e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 02:38:12 +0200 Subject: [PATCH 095/298] test(be): check a revoked session through the device revoke Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/tests/integration/sessions.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index b2a98274e7..6b13e34236 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -796,15 +796,13 @@ fn should_report_a_revoked_session_as_gone() -> Result<(), RejectResponse> { let identity_number = flows::register_anchor(&env, canister_id); let (prepared, session_principal) = create_session(&env, canister_id, identity_number); - revoke_account_session( + revoke_device_sessions( &env, canister_id, principal_1(), - RevokeAccountSessionRequest { + RevokeDeviceSessionsRequest { identity_number, - origin: ORIGIN.to_string(), - account_number: None, - created_at: prepared.created_at, + device_id: prepared.device_id, }, )? .unwrap(); From 2b7a48d710649db4bc92e0cab8382477aa3951cd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 10:44:47 +0200 Subject: [PATCH 096/298] fix(fe): name the session get_account_session is fetching The call left out device_id and created_at, which prepare_account_session returns and the request type requires. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index dbf0f370a6..345629f89c 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -262,6 +262,8 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, + device_id: prepared.device_id, + created_at: prepared.created_at, expiration: prepared.expiration, }) .then(throwCanisterError), From 208d3c4abea3087ab50e90daf602172326e89375 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 10:48:31 +0200 Subject: [PATCH 097/298] test: put sessions back in the two tests that name them Both had been reduced to references with an empty session list, which left every conversion between SessionRecord and its stored form unexecuted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 32 ++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 2b3ad6e5f4..2ae89db0a7 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4325,7 +4325,31 @@ mod session_record_tests { #[test] fn a_reference_with_sessions_round_trips() { - let reference = AccountReference::new(Some(3), Some(9)); + // Every field differs, inside a record and between the two, so a pair of fields + // swapped on the way through either conversion reads back as a mismatch rather + // than as a value that happens to match the one it was swapped with. + let reference = AccountReference { + account_number: Some(3), + last_used: Some(9), + sessions: vec![ + SessionRecord { + created_at_ns: 11, + valid_till_ns: 22, + max_idle_ns: 33, + last_refreshed_ns: Some(44), + device_id: 55, + read_only: false, + }, + SessionRecord { + created_at_ns: 66, + valid_till_ns: 77, + max_idle_ns: 88, + last_refreshed_ns: None, + device_id: 99, + read_only: true, + }, + ], + }; let stored = StorableAccountReference::from(reference.clone()); let decoded = @@ -4408,7 +4432,11 @@ mod session_record_tests { .write_account_state( anchor_number, application_number, - vec![AccountReference::new(None, Some(1))], + vec![AccountReference { + account_number: None, + last_used: Some(1), + sessions: vec![session(0, u64::MAX)], + }], None, None, ) From cbab2510f1e5024859dcb018a9074d07a95e2693 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 10:58:52 +0200 Subject: [PATCH 098/298] feat: name a session by an id nothing else can produce time() is constant across a consensus round, so a session named by its creation time and its browser is not distinguishable from one created in the same round at the same account. A chain issued against a replaced session then verifies against its successor. Sessions are now allocated from a single monotonic counter, and the id is the only input to the session seed besides the account's own. An id is never reissued, so a session that is gone stays gone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/delegation.rs | 13 +- src/internet_identity/src/storage.rs | 57 ++++++-- src/internet_identity/src/storage/account.rs | 31 ++-- src/internet_identity/src/storage/storable.rs | 1 + .../src/storage/storable/session_handle.rs | 12 +- .../src/storage/storable/session_id.rs | 1 + .../src/storage/storable/session_record.rs | 9 +- src/internet_identity/src/storage/tests.rs | 136 ++++++++---------- .../src/internet_identity/types.rs | 4 + 9 files changed, 144 insertions(+), 120 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/session_id.rs diff --git a/src/internet_identity/src/delegation.rs b/src/internet_identity/src/delegation.rs index 7aebe803ce..33490b1c2b 100644 --- a/src/internet_identity/src/delegation.rs +++ b/src/internet_identity/src/delegation.rs @@ -129,14 +129,14 @@ 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. +/// account's principal unchanged, including naming a default account. `session_id` is +/// the only other input, and no two sessions are ever allocated the same one, so a +/// revoked session's identity can never be arrived at a second time. Unguessability +/// comes from the salt. pub fn calculate_session_seed_with_salt( salt: &[u8; 32], account_seed: &Hash, - created_at: Timestamp, - device_id: SessionDeviceId, + session_id: SessionId, ) -> Hash { fn push_field(blob: &mut Vec, data: &[u8]) { blob.extend_from_slice(&(data.len() as u64).to_be_bytes()); @@ -147,8 +147,7 @@ pub fn calculate_session_seed_with_salt( 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()); + push_field(&mut blob, &session_id.to_be_bytes()); hash_bytes(blob) } diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 80b753d62d..674c879fce 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -125,6 +125,7 @@ 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 crate::storage::storable::session_id::StorableSessionId; use internet_identity_interface::internet_identity::types::*; use storable::anchor::StorableAnchor; use storable::anchor_number::StorableAnchorNumber; @@ -215,6 +216,7 @@ 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 NEXT_SESSION_ID_MEMORY_INDEX: u8 = 36u8; const ANCHOR_MEMORY_ID: MemoryId = MemoryId::new(ANCHOR_MEMORY_INDEX); const ARCHIVE_BUFFER_MEMORY_ID: MemoryId = MemoryId::new(ARCHIVE_BUFFER_MEMORY_INDEX); @@ -305,6 +307,10 @@ const LOOKUP_SESSION_WITH_PRINCIPAL_MEMORY_ID: MemoryId = const LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_ID: MemoryId = MemoryId::new(LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_INDEX); +/// Monotonic [`SessionId`] allocator. A revoked session's id is retired, never reissued, +/// which is what makes the revocation final: the id is an input to the session seed. +const NEXT_SESSION_ID_MEMORY_ID: MemoryId = MemoryId::new(NEXT_SESSION_ID_MEMORY_INDEX); + // The bucket size 128 is relatively low, to avoid wasting memory when using // multiple virtual memories for smaller amounts of data. // This value results in 256 GB of total managed memory, which should be enough @@ -405,6 +411,7 @@ pub struct Storage { >, stable_account_counter_memory: StableCell>, next_application_number_memory: StableCell>, + next_session_id_memory: StableCell>, lookup_account_with_principal_memory_wrapper: MemoryWrapper>, lookup_account_with_principal_memory: StableBTreeMap>, @@ -549,6 +556,7 @@ impl Storage { memory_manager.get(STABLE_DEFAULT_ACCOUNT_REFERENCE_MEMORY_ID); let stable_account_counter_memory = memory_manager.get(STABLE_ACCOUNT_COUNTER_MEMORY_ID); let next_application_number_memory = memory_manager.get(NEXT_APPLICATION_NUMBER_MEMORY_ID); + let next_session_id_memory = memory_manager.get(NEXT_SESSION_ID_MEMORY_ID); let lookup_account_with_principal_memory = memory_manager.get(LOOKUP_ACCOUNT_WITH_PRINCIPAL_MEMORY_ID); let lookup_session_with_principal_memory = @@ -637,6 +645,8 @@ impl Storage { .expect("stable_account_counter_memory"), next_application_number_memory: StableCell::init(next_application_number_memory, 0) .expect("next_application_number_memory"), + next_session_id_memory: StableCell::init(next_session_id_memory, 0) + .expect("next_session_id_memory"), lookup_account_with_principal_memory_wrapper: MemoryWrapper::new( lookup_account_with_principal_memory.clone(), ), @@ -1604,6 +1614,23 @@ impl Storage { Ok(new_number) } + /// Hands out the next session id, which no session has held before. + /// + /// Refuses at the ceiling rather than saturating. The id is an input to the session + /// seed, so reissuing one would let a revoked session's identity be arrived at a + /// second time — the thing this counter exists to prevent. + fn allocate_session_id(&mut self) -> Result { + let session_id = *self.next_session_id_memory.get(); + self.next_session_id_memory + .set( + session_id + .checked_add(1) + .ok_or(StorageError::SessionIdOverflow)?, + ) + .map_err(|_| StorageError::ErrorUpdatingSessionIdAllocator)?; + Ok(session_id) + } + pub fn lookup_application_number_with_origin( &self, origin: &FrontendHostname, @@ -1753,8 +1780,7 @@ impl Storage { let seed = calculate_session_seed_with_salt( &salt, &account.calculate_seed_with_salt(&salt), - session.created_at_ns, - session.device_id, + session.session_id, ); Some(canister_sig_principal(canister_id(), seed.to_vec())) } @@ -1798,8 +1824,7 @@ impl Storage { /// The session `key` names, or `None` where the identity holds no such session. /// /// A key whose session was replaced reads as `None` rather than as its successor: - /// a browser keeps its id across sign-ins, so the creation time is what tells two - /// of that browser's sessions apart. + /// the successor was allocated an id of its own. pub fn read_session(&self, key: &SessionRecordKey) -> Option { let application_number = self.lookup_application_number_with_origin(&key.origin)?; @@ -1808,9 +1833,7 @@ impl Storage { .find(|reference| reference.account_number == key.account_number)? .sessions .into_iter() - .find(|session| { - session.device_id == key.device_id && session.created_at_ns == key.created_at - }) + .find(|session| session.session_id == key.session_id) } // Called by the sign-in ceremony, which lands two PRs up. @@ -1898,7 +1921,12 @@ impl Storage { true }); + // After the checks that can refuse this ceremony, so a refused one does not burn + // an id. Ids need not be contiguous, so a later failure leaving a gap is fine; + // what must never happen is one being handed out twice. + let session_id = self.allocate_session_id()?; let session = SessionRecord { + session_id, created_at_ns: now_ns, valid_till_ns, max_idle_ns, @@ -1939,8 +1967,7 @@ impl Storage { principal, StorableSessionHandle { account_principal: account_principal.as_slice().to_vec(), - device_id, - created_at: session.created_at_ns, + session_id, }, ); } @@ -1950,8 +1977,7 @@ impl Storage { anchor_number, origin, account_number, - device_id, - created_at: session.created_at_ns, + session_id, }; Ok((key, session)) } @@ -3371,6 +3397,11 @@ pub enum StorageError { /// put two origins on a single row. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, + /// No session ids left to hand out. Refused rather than saturated: the id is an + /// input to the session seed, so reissuing one would resurrect a revoked session's + /// identity. + SessionIdOverflow, + ErrorUpdatingSessionIdAllocator, /// The references a write assembled cannot be stored as they stand. UnstorableAccountReferenceList { anchor_number: AnchorNumber, @@ -3464,6 +3495,10 @@ impl fmt::Display for StorageError { Self::ErrorUpdatingApplicationNumberAllocator => { write!(f, "Error updating the application number allocator") } + Self::SessionIdOverflow => write!(f, "No session ids left to allocate"), + Self::ErrorUpdatingSessionIdAllocator => { + write!(f, "Error updating the session id allocator") + } Self::UnstorableAccountReferenceList { anchor_number, application_number, diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 1b2aaa7b5b..2bbc3f275d 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -8,7 +8,7 @@ use ic_cdk::trap; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, FrontendHostname, - SessionDeviceId, Timestamp, UserKey, + SessionDeviceId, SessionId, Timestamp, UserKey, }; use serde::{Deserialize, Serialize}; @@ -69,20 +69,18 @@ pub const MIN_SESSION_IDLE_NS: u64 = 10 * crate::MINUTE_NS; /// and a machine walked away from stops being signed in within one. pub const DEFAULT_SESSION_IDLE_NS: u64 = 7 * crate::DAY_NS; -/// The four things that name one session, plus the creation time that tells two of one -/// browser's apart. +/// Where one session is stored, and which session it is. /// -/// A browser keeps its id across sign-ins, so `device_id` alone names whatever that -/// browser holds now rather than the session a caller means. With `created_at` every -/// operation is compare-and-act: a key for a session that was replaced reads as `None` -/// and revokes nothing, instead of landing on its successor. +/// The account addresses the row; `session_id` picks the record out of it. The id is +/// unique on its own, so every operation is compare-and-act: a key for a session that +/// was replaced reads as `None` and revokes nothing, instead of landing on its +/// successor. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SessionRecordKey { pub anchor_number: AnchorNumber, pub origin: FrontendHostname, pub account_number: Option, - pub device_id: SessionDeviceId, - pub created_at: Timestamp, + pub session_id: SessionId, } impl SessionRecordKey { @@ -98,8 +96,11 @@ impl SessionRecordKey { } } -/// A revocable session at one account. Only `last_refreshed` is mutable, which is why -/// it is the one field absent from the seed. +/// A revocable session at one account. +/// +/// `session_id` is what the seed binds, so the identity this session signs with is +/// tied to the one record that was allocated that id. Every other field describes the +/// session and can be rewritten without changing who it signs as. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub struct SessionRecord { pub created_at_ns: Timestamp, @@ -108,6 +109,7 @@ pub struct SessionRecord { pub last_refreshed_ns: Option, pub device_id: SessionDeviceId, pub read_only: bool, + pub session_id: SessionId, } impl SessionRecord { @@ -141,13 +143,14 @@ impl SessionRecord { /// /// The extension is what separates an app in weekly use from one opened once and /// abandoned, which recency alone gets backwards — the abandoned one was touched more - /// recently. `device_id` only makes the order total. - pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, SessionDeviceId) { + /// recently. `session_id` only makes the order total, which it can because no two + /// sessions share one. + pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, SessionId) { let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); ( !self.is_over(now), last_used.saturating_add(self.demonstrated_use()), - self.device_id, + self.session_id, ) } } diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 21c80a69b0..f7b33d8ac5 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -28,6 +28,7 @@ pub mod recovery_key; pub mod session_device; pub mod session_device_id; pub mod session_handle; +pub mod session_id; pub mod session_record; pub mod special_device_migration; pub mod sso_stable_id_key; diff --git a/src/internet_identity/src/storage/storable/session_handle.rs b/src/internet_identity/src/storage/storable/session_handle.rs index 3f1770520a..84bbe0ff81 100644 --- a/src/internet_identity/src/storage/storable/session_handle.rs +++ b/src/internet_identity/src/storage/storable/session_handle.rs @@ -1,4 +1,4 @@ -use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use crate::storage::storable::session_id::StorableSessionId; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use minicbor::{Decode, Encode}; @@ -10,19 +10,15 @@ use std::borrow::Cow; /// 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. +/// The session itself is named by its id, which is an input 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, + pub session_id: StorableSessionId, } impl Storable for StorableSessionHandle { diff --git a/src/internet_identity/src/storage/storable/session_id.rs b/src/internet_identity/src/storage/storable/session_id.rs new file mode 100644 index 0000000000..ccb2b09270 --- /dev/null +++ b/src/internet_identity/src/storage/storable/session_id.rs @@ -0,0 +1 @@ +pub type StorableSessionId = u64; diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index 0feda7eef6..c53689d78c 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -1,6 +1,7 @@ use crate::storage::account::SessionRecord; use crate::storage::storable::duration::StorableDuration; use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use crate::storage::storable::session_id::StorableSessionId; use crate::storage::storable::timestamp::StorableTimestamp; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; @@ -22,6 +23,8 @@ pub struct StorableSessionRecord { pub device_id: StorableSessionDeviceId, #[n(5)] pub read_only: bool, + #[n(6)] + pub session_id: StorableSessionId, } impl Storable for StorableSessionRecord { @@ -43,10 +46,11 @@ impl From for SessionRecord { SessionRecord { created_at_ns: value.created_at_ns, valid_till_ns: value.valid_till_ns, - last_refreshed_ns: value.last_refreshed_ns, max_idle_ns: value.max_idle_ns, + last_refreshed_ns: value.last_refreshed_ns, device_id: value.device_id, read_only: value.read_only, + session_id: value.session_id, } } } @@ -56,10 +60,11 @@ impl From for StorableSessionRecord { StorableSessionRecord { created_at_ns: value.created_at_ns, valid_till_ns: value.valid_till_ns, - last_refreshed_ns: value.last_refreshed_ns, max_idle_ns: value.max_idle_ns, + last_refreshed_ns: value.last_refreshed_ns, device_id: value.device_id, read_only: value.read_only, + session_id: value.session_id, } } } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 8bbae90abb..2f7e16c311 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4365,7 +4365,7 @@ mod session_record_tests { /// what the tests about the absolute bound want. const NEVER_IDLE: u64 = u64::MAX; - fn session(created_at_ns: u64, valid_till_ns: u64) -> SessionRecord { + fn session(session_id: u64, created_at_ns: u64, valid_till_ns: u64) -> SessionRecord { SessionRecord { created_at_ns, valid_till_ns, @@ -4373,6 +4373,7 @@ mod session_record_tests { last_refreshed_ns: None, device_id: 1, read_only: false, + session_id, } } @@ -4401,14 +4402,16 @@ mod session_record_tests { last_refreshed_ns: Some(44), device_id: 55, read_only: false, + session_id: 66, }, SessionRecord { - created_at_ns: 66, - valid_till_ns: 77, - max_idle_ns: 88, + created_at_ns: 77, + valid_till_ns: 88, + max_idle_ns: 99, last_refreshed_ns: None, - device_id: 99, + device_id: 111, read_only: true, + session_id: 122, }, ], }; @@ -4422,7 +4425,7 @@ mod session_record_tests { #[test] fn a_bound_further_out_than_the_session_never_bites() { - let record = session(0, DAY_NS); + let record = session(1, 0, DAY_NS); assert!(!record.is_over(0)); // Past its own lifetime, so over on the other bound — which is the point: @@ -4435,7 +4438,7 @@ mod session_record_tests { let record = SessionRecord { max_idle_ns: 30 * MINUTE_NS, last_refreshed_ns: Some(10 * MINUTE_NS), - ..session(0, DAY_NS) + ..session(1, 0, DAY_NS) }; assert!(!record.is_over(39 * MINUTE_NS)); @@ -4448,7 +4451,7 @@ mod session_record_tests { let record = SessionRecord { max_idle_ns: 30 * MINUTE_NS, last_refreshed_ns: None, - ..session(5 * MINUTE_NS, DAY_NS) + ..session(1, 5 * MINUTE_NS, DAY_NS) }; // Otherwise a session abandoned straight after sign-in would sit unbounded @@ -4497,7 +4500,7 @@ mod session_record_tests { vec![AccountReference { account_number: None, last_used: Some(1), - sessions: vec![session(0, u64::MAX)], + sessions: vec![session(1, 0, u64::MAX)], }], None, None, @@ -4530,7 +4533,7 @@ mod session_record_tests { vec![AccountReference { account_number: None, last_used: Some(last_used), - sessions: vec![session(1, u64::MAX)], + sessions: vec![session(1, 1, u64::MAX)], }], None, None, @@ -4565,11 +4568,11 @@ mod session_record_tests { let idle = SessionRecord { max_idle_ns: DAY_NS, last_refreshed_ns: Some(now - 10 * DAY_NS), - ..session(now - 20 * DAY_NS, now + DAY_NS) + ..session(1, now - 20 * DAY_NS, now + DAY_NS) }; let live = SessionRecord { last_refreshed_ns: Some(now - 1), - ..session(now - 20 * DAY_NS, now + DAY_NS) + ..session(2, now - 20 * DAY_NS, now + DAY_NS) }; // Both are inside their lifetime, so ranking on that alone would have them @@ -4580,13 +4583,13 @@ mod session_record_tests { #[test] fn reclaim_order_ranks_dead_sessions_first() { let now = 1_000; - let expired = session(1, 500); + let expired = session(1, 1, 500); let live = SessionRecord { max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(900), - ..session(400, 10_000) + ..session(2, 400, 10_000) }; - let live_untouched = session(400, 10_000); + let live_untouched = session(3, 400, 10_000); assert!(expired.reclaim_order(now) < live.reclaim_order(now)); assert!(expired.reclaim_order(now) < live_untouched.reclaim_order(now)); @@ -4598,14 +4601,14 @@ mod session_record_tests { let held = SessionRecord { max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - DAY_NS), - ..session(now - 20 * DAY_NS, now + DAY_NS) + ..session(501, 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) + ..session(index as u64 + 1, now - 1, now + DAY_NS) }) .collect(); @@ -4621,13 +4624,13 @@ mod session_record_tests { let weekly = SessionRecord { max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - 3 * DAY_NS), - ..session(now - 90 * DAY_NS, now + DAY_NS) + ..session(1, now - 90 * DAY_NS, now + DAY_NS) }; // Signed in yesterday, used for five minutes, never opened again. let one_sitting = SessionRecord { max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - DAY_NS + 5 * MINUTE_NS), - ..session(now - DAY_NS, now + DAY_NS) + ..session(2, now - DAY_NS, now + DAY_NS) }; assert!( @@ -4640,9 +4643,7 @@ mod session_record_tests { mod session_creation_tests { use super::held_references; use crate::delegation::calculate_session_seed_with_salt; - use crate::storage::account::{ - AccountReference, SessionRecord, DEFAULT_SESSION_IDLE_NS, MIN_SESSION_IDLE_NS, - }; + use crate::storage::account::{SessionRecord, DEFAULT_SESSION_IDLE_NS, MIN_SESSION_IDLE_NS}; use crate::storage::CreateSessionParams; use crate::{Storage, DAY_NS, MINUTE_NS}; use ic_stable_structures::VectorMemory; @@ -4921,42 +4922,33 @@ mod session_creation_tests { assert!(result.is_err()); } + /// The hazard the session id exists for: `time()` is constant across a consensus + /// round, so two records created in one round agree on every field that describes + /// them. If identity came from those fields, the second would sign as the first — + /// and a chain issued against a session that has since been replaced would verify + /// again. #[test] - fn an_expired_same_round_record_is_pruned_rather_than_colliding() { + fn a_session_replaced_in_the_same_round_does_not_inherit_its_identity() { let (mut storage, anchor_number) = storage_with_anchor(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&ORIGIN.to_string()) - .unwrap(); - storage - .write_account_state( - anchor_number, - application_number, - vec![AccountReference { - account_number: None, - last_used: Some(1), - sessions: vec![SessionRecord { - created_at_ns: 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_ns: 1_000, - max_idle_ns: u64::MAX, - last_refreshed_ns: None, - device_id: 1, - read_only: false, - }], - }], - None, - None, - ) - .unwrap(); + let same_round = |device_id| CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id, + valid_till_ns: 10_000, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + }; - // 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() - .1; - assert_eq!(created.created_at_ns, 1_000); + let first = storage.create_session(same_round(1)).unwrap().1; + let replacement = storage.create_session(same_round(1)).unwrap().1; + let sibling = storage.create_session(same_round(2)).unwrap().1; + + assert_eq!(first.created_at_ns, replacement.created_at_ns); + assert_eq!(first.device_id, replacement.device_id); + assert_ne!(first.session_id, replacement.session_id); + assert_ne!(replacement.session_id, sibling.session_id); } /// Creating twice from one browser at one account replaces, so there is never a second @@ -4985,7 +4977,7 @@ mod session_creation_tests { } #[test] - fn the_session_seed_binds_the_account_and_every_immutable_field() { + fn the_session_seed_binds_the_account_and_the_session_id() { use crate::storage::account::Account; let account = Account::new(10_000, ORIGIN.to_string(), None, None); @@ -4993,27 +4985,23 @@ mod session_creation_tests { 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); + let base = calculate_session_seed_with_salt(&SALT, &account_seed, 1); assert_ne!( base, - calculate_session_seed_with_salt(&SALT, &other_seed, 1_000, 1) + calculate_session_seed_with_salt(&SALT, &other_seed, 1) ); assert_ne!( base, - calculate_session_seed_with_salt(&SALT, &account_seed, 1_001, 1) + calculate_session_seed_with_salt(&SALT, &account_seed, 2) ); 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) + calculate_session_seed_with_salt(&[18u8; 32], &account_seed, 1) ); assert_eq!( base, - calculate_session_seed_with_salt(&SALT, &account_seed, 1_000, 1) + calculate_session_seed_with_salt(&SALT, &account_seed, 1) ); } @@ -5023,7 +5011,7 @@ mod session_creation_tests { 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); + let session_seed = calculate_session_seed_with_salt(&SALT, &account_seed, 1); assert_ne!(account_seed, session_seed); } @@ -5034,12 +5022,8 @@ mod session_creation_tests { 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 before = + calculate_session_seed_with_salt(&SALT, &default.calculate_seed_with_salt(&SALT), 1); let named = Account::new_full( 10_000, @@ -5049,12 +5033,8 @@ mod session_creation_tests { None, Some(10_000), ); - let after = calculate_session_seed_with_salt( - &SALT, - &named.calculate_seed_with_salt(&SALT), - 1_000, - 1, - ); + let after = + calculate_session_seed_with_salt(&SALT, &named.calculate_seed_with_salt(&SALT), 1); assert_eq!(before, after); } diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 4dcf0b549a..3111f7b93f 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -16,6 +16,10 @@ pub type ApplicationNumber = u64; pub type Timestamp = u64; // in nanos since epoch /// Per-anchor label for one browser, so a browser's sessions can be revoked together. pub type SessionDeviceId = u32; +/// Names one session for as long as the canister runs. Allocated from a single +/// counter, so no two sessions ever share one, and a revoked session's id is never +/// handed out again. +pub type SessionId = u64; pub type Signature = ByteBuf; pub type DeviceConfirmationCode = String; pub type FailedAttemptsCounter = u8; From ddff645135356d43410e17c158b46503394b95e7 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:04:34 +0200 Subject: [PATCH 099/298] feat: carry the session id on the wire prepare_account_session returns the id of the session it created and get_account_session is given that id, in place of the browser and creation time that used to stand in for one. device_id stays on the response: it names the browser registration the client's key now belongs to, not the session. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/internet_identity.did | 15 ++++++++------- src/internet_identity/src/sessions.rs | 19 ++++++++----------- .../tests/integration/sessions.rs | 3 +-- .../src/internet_identity/types.rs | 14 ++++++++++---- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 3a879a39e5..5363213a12 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1050,9 +1050,13 @@ type PrepareAccountSessionResponse = record { user_key : PublicKey; // The session's valid_till. expiration : Timestamp; - created_at : Timestamp; + // Names the session this ceremony created, and is what get_account_session is given + // to collect the delegation signed for it. Not a credential: it names a session, it + // does not authorise one. + session_id : nat64; // 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. + // the user is looking at, and so the browser knows which registration its key now + // belongs to. 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. @@ -1065,11 +1069,8 @@ type GetAccountSessionRequest = record { account_number : opt AccountNumber; session_key : SessionKey; expiration : Timestamp; - // Which browser and which sign-in, both returned by prepare_account_session. The - // pair names one session: a browser keeps its id across sign-ins, so the creation - // time is what tells two of its sessions apart. - device_id : nat32; - created_at : Timestamp; + // The session prepare_account_session created, named exactly rather than searched for. + session_id : nat64; }; type GetAccountSessionResponse = record { diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index f74bfb5147..6cb3021354 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -183,7 +183,7 @@ pub async fn prepare_account_session( Ok(PrepareAccountSessionResponse { user_key: ByteBuf::from(der_encode_canister_sig_key(seed.to_vec())), expiration: session.valid_till_ns, - created_at: session.created_at_ns, + session_id: session.session_id, device_id, account_principal, }) @@ -198,24 +198,22 @@ pub fn get_account_session( account_number, session_key, expiration, - device_id, - created_at, + session_id, } = request; check_authorization(identity_number)?; check_frontend_length(&origin); - // `prepare_account_session` handed the browser back both halves of this key, so the - // session is named exactly rather than searched for. A key naming a session that was - // replaced since finds nothing, which is the honest answer: the delegation this call - // is collecting was signed for the session that is gone. + // `prepare_account_session` handed the browser this id, so the session is named + // exactly rather than searched for. An id naming a session that was replaced since + // finds nothing, which is the honest answer: the delegation this call is collecting + // was signed for the session that is gone. let session = storage_borrow(|storage| { storage.read_session(&SessionRecordKey { anchor_number: identity_number, origin: origin.clone(), account_number, - device_id, - created_at, + session_id, }) }) .ok_or(AccountSessionError::NoSuchSession)?; @@ -279,8 +277,7 @@ fn session_identity( let seed = calculate_session_seed_with_salt( &salt, &account.calculate_seed_with_salt(&salt), - session.created_at_ns, - session.device_id, + session.session_id, ); Ok(seed) } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index d257aefac0..76ceb7c08a 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -86,8 +86,7 @@ fn should_create_a_session_and_witness_its_delegation() -> Result<(), RejectResp account_number: None, session_key: ByteBuf::from(vec![1; 32]), expiration: prepared.expiration, - device_id: prepared.device_id, - created_at: prepared.created_at, + session_id: prepared.session_id, }, )? .unwrap(); diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 0f0842eabb..1f49f8e8f6 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -793,9 +793,14 @@ pub struct PrepareAccountSessionRequest { pub struct PrepareAccountSessionResponse { pub user_key: UserKey, pub expiration: Timestamp, - pub created_at: Timestamp, + /// Names the session this ceremony created, and is what `get_account_session` is + /// given to collect the delegation signed for it. Not a credential: it names a + /// session, it does not authorise one, and the caller has just proved it owns this + /// one anyway. + pub session_id: SessionId, /// 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. + /// the user is looking at, and so the browser knows which registration its key now + /// belongs to. 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 @@ -810,8 +815,9 @@ pub struct GetAccountSessionRequest { pub account_number: Option, pub session_key: SessionKey, pub expiration: Timestamp, - pub device_id: SessionDeviceId, - pub created_at: Timestamp, + /// The session `prepare_account_session` created, named exactly rather than + /// searched for. + pub session_id: SessionId, } #[derive(Clone, Debug, CandidType, Deserialize)] From 1f9e1ab64ae616072541b397adcbdb64d8fff0fa Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:08:04 +0200 Subject: [PATCH 100/298] fix: stamp the session the id names Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 10 +++++----- src/internet_identity/src/storage/tests.rs | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 033d2c82e5..1098218714 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2008,11 +2008,10 @@ impl Storage { anchor_number, origin, account_number, - device_id, - created_at, + session_id, } = key; - let (anchor_number, account_number, device_id, created_at) = - (*anchor_number, *account_number, *device_id, *created_at); + let (anchor_number, account_number, session_id) = + (*anchor_number, *account_number, *session_id); let Some(application_number) = self.lookup_application_number_with_origin(origin) else { return Ok(false); @@ -2028,12 +2027,13 @@ impl Storage { let Some(session) = reference .sessions .iter_mut() - .find(|session| session.created_at_ns == created_at && session.device_id == device_id) + .find(|session| session.session_id == session_id) else { return Ok(false); }; session.last_refreshed_ns = Some(now); + let device_id = session.device_id; reference.last_used = Some(now); // This row is being rewritten anyway, so its dead sessions go now. It costs one diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index e0b8f513a8..ba8fcd658f 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5599,8 +5599,7 @@ mod session_refresh_stamp_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 1, - created_at: 9_999, + session_id: 9_999, }, 5_000, ) From f456a3e2028ec6cbe0537eb1228d0f7bc91a00ad Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:10:30 +0200 Subject: [PATCH 101/298] feat: revoke the session the id names Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 13 ++-- src/internet_identity/src/storage/tests.rs | 91 +++++++++------------- 2 files changed, 42 insertions(+), 62 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index abe72c2d67..ce3d6d4f80 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1893,9 +1893,8 @@ impl Storage { /// Removes one session. Returns whether anything was removed. pub fn revoke_session(&mut self, key: &SessionRecordKey) -> Result { - // The key carries the creation time as well as the browser, so a key for a - // session that was replaced since finds nothing rather than taking its - // successor down with it. + // The key names one session by its id, so a key for a session that was replaced + // since finds nothing rather than taking its successor down with it. if self.read_session(key).is_none() { return Ok(false); } @@ -1910,7 +1909,7 @@ impl Storage { anchor_number, application_number, key.account_number, - key.device_id, + key.session_id, )?; if dropped > 0 { self.change_session_count(anchor_number, dropped, 0)?; @@ -2116,7 +2115,7 @@ impl Storage { anchor_number: AnchorNumber, application_number: ApplicationNumber, account_number: Option, - device_id: SessionDeviceId, + session_id: SessionId, ) -> Result { let mut references = self.account_references(anchor_number, application_number); let Some(reference) = references @@ -2128,7 +2127,7 @@ impl Storage { let dropped: Vec = reference .sessions .iter() - .filter(|session| session.device_id == device_id) + .filter(|session| session.session_id == session_id) .cloned() .collect(); if dropped.is_empty() { @@ -2136,7 +2135,7 @@ impl Storage { } reference .sessions - .retain(|session| session.device_id != device_id); + .retain(|session| session.session_id != session_id); self.write_account_state(anchor_number, application_number, references, None, None)?; self.unindex_sessions(anchor_number, application_number, account_number, &dropped); Ok(dropped.len()) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index a9963cccc3..e1f1a6b7e2 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5714,30 +5714,43 @@ mod session_removal_tests { const ORIGIN: &str = "https://example.com"; - fn storage_with_sessions(devices: &[u32]) -> (Storage, AnchorNumber, u64) { + /// The keys come back with the storage: a session is named by the id it was + /// allocated, which only the ceremony that created it knows. + fn storage_with_sessions( + devices: &[u32], + ) -> ( + Storage, + AnchorNumber, + u64, + Vec, + ) { 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_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - }) - .unwrap(); - } + let keys = devices + .iter() + .map(|device_id| { + storage + .create_session(CreateSessionParams { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + device_id: *device_id, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + }) + .unwrap() + .0 + }) + .collect(); let application_number = storage .lookup_application_number_with_origin(&ORIGIN.to_string()) .unwrap(); - (storage, anchor_number, application_number) + (storage, anchor_number, application_number, keys) } fn sessions(storage: &Storage, anchor_number: AnchorNumber) -> Vec { @@ -5756,17 +5769,9 @@ mod session_removal_tests { #[test] fn removing_a_session_leaves_the_others() { - let (mut storage, anchor_number, _) = storage_with_sessions(&[1, 2, 3]); + let (mut storage, anchor_number, _, keys) = storage_with_sessions(&[1, 2, 3]); - let removed = storage - .revoke_session(&SessionRecordKey { - anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - device_id: 2, - created_at: 1_000, - }) - .unwrap(); + let removed = storage.revoke_session(&keys[1]).unwrap(); assert!(removed); assert_eq!(sessions(&storage, anchor_number), vec![1, 3]); @@ -5774,26 +5779,10 @@ mod session_removal_tests { #[test] fn removing_a_session_twice_reports_nothing_removed() { - let (mut storage, anchor_number, _) = storage_with_sessions(&[1]); - storage - .revoke_session(&SessionRecordKey { - anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - device_id: 1, - created_at: 1_000, - }) - .unwrap(); + let (mut storage, anchor_number, _, keys) = storage_with_sessions(&[1]); + storage.revoke_session(&keys[0]).unwrap(); - let removed = storage - .revoke_session(&SessionRecordKey { - anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - device_id: 1, - created_at: 1_000, - }) - .unwrap(); + let removed = storage.revoke_session(&keys[0]).unwrap(); assert!(!removed); assert_eq!(sessions(&storage, anchor_number), Vec::::new()); @@ -5801,17 +5790,9 @@ mod session_removal_tests { #[test] fn removing_the_last_session_keeps_the_reference() { - let (mut storage, anchor_number, application_number) = storage_with_sessions(&[1]); + let (mut storage, anchor_number, application_number, keys) = storage_with_sessions(&[1]); - storage - .revoke_session(&SessionRecordKey { - anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - device_id: 1, - created_at: 1_000, - }) - .unwrap(); + storage.revoke_session(&keys[0]).unwrap(); assert_ne!( storage.stored_account_references(anchor_number, application_number), From 7b55a5591c7eea1f4e34ff260756db0dbce79fe6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:11:27 +0200 Subject: [PATCH 102/298] chore: regenerate the candid bindings Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/generated/internet_identity_idl.js | 5 ++--- .../generated/internet_identity_types.d.ts | 21 +++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index b2b8a9309d..fe3f19c532 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -387,10 +387,9 @@ export const idlFactory = ({ IDL }) => { 'Unauthorized' : IDL.Principal, }); const GetAccountSessionRequest = IDL.Record({ + 'session_id' : IDL.Nat64, 'session_key' : SessionKey, 'origin' : FrontendHostname, - 'device_id' : IDL.Nat32, - 'created_at' : Timestamp, 'account_number' : IDL.Opt(AccountNumber), 'expiration' : Timestamp, 'identity_number' : UserNumber, @@ -721,8 +720,8 @@ export const idlFactory = ({ IDL }) => { }); const PrepareAccountSessionResponse = IDL.Record({ 'user_key' : PublicKey, + 'session_id' : IDL.Nat64, 'device_id' : IDL.Nat32, - 'created_at' : Timestamp, 'expiration' : Timestamp, 'account_principal' : IDL.Principal, }); 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 4bc4135249..bf59267a41 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -699,15 +699,12 @@ export type GetAccountError = { } }; export interface GetAccountSessionRequest { - 'session_key' : SessionKey, - 'origin' : FrontendHostname, /** - * Which browser and which sign-in, both returned by prepare_account_session. The - * pair names one session: a browser keeps its id across sign-ins, so the creation - * time is what tells two of its sessions apart. + * The session prepare_account_session created, named exactly rather than searched for. */ - 'device_id' : number, - 'created_at' : Timestamp, + 'session_id' : bigint, + 'session_key' : SessionKey, + 'origin' : FrontendHostname, 'account_number' : [] | [AccountNumber], 'expiration' : Timestamp, 'identity_number' : UserNumber, @@ -1393,12 +1390,18 @@ export interface PrepareAccountSessionRequest { } export interface PrepareAccountSessionResponse { 'user_key' : PublicKey, + /** + * Names the session this ceremony created, and is what get_account_session is given + * to collect the delegation signed for it. Not a credential: it names a session, it + * does not authorise one. + */ + 'session_id' : bigint, /** * 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. + * the user is looking at, and so the browser knows which registration its key now + * belongs to. Not a credential: a caller never presents it. */ 'device_id' : number, - 'created_at' : Timestamp, /** * The session's valid_till. */ From 245b0a8d36e8ad77aca99cef503fedd354471fce Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:12:21 +0200 Subject: [PATCH 103/298] refactor(fe): hold the session id the canister named Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/lib/stores/app-session.store.test.ts | 2 +- src/frontend/src/lib/stores/app-session.store.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/stores/app-session.store.test.ts b/src/frontend/src/lib/stores/app-session.store.test.ts index a280722b58..e56fe5d062 100644 --- a/src/frontend/src/lib/stores/app-session.store.test.ts +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -16,7 +16,7 @@ const record = (expiresAtMillis: number): AppSessionRecord => ({ keyPair: {} as CryptoKeyPair, chainJson: "{}", expiresAtMillis, - createdAtNanos: BigInt(1_000), + sessionId: BigInt(1_000), accessLevel: "full-access" as const, }); diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index 422e695847..c60f00ffd8 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -17,10 +17,9 @@ export interface AppSessionRecord { keyPair: CryptoKeyPair; chainJson: string; expiresAtMillis: number; - /** Which sign-in this is. Paired with the browser's device id it names the session to - * `get_account_session`, since a browser keeps its id across sign-ins. Ending one - * session is the app's own call; from settings the user signs a whole browser out. */ - createdAtNanos: bigint; + /** The session this record holds, as the canister named it. Ending one session is the + * app's own call; from settings the user signs a whole browser out. */ + sessionId: bigint; /** What the user consented to when this session was created. Recorded for display; the * canister enforces it at every mint, and an app cannot request a level of its own. */ accessLevel: AccessLevel; From ce2afee9aa338dc9e8ed704e26b6f7f7defa4e5f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:12:56 +0200 Subject: [PATCH 104/298] fix(fe): name the session get_account_session is fetching by its id Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 345629f89c..e388c0378f 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -262,8 +262,7 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, - device_id: prepared.device_id, - created_at: prepared.created_at, + session_id: prepared.session_id, expiration: prepared.expiration, }) .then(throwCanisterError), @@ -288,7 +287,7 @@ const createSession = async ( keyPair: iiKey.getKeyPair(), chainJson: JSON.stringify(canisterChain.toJSON()), expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), - createdAtNanos: prepared.created_at, + sessionId: prepared.session_id, accessLevel: authorized.accessLevel, }; await rememberAppAccount(key, { From 88266428cdabfa900de37686aaf785112a7bea6e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:13:39 +0200 Subject: [PATCH 105/298] test(fe): the session record holds a session id Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../manage/(authenticated)/settings/sessionDevices.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts index 42164f25ff..0aea1e247d 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts @@ -148,7 +148,7 @@ describe("signOutSessionDevice", () => { keyPair: undefined as unknown as CryptoKeyPair, chainJson: "{}", expiresAtMillis: Date.now() + 60 * 60 * 1000, - createdAtNanos: BigInt(1_000), + sessionId: BigInt(1_000), accessLevel: "full-access" as const, accountPrincipal: "2vxsx-fae", }; From c910d7e80b0b1c8c06580c94a7e739442032302b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:14:04 +0200 Subject: [PATCH 106/298] test(fe): the resumed session record holds a session id Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index db6dafa019..a86d2e8ffc 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -192,7 +192,7 @@ const storedSession = async (identityNumber: bigint) => { keyPair: key.getKeyPair(), chainJson: JSON.stringify(chain.toJSON()), expiresAtMillis: Date.now() + 60 * 60 * 1000, - createdAtNanos: BigInt(1_000), + sessionId: BigInt(1_000), accessLevel: "full-access" as const, }, ); From 1ac6256bbd4f96a4b829e418150fdb84f767b74e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:16:53 +0200 Subject: [PATCH 107/298] test: prove a revoked session stays gone without waiting Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/tests/integration/sessions.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 9f1bfbaec3..0bb609c529 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -751,8 +751,9 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { 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)); + // revoked one occupied. The revoked chain must not reach it — and no time is allowed + // to pass, because the new session is told apart from the revoked one by its id and + // not by anything a shared consensus round would make equal. prepare_account_session( &env, canister_id, From f1058d763bcb416f32a3b52271ee677f91f82aef Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:26:44 +0200 Subject: [PATCH 108/298] feat: accept only the successor a browser announced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry was reachable by either key it held, so the key a browser had already rotated away from stayed usable — a copied profile could sign in with it for as long as the entry kept it. An entry is now advanced only by the successor it is waiting for, and a key it has retired is refused rather than registered afresh, so the browser that lost a response is told to promote its own successor instead of becoming a second row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/anchor.rs | 46 ++++++++--- .../src/storage/anchor/tests.rs | 77 +++++++++++++++---- 2 files changed, 96 insertions(+), 27 deletions(-) diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 577702d4d7..30d7b618b1 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -64,6 +64,16 @@ pub enum SessionDeviceError { /// so a browser that named itself its own successor would keep the key alive for as /// long as it kept asking — and whoever leaked it would too. SuccessorMatchesCurrent, + /// The presented key is one this anchor has already retired: an entry holds it as the + /// key it was last proven with, and now awaits that entry's successor. + /// + /// A browser reaches this only when it never learned that its last sign-in succeeded, + /// so it is still proving with the key it announced a successor for. The answer is for + /// the browser to promote its own successor and present that — it is the only party + /// holding both keys. Registering it as a new browser instead would turn every dropped + /// response into a second row for one browser, and accepting it would leave a leaked + /// key useful for longer than the one sign-in rotation allows it. + StaleDeviceKey, } /// A browser this anchor has signed in from. The name is self-reported by the client. @@ -724,12 +734,15 @@ impl Anchor { &self.session_devices } - /// 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. + /// Resolves the browser a sign-in came from by the public key it proved possession of. /// - /// A proof from the successor promotes it, retiring the key it replaces. Either way the - /// entry then awaits `next_device_key`, which is what a browser presents once this - /// sign-in has reached it. + /// An entry is reached only by the successor it announced. Presenting it promotes that + /// successor, retires the key it replaces, and leaves the entry awaiting + /// `next_device_key` — what the browser presents at its next sign-in. A key some entry + /// has already retired is refused with [`SessionDeviceError::StaleDeviceKey`] rather + /// than accepted or registered afresh, so a key is good for exactly one sign-in and a + /// browser that lost a response is told to promote its own successor instead of + /// becoming a second row. A key no entry holds at all registers a new browser. /// /// At the cap the least recently used records are dropped, and their ids returned so /// the caller can end their sessions too. @@ -744,18 +757,27 @@ impl Anchor { return Err(SessionDeviceError::SuccessorMatchesCurrent); } - let device_index_for_key = |candidate: &PublicKey| { + // Two questions, and they are not the same one. An entry is *advanced* only by the + // successor it is waiting for; an entry *holds* a key in either slot, which is what + // a successor must not collide with. + let entry_awaiting = |candidate: &PublicKey| { + self.session_devices + .iter() + .position(|device| device.next_device_key == *candidate) + }; + let entry_holding = |candidate: &PublicKey| { self.session_devices.iter().position(|device| { device.current_device_key == *candidate || device.next_device_key == *candidate }) }; - let current_index = device_index_for_key(¤t_device_key); - let next_index = device_index_for_key(&next_device_key); - if next_index.is_some() && next_index != current_index { + + let advances = entry_awaiting(¤t_device_key); + let successor_holder = entry_holding(&next_device_key); + if successor_holder.is_some() && successor_holder != advances { return Err(SessionDeviceError::SuccessorAlreadyInUse); } - if let Some(index) = current_index { + if let Some(index) = advances { let device = &mut self.session_devices[index]; device.current_device_key = current_device_key; device.next_device_key = next_device_key; @@ -763,6 +785,10 @@ impl Anchor { return Ok((device.id, vec![])); } + if entry_holding(¤t_device_key).is_some() { + return Err(SessionDeviceError::StaleDeviceKey); + } + let id = self.next_session_device_id; self.next_session_device_id = self.next_session_device_id.saturating_add(1); self.session_devices.push(SessionDevice { diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index bfc777df6c..1774ac2c0f 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -1285,6 +1285,14 @@ mod session_device_tests { ByteBuf::from(vec![seed; 92]) } + /// One browser's keys in the order it presents them. A sign-in promotes the successor + /// announced last time and announces a fresh one, so no key is ever presented twice. + fn rotating_key(browser: u8, step: u8) -> PublicKey { + let mut key = vec![browser; 91]; + key[0] = step; + ByteBuf::from(key) + } + #[test] fn an_unseen_key_registers_a_new_device() { let mut anchor = anchor(); @@ -1309,7 +1317,7 @@ mod session_device_tests { } #[test] - fn a_key_it_already_holds_reuses_the_device_and_leaves_its_name_alone() { + fn a_browser_that_rotates_reuses_the_device_and_leaves_its_name_alone() { let mut anchor = anchor(); let (id, _) = anchor .resolve_session_device( @@ -1322,8 +1330,8 @@ mod session_device_tests { let (again, _) = anchor .resolve_session_device( - browser_key(1), successor_key(1), + browser_key(2), "Something else".to_string(), 2_000, ) @@ -1365,8 +1373,8 @@ mod session_device_tests { anchor .resolve_session_device( - browser_key(1), successor_key(1), + browser_key(2), "Chrome".to_string(), 5_000, ) @@ -1481,8 +1489,8 @@ mod session_device_tests { } anchor .resolve_session_device( - browser_key(0), successor_key(0), + rotating_key(0, 200), "oldest".to_string(), 9_000, ) @@ -1507,18 +1515,21 @@ mod session_device_tests { #[test] fn a_browser_that_clears_storage_evicts_its_own_records_before_a_used_one() { let mut anchor = anchor(); + // The phone rotates on every sign-in, as a browser that kept its storage does. let (kept, _) = anchor - .resolve_session_device(browser_key(0), successor_key(0), "phone".to_string(), 1) + .resolve_session_device(browser_key(0), rotating_key(0, 1), "phone".to_string(), 1) .unwrap(); for wipe in 0..MAX_SESSION_DEVICES as u64 { anchor .resolve_session_device( - browser_key(0), - successor_key(0), + rotating_key(0, wipe as u8 + 1), + rotating_key(0, wipe as u8 + 2), "phone".to_string(), 1_000 + wipe * 10, ) .unwrap(); + // A wiped browser has no key to promote, so each pass is a browser this + // anchor has never seen. anchor .resolve_session_device( browser_key(wipe as u8 + 1), @@ -1624,9 +1635,40 @@ mod session_device_tests { } /// A response that never reached the browser leaves it proving with the key the entry - /// still holds, which must not read as a new browser. + /// has already retired. That is refused rather than registered: a second row for one + /// browser is exactly what a dropped response must not cost, and the browser holds the + /// successor that does resolve. + #[test] + fn a_retired_key_is_refused_rather_than_registered() { + let mut anchor = anchor(); + anchor + .resolve_session_device( + browser_key(1), + successor_key(1), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + + let retried = anchor.resolve_session_device( + browser_key(1), + successor_key(2), + "Chrome".to_string(), + 2_000, + ); + + assert_eq!(retried, Err(SessionDeviceError::StaleDeviceKey)); + assert_eq!(anchor.session_devices().len(), 1); + assert_eq!( + anchor.session_devices()[0].next_device_key, + successor_key(1) + ); + } + + /// The other half of the same rule, from the browser's side: promoting the successor + /// it announced is what gets it back to its own entry. #[test] - fn the_current_key_still_resolves_when_a_response_was_lost() { + fn promoting_the_announced_successor_resolves_the_same_browser() { let mut anchor = anchor(); let (id, _) = anchor .resolve_session_device( @@ -1639,7 +1681,7 @@ mod session_device_tests { let (again, _) = anchor .resolve_session_device( - browser_key(1), + successor_key(1), successor_key(2), "Chrome".to_string(), 2_000, @@ -1650,7 +1692,7 @@ mod session_device_tests { assert_eq!(anchor.session_devices().len(), 1); assert_eq!( anchor.session_devices()[0].current_device_key, - browser_key(1) + successor_key(1) ); assert_eq!( anchor.session_devices()[0].next_device_key, @@ -1720,7 +1762,7 @@ mod session_device_tests { /// The browser that already holds it is re-announcing, which a retry does. #[test] - fn re_announcing_its_own_successor_is_allowed() { + fn a_second_sign_in_from_a_key_a_browser_never_announced_is_a_new_browser() { let mut anchor = anchor(); let (id, _) = anchor .resolve_session_device( @@ -1731,17 +1773,18 @@ mod session_device_tests { ) .unwrap(); - let (again, _) = anchor + // Neither slot holds it, so there is nothing to say this is the same browser. + let (other, _) = anchor .resolve_session_device( - browser_key(1), - successor_key(1), + browser_key(7), + successor_key(7), "Chrome".to_string(), 2_000, ) .unwrap(); - assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); + assert_ne!(other, id); + assert_eq!(anchor.session_devices().len(), 2); } #[test] From 6c6558e3f1ac33bc21eb1332c755ecb6ec159cb8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:30:06 +0200 Subject: [PATCH 109/298] feat: tell a browser its key is stale rather than that it is wrong A browser whose last sign-in response was lost is still holding the successor that resolves, so a rejection it can act on is worth its own variant. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/lib/generated/internet_identity_idl.js | 1 + .../src/lib/generated/internet_identity_types.d.ts | 8 ++++++++ src/internet_identity/internet_identity.did | 4 ++++ src/internet_identity/src/sessions.rs | 8 +++++++- .../src/internet_identity/types.rs | 4 ++++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index fe3f19c532..34e28e09ca 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -403,6 +403,7 @@ export const idlFactory = ({ IDL }) => { 'NoSuchSession' : IDL.Null, 'NoSuchAccount' : IDL.Null, 'InvalidDeviceKey' : IDL.Null, + 'StaleDeviceKey' : IDL.Null, }); const GetAccountsError = IDL.Variant({ 'InternalCanisterError' : IDL.Text, 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 bf59267a41..701e39548f 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -28,6 +28,14 @@ export type AccountSessionError = { 'InternalCanisterError' : string } | * The browser's key is unusable, or its signature does not verify against it. */ 'InvalidDeviceKey' : null + } | + { + /** + * The browser presented a key it has already rotated away from, which happens when it + * never learned that its last sign-in succeeded. It holds the successor that does + * resolve, so the answer is to promote that one and present it. + */ + 'StaleDeviceKey' : null }; export interface AccountUpdate { 'name' : [] | [string] } export type AddTentativeDeviceResponse = { diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 5363213a12..c76ad9f17f 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1083,6 +1083,10 @@ type AccountSessionError = variant { NoSuchSession; // The browser's key is unusable, or its signature does not verify against it. InvalidDeviceKey; + // The browser presented a key it has already rotated away from, which happens when it + // never learned that its last sign-in succeeded. It holds the successor that does + // resolve, so the answer is to promote that one and present it. + StaleDeviceKey; InternalCanisterError : text; }; diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 6cb3021354..93e94e9e79 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::{AccountKey, SessionRecord, SessionRecordKey}; +use crate::storage::anchor::SessionDeviceError; use crate::storage::{CreateSessionParams, StorageError}; use crate::{update_root_hash, DAY_NS, MINUTE_NS}; use candid::Principal; @@ -129,7 +130,12 @@ pub async fn prepare_account_session( }); let (device_id, dropped_devices) = anchor .resolve_session_device(current_device_key, next_device_key, device_name, now) - .map_err(|_| AccountSessionError::InvalidDeviceKey)?; + .map_err(|error| match error { + // Told apart from the rest because the browser can act on it: it is the only + // party holding the successor that does resolve. + SessionDeviceError::StaleDeviceKey => AccountSessionError::StaleDeviceKey, + _ => AccountSessionError::InvalidDeviceKey, + })?; storage_borrow_mut(|storage| storage.write(anchor)) .expect("failed to write the anchor while registering a browser"); diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 1f49f8e8f6..a2827e9bc3 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -832,6 +832,10 @@ pub enum AccountSessionError { NoSuchSession, /// The browser's key is unusable, or its signature does not verify against it. InvalidDeviceKey, + /// The browser presented a key it has already rotated away from, which happens when + /// it never learned that its last sign-in succeeded. It holds the successor that does + /// resolve, so the answer is to promote that one and present it. + StaleDeviceKey, InternalCanisterError(String), } From 56b395d6270836767026b95e02c77d6853bc7824 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:36:38 +0200 Subject: [PATCH 110/298] test: a browser presents the successor it announced Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../tests/integration/sessions.rs | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 76ceb7c08a..497983cb87 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -145,16 +145,18 @@ fn should_archive_a_browser_registration_with_the_name_redacted() -> Result<(), }; let identity_number = flows::register_anchor(&env, ii_canister); + let browser = BrowserKey::new(1); prepare_account_session( &env, ii_canister, principal_1(), - session_request(identity_number), + session_request_from(identity_number, &browser), )? .unwrap(); - // The same browser signing in again is not a registration. - let mut again = session_request(identity_number); + // The same browser signing in again is not a registration. It presents the successor + // it announced, which is the only key that reaches its entry. + let mut again = session_request_from(identity_number, &browser.successor()); again.origin = "https://another-dapp.com".to_string(); prepare_account_session(&env, ii_canister, principal_1(), again)?.unwrap(); @@ -468,10 +470,12 @@ fn should_refuse_a_replayed_announcement() -> Result<(), RejectResponse> { Ok(()) } -/// A response the browser never received leaves it proving with the key the entry still -/// holds, which must not cost it its identity. +/// A response the browser never received leaves it proving with a key the entry has +/// already retired. That is refused, and named so the browser knows what to do about it: +/// promote the successor it announced and present that, which lands on its own entry +/// rather than on a second one. #[test] -fn should_accept_the_current_key_when_a_response_was_lost() -> Result<(), RejectResponse> { +fn should_refuse_a_retired_key_and_accept_the_successor() -> Result<(), RejectResponse> { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); let identity_number = flows::register_anchor(&env, canister_id); @@ -485,11 +489,21 @@ fn should_accept_the_current_key_when_a_response_was_lost() -> Result<(), Reject )? .unwrap(); + assert_eq!( + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )?, + Err(AccountSessionError::StaleDeviceKey) + ); + let retried = prepare_account_session( &env, canister_id, principal_1(), - session_request_from(identity_number, &browser), + session_request_from(identity_number, &browser.successor()), )? .unwrap(); From 38f2a890862aace9e349de5de5abeda70781dd47 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:37:16 +0200 Subject: [PATCH 111/298] test: a browser presents the successor it announced Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../tests/integration/sessions.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 66a5d976ae..768aa34673 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -108,7 +108,15 @@ fn should_replace_the_session_of_a_browser_signing_in_again() -> Result<(), Reje 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 browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + let first_principal = Principal::self_authenticating(&first.user_key); // No time is allowed to pass: two ceremonies in one consensus round agree on every // field that describes them, and it is the id that keeps them apart. @@ -116,7 +124,7 @@ fn should_replace_the_session_of_a_browser_signing_in_again() -> Result<(), Reje &env, canister_id, principal_1(), - session_request(identity_number), + session_request_from(identity_number, &browser.successor()), )? .unwrap(); @@ -340,15 +348,16 @@ fn should_not_reuse_a_session_across_a_consent_change() -> Result<(), RejectResp 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 full_access = prepare_account_session( &env, canister_id, principal_1(), - session_request(identity_number), + session_request_from(identity_number, &browser), )? .unwrap(); - let mut downgraded = session_request(identity_number); + let mut downgraded = session_request_from(identity_number, &browser.successor()); downgraded.permissions = Some(Permissions::Queries); let read_only = prepare_account_session(&env, canister_id, principal_1(), downgraded)?.unwrap(); From c88e4b941d981601e71976c2fc8c56e009eb22be Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:37:52 +0200 Subject: [PATCH 112/298] test: a browser presents the successor it announced Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../tests/integration/sessions.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index acf299ab87..b0a5b7b5ea 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -700,8 +700,18 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { 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); + // One browser, three sign-ins, each presenting the successor announced by the last. + let browser = BrowserKey::new(1); + let first_app = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + let first_principal = Principal::self_authenticating(&first_app.user_key); + + let mut other_app = session_request_from(identity_number, &browser.successor()); 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); @@ -769,7 +779,7 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { &env, canister_id, principal_1(), - session_request(identity_number), + session_request_from(identity_number, &browser.successor().successor()), )? .unwrap(); From 6dbe7afec4d322faa0b7e7cb47859952d0c52d2c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 11:42:51 +0200 Subject: [PATCH 113/298] fix: a browser retrying a lost sign-in keeps its own successor Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/anchor.rs | 8 ++++++-- src/internet_identity/src/storage/anchor/tests.rs | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 30d7b618b1..56b1bc6dd3 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -772,8 +772,12 @@ impl Anchor { }; let advances = entry_awaiting(¤t_device_key); + // The entry this request belongs to, which is not always one it can advance: a + // browser retrying a lost sign-in still belongs to the entry that retired its key, + // and re-announcing the successor it announced then is not stealing anyone's key. + let owner = entry_holding(¤t_device_key); let successor_holder = entry_holding(&next_device_key); - if successor_holder.is_some() && successor_holder != advances { + if successor_holder.is_some() && successor_holder != owner { return Err(SessionDeviceError::SuccessorAlreadyInUse); } @@ -785,7 +789,7 @@ impl Anchor { return Ok((device.id, vec![])); } - if entry_holding(¤t_device_key).is_some() { + if owner.is_some() { return Err(SessionDeviceError::StaleDeviceKey); } diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index 1774ac2c0f..ac68a0e831 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -1650,9 +1650,11 @@ mod session_device_tests { ) .unwrap(); + // The successor it re-announces is the one this entry is already waiting for, which + // is the shape a retry actually takes: the browser has not moved on either. let retried = anchor.resolve_session_device( browser_key(1), - successor_key(2), + successor_key(1), "Chrome".to_string(), 2_000, ); From ed28779e9f6cc1732bc105e50215129713d4735c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 12:02:56 +0200 Subject: [PATCH 114/298] feat(fe): promote the announced successor when the canister calls a key stale The successor is now stored before it is announced, so a sign-in whose response never arrived does not take it with it. On a stale-key refusal the browser promotes that successor and signs in once more, which lands on its own entry rather than enrolling it a second time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/browser-key.store.test.ts | 101 ++++++++++++- .../src/lib/stores/browser-key.store.ts | 133 +++++++++++++----- 2 files changed, 196 insertions(+), 38 deletions(-) diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts index 6ebcd356a6..62705c699f 100644 --- a/src/frontend/src/lib/stores/browser-key.store.test.ts +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -1,7 +1,26 @@ import "fake-indexeddb/auto"; -import { beforeEach, describe, expect, it } from "vitest"; -import { clear, createStore } from "idb-keyval"; -import { currentDeviceId, withBrowserProof } from "./browser-key.store"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clear, createStore, set as idbSet } from "idb-keyval"; + +/** Lets one test refuse a write, which is the only way the store ends up holding a key + * without the successor it announced. */ +const storage = vi.hoisted(() => ({ writesFail: false })); + +vi.mock("idb-keyval", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + set: (...args: Parameters) => + storage.writesFail + ? Promise.reject(new Error("quota exceeded")) + : actual.set(...args), + }; +}); +import { + currentDeviceId, + StaleBrowserKeyError, + 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"); @@ -89,6 +108,7 @@ const withoutLockApi = (): void => { describe("browser key", () => { beforeEach(async () => { + storage.writesFail = false; await clear(BROWSER_KEY_STORE); withoutLockApi(); }); @@ -135,7 +155,80 @@ describe("browser key", () => { const second = await attempt(IDENTITY, 2); expect(second.publicKey).toEqual(first.publicKey); - expect(second.nextPublicKey).not.toEqual(first.nextPublicKey); + }); + + /// The canister may have accepted the sign-in and never told us, and from then on the + /// announced successor is the only key that reaches our entry. Announcing a fresh one + /// instead would leave the entry waiting for a key nobody holds. + it("re-announces the successor it already announced", async () => { + const first = await attempt(IDENTITY, 1); + + const second = await attempt(IDENTITY, 2); + + expect(second.nextPublicKey).toEqual(first.nextPublicKey); + }); + + it("promotes the announced successor when the canister calls the key stale", async () => { + const first = await attempt(IDENTITY, 1); + + let seen = 0; + const proof = await withBrowserProof( + IDENTITY, + sessionKey(2), + (attempted) => { + seen += 1; + if (seen === 1) { + return Promise.reject(new StaleBrowserKeyError()); + } + return Promise.resolve(attempted); + }, + ); + + expect(seen).toBe(2); + expect(proof.publicKey).toEqual(first.nextPublicKey); + }); + + /// Reachable because a write is allowed to fail silently: a browser that could not keep + /// the successor it announced holds nothing the canister's entry is waiting for. A second + /// row in the user's list beats a browser that can never sign in again. + it("starts over when a stale key has no successor to promote", async () => { + const orphaned = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ); + await idbSet(IDENTITY.toString(), { keyPair: orphaned }, BROWSER_KEY_STORE); + const stranded = new Uint8Array( + await crypto.subtle.exportKey("spki", orphaned.publicKey), + ); + storage.writesFail = true; + + let seen = 0; + const proof = await withBrowserProof( + IDENTITY, + sessionKey(1), + (attempted) => { + seen += 1; + return seen === 1 + ? Promise.reject(new StaleBrowserKeyError()) + : Promise.resolve(attempted); + }, + ); + + expect(seen).toBe(2); + expect(proof.publicKey).not.toEqual(stranded); + }); + + it("does not retry a failure that is not a stale key", async () => { + let seen = 0; + + await expect( + withBrowserProof(IDENTITY, sessionKey(1), () => { + seen += 1; + return Promise.reject(new Error("network")); + }), + ).rejects.toThrow("network"); + expect(seen).toBe(1); }); it("holds a separate key per identity", async () => { diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts index abc2018fab..3ca4c8bd82 100644 --- a/src/frontend/src/lib/stores/browser-key.store.ts +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -11,10 +11,29 @@ import { createStore, get as idbGet, set as idbSet } from "idb-keyval"; */ interface BrowserKeyRecord { keyPair: CryptoKeyPair; + /** The successor announced at the last sign-in, kept from before the call until that + * sign-in is known to have been accepted. The canister reaches this browser's entry + * only through the successor it announced, so losing this key while the canister kept + * it would leave the browser unable to prove it is itself ever again. */ + announced?: CryptoKeyPair; /** Absent until a sign-in has told us which browser we are. */ deviceId?: number; } +/** + * Thrown by a sign-in the canister refused because this browser's key is one it has + * already retired, which is what a lost response leaves behind. + * + * Raised by the caller that can read the canister's answer; handled here, because this is + * where the successor that does resolve is kept. + */ +export class StaleBrowserKeyError extends Error { + constructor() { + super("the canister has already retired this browser's key"); + this.name = "StaleBrowserKeyError"; + } +} + const BROWSER_KEY_STORE = createStore("ii-browser-keys", "keys"); /** Must match the domains the canister verifies the two signatures under. */ @@ -106,13 +125,72 @@ const exclusively = async ( return await locks.request(`ii-browser-key:${identityNumber}`, run); }; +/** The record a sign-in proves with: what is stored, completed with whatever it lacks. */ +const prepared = async ( + identityNumber: bigint, + from?: BrowserKeyRecord, +): Promise>> => { + const stored = from ?? (await read(identityNumber)); + const keyPair = stored?.keyPair ?? (await generate()); + // Both halves go on disk before the call. The canister may accept this sign-in and + // never tell us, and from that moment the only key that reaches our entry is the + // successor we announced — a successor generated and discarded per attempt would be + // gone with the response that carried it. + const announced = stored?.announced ?? (await generate()); + if (stored?.keyPair !== keyPair || stored?.announced !== announced) { + await write(identityNumber, { ...stored, keyPair, announced }); + } + return { keyPair, announced }; +}; + +/** One attempt, proving `keyPair` and announcing `announced`. */ +const attempt = async ( + identityNumber: bigint, + sessionKey: Uint8Array, + signIn: (proof: BrowserProof) => Promise, + from?: BrowserKeyRecord, +): Promise => { + const { keyPair, announced: successor } = await prepared( + identityNumber, + from, + ); + + 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 }), + }); +}; + /** * 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. + * exactly one sign-in. `accept` is what advances this browser to the successor. + * + * The canister accepts only the successor an entry is waiting for, so a sign-in whose + * response was lost leaves this browser proving with a key that has since been retired. + * That is refused rather than registered afresh, and this is the only party holding the + * key that does resolve: on refusal the announced successor is promoted and the sign-in + * runs once more. The old key is discarded only after that succeeds. */ export const withBrowserProof = ( identityNumber: bigint, @@ -120,38 +198,25 @@ export const withBrowserProof = ( 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 }); + try { + return await attempt(identityNumber, sessionKey, signIn); + } catch (error) { + if (!(error instanceof StaleBrowserKeyError)) { + throw error; + } + const stored = await read(identityNumber); + // Nothing to promote means the canister holds an entry this browser can no longer + // reach — only possible where a write was lost, since the successor is stored before + // it is announced. Starting over costs a second row in the user's list, which beats + // a browser that can never sign in again. + const promoted: BrowserKeyRecord = { + keyPair: stored?.announced ?? (await generate()), + }; + // Carried into the retry rather than read back, so a storage failure costs the + // rotation and not the sign-in. + await write(identityNumber, promoted); + return await attempt(identityNumber, sessionKey, signIn, promoted); } - 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. */ From 69579e4c042d582f8a440bb820389858ed98de9a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 12:04:54 +0200 Subject: [PATCH 115/298] feat(fe): retry a sign-in the canister refused as a stale key Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 29 ++++++++++++++++++- .../channelHandlers/sessionDelegation.ts | 28 ++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index f6f1da0b26..f71a359a72 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -26,7 +26,12 @@ vi.mock("$lib/stores/authorization.store", () => ({ authorizedStore: { subscribe: () => () => {} }, })); -import { handleSessionDelegationRequest } from "./sessionDelegation"; +import { + asBrowserKeyError, + handleSessionDelegationRequest, +} from "./sessionDelegation"; +import { StaleBrowserKeyError } from "$lib/stores/browser-key.store"; +import { CanisterError } from "$lib/utils/utils"; import { purgeAppSessions } from "$lib/stores/app-session.store"; const channelWith = () => { @@ -90,3 +95,25 @@ describe("ii_session_delegation", () => { expect(onError).toHaveBeenCalledWith("invalid-request"); }); }); + +describe("asBrowserKeyError", () => { + it("names a retired browser key so the key store can promote its successor", () => { + const stale = asBrowserKeyError( + new CanisterError({ StaleDeviceKey: null }), + ); + + expect(stale).toBeInstanceOf(StaleBrowserKeyError); + }); + + it("leaves every other canister error alone", () => { + const other = new CanisterError({ NoSuchAccount: null }); + + expect(asBrowserKeyError(other)).toBe(other); + }); + + it("leaves a transport failure alone", () => { + const network = new Error("network"); + + expect(asBrowserKeyError(network)).toBe(network); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index e388c0378f..c04509c13c 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -19,7 +19,12 @@ import { 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 { + isCanisterError, + retryFor, + throwCanisterError, + waitForStore, +} from "$lib/utils/utils"; import { canisterId } from "$lib/globals"; import { Principal } from "@icp-sdk/core/principal"; import { @@ -28,8 +33,12 @@ import { ECDSAKeyIdentity, } from "@icp-sdk/core/identity"; import type { PublicKey, Signature } from "@icp-sdk/core/agent"; +import type { AccountSessionError } from "$lib/generated/internet_identity_types"; import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; -import { withBrowserProof } from "$lib/stores/browser-key.store"; +import { + StaleBrowserKeyError, + 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"; @@ -193,6 +202,16 @@ export const handleSessionDelegationRequest = }); }; +/** + * The canister reaches a browser's entry only through the successor that browser + * announced, so a key it has retired is refused rather than enrolled again. Named in the + * form the key store acts on, which is where the successor that does resolve is kept. + */ +export const asBrowserKeyError = (error: unknown): unknown => + isCanisterError(error) && error.type === "StaleDeviceKey" + ? new StaleBrowserKeyError() + : error; + const createSession = async ( effectiveOrigin: string, requestedMaxTimeToLive: bigint | undefined, @@ -249,7 +268,10 @@ const createSession = async ( ? [requestedMaxTimeToIdle] : [], }) - .then(throwCanisterError); + .then(throwCanisterError) + .catch((error: unknown) => { + throw asBrowserKeyError(error); + }); await browser.accept(prepared.device_id); return prepared; }, From a89087c949b6a3a4cc55b764e624cf094aa7691b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 12:14:38 +0200 Subject: [PATCH 116/298] feat: count the rows an application holds that hold nothing An application was retired once no identity referenced it, and a tombstone references nothing while meaning everything: the row that says an identity moved every account away from this origin and its default must not come back. Retiring the application dropped the origin index with it, so the next visit minted a fresh number the tombstone no longer applied to. The count is exactly those rows, so retirement now waits for both to reach zero. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 48 ++++-- .../src/storage/account/tests.rs | 1 + .../src/storage/storable/application.rs | 13 ++ src/internet_identity/src/storage/tests.rs | 141 +++++++++++++++++- 4 files changed, 193 insertions(+), 10 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 68f8fae4ff..9807c32a8f 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1522,6 +1522,7 @@ impl Storage { origin: origin.to_string(), stored_accounts: 0u64, stored_account_references: 0u64, + tombstones: 0u64, }; self.stable_application_memory @@ -1803,10 +1804,7 @@ impl Storage { .stable_application_memory .get(&application_number) .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; - let deltas = ReferenceListDeltas::between( - stored_references.as_deref().unwrap_or_default(), - &references, - ); + let deltas = ReferenceListDeltas::between(stored_references.as_deref(), &references); Some((storable_references, application, deltas)) }; @@ -1899,6 +1897,11 @@ impl Storage { application.stored_accounts, application.stored_account_references, )?; + let application_tombstones = deltas.apply_one( + ReferenceCounter::Application { application_number }, + ReferenceCount::Tombstones, + application.tombstones, + )?; // The only write here that reports a failure, so it goes before the two that // cannot: past this line nothing can return an error and leave a partial update. @@ -1919,7 +1922,11 @@ impl Storage { // A zero here now means what it says. The delta refuses rather than clamping, so // the row is only retired when no anchor references it, not when a counter that // had already drifted was pulled below zero. - if application_references == 0 { + // + // Tombstones count too, and they are the reason references alone are not enough: + // a tombstone holds no reference and still has to keep this application number + // alive, or the identity it belongs to gets its moved-away default back. + if application_references == 0 && application_tombstones == 0 { self.remove_unreferenced_application(application_number, &application.origin); } else { self.stable_application_memory.insert( @@ -1928,6 +1935,7 @@ impl Storage { origin: application.origin, stored_accounts: application_accounts, stored_account_references: application_references, + tombstones: application_tombstones, }, ); } @@ -2602,6 +2610,8 @@ pub enum ReferenceCount { Accounts, /// References, named and tracked-default alike. References, + /// Rows that exist while holding no reference. + Tombstones, } impl fmt::Display for ReferenceCount { @@ -2609,6 +2619,7 @@ impl fmt::Display for ReferenceCount { match self { Self::Accounts => write!(f, "stored accounts"), Self::References => write!(f, "stored account references"), + Self::Tombstones => write!(f, "stored tombstones"), } } } @@ -2624,6 +2635,9 @@ struct ReferenceListDeltas { accounts: i64, /// Change in references, named and tracked-default alike. references: i64, + /// Change in rows that exist while holding no reference. Only ever -1, 0 or 1: one + /// write touches one row. + tombstones: i64, } impl ReferenceListDeltas { @@ -2635,7 +2649,7 @@ impl ReferenceListDeltas { /// holding nothing, so a diff against it would report no change and leave the /// counters claiming references the removed row no longer has. fn between( - previous_references: &[AccountReference], + previous_references: Option<&[AccountReference]>, new_references: &[AccountReference], ) -> Self { /// Saturating rather than `as`: a list long enough to overflow `i64` cannot @@ -2653,12 +2667,23 @@ impl ReferenceListDeltas { (named, total) } - let (previous_named, previous_total) = counts(previous_references); + let (previous_named, previous_total) = counts(previous_references.unwrap_or_default()); let (new_named, new_total) = counts(new_references); + // A row that does not exist is not a tombstone — a tombstone is a row someone + // stored, and absence is what normalisation reads as "derive the default". + let was_tombstone = previous_references.is_some_and(<[_]>::is_empty); + let is_tombstone = new_references.is_empty(); + let tombstones = match (was_tombstone, is_tombstone) { + (false, true) => 1, + (true, false) => -1, + _ => 0, + }; + Self { accounts: new_named.saturating_sub(previous_named), references: new_total.saturating_sub(previous_total), + tombstones, } } @@ -2668,15 +2693,19 @@ impl ReferenceListDeltas { /// an empty list cannot be written at all: a row holding nothing is a tombstone /// and stays, so only an outright removal gets to zero these out. fn removing(previous: &[AccountReference]) -> Self { - let removed = Self::between(&[], previous); + let removed = Self::between(Some(&[]), previous); Self { accounts: removed.accounts.saturating_neg(), references: removed.references.saturating_neg(), + // The row is gone, so a tombstone goes with it. Not the negation of what + // `between` reported: that describes writing this list, and this describes + // removing the row it was in. + tombstones: if previous.is_empty() { -1 } else { 0 }, } } fn is_empty(&self) -> bool { - self.accounts == 0 && self.references == 0 + self.accounts == 0 && self.references == 0 && self.tombstones == 0 } /// Both counts of `counter`, moved by this delta. @@ -2711,6 +2740,7 @@ impl ReferenceListDeltas { let delta = match count { ReferenceCount::Accounts => self.accounts, ReferenceCount::References => self.references, + ReferenceCount::Tombstones => self.tombstones, }; stored .checked_add_signed(delta) diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 82a0773497..868a2712a6 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -71,6 +71,7 @@ fn should_create_a_named_account() { origin: origin.clone(), stored_accounts: 1, stored_account_references: 2, + tombstones: 0, } ); assert_eq!( diff --git a/src/internet_identity/src/storage/storable/application.rs b/src/internet_identity/src/storage/storable/application.rs index fe15ea398d..f236540732 100644 --- a/src/internet_identity/src/storage/storable/application.rs +++ b/src/internet_identity/src/storage/storable/application.rs @@ -17,6 +17,19 @@ pub struct StorableApplication { pub stored_accounts: u64, #[n(2)] pub stored_account_references: u64, + /// Rows that exist here while holding no reference at all. + /// + /// A row holding nothing is a tombstone: it says every account an identity had at + /// this origin was moved away and its default must never be derived again. It + /// contributes nothing to `stored_account_references`, so without counting it + /// separately this application would look unreferenced and be retired — and the next + /// visit would mint a fresh application number the tombstone no longer applies to, + /// handing the identity back the default it had moved away from. + /// + /// A field added here decodes as absent for every application already stored, and + /// `0` is the truth for those: nothing can write an empty list yet. + #[n(3)] + pub tombstones: u64, } impl Storable for StorableApplication { diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 5a091a04ac..f37b2161fd 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2599,6 +2599,7 @@ mod reference_list_write_path_tests { mod account_reference_state_tests { use crate::storage::account::{Account, AccountKey, AccountReference}; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; + use crate::storage::storable::application::StorableApplication; use crate::storage::StorageError; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -2627,6 +2628,21 @@ mod account_reference_state_tests { (anchor_number, application_number), StorableAccountReferenceList::tombstone_for_testing(), ); + // The counter goes up with the row, as the move that will one day leave a + // tombstone behind has to do: a stored tombstone the application does not count + // is a divergence, and the write path refuses those rather than papering over + // them. + let application = storage + .stable_application_memory + .get(&application_number) + .expect("the origin was just interned"); + storage.stable_application_memory.insert( + application_number, + StorableApplication { + tombstones: application.tombstones + 1, + ..application + }, + ); assert_eq!( storage.stored_account_references(anchor_number, application_number), Some(vec![]) @@ -2915,6 +2931,7 @@ mod application_number_allocator_tests { origin: origin.to_string(), stored_accounts: 0, stored_account_references: 0, + tombstones: 0, } } @@ -3649,7 +3666,7 @@ mod application_removal_tests { use super::record_use; use crate::storage::account::AccountReference; - use crate::storage::storable::application::StorableOriginSha256; + use crate::storage::storable::application::{StorableApplication, StorableOriginSha256}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -3689,6 +3706,128 @@ mod application_removal_tests { assert_eq!(storage.get_total_application_count(), 0); } + /// A tombstone holds no reference, so the reference count alone says this origin is + /// unused. Retiring it would drop the origin index entry, and the next visit would + /// mint a fresh application number the tombstone no longer applies to — handing the + /// identity back the default it moved away from, which is the one thing a tombstone + /// exists to prevent. + #[test] + fn an_application_another_anchor_has_tombstoned_is_kept() { + let (mut storage, anchor_number, other_anchor_number) = storage_with_anchors(); + let origin = "https://example.com".to_string(); + record_use(&mut storage, anchor_number, origin.clone(), None, 1_000).unwrap(); + record_use( + &mut storage, + other_anchor_number, + origin.clone(), + None, + 1_000, + ) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + plant_tombstone(&mut storage, other_anchor_number, application_number); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert_eq!( + storage.lookup_application_number_with_origin(&origin), + Some(application_number) + ); + assert_eq!( + storage + .stable_application_memory + .get(&application_number) + .map(|application| ( + application.stored_account_references, + application.tombstones + )), + Some((0, 1)) + ); + } + + /// The other side of the same rule: an account moved back clears the tombstone, and + /// with nothing left the application is retired like any other. + #[test] + fn an_application_whose_last_tombstone_is_cleared_is_retired() { + let (mut storage, anchor_number, _) = storage_with_anchors(); + let origin = "https://example.com".to_string(); + record_use(&mut storage, anchor_number, origin.clone(), None, 1_000).unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + plant_tombstone(&mut storage, anchor_number, application_number); + + // The move back: the tombstoned row gains a reference again. + storage + .write_account_state( + anchor_number, + application_number, + vec![AccountReference { + account_number: None, + last_used: Some(2_000), + }], + None, + None, + ) + .unwrap(); + assert_eq!( + storage + .stable_application_memory + .get(&application_number) + .map(|application| application.tombstones), + Some(0) + ); + + storage + .remove_reference_list(anchor_number, application_number) + .unwrap(); + + assert!(storage + .lookup_application_number_with_origin(&origin) + .is_none()); + } + + /// Moves every account out of a row, leaving the tombstone a future account move + /// will. The write path refuses to store an empty list, which is what makes a + /// tombstone a thing only a move can create, so it is written here directly — with + /// the application's counters moved as that move will have to move them. + fn plant_tombstone( + storage: &mut Storage, + anchor_number: AnchorNumber, + application_number: u64, + ) { + let moved_away = storage + .stored_account_references(anchor_number, application_number) + .expect("a row has to exist before it can be emptied"); + let named = moved_away + .iter() + .filter(|reference| reference.account_number.is_some()) + .count() as u64; + + storage.stable_account_reference_list_memory.insert( + (anchor_number, application_number), + StorableAccountReferenceList::tombstone_for_testing(), + ); + let application = storage + .stable_application_memory + .get(&application_number) + .expect("the application should still be stored"); + storage.stable_application_memory.insert( + application_number, + StorableApplication { + stored_accounts: application.stored_accounts - named, + stored_account_references: application.stored_account_references + - moved_away.len() as u64, + tombstones: application.tombstones + 1, + ..application + }, + ); + } + #[test] fn an_application_another_anchor_still_references_is_kept() { let (mut storage, anchor_number, other_anchor_number) = storage_with_anchors(); From 211bc8d830fb4eb2e09c91bb762699fcb4239250 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 12:16:39 +0200 Subject: [PATCH 117/298] test: build the moved-back reference the way this branch builds them Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 8211ce566e..0e16f1d148 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3711,10 +3711,7 @@ mod application_removal_tests { .write_account_state( anchor_number, application_number, - vec![AccountReference { - account_number: None, - last_used: Some(2_000), - }], + vec![AccountReference::new(None, Some(2_000))], None, None, ) From 822da24c0ba4e6dec3df09e8b954839348cb2674 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 12:31:37 +0200 Subject: [PATCH 118/298] fix: let an application stored without a tombstone count decode Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/storage/storable/application.rs | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/src/storage/storable/application.rs b/src/internet_identity/src/storage/storable/application.rs index f236540732..a996a08290 100644 --- a/src/internet_identity/src/storage/storable/application.rs +++ b/src/internet_identity/src/storage/storable/application.rs @@ -26,9 +26,11 @@ pub struct StorableApplication { /// visit would mint a fresh application number the tombstone no longer applies to, /// handing the identity back the default it had moved away from. /// - /// A field added here decodes as absent for every application already stored, and - /// `0` is the truth for those: nothing can write an empty list yet. + /// Absent from every application stored before this field existed, and `0` is the + /// truth for those: nothing could write an empty reference list when they were + /// written. `default` is what makes that absence decode rather than trap. #[n(3)] + #[cbor(default)] pub tombstones: u64, } @@ -79,3 +81,64 @@ impl Storable for StorableOriginSha256 { is_fixed_size: true, }; } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + /// The shape every application stored before the tombstone count existed has on + /// disk. Decoding one has to keep working, and has to read as no tombstones: nothing + /// could write an empty reference list when these were written. + #[test] + fn an_application_stored_without_a_tombstone_count_decodes_as_zero() { + #[derive(Encode)] + #[cbor(map)] + struct BeforeTombstones { + #[n(0)] + origin: FrontendHostname, + #[n(1)] + stored_accounts: u64, + #[n(2)] + stored_account_references: u64, + } + + let mut bytes = Vec::new(); + minicbor::encode( + &BeforeTombstones { + origin: "https://example.com".to_string(), + stored_accounts: 3, + stored_account_references: 4, + }, + &mut bytes, + ) + .unwrap(); + + let decoded = StorableApplication::from_bytes(Cow::Owned(bytes)); + + assert_eq!( + decoded, + StorableApplication { + origin: "https://example.com".to_string(), + stored_accounts: 3, + stored_account_references: 4, + tombstones: 0, + } + ); + } + + #[test] + fn a_tombstone_count_survives_the_round_trip() { + let application = StorableApplication { + origin: "https://example.com".to_string(), + stored_accounts: 1, + stored_account_references: 2, + tombstones: 5, + }; + + assert_eq!( + StorableApplication::from_bytes(application.to_bytes()), + application + ); + } +} From 719652edf6530a5cb4c9b611a609c6d5b4cea136 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 12:49:49 +0200 Subject: [PATCH 119/298] fix: bound a backfill batch by the work it does, not by rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A batch took 2000 reference-list rows, and a row holds up to 500 references, each costing a seed hash and a principal derivation — so the bound only held for the shape of data that happens to be common. A batch now stops when it has derived its budget, inside a row if that is where the budget runs out, and the cursor carries how far into the row it got. Two answers the sweep gave without looking are gone with it: a batch size of zero reported completion having read nothing, and a canister with no rows and no salt yet never reported completion at all, ticking its timer for good. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 4 +- src/internet_identity/src/storage.rs | 114 ++++++++++++++++----- src/internet_identity/src/storage/tests.rs | 85 ++++++++++++++- 3 files changed, 176 insertions(+), 27 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 67667c0a1f..21935248d1 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -42,7 +42,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::time::Duration; use storage::account::{AccountDelegationError, PrepareAccountDelegation}; -use storage::{Salt, Storage}; +use storage::{AccountPrincipalIndexBackfillCursor, Salt, Storage}; mod account_management; mod anchor_management; @@ -839,7 +839,7 @@ const ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BACKOFF: Duration = Duration::from_secs(1 const ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BATCH_SIZE: u64 = 2_000; thread_local! { - static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR: RefCell> = const { RefCell::new(None) }; + static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR: RefCell> = const { RefCell::new(None) }; static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE: RefCell = const { RefCell::new(false) }; static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED: RefCell = const { RefCell::new(0) }; static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED: RefCell = const { RefCell::new(0) }; diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 6f0a0dd143..6e484c10b8 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1933,9 +1933,15 @@ impl Storage { /// Indexes one batch of existing reference-list rows. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. + /// + /// `batch_size` bounds **derivations**, not rows. One row is an identity's references + /// at one origin and holds up to [`MAX_ANCHOR_ACCOUNTS`] of them, each costing a seed + /// hash, a principal derivation and a stable write — so a row-bounded batch is only + /// bounded in the shape of data that happens to be common. A batch stops mid-row and + /// the cursor says where, which is why it carries an offset into the row. pub fn backfill_account_principal_index_batch( &mut self, - cursor: Option<(AnchorNumber, ApplicationNumber)>, + cursor: Option, batch_size: u64, ) -> AccountPrincipalIndexBackfillOutcome { let mut outcome = AccountPrincipalIndexBackfillOutcome { @@ -1943,50 +1949,87 @@ impl Storage { ..Default::default() }; + // Examining nothing is not finishing. Reporting completion here would stop a + // sweep that has not read a single row, and a lookup miss would then be taken as + // proof no account has that principal. if batch_size == 0 { + return outcome; + } + + use std::ops::Bound as RangeBound; + // Inclusive of the cursor's own row: a batch may have stopped part-way through + // it, and the offset says how far it got. + let range = match cursor { + Some(cursor) => (RangeBound::Included(cursor.row()), RangeBound::Unbounded), + None => (RangeBound::Unbounded, RangeBound::Unbounded), + }; + + // Read far enough ahead to spend the budget and no further, so the rows behind + // this batch are never materialised. The borrow ends here, which is what lets the + // indexing below write. + let mut outstanding = batch_size; + let mut ran_out = false; + let mut rows: Vec<( + AnchorNumber, + ApplicationNumber, + Vec, + usize, + )> = vec![]; + for (key, list) in self.stable_account_reference_list_memory.range(range) { + let references = Vec::::from(list); + let already_done = match cursor { + Some(cursor) if cursor.row() == key => cursor.references_done, + _ => 0, + }; + let left_in_row = references.len().saturating_sub(already_done) as u64; + rows.push((key.0, key.1, references, already_done)); + if left_in_row >= outstanding { + ran_out = true; + break; + } + outstanding -= left_in_row; + } + + // Nothing left to index, whatever else is true of this canister. Checked before + // the salt, because a fresh install has no salt until its first sign-in and no + // rows either — and a sweep that waits for the salt there never reports done and + // ticks its timer for the life of the canister. + if rows.is_empty() { outcome.is_done = true; return outcome; } + // Not done, so the caller comes back. A canister whose salt is unset has not // finished starting up rather than finished backfilling. let Some(salt) = self.salt().copied() else { return outcome; }; - use std::ops::Bound as RangeBound; - let range = match cursor { - Some(cursor) => (RangeBound::Excluded(cursor), RangeBound::Unbounded), - None => (RangeBound::Unbounded, RangeBound::Unbounded), - }; - - let mut examined = 0u64; - let rows: Vec<_> = self - .stable_account_reference_list_memory - .range(range) - .take(batch_size as usize) - .map(|(key, list)| { - examined += 1; - outcome.next_cursor = Some(key); - (key, Vec::::from(list)) - }) - .collect(); + outcome.is_done = !ran_out; - for ((anchor_number, application_number), references) in rows { + let mut budget = batch_size; + for (anchor_number, application_number, references, already_done) in rows { let Some(origin) = self .stable_application_memory .get(&application_number) .map(|application| application.origin) else { outcome.skipped += 1; + outcome.next_cursor = Some(AccountPrincipalIndexBackfillCursor { + anchor_number, + application_number, + references_done: references.len(), + }); continue; }; + let taking = (budget as usize).min(references.len().saturating_sub(already_done)); for (principal, locator) in self.account_principals( anchor_number, application_number, &origin, &salt, - &references, + &references[already_done..already_done + taking], ) { if self.lookup_account_with_principal_memory.get(&principal) == Some(locator.clone()) @@ -1997,9 +2040,18 @@ impl Storage { .insert(principal, locator); outcome.indexed += 1; } + + budget -= taking as u64; + outcome.next_cursor = Some(AccountPrincipalIndexBackfillCursor { + anchor_number, + application_number, + references_done: already_done + taking, + }); + if budget == 0 { + break; + } } - outcome.is_done = examined < batch_size; outcome } @@ -2806,9 +2858,25 @@ impl Storage { } } -#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// How far the sweep has got: which row, and how many of that row's references are +/// already indexed. The offset is what lets a batch stop inside a row that holds more +/// references than one message can derive principals for. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AccountPrincipalIndexBackfillCursor { + pub anchor_number: AnchorNumber, + pub application_number: ApplicationNumber, + pub references_done: usize, +} + +impl AccountPrincipalIndexBackfillCursor { + fn row(&self) -> (AnchorNumber, ApplicationNumber) { + (self.anchor_number, self.application_number) + } +} + +#[derive(Debug, Default)] pub struct AccountPrincipalIndexBackfillOutcome { - pub next_cursor: Option<(AnchorNumber, ApplicationNumber)>, + pub next_cursor: Option, pub indexed: u64, /// Rows whose application is gone, so no principal can be derived for them. A row /// in that state is an inconsistency rather than a normal skip, and a run that diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 94628b046a..b7f6094e43 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4377,6 +4377,7 @@ mod account_principal_index_backfill_tests { use crate::delegation::canister_sig_principal; use crate::storage::account::{Account, AccountReference}; use crate::storage::canister_id; + use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::Storage; use candid::Principal; use ic_stable_structures::VectorMemory; @@ -4487,7 +4488,21 @@ mod account_principal_index_backfill_tests { fn a_sweep_without_a_salt_indexes_nothing_and_stays_unfinished() { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); + // Written straight into the map: the write path derives principals, so it needs + // the salt this test is about not having. + let application_number = storage + .lookup_or_insert_application_number_with_origin(&"https://d-0.com".to_string()) + .unwrap(); + storage.stable_account_reference_list_memory.insert( + (anchor_number, application_number), + StorableAccountReferenceList::try_from(vec![AccountReference { + account_number: None, + last_used: Some(1), + }]) + .unwrap(), + ); let outcome = storage.backfill_account_principal_index_batch(None, 100); @@ -4495,13 +4510,79 @@ mod account_principal_index_backfill_tests { assert_eq!(outcome.indexed, 0); } + /// A canister that has never been signed in to has no salt and no rows, and the sweep + /// has to finish on the second of those. Waiting for the salt would leave its timer + /// running for the life of the canister. + #[test] + fn a_sweep_with_nothing_to_index_finishes_without_a_salt() { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + + let outcome = storage.backfill_account_principal_index_batch(None, 100); + + assert!(outcome.is_done); + assert_eq!(outcome.indexed, 0); + } + + /// The one answer this sweep must never give without looking: a lookup miss is only + /// meaningful once the sweep says it is finished. #[test] - fn an_empty_batch_size_finishes_immediately() { + fn an_empty_batch_size_does_not_report_completion() { let (mut storage, _) = storage_with_rows(3); + clear_index(&mut storage); let outcome = storage.backfill_account_principal_index_batch(None, 0); - assert!(outcome.is_done); + assert!(!outcome.is_done); assert_eq!(outcome.indexed, 0); } + + /// A row can hold up to `MAX_ANCHOR_ACCOUNTS` references, so a batch that stopped only + /// on row boundaries would derive that many principals in one message however small + /// the batch. It stops inside the row and the cursor says where. + #[test] + fn a_batch_stops_inside_a_row_too_big_to_finish() { + let (mut storage, anchors) = storage_with_rows(1); + let anchor_number = anchors[0]; + let origin = "https://d-0.com".to_string(); + let mut references = vec![AccountReference { + account_number: None, + last_used: Some(1), + }]; + for _ in 0..4 { + let account = storage + .create_account(anchor_number, origin.clone(), "named".to_string()) + .unwrap(); + references.push(AccountReference { + account_number: account.account_number, + last_used: None, + }); + } + clear_index(&mut storage); + + let first = storage.backfill_account_principal_index_batch(None, 2); + + assert!(!first.is_done); + assert_eq!(first.indexed, 2); + assert_eq!( + first.next_cursor.map(|cursor| cursor.references_done), + Some(2), + "the cursor should point inside the row, not past it" + ); + assert_eq!(storage.lookup_account_with_principal_memory.len(), 2); + + let second = storage.backfill_account_principal_index_batch(first.next_cursor, 2); + + assert!(!second.is_done); + assert_eq!(second.indexed, 2); + assert_eq!(storage.lookup_account_with_principal_memory.len(), 4); + + let third = storage.backfill_account_principal_index_batch(second.next_cursor, 2); + + assert!(third.is_done); + assert_eq!(third.indexed, 1); + assert_eq!( + storage.lookup_account_with_principal_memory.len() as usize, + references.len() + ); + } } From 951e0fda62ad37fd5a78d67efb91e1095ec97c12 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:07:05 +0200 Subject: [PATCH 120/298] fix(fe): treat check_session as a hint, not as proof A query reply is uncertified, so anyone able to answer one could have the client delete a working session's key. A negative answer now only skips the silent path; the record stays, and the next request asks again. That leaves discardAppSession with no callers, so it goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/app-session.store.test.ts | 20 ----------- .../src/lib/stores/app-session.store.ts | 8 ----- .../channelHandlers/sessionDelegation.test.ts | 34 +++++++++++++++++-- .../channelHandlers/sessionDelegation.ts | 15 ++++---- 4 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/frontend/src/lib/stores/app-session.store.test.ts b/src/frontend/src/lib/stores/app-session.store.test.ts index e56fe5d062..df8221a579 100644 --- a/src/frontend/src/lib/stores/app-session.store.test.ts +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -3,7 +3,6 @@ import "fake-indexeddb/auto"; import { appAccountsForOrigin, appSessionsForOrigin, - discardAppSession, purgeAppSessions, rememberAppAccount, storeAppSession, @@ -64,16 +63,6 @@ describe("app session store", () => { ]); }); - it("keeps the account mapping when the session is discarded", async () => { - const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; - await rememberAppAccount(key, { accountPrincipal: "2vxsx-fae" }); - await storeAppSession(key, record(anHourFromNow())); - - await discardAppSession(key); - - await expect(appAccountsForOrigin(ORIGIN)).resolves.toHaveLength(1); - }); - it("keeps accounts of one identity apart", async () => { const identityNumber = BigInt(10_000); await storeAppSession( @@ -98,15 +87,6 @@ describe("app session store", () => { await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); }); - it("discards a session", async () => { - const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; - await storeAppSession(key, record(anHourFromNow())); - - await discardAppSession(key); - - await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); - }); - it("lists every identity holding a session at one origin", async () => { await storeAppSession( { identityNumber: BigInt(10_000), origin: ORIGIN }, diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index c60f00ffd8..20bcf1df15 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -109,14 +109,6 @@ export const rememberAppAccount = async ( } }; -export const discardAppSession = async (key: SessionKey): 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. * * Each carries the principal its account is known by, which lives in the other store diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 72b67b554d..15c949932b 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -391,7 +391,11 @@ describe("a session the canister no longer holds", () => { }); }); - it("forgets a record the canister no longer holds", async () => { + /// `check_session` is a query, so its reply is uncertified: anyone able to answer it + /// can say no. Deleting on that would let a wrong no destroy a working session's key, + /// which is an attacker-triggerable loss rather than a flake. The record stays, and a + /// later attempt asks again. + it("keeps the record, because a query reply is not proof", async () => { checkSession.mockResolvedValueOnce(false); await storedSession(BigInt(10_000)); const { channel } = channelWith(); @@ -407,7 +411,33 @@ describe("a session the canister no longer holds", () => { params: { sessionPublicKey: await appKey() }, }); - expect(await appSessionsForOrigin(ORIGIN)).toEqual([]); + expect(await appSessionsForOrigin(ORIGIN)).toHaveLength(1); + }); + + /// The denial is a skip, not a verdict: the very next request finds the record still + /// there and can succeed on it. + it("serves the same record once the canister answers again", async () => { + checkSession.mockResolvedValueOnce(false); + await storedSession(BigInt(10_000)); + (await promptStore()).set({ prompt: "none" }); + const request = { + jsonrpc: "2.0" as const, + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }; + + const denied = channelWith(); + await handleSessionDelegationRequest(denied.channel, vi.fn())(request); + + checkSession.mockResolvedValueOnce(true); + const served = channelWith(); + await handleSessionDelegationRequest(served.channel, vi.fn())(request); + + expect(denied.sent[0]).toMatchObject({ + error: { code: INTERACTION_REQUIRED_ERROR_CODE }, + }); + expect(served.sent[0]).toMatchObject({ result: {} }); }); }); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index e599fde2f8..f8b02a72c3 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -15,7 +15,6 @@ import { import { authenticationStore } from "$lib/stores/authentication.store"; import { appSessionsForOrigin, - discardAppSession, rememberAppAccount, storeAppSession, type AppSessionRecord, @@ -150,6 +149,12 @@ const extendToApp = async ( * this browser's copy in place. Answering from the record alone would hand the app a * chain that cannot mint, and the failure would surface later as something the client * cannot tell apart from a real error. + * + * A hint and not an authority. The reply is a query reply, so it is uncertified and + * anyone able to answer it can say no — which is why a no only skips the silent path. + * Nothing here is deleted on it: a wrong no would otherwise destroy a working session's + * key, turning a flake into something an attacker can trigger. A no that was a lie costs + * one silent attempt, and the next one asks again. */ const sessionIsLive = async (record: AppSessionRecord): Promise => { try { @@ -248,11 +253,9 @@ export const handleSessionDelegationRequest = let usable = "session" in chosen ? chosen.session : undefined; if (usable && !(await sessionIsLive(usable.record))) { - await discardAppSession({ - identityNumber: usable.identityNumber, - accountNumber: usable.accountNumber, - origin: effectiveOrigin, - }); + // The record stays. A session that is really gone leaves a record that is + // filtered out on read once it expires, and removing it would need a certified + // answer — which is an update call, made by the app, not by this. usable = undefined; } From 1afec22535ef46f1e73df5394444525390b27e8e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:14:31 +0200 Subject: [PATCH 121/298] feat(fe): forgetting an identity signs this browser out of its apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the local records only stopped II from silently signing the user back in. The apps went on holding delegation chains rooted at session records the canister still had, and refreshing against them until they expired — so "forget this identity" did not mean signed out. Every forget site now ends this browser's sessions for that identity first. Three sites that dropped the II session and left the app sessions untouched go through the same path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../stores/session-delegation.store.test.ts | 127 ++++++++++++++++++ .../lib/stores/session-delegation.store.ts | 34 +++++ .../src/routes/(new-styling)/+page.svelte | 6 +- .../(new-styling)/authorize/+layout.svelte | 4 +- .../authorize/views/ContinueView.svelte | 15 +-- .../routes/(new-styling)/cli/+layout.svelte | 6 +- .../manage/(authenticated)/+layout.svelte | 9 +- .../(authenticated)/access/+page.svelte | 6 +- .../(new-styling)/recovery/+page.svelte | 6 +- 9 files changed, 180 insertions(+), 33 deletions(-) diff --git a/src/frontend/src/lib/stores/session-delegation.store.test.ts b/src/frontend/src/lib/stores/session-delegation.store.test.ts index 9443444571..7e5b25b916 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.test.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.test.ts @@ -318,3 +318,130 @@ describe("mintSession — failure is swallowed", () => { expect(remaining).toEqual(existingRecord); }); }); + +describe("forgetIdentity", () => { + const BROWSER_KEY_STORE = idbCreateStore("ii-browser-keys", "keys"); + + const storedSessionDelegation = async () => { + await idbSet( + IDENTITY_NUMBER.toString(), + { + identityNumber: IDENTITY_NUMBER, + keyPair: await makeKeyPair(), + chainJson: await makeChainJson(), + expiresAtMillis: FAR_FUTURE, + }, + TEST_STORE, + ); + }; + + const storedAppSession = async () => { + const { storeAppSession } = await import("$lib/stores/app-session.store"); + await storeAppSession( + { identityNumber: IDENTITY_NUMBER, origin: "https://app.example.com" }, + { + keyPair: await makeKeyPair(), + chainJson: await makeChainJson(), + expiresAtMillis: FAR_FUTURE, + sessionId: BigInt(1), + accessLevel: "full-access", + }, + ); + }; + + const knownBrowser = (deviceId: number) => + idbSet( + IDENTITY_NUMBER.toString(), + { keyPair: undefined, deviceId }, + BROWSER_KEY_STORE, + ); + + beforeEach(async () => { + await idbDel(IDENTITY_NUMBER.toString(), BROWSER_KEY_STORE); + const { purgeAppSessions } = await import("$lib/stores/app-session.store"); + await purgeAppSessions(IDENTITY_NUMBER); + }); + + /// The gap this exists to close: dropping the local records alone leaves the apps + /// holding chains rooted at session records the canister still has, so they stay + /// signed in and keep refreshing. + it("ends this browser's sessions for the identity it forgets", async () => { + const revoke = vi.fn(() => Promise.resolve({ Ok: null })); + const actor = { + revoke_device_sessions: revoke, + } as unknown as ActorSubclass<_SERVICE>; + const { authenticationStore } = + await import("$lib/stores/authentication.store"); + vi.spyOn(authenticationStore, "subscribe").mockImplementation((cb) => { + cb({ identityNumber: IDENTITY_NUMBER, actor } as Parameters< + typeof cb + >[0]); + return () => {}; + }); + await knownBrowser(7); + await storedSessionDelegation(); + await storedAppSession(); + + const { forgetIdentity } = + await import("$lib/stores/session-delegation.store"); + await forgetIdentity(IDENTITY_NUMBER); + + expect(revoke).toHaveBeenCalledWith({ + identity_number: IDENTITY_NUMBER, + device_id: 7, + }); + const { appSessionsForOrigin } = + await import("$lib/stores/app-session.store"); + await expect( + appSessionsForOrigin("https://app.example.com"), + ).resolves.toEqual([]); + await expect( + idbGet(IDENTITY_NUMBER.toString(), TEST_STORE), + ).resolves.toBeUndefined(); + }); + + /// The local half must not depend on the canister being reachable: keeping the records + /// because a call failed would leave II able to sign the user back in silently, which + /// is the thing they asked it to stop doing. + it("forgets locally even when the canister call fails", async () => { + const actor = { + revoke_device_sessions: vi.fn(() => Promise.reject(new Error("offline"))), + } as unknown as ActorSubclass<_SERVICE>; + const { authenticationStore } = + await import("$lib/stores/authentication.store"); + vi.spyOn(authenticationStore, "subscribe").mockImplementation((cb) => { + cb({ identityNumber: IDENTITY_NUMBER, actor } as Parameters< + typeof cb + >[0]); + return () => {}; + }); + await knownBrowser(7); + await storedSessionDelegation(); + await storedAppSession(); + + const { forgetIdentity } = + await import("$lib/stores/session-delegation.store"); + await expect(forgetIdentity(IDENTITY_NUMBER)).resolves.toBeUndefined(); + + const { appSessionsForOrigin } = + await import("$lib/stores/app-session.store"); + await expect( + appSessionsForOrigin("https://app.example.com"), + ).resolves.toEqual([]); + }); + + it("forgets locally when this browser has never signed in as the identity", async () => { + await storedSessionDelegation(); + await storedAppSession(); + + const { forgetIdentity } = + await import("$lib/stores/session-delegation.store"); + await forgetIdentity(IDENTITY_NUMBER); + + const { appSessionsForOrigin } = + await import("$lib/stores/app-session.store"); + await expect( + appSessionsForOrigin("https://app.example.com"), + ).resolves.toEqual([]); + }); +}); diff --git a/src/frontend/src/lib/stores/session-delegation.store.ts b/src/frontend/src/lib/stores/session-delegation.store.ts index 2a0a9a9f63..0bc38101a2 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.ts @@ -9,6 +9,8 @@ import { Actor, ActorSubclass, HttpAgent } from "@icp-sdk/core/agent"; import type { _SERVICE } from "$lib/generated/internet_identity_types"; import { idlFactory as internet_identity_idl } from "$lib/generated/internet_identity_idl"; import { authenticationStore } from "$lib/stores/authentication.store"; +import { currentDeviceId } from "$lib/stores/browser-key.store"; +import { purgeAppSessions } from "$lib/stores/app-session.store"; import { canisterId, agentOptions } from "$lib/globals"; import { mintSessionDelegation, @@ -97,3 +99,35 @@ export const actorForIdentity = async ( return undefined; } }; + +/** + * Forgets an identity on this device, and signs it out of every app it is signed into + * from here. + * + * Dropping the local records alone would only stop II from silently signing the user + * back in: the apps hold delegation chains rooted at session records the canister still + * has, and go on refreshing against them until they expire. Ending this browser's + * sessions for this identity is what makes "forget" mean signed out. + * + * Sessions are per browser and per identity, so this leaves other identities on this + * browser, and this identity on the user's other browsers, alone. + */ +export const forgetIdentity = async (identityNumber: bigint): Promise => { + const deviceId = await currentDeviceId(identityNumber); + const actor = + deviceId === undefined ? undefined : await actorForIdentity(identityNumber); + if (deviceId !== undefined && actor !== undefined) { + try { + await actor.revoke_device_sessions({ + identity_number: identityNumber, + device_id: deviceId, + }); + } catch { + // The local records go either way. Keeping them because the canister could not be + // reached would leave II able to sign the user back in silently, which is the + // thing the user asked it to stop doing. + } + } + await purgeSession(identityNumber); + await purgeAppSessions(identityNumber); +}; diff --git a/src/frontend/src/routes/(new-styling)/+page.svelte b/src/frontend/src/routes/(new-styling)/+page.svelte index 4aa8bfcaba..bc10be47f0 100644 --- a/src/frontend/src/routes/(new-styling)/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/+page.svelte @@ -13,8 +13,7 @@ import type { AuthMode } from "$lib/flows/authFlow.svelte"; 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 { forgetIdentity } from "$lib/stores/session-delegation.store"; import { goto } from "$app/navigation"; import { toaster } from "$lib/components/utils/toaster"; import { @@ -111,8 +110,7 @@ const removedIdentity = $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void purgeSession(identityNumber); - void purgeAppSessions(identityNumber); + void forgetIdentity(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { diff --git a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte index 26fd5caccc..e5a82ab905 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte @@ -11,7 +11,7 @@ authorizedStore, } from "$lib/stores/authorization.store"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; - import { purgeSession } from "$lib/stores/session-delegation.store"; + import { forgetIdentity } from "$lib/stores/session-delegation.store"; import { authenticationStore } from "$lib/stores/authentication.store"; import { goto } from "$app/navigation"; import { toaster } from "$lib/components/utils/toaster"; @@ -189,7 +189,7 @@ const removedIdentity = $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void purgeSession(identityNumber); + void forgetIdentity(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 b0c2e502e4..441c57ffe8 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte @@ -18,11 +18,7 @@ authenticationStore, isAuthenticatedStore, } from "$lib/stores/authentication.store"; - import { - actorForIdentity, - purgeSession, - } from "$lib/stores/session-delegation.store"; - import { purgeAppSessions } from "$lib/stores/app-session.store"; + import { actorForIdentity, forgetIdentity } from "$lib/stores/session-delegation.store"; import { throwCanisterError, isCanisterError } from "$lib/utils/utils"; import type { ActorSubclass } from "@icp-sdk/core/agent"; import type { @@ -263,8 +259,7 @@ isCanisterError(err) && err.type === "Unauthorized" ) { - void purgeSession(selectedIdentityNumber); - void purgeAppSessions(selectedIdentityNumber); + void forgetIdentity(selectedIdentityNumber); } else { throw err; } @@ -352,8 +347,7 @@ isCanisterError(err) && err.type === "Unauthorized" ) { - void purgeSession(selectedIdentityNumber); - void purgeAppSessions(selectedIdentityNumber); + void forgetIdentity(selectedIdentityNumber); } else { throw err; } @@ -484,8 +478,7 @@ isCanisterError(err) && err.type === "Unauthorized" ) { - void purgeSession(selectedIdentityNumber); - void purgeAppSessions(selectedIdentityNumber); + void forgetIdentity(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 ef2c685e1a..9ff0a8a7ff 100644 --- a/src/frontend/src/routes/(new-styling)/cli/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/cli/+layout.svelte @@ -2,8 +2,7 @@ import type { LayoutProps } from "./$types"; 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 { forgetIdentity } from "$lib/stores/session-delegation.store"; import { t } from "$lib/stores/locale.store"; import { AuthWizard } from "$lib/components/wizards/auth"; import Header from "$lib/components/layout/Header.svelte"; @@ -64,8 +63,7 @@ const removedIdentity = $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void purgeSession(identityNumber); - void purgeAppSessions(identityNumber); + void forgetIdentity(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 f2648c978b..4827987ed0 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte @@ -25,8 +25,7 @@ } from "$lib/stores/authentication.store"; 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 { forgetIdentity } from "$lib/stores/session-delegation.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,8 +118,7 @@ const handleConfirmSignOutAndRemove = () => { const identityNumber = $authenticatedStore.identityNumber; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void purgeSession(identityNumber); - void purgeAppSessions(identityNumber); + void forgetIdentity(identityNumber); sessionStore.reset(); window.location.replace("/"); }; @@ -131,8 +129,7 @@ const removedIdentity = $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void purgeSession(identityNumber); - void purgeAppSessions(identityNumber); + void forgetIdentity(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { const identityName = diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte index 1fa04891c1..ae22f0d60b 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte @@ -23,7 +23,7 @@ } from "$app/navigation"; import { canisterId } from "$lib/globals"; import { authenticationStore } from "$lib/stores/authentication.store"; - import { purgeSession } from "$lib/stores/session-delegation.store"; + import { forgetIdentity } from "$lib/stores/session-delegation.store"; import { authenticateWithPasskey } from "$lib/utils/authentication/passkey"; import { authenticateWithJWT } from "$lib/utils/authentication/jwt"; import { @@ -395,7 +395,9 @@ if (isCurrentAccessMethod($authenticatedStore, removingAccessMethod)) { const identityNumber = $authenticatedStore.identityNumber; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void purgeSession(identityNumber); + // Awaited, unlike the other forget sites: the navigation below would cut a + // fire-and-forget call off before it reached the canister. + await forgetIdentity(identityNumber); sessionStore.reset(); location.replace("/login"); return; diff --git a/src/frontend/src/routes/(new-styling)/recovery/+page.svelte b/src/frontend/src/routes/(new-styling)/recovery/+page.svelte index 4dd9999fec..7b347d93c4 100644 --- a/src/frontend/src/routes/(new-styling)/recovery/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/recovery/+page.svelte @@ -38,8 +38,7 @@ import { throwCanisterError } from "$lib/utils/utils"; 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 { forgetIdentity, purgeSession } from "$lib/stores/session-delegation.store"; import { authenticateWithSession } from "$lib/utils/authentication"; import { goto, preloadData } from "$app/navigation"; import { page } from "$app/state"; @@ -201,8 +200,7 @@ } catch (error) { showRecoveryDialog = false; authenticationStore.reset(); - void purgeSession(identityNumber); - void purgeAppSessions(identityNumber); + void forgetIdentity(identityNumber); handleError(error); } }; From a1b908ae04cebb434bdaa4c3c7c8b93793a347ef Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:18:21 +0200 Subject: [PATCH 122/298] fix(fe): read which browser is signing out at the moment it signs out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list renders that flag from a promise, so a click landing before it resolved passed false for the user's own browser — and left behind exactly the chains signing out exists to discard. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../components/SessionDevicesSection.svelte | 1 - .../settings/sessionDevices.test.ts | 14 ++++++++++++-- .../(authenticated)/settings/sessionDevices.ts | 9 +++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte index 101d02e1d7..66732a39c1 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte @@ -26,7 +26,6 @@ $authenticatedStore.actor, identityNumber, device.id, - device.isCurrent, ); signedOut = [...signedOut, device.id]; } catch (error) { diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts index 0aea1e247d..49633a9da6 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts @@ -141,9 +141,13 @@ describe("signOutSessionDevice", () => { ).rejects.toThrow("boom"); }); + /// Which browser is signing out is read from the key record, not passed in: the list + /// renders that flag from a promise, and a click landing before it resolved used to + /// leave this browser's own chains behind — the one thing signing out must not do. it("discards this browser's stored chains, and another browser's not", async () => { const { storeAppSession, appSessionsForOrigin } = await import("$lib/stores/app-session.store"); + const { set: idbSet, createStore } = await import("idb-keyval"); const record = { keyPair: undefined as unknown as CryptoKeyPair, chainJson: "{}", @@ -155,18 +159,24 @@ describe("signOutSessionDevice", () => { const actor = { revoke_device_sessions: vi.fn(() => Promise.resolve({ Ok: null })), } as unknown as ActorSubclass<_SERVICE>; + // This browser is device 3. + await idbSet( + BigInt(10_000).toString(), + { keyPair: undefined, deviceId: 3 }, + createStore("ii-browser-keys", "keys"), + ); await storeAppSession( { identityNumber: BigInt(10_000), origin: "https://app.example.com" }, record, ); // Signing another browser out must leave this one signed in locally. - await signOutSessionDevice(actor, BigInt(10_000), 3, false); + await signOutSessionDevice(actor, BigInt(10_000), 9); expect(await appSessionsForOrigin("https://app.example.com")).toHaveLength( 1, ); - await signOutSessionDevice(actor, BigInt(10_000), 3, true); + await signOutSessionDevice(actor, BigInt(10_000), 3); expect(await appSessionsForOrigin("https://app.example.com")).toEqual([]); }); }); diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts index 65f6614238..914b9a94ed 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts @@ -1,5 +1,6 @@ import type { ActorSubclass } from "@icp-sdk/core/agent"; import { purgeAppSessions } from "$lib/stores/app-session.store"; +import { currentDeviceId } from "$lib/stores/browser-key.store"; import type { _SERVICE, SessionDeviceInfo, @@ -38,12 +39,16 @@ export const fromCanisterSessionDevices = ( * Signing *this* browser out also discards the session chains it holds locally. The * canister has already stopped honouring them, and leaving them would have the next * silent request offer a chain that cannot mint. + * + * Which browser this is comes from the key record here rather than from a caller: the + * list renders that flag from a promise, so a click landing before it resolves would + * otherwise pass `false` for the user's own browser and leave exactly those chains + * behind. */ export const signOutSessionDevice = async ( actor: ActorSubclass<_SERVICE>, identityNumber: bigint, deviceId: number, - isCurrentBrowser = false, ): Promise => { const result = await actor.revoke_device_sessions({ identity_number: identityNumber, @@ -56,7 +61,7 @@ export const signOutSessionDevice = async ( : result.Err.InternalCanisterError, ); } - if (isCurrentBrowser) { + if ((await currentDeviceId(identityNumber)) === deviceId) { await purgeAppSessions(identityNumber); } }; From 6cfc1f59f634da439f15b3d48f6e9263a716bb0b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:19:25 +0200 Subject: [PATCH 123/298] chore: the session key's account has a caller now Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/account.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 2bbc3f275d..afbaaf1daa 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -84,8 +84,6 @@ pub struct SessionRecordKey { } impl SessionRecordKey { - // Used by the app delegation path, which lands four PRs up. - #[allow(dead_code)] /// The account this session is at. pub fn account(&self) -> AccountKey { AccountKey { From 9b8dbdcedf05f78bfa1b120106288676d3a859f9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:19:36 +0200 Subject: [PATCH 124/298] chore: revoke_device_sessions has a caller now Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 38997a0c46..10130789b5 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1717,8 +1717,6 @@ impl Storage { .map(Vec::::from) } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] /// Signs one browser out of everything, in a single message. pub fn revoke_device_sessions( &mut self, From 732beee188595ca1505e9f88e8d40212f3c55bd8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:26:37 +0200 Subject: [PATCH 125/298] fix: sign a browser out all at once or not at all The sweep writes one row per application and adjusts the count after the loop. An Err reply commits everything written before it, so a failure part-way left a browser signed out of some of its applications and not others, told the sign-out had failed. It traps now, as app_revoke_session already does for the same class of failure, and the whole message rolls back. That leaves SessionRevokeError with one reachable variant, so the other goes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/generated/internet_identity_idl.js | 5 +---- .../src/lib/generated/internet_identity_types.d.ts | 9 +++++++-- src/internet_identity/internet_identity.did | 3 ++- src/internet_identity/src/sessions.rs | 10 ++++++++-- .../src/internet_identity/types.rs | 1 - 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 3e2cec1c54..058ce2b75e 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -814,10 +814,7 @@ export const idlFactory = ({ IDL }) => { 'device_id' : IDL.Nat32, 'identity_number' : UserNumber, }); - const SessionRevokeError = IDL.Variant({ - 'InternalCanisterError' : IDL.Text, - 'Unauthorized' : IDL.Principal, - }); + const SessionRevokeError = IDL.Variant({ 'Unauthorized' : IDL.Principal }); const SetDefaultAccountError = IDL.Variant({ 'NoSuchOrigin' : IDL.Record({ 'anchor_number' : UserNumber }), 'NoSuchAnchor' : IDL.Null, 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 39a47c0adf..12810dc582 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1682,8 +1682,13 @@ export interface SessionDeviceInfo { 'last_used' : Timestamp, } export type SessionKey = PublicKey; -export type SessionRevokeError = { 'InternalCanisterError' : string } | - { 'Unauthorized' : Principal }; +export type SessionRevokeError = { + /** + * The only way this can fail. A storage failure traps instead, so a browser is never + * left signed out of some of its applications and not others. + */ + 'Unauthorized' : Principal + }; export type SetDefaultAccountError = { 'NoSuchOrigin' : { 'anchor_number' : UserNumber } } | diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index cb9aa98772..670d099660 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1114,8 +1114,9 @@ type RevokeDeviceSessionsRequest = record { }; type SessionRevokeError = variant { + // The only way this can fail. A storage failure traps instead, so a browser is never + // left signed out of some of its applications and not others. Unauthorized : principal; - InternalCanisterError : text; }; type AppSessionError = variant { diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 5b19dbc9ed..00d5119901 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -456,9 +456,15 @@ pub fn revoke_device_sessions( check_authorization(request.identity_number) .map_err(|err| SessionRevokeError::Unauthorized(err.principal))?; + // Trapping rather than reporting a failure, as `app_revoke_session` does for the same + // class of error: the sweep writes one row per application and adjusts the count after + // the loop, and an `Err` reply commits everything written before it. That would leave + // a browser signed out of some of its applications and not others, told the sign-out + // failed, with a retry the only way back to a defined state. A trap rolls the whole + // message back. storage_borrow_mut(|storage| { storage.revoke_device_sessions(request.identity_number, request.device_id) }) - .map(|_| ()) - .map_err(|err| SessionRevokeError::InternalCanisterError(err.to_string())) + .expect("failed to sign out a browser of an identity the caller is authorized for"); + Ok(()) } diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index db8c55bd35..1639ace7fe 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -877,5 +877,4 @@ pub struct RevokeDeviceSessionsRequest { #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub enum SessionRevokeError { Unauthorized(Principal), - InternalCanisterError(String), } From ac8c1b51654b490b58e9a84301a3fb643e9924eb Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:27:51 +0200 Subject: [PATCH 126/298] fix(fe): name the one refusal signing out can report Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(authenticated)/settings/sessionDevices.test.ts | 11 ----------- .../manage/(authenticated)/settings/sessionDevices.ts | 8 +++----- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts index 49633a9da6..22c3c2e869 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts @@ -130,17 +130,6 @@ describe("signOutSessionDevice", () => { ).rejects.toThrow(/Not authorized/); }); - it("surfaces an internal failure", async () => { - const actor = { - revoke_device_sessions: () => - Promise.resolve({ Err: { InternalCanisterError: "boom" } }), - } as unknown as ActorSubclass<_SERVICE>; - - await expect( - signOutSessionDevice(actor, BigInt(10_000), 3), - ).rejects.toThrow("boom"); - }); - /// Which browser is signing out is read from the key record, not passed in: the list /// renders that flag from a promise, and a click landing before it resolved used to /// leave this browser's own chains behind — the one thing signing out must not do. diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts index 914b9a94ed..1bf964b50d 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts @@ -54,12 +54,10 @@ export const signOutSessionDevice = async ( identity_number: identityNumber, device_id: deviceId, }); + // The only refusal the canister can report. A storage failure traps, which arrives as + // a rejected call rather than as an `Err`, so there is nothing else to name here. if ("Err" in result) { - throw new Error( - "Unauthorized" in result.Err - ? "Not authorized to end this browser's sessions" - : result.Err.InternalCanisterError, - ); + throw new Error("Not authorized to end this browser's sessions"); } if ((await currentDeviceId(identityNumber)) === deviceId) { await purgeAppSessions(identityNumber); From 5e8b71e617f4fa590f2d6f488eaa8c3e8939d7fb Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 13:42:55 +0200 Subject: [PATCH 127/298] test: build references the way this branch builds them Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index a2dff2bd14..37a6da65b2 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4418,11 +4418,8 @@ mod account_principal_index_backfill_tests { .unwrap(); storage.stable_account_reference_list_memory.insert( (anchor_number, application_number), - StorableAccountReferenceList::try_from(vec![AccountReference { - account_number: None, - last_used: Some(1), - }]) - .unwrap(), + StorableAccountReferenceList::try_from(vec![AccountReference::new(None, Some(1))]) + .unwrap(), ); let outcome = storage.backfill_account_principal_index_batch(None, 100); @@ -4465,18 +4462,12 @@ mod account_principal_index_backfill_tests { let (mut storage, anchors) = storage_with_rows(1); let anchor_number = anchors[0]; let origin = "https://d-0.com".to_string(); - let mut references = vec![AccountReference { - account_number: None, - last_used: Some(1), - }]; + let mut references = vec![AccountReference::new(None, Some(1))]; for _ in 0..4 { let account = storage .create_account(anchor_number, origin.clone(), "named".to_string()) .unwrap(); - references.push(AccountReference { - account_number: account.account_number, - last_used: None, - }); + references.push(AccountReference::new(account.account_number, None)); } clear_index(&mut storage); From 668e7e98435261a3ad3b918127183cebb8ff9a82 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 14:08:14 +0200 Subject: [PATCH 128/298] fix: a new session resolves without waiting for the backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session handle names its account by principal, and resolving that principal is a second index lookup. That index gains entries only where a row's set of account numbers changes, or when the sweep reaches the row — and creating a session does neither. So a session at any row that predates the index resolved to nothing until the sweep happened to arrive: every returning user of an app they have used before, for as long as the sweep takes, presenting as "sign in again". Creating a session now writes the account's entry alongside the session's, with the value the sweep would have written. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 17 ++++++++ src/internet_identity/src/storage/tests.rs | 49 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 63fe516828..db0094a2b4 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1964,6 +1964,23 @@ impl Storage { self.session_principal(anchor_number, application_number, account_number, &session), self.account_principal_of(anchor_number, application_number, account_number), ) { + // The account's own entry goes in alongside the session's. A handle names its + // account by principal, and resolving that principal is a second index lookup — + // one whose entry is written only when a row's set of account numbers changes, + // or by the backfill sweep. Creating a session changes neither, so a session at + // a row that predates the index would resolve to nothing until the sweep + // happened to reach it, which is every returning user of an app they have used + // before, for as long as the sweep takes. + // + // Writing the same value the sweep would write, so the two cannot disagree. + self.lookup_account_with_principal_memory.insert( + account_principal, + StorableAccountKey { + anchor_number, + application_number, + account_number, + }, + ); self.lookup_session_with_principal_memory.insert( principal, StorableSessionHandle { diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 1ad3f1ec8c..edcafab7b5 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4883,6 +4883,55 @@ mod session_creation_tests { } } + /// A row that predates the principal index, which is every row an existing user has: + /// the index is written only where a row's set of account numbers changes, and by the + /// backfill sweep. Emptied here to stand in for a row the sweep has not reached. + fn forget_account_principals(storage: &mut Storage) { + let principals: Vec<_> = storage + .lookup_account_with_principal_memory + .iter() + .map(|(principal, _)| principal) + .collect(); + for principal in principals { + storage + .lookup_account_with_principal_memory + .remove(&principal); + } + } + + /// Creating a session indexes its own account, rather than waiting for the sweep to. + /// A session handle names its account by principal, so a handle whose account is not + /// indexed resolves to nothing — which would be every returning user, for as long as + /// the backfill takes, presenting as "sign in again". + #[test] + fn creating_a_session_indexes_the_account_it_belongs_to() { + let (mut storage, anchor_number) = storage_with_anchor(); + storage + .create_session(params(anchor_number, 1, 1_000)) + .unwrap(); + forget_account_principals(&mut storage); + + storage + .create_session(params(anchor_number, 2, 2_000)) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&ORIGIN.to_string()) + .unwrap(); + assert_eq!( + storage + .lookup_account_with_principal_memory + .iter() + .map(|(_, stored)| ( + stored.anchor_number, + stored.application_number, + stored.account_number + )) + .collect::>(), + vec![(anchor_number, application_number, None)] + ); + } + fn sessions_of( storage: &Storage, anchor_number: AnchorNumber, From 7655c99e089dc7824033d0f76683235c474ebf8e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 14:10:04 +0200 Subject: [PATCH 129/298] fix: a session count that cannot be moved rolls the message back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The count is adjusted after the row writes it describes, and an Err reply commits those — so reporting the failure left the stored sessions and the count that gates sign-in permanently disagreeing, while the caller was told nothing had happened. It traps now. Also corrects the comment in remove_reference_list that called the counter "the only fallible step left"; this PR had added a second one below it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index db0094a2b4..2a3ba53fc6 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1752,7 +1752,8 @@ impl Storage { } } if removed > 0 { - self.change_session_count(anchor_number, removed as usize, 0)?; + self.change_session_count(anchor_number, removed as usize, 0) + .expect("failed to move the session count of an anchor that was just read"); } Ok(removed) @@ -1787,6 +1788,12 @@ impl Storage { } /// Moves the count without considering the cap, for the paths that only remove. + /// + /// Traps rather than reporting, at every caller. It runs after the row writes it + /// describes, and on the IC an `Err` commits those — so reporting a failure here + /// would leave the stored sessions and the count that gates sign-in permanently + /// disagreeing, while telling the caller nothing happened. A trap rolls the whole + /// message back. fn change_session_count( &mut self, anchor_number: AnchorNumber, @@ -1989,7 +1996,8 @@ impl Storage { }, ); } - self.change_session_count(anchor_number, dropped.len(), 1)?; + self.change_session_count(anchor_number, dropped.len(), 1) + .expect("failed to move the session count of an anchor that was just read"); let key = SessionRecordKey { anchor_number, @@ -2077,9 +2085,9 @@ impl Storage { ); } - // Counters first, for the reason `write_account_state` does it: it is the - // only fallible step left, and an error after the removes would commit them - // without it. + // Counters first, for the reason `write_account_state` does it: it is the only + // step left that can *report* a failure, and an error after the removes would + // commit them without it. What follows either cannot fail or traps. self.apply_reference_counter_deltas( anchor_number, application_number, @@ -2087,7 +2095,8 @@ impl Storage { ReferenceListDeltas::removing(&previous), )?; if dropped > 0 { - self.change_session_count(anchor_number, dropped, 0)?; + self.change_session_count(anchor_number, dropped, 0) + .expect("failed to move the session count of an anchor that was just read"); } self.sync_account_principal_index( From c21b7ad9e8251b22eb2aa0ba5bc6e9cb631a99de Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 14:14:20 +0200 Subject: [PATCH 130/298] fix: a reservation naming an account that is gone is no reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rename or a move can leave the config pointing at a number this identity no longer holds. The read then failed and reported an internal canister error, so an identity that still has its tracked default was told it has no default at all — and the two readers of the same config disagreed, since mcp::default_account_number already falls back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 8ee57c9f49..09938130c3 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -104,8 +104,8 @@ pub fn get_account_for_origin( /// The account this identity signs in with at `origin` by default: the one it reserved, /// or its tracked default where it reserved none. /// -/// An `Err` means the identity has neither, which it can only reach by moving every -/// account away from the origin. +/// An `Err` means the identity has no account at this origin at all, which it can only +/// reach by moving every account away from it. pub fn get_default_account_for_origin( anchor_number: AnchorNumber, origin: FrontendHostname, @@ -122,7 +122,16 @@ pub fn get_default_account_for_origin( // A `None` here reads the tracked default, so an anchor that reserved nothing and // one whose reservation is still the default answer the same way. - try_read_account_info(anchor_number, &origin, reserved) + if let Ok(account) = try_read_account_info(anchor_number, &origin, reserved) { + return Ok(account); + } + + // A reservation naming an account this identity no longer holds answers the same as + // no reservation at all, which is what `mcp::default_account_number` already does + // with the same config. Reporting an internal error here would tell an identity that + // still holds its tracked default that it has no default, over a state a rename or a + // move can leave behind. + try_read_account_info(anchor_number, &origin, None) .map_err(GetDefaultAccountError::InternalCanisterError) } @@ -1129,6 +1138,46 @@ fn should_succeed_get_default_account_for_nonexistent_anchor() { ); } +/// A reservation can outlive the account it names — a rename or a move leaves the config +/// pointing at a number this identity no longer holds. That identity still has its +/// tracked default, so answering with an internal error would tell it it has no default +/// when it does, and would disagree with `mcp::default_account_number`, which reads the +/// same config and falls back. +#[test] +fn should_fall_back_to_the_tracked_default_when_the_reservation_is_stale() { + use crate::state::{storage_borrow_mut, storage_replace}; + use crate::storage::Storage; + use ic_stable_structures::VectorMemory; + + storage_replace(Storage::new((0, 10000), VectorMemory::default())); + let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); + let anchor_number = anchor.anchor_number(); + let origin = "https://example.com".to_string(); + storage_borrow_mut(|storage| storage.write(anchor)).unwrap(); + create_account_for_origin(anchor_number, origin.clone(), "Alice".to_string()).unwrap(); + + // A number this identity does not hold, which is the shape a stale reservation has. + // Written through storage rather than through `set_default_account_for_origin`, + // which reads the account first and so cannot produce this state — a rename or a + // move is what leaves it behind. + storage_borrow_mut(|storage| { + storage.set_default_account(anchor_number, origin.clone(), Some(9_999)) + }) + .unwrap(); + + let result = get_default_account_for_origin(anchor_number, origin.clone()); + + assert_eq!( + result, + Ok(AccountInfo { + account_number: None, + origin, + last_used: None, + name: None, + }) + ); +} + #[test] fn should_get_default_account_for_different_origins() { use crate::state::{storage_borrow_mut, storage_replace}; From ada63b61af2281f2cfb536f729c35302dff71465 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 14:35:21 +0200 Subject: [PATCH 131/298] test: the stale-reservation test needs a salt to hold up the stack Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/account_management.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 09938130c3..00112c0cf5 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -1149,7 +1149,11 @@ fn should_fall_back_to_the_tracked_default_when_the_reservation_is_stale() { use crate::storage::Storage; use ic_stable_structures::VectorMemory; - storage_replace(Storage::new((0, 10000), VectorMemory::default())); + let mut storage = Storage::new((0, 10000), VectorMemory::default()); + // A write of this row derives account principals on later branches, so the salt has + // to be there for the same test to hold all the way up the stack. + storage.update_salt([17u8; 32]); + storage_replace(storage); let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); let anchor_number = anchor.anchor_number(); let origin = "https://example.com".to_string(); From 9747fe7e0d7035d3c945af9509eacd7f96238546 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 14:36:44 +0200 Subject: [PATCH 132/298] refactor: resolve the application before burning an account number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both create paths allocated a number and stored the account, then asked for the application number — a fallible step after two writes, in functions whose whole shape is about not doing that. Nothing in between depends on the number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index d336aca253..c5e04ac696 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1939,6 +1939,11 @@ impl Storage { let anchor_number = params.anchor_number; let origin = ¶ms.origin; + // Ahead of the allocation, which writes: nothing between the two depends on the + // account number, and an `Err` here after it would commit a number that is now + // burned and an account record nothing references. + let application_number = self.lookup_or_insert_application_number_with_origin(origin)?; + // Create and store account in stable memory let account_number = self.allocate_account_number()?; let storable_account = StorableAccount { @@ -1948,9 +1953,6 @@ impl Storage { self.stable_account_memory .insert(account_number, storable_account); - // Update application data - let application_number = self.lookup_or_insert_application_number_with_origin(origin)?; - // last_used will be set once the user signs in with the account. let last_used = None; @@ -2218,6 +2220,12 @@ impl Storage { } }; + // Get or create an application number from the account's origin. Ahead of the + // allocation, which writes: nothing between the two depends on the account number, + // and an `Err` here after it would commit a number that is now burned and an + // account record nothing references. + let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; + // Create and store the default account. let new_account_number = self.allocate_account_number()?; let storable_account = StorableAccount { @@ -2228,9 +2236,6 @@ impl Storage { self.stable_account_memory .insert(new_account_number, storable_account.clone()); - // Get or create an application number from the account's origin. - let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; - // Update default account in the (anchor, origin) config. { let mut config = From 7b5eafc4681c06b4061727052d779672d8807144 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 18:50:53 +0200 Subject: [PATCH 133/298] fix(fe): say that forgetting an identity signed its apps out Undo restores the list entry and nothing else, which is now half of what forgetting does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/routes/(new-styling)/+page.svelte | 2 +- src/frontend/src/routes/(new-styling)/authorize/+layout.svelte | 2 +- src/frontend/src/routes/(new-styling)/cli/+layout.svelte | 2 +- .../routes/(new-styling)/manage/(authenticated)/+layout.svelte | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/+page.svelte b/src/frontend/src/routes/(new-styling)/+page.svelte index bc10be47f0..9ee271b794 100644 --- a/src/frontend/src/routes/(new-styling)/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/+page.svelte @@ -118,7 +118,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device.`, + description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, closable: true, duration: 5000, action: { diff --git a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte index e5a82ab905..4eb6e31e1b 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte @@ -197,7 +197,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device.`, + description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, closable: true, duration: 5000, action: { diff --git a/src/frontend/src/routes/(new-styling)/cli/+layout.svelte b/src/frontend/src/routes/(new-styling)/cli/+layout.svelte index 9ff0a8a7ff..6eac7d1916 100644 --- a/src/frontend/src/routes/(new-styling)/cli/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/cli/+layout.svelte @@ -71,7 +71,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device.`, + description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, closable: true, duration: 5000, action: { 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 4827987ed0..fd5f31cadb 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte @@ -136,7 +136,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device.`, + description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, closable: true, duration: 5000, action: { From 4f46c1a4c6f45cc2715f603a76a7c750f000ed42 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 19:07:52 +0200 Subject: [PATCH 134/298] fix(fe): removing an identity from the MCP layout forgets it too It dropped the identity from the list and left II's session delegation on disk, so II could still silently sign the user back in as an identity they had just told it to forget. Same dialog and same toast as the other four sites; now the same behaviour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/routes/(new-styling)/mcp/+layout.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte b/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte index 8e1008e836..3bf7a65455 100644 --- a/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte @@ -2,6 +2,7 @@ import type { LayoutProps } from "./$types"; import { ChevronDownIcon, UserIcon } from "@lucide/svelte"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; + import { forgetIdentity } from "$lib/stores/session-delegation.store"; import { t } from "$lib/stores/locale.store"; import { AuthWizard } from "$lib/components/wizards/auth"; import Header from "$lib/components/layout/Header.svelte"; @@ -91,6 +92,7 @@ const removedIdentity = $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); + void forgetIdentity(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { @@ -98,7 +100,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device.`, + description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, closable: true, duration: 5000, action: { From fb82fdf1c3ef5ff133b467ef1d98ae0743e7cfc9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 20:32:02 +0200 Subject: [PATCH 135/298] refactor: say why the session count cannot fail instead of trapping on it The count moves after the row writes it describes, so an Err would commit them with the count untouched. It cannot report one: the caller has already written the anchor, so the read resolves, and writing it back with one u32 changed leaves every field the write validates as it was read. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 2a3ba53fc6..373e7cab32 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1752,8 +1752,7 @@ impl Storage { } } if removed > 0 { - self.change_session_count(anchor_number, removed as usize, 0) - .expect("failed to move the session count of an anchor that was just read"); + self.change_session_count(anchor_number, removed as usize, 0)?; } Ok(removed) @@ -1789,11 +1788,11 @@ impl Storage { /// Moves the count without considering the cap, for the paths that only remove. /// - /// Traps rather than reporting, at every caller. It runs after the row writes it - /// describes, and on the IC an `Err` commits those — so reporting a failure here - /// would leave the stored sessions and the count that gates sign-in permanently - /// disagreeing, while telling the caller nothing happened. A trap rolls the whole - /// message back. + /// This runs after the row writes it describes, which on the IC means an `Err` here + /// would commit them with the count untouched. It cannot report one: the anchor was + /// written by the caller that got here, so the read resolves, and writing it back + /// with one `u32` changed leaves every field the write validates — the email + /// recovery binding included — exactly as it was read. fn change_session_count( &mut self, anchor_number: AnchorNumber, @@ -1996,8 +1995,7 @@ impl Storage { }, ); } - self.change_session_count(anchor_number, dropped.len(), 1) - .expect("failed to move the session count of an anchor that was just read"); + self.change_session_count(anchor_number, dropped.len(), 1)?; let key = SessionRecordKey { anchor_number, @@ -2085,9 +2083,9 @@ impl Storage { ); } - // Counters first, for the reason `write_account_state` does it: it is the only - // step left that can *report* a failure, and an error after the removes would - // commit them without it. What follows either cannot fail or traps. + // Counters first, for the reason `write_account_state` does it: an error after + // the removes would commit them without it. The session count below moves after + // this, and cannot report a failure of its own — see `change_session_count`. self.apply_reference_counter_deltas( anchor_number, application_number, @@ -2095,8 +2093,7 @@ impl Storage { ReferenceListDeltas::removing(&previous), )?; if dropped > 0 { - self.change_session_count(anchor_number, dropped, 0) - .expect("failed to move the session count of an anchor that was just read"); + self.change_session_count(anchor_number, dropped, 0)?; } self.sync_account_principal_index( From 787bd05ee0245a03613af38881c928a312061e9b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 20:35:18 +0200 Subject: [PATCH 136/298] fix: resolve every application before the sweep writes any of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep signed a browser out one application at a time and only then looked each application up, so the one failure it could report arrived after the earlier rows were already written — and an Err commits those. A browser was left out of some of its apps and still in others, told the sign-out had failed. The lookup moves ahead of the first write. What remains cannot report a failure: removing sessions leaves the reference list non-empty with every account number where it was, so the counter deltas are zero and that write short-circuits, and the count at the end is this anchor read and written back with one u32 changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/generated/internet_identity_idl.js | 5 ++++- .../generated/internet_identity_types.d.ts | 9 ++++---- src/internet_identity/internet_identity.did | 5 +++-- src/internet_identity/src/sessions.rs | 10 ++------- src/internet_identity/src/storage.rs | 22 +++++++++++++++++++ .../src/internet_identity/types.rs | 1 + 6 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 058ce2b75e..3e2cec1c54 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -814,7 +814,10 @@ export const idlFactory = ({ IDL }) => { 'device_id' : IDL.Nat32, 'identity_number' : UserNumber, }); - const SessionRevokeError = IDL.Variant({ 'Unauthorized' : IDL.Principal }); + const SessionRevokeError = IDL.Variant({ + 'InternalCanisterError' : IDL.Text, + 'Unauthorized' : IDL.Principal, + }); const SetDefaultAccountError = IDL.Variant({ 'NoSuchOrigin' : IDL.Record({ 'anchor_number' : UserNumber }), 'NoSuchAnchor' : IDL.Null, 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 12810dc582..00646f4baf 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1684,11 +1684,12 @@ export interface SessionDeviceInfo { export type SessionKey = PublicKey; export type SessionRevokeError = { /** - * The only way this can fail. A storage failure traps instead, so a browser is never - * left signed out of some of its applications and not others. + * Raised before the sweep writes anything, so a browser is never left signed out of + * some of its applications and not others. */ - 'Unauthorized' : Principal - }; + 'InternalCanisterError' : string + } | + { 'Unauthorized' : Principal }; export type SetDefaultAccountError = { 'NoSuchOrigin' : { 'anchor_number' : UserNumber } } | diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 670d099660..6c7aa01d7c 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1114,9 +1114,10 @@ type RevokeDeviceSessionsRequest = record { }; type SessionRevokeError = variant { - // The only way this can fail. A storage failure traps instead, so a browser is never - // left signed out of some of its applications and not others. Unauthorized : principal; + // Raised before the sweep writes anything, so a browser is never left signed out of + // some of its applications and not others. + InternalCanisterError : text; }; type AppSessionError = variant { diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 00d5119901..5b19dbc9ed 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -456,15 +456,9 @@ pub fn revoke_device_sessions( check_authorization(request.identity_number) .map_err(|err| SessionRevokeError::Unauthorized(err.principal))?; - // Trapping rather than reporting a failure, as `app_revoke_session` does for the same - // class of error: the sweep writes one row per application and adjusts the count after - // the loop, and an `Err` reply commits everything written before it. That would leave - // a browser signed out of some of its applications and not others, told the sign-out - // failed, with a retry the only way back to a defined state. A trap rolls the whole - // message back. storage_borrow_mut(|storage| { storage.revoke_device_sessions(request.identity_number, request.device_id) }) - .expect("failed to sign out a browser of an identity the caller is authorized for"); - Ok(()) + .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 33b3b0c975..23fd851794 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1740,6 +1740,28 @@ impl Storage { }) .collect(); + // Every application this sweep will write is resolved before it writes any of + // them. It is the one failure the loop below could otherwise report, and it would + // report it having already signed the browser out of the applications ahead of + // it — an `Err` commits those — leaving a browser out of some of its apps and + // still in others, told the sign-out failed. + // + // What remains cannot report a failure: removing sessions leaves the reference + // list non-empty and every account number where it was, so the deltas are zero + // and the counter write short-circuits, and the count at the end is this anchor + // read and written back with one `u32` changed. + for (application_number, _) in &affected { + if self + .stable_application_memory + .get(application_number) + .is_none() + { + return Err(StorageError::OriginNotFoundForApplicationNumber { + application_number: *application_number, + }); + } + } + let mut removed = 0u64; for (application_number, mut references) in affected { let mut dropped: Vec<(Option, SessionRecord)> = vec![]; diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 1639ace7fe..db8c55bd35 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -877,4 +877,5 @@ pub struct RevokeDeviceSessionsRequest { #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub enum SessionRevokeError { Unauthorized(Principal), + InternalCanisterError(String), } From 21a13283b5cc4a31a3733f6992d2cc4563b97094 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 20:36:00 +0200 Subject: [PATCH 137/298] fix(fe): name the storage failure signing out can report again Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(authenticated)/settings/sessionDevices.test.ts | 11 +++++++++++ .../manage/(authenticated)/settings/sessionDevices.ts | 8 +++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts index 22c3c2e869..439b99345a 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts @@ -119,6 +119,17 @@ describe("signOutSessionDevice", () => { }); }); + it("surfaces an internal failure", async () => { + const actor = { + revoke_device_sessions: () => + Promise.resolve({ Err: { InternalCanisterError: "boom" } }), + } as unknown as ActorSubclass<_SERVICE>; + + await expect( + signOutSessionDevice(actor, BigInt(10_000), 3), + ).rejects.toThrow("boom"); + }); + it("surfaces an unauthorized refusal", async () => { const actor = { revoke_device_sessions: () => diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts index 1bf964b50d..914b9a94ed 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts @@ -54,10 +54,12 @@ export const signOutSessionDevice = async ( identity_number: identityNumber, device_id: deviceId, }); - // The only refusal the canister can report. A storage failure traps, which arrives as - // a rejected call rather than as an `Err`, so there is nothing else to name here. if ("Err" in result) { - throw new Error("Not authorized to end this browser's sessions"); + throw new Error( + "Unauthorized" in result.Err + ? "Not authorized to end this browser's sessions" + : result.Err.InternalCanisterError, + ); } if ((await currentDeviceId(identityNumber)) === deviceId) { await purgeAppSessions(identityNumber); From c52c336808be84c2fcc5c1df02181c6e206cd8e1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sat, 5 Sep 2026 21:35:20 +0200 Subject: [PATCH 138/298] refactor: derive the session index and count where the row is written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions live on the reference, so a reference that goes takes its sessions with it. What did not follow were the things pointing at them: six call sites each remembered to drop index entries after mutating a row, and two more to move the identity's session count. The gate holds both versions of the list already — it derives the counter deltas from exactly that pair. It now derives the session index changes and the count delta the same way, so no caller states them and none can forget. An account's index entry goes in beside its session's, because a handle that names an account the index cannot resolve is a session nobody can find. session_principal, change_session_count and unindex_sessions have no callers left. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 432 ++++++++++++++++----------- 1 file changed, 251 insertions(+), 181 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 373e7cab32..56cde2e25f 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1727,103 +1727,26 @@ impl Storage { }) .collect(); - let mut removed = 0u64; + // One anchor write for the whole sweep rather than one per application: each row + // reports what it did to the count, and the total is applied once at the end. + let mut delta = 0i64; 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_account_state(anchor_number, application_number, references, None, None)?; - for (account_number, session) in &dropped { - self.unindex_sessions( - anchor_number, - application_number, - *account_number, - std::slice::from_ref(session), - ); + reference + .sessions + .retain(|session| session.device_id != device_id); } + delta += self.write_account_state_deferring_count( + anchor_number, + application_number, + references, + None, + None, + )?; } - if removed > 0 { - self.change_session_count(anchor_number, removed as usize, 0)?; - } - - Ok(removed) - } - - /// 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(&AccountKey { - anchor_number, - origin: self - .stable_application_memory - .get(&application_number)? - .origin - .clone(), - account_number, - })?; - let seed = calculate_session_seed_with_salt( - &salt, - &account.calculate_seed_with_salt(&salt), - session.session_id, - ); - Some(canister_sig_principal(canister_id(), seed.to_vec())) - } + self.apply_session_count_delta(anchor_number, delta)?; - /// Moves the count without considering the cap, for the paths that only remove. - /// - /// This runs after the row writes it describes, which on the IC means an `Err` here - /// would commit them with the count untouched. It cannot report one: the anchor was - /// written by the caller that got here, so the read resolves, and writing it back - /// with one `u32` changed leaves every field the write validates — the email - /// recovery binding included — exactly as it was read. - 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) - } - - /// 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); - } - } + Ok(delta.unsigned_abs()) } // Called by the sign-in ceremony, which lands two PRs up. @@ -1957,45 +1880,10 @@ impl Storage { }); } + // The row is the whole of it: the index entries for the session created here and + // for the ones pruned above, and the identity's session count, all follow from + // the list this writes. self.write_account_state(anchor_number, application_number, references, None, None)?; - for (account_number, session) in &dropped { - self.unindex_sessions( - anchor_number, - application_number, - *account_number, - std::slice::from_ref(session), - ); - } - if let (Some(principal), Some(account_principal)) = ( - self.session_principal(anchor_number, application_number, account_number, &session), - self.account_principal_of(anchor_number, application_number, account_number), - ) { - // The account's own entry goes in alongside the session's. A handle names its - // account by principal, and resolving that principal is a second index lookup — - // one whose entry is written only when a row's set of account numbers changes, - // or by the backfill sweep. Creating a session changes neither, so a session at - // a row that predates the index would resolve to nothing until the sweep - // happened to reach it, which is every returning user of an app they have used - // before, for as long as the sweep takes. - // - // Writing the same value the sweep would write, so the two cannot disagree. - self.lookup_account_with_principal_memory.insert( - account_principal, - StorableAccountKey { - anchor_number, - application_number, - account_number, - }, - ); - self.lookup_session_with_principal_memory.insert( - principal, - StorableSessionHandle { - account_principal: account_principal.as_slice().to_vec(), - session_id, - }, - ); - } - self.change_session_count(anchor_number, dropped.len(), 1)?; let key = SessionRecordKey { anchor_number, @@ -2062,39 +1950,27 @@ impl Storage { // before the first removal so a missing salt refuses with the row intact. let salt = *self.salt().ok_or(StorageError::SaltNotSet)?; let origin = application.origin.clone(); - let dropped: usize = previous - .iter() - .map(|reference| reference.sessions.len()) - .sum(); - - // 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. - // - // Ahead of the counters because a session's principal is derived through the - // application row, and applying the deltas is what retires it — afterwards there - // would be nothing left to derive the keys to remove. - for reference in previous.iter() { - self.unindex_sessions( - anchor_number, - application_number, - reference.account_number, - &reference.sessions, - ); - } + // Removing the row rather than writing one, so this is the one place that says + // what leaves outside the gate — and it says it the same way, by diffing what the + // row held against nothing. + let delta = self.sync_session_index( + anchor_number, + application_number, + &origin, + &salt, + &previous, + &[], + ); // Counters first, for the reason `write_account_state` does it: an error after - // the removes would commit them without it. The session count below moves after - // this, and cannot report a failure of its own — see `change_session_count`. + // the removes would commit them without it. self.apply_reference_counter_deltas( anchor_number, application_number, application, ReferenceListDeltas::removing(&previous), )?; - if dropped > 0 { - self.change_session_count(anchor_number, dropped, 0)?; - } + self.apply_session_count_delta(anchor_number, delta)?; self.sync_account_principal_index( anchor_number, @@ -2210,6 +2086,30 @@ impl Storage { record: Option<(AccountNumber, StorableAccount)>, config: Option, ) -> Result<(), StorageError> { + let delta = self.write_account_state_deferring_count( + anchor_number, + application_number, + references, + record, + config, + )?; + self.apply_session_count_delta(anchor_number, delta) + } + + /// [`Self::write_account_state`], reporting the change in the identity's session + /// count rather than applying it. + /// + /// The delta is still *derived* here — no caller says what it is. What a caller may + /// choose is when to apply it, so an operation spanning rows moves the anchor once + /// instead of once per row. + fn write_account_state_deferring_count( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + references: Vec, + record: Option<(AccountNumber, StorableAccount)>, + config: Option, + ) -> Result { let stored_references = self.stored_account_references(anchor_number, application_number); // Nothing to say: the row already holds these bytes, so it is not written and @@ -2244,19 +2144,41 @@ impl Storage { .iter() .zip(&references) .any(|(previous, new)| previous.account_number != new.account_number); + // A session's principal is derived from its id, so a write that leaves every + // id in place — a refresh stamp, which is the most frequent write there is — + // cannot have changed one. Compared as sets because a ceremony replaces one + // session with another and leaves the count where it was. + let sessions_changed = + Self::session_ids_of(previous) != Self::session_ids_of(&references); // Resolved here, so a missing salt refuses with nothing written rather than // half-way through. - let salt = if accounts_changed { + let salt = if accounts_changed || sessions_changed { Some(*self.salt().ok_or(StorageError::SaltNotSet)?) } else { None }; - Some((storable_references, application, deltas, salt)) + Some(( + storable_references, + application, + deltas, + salt, + accounts_changed, + sessions_changed, + )) }; let mut record = record; - if let Some((storable_references, application, deltas, salt)) = list_write { + let mut session_delta = 0i64; + if let Some(( + storable_references, + application, + deltas, + salt, + accounts_changed, + sessions_changed, + )) = list_write + { let origin = application.origin.clone(); // Counters first: the only step left that can fail, so an out-of-bounds // delta refuses with nothing stored rather than a list without its counts. @@ -2275,14 +2197,27 @@ impl Storage { .insert(account_number, storable_account); } if let Some(salt) = salt { - self.sync_account_principal_index( - anchor_number, - application_number, - &origin, - &salt, - stored_references.as_deref().unwrap_or_default(), - &references, - ); + let previous = stored_references.as_deref().unwrap_or_default(); + if accounts_changed { + self.sync_account_principal_index( + anchor_number, + application_number, + &origin, + &salt, + previous, + &references, + ); + } + if sessions_changed { + session_delta = self.sync_session_index( + anchor_number, + application_number, + &origin, + &salt, + previous, + &references, + ); + } } self.stable_account_reference_list_memory .insert((anchor_number, application_number), storable_references); @@ -2299,7 +2234,42 @@ impl Storage { .insert((anchor_number, application_number), config); } - Ok(()) + Ok(session_delta) + } + + /// Moves the identity's session count by what a write to its rows implied. + /// + /// Cannot report a failure: the caller has already written the anchor to get here, so + /// the read resolves, and writing it back with one `u32` changed leaves every field + /// the write validates — the email recovery binding included — as it was read. + fn apply_session_count_delta( + &mut self, + anchor_number: AnchorNumber, + delta: i64, + ) -> Result<(), StorageError> { + if delta == 0 { + return Ok(()); + } + let mut anchor = self.read(anchor_number)?; + anchor.session_count = if delta < 0 { + anchor + .session_count + .saturating_sub(delta.unsigned_abs() as u32) + } else { + anchor.session_count.saturating_add(delta as u32) + }; + self.write(anchor) + } + + /// The ids of every session a reference list holds, sorted, for comparing two + /// versions of a list. + fn session_ids_of(references: &[AccountReference]) -> Vec { + let mut ids: Vec = references + .iter() + .flat_map(|reference| reference.sessions.iter().map(|session| session.session_id)) + .collect(); + ids.sort_unstable(); + ids } /// [`Self::write_account_state`] for a row the tracked default is the reason for, @@ -2493,6 +2463,119 @@ impl Storage { /// The principals a set of references derives to. A reference whose account row is /// gone derives nothing and is skipped. + /// The account one reference names, built from the reference and the record it + /// points at. + /// + /// Not [`Self::read_account`], which reads the stored list and so answers `None` for + /// a reference that is being removed. This derives from the list it is handed, which + /// is what lets the index be diffed across a write. + fn account_of_reference( + &self, + anchor_number: AnchorNumber, + origin: &FrontendHostname, + reference: &AccountReference, + ) -> Option { + match reference.account_number { + None => Some(Account::new(anchor_number, origin.clone(), None, None)), + Some(account_number) => { + let stored = self.stable_account_memory.get(&account_number)?; + Some(Account::new_full( + anchor_number, + origin.clone(), + Some(stored.name), + Some(account_number), + reference.last_used, + stored.seed_from_anchor, + )) + } + } + } + + /// The session index entries a reference list implies: one per session it holds, + /// each with the account entry its handle needs in order to resolve. + fn session_entries( + &self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + origin: &FrontendHostname, + salt: &[u8; 32], + references: &[AccountReference], + ) -> BTreeMap { + let mut entries = BTreeMap::new(); + for reference in references { + let Some(account) = self.account_of_reference(anchor_number, origin, reference) else { + continue; + }; + let account_seed = account.calculate_seed_with_salt(salt); + let account_principal = + delegation::canister_sig_principal(canister_id(), account_seed.to_vec()); + for session in &reference.sessions { + let seed = + calculate_session_seed_with_salt(salt, &account_seed, session.session_id); + entries.insert( + delegation::canister_sig_principal(canister_id(), seed.to_vec()), + ( + StorableSessionHandle { + account_principal: account_principal.as_slice().to_vec(), + session_id: session.session_id, + }, + StorableAccountKey { + anchor_number, + application_number, + account_number: reference.account_number, + }, + ), + ); + } + } + entries + } + + /// Keeps the session index in step with one reference-list write, and reports what + /// the write does to the identity's session count. + /// + /// Sessions live on the reference, so a reference that goes takes its sessions with + /// it and this sees them as removed without any caller saying so. That is the point: + /// the row and everything derived from it move together, in the one place holding + /// both versions of it. + fn sync_session_index( + &mut self, + anchor_number: AnchorNumber, + application_number: ApplicationNumber, + origin: &FrontendHostname, + salt: &[u8; 32], + previous: &[AccountReference], + current: &[AccountReference], + ) -> i64 { + let before = + self.session_entries(anchor_number, application_number, origin, salt, previous); + let after = self.session_entries(anchor_number, application_number, origin, salt, current); + + for principal in before.keys() { + if !after.contains_key(principal) { + self.lookup_session_with_principal_memory.remove(principal); + } + } + for (principal, (handle, account)) in &after { + if before.contains_key(principal) { + continue; + } + // The account's entry goes in with the session's. A handle names its account + // by principal, and that index gains entries only where a row's set of + // account numbers changes or when the backfill reaches the row — neither of + // which a sign-in does. Without this a session at a row that predates the + // index resolves to nothing until the sweep happens to arrive. + self.lookup_account_with_principal_memory.insert( + Principal::from_slice(&handle.account_principal), + account.clone(), + ); + self.lookup_session_with_principal_memory + .insert(*principal, handle.clone()); + } + + after.len() as i64 - before.len() as i64 + } + fn account_principals( &self, anchor_number: AnchorNumber, @@ -2504,20 +2587,7 @@ impl Storage { references .iter() .filter_map(|reference| { - let account = match reference.account_number { - None => Account::new(anchor_number, origin.clone(), None, None), - Some(account_number) => { - let stored = self.stable_account_memory.get(&account_number)?; - Account::new_full( - anchor_number, - origin.clone(), - Some(stored.name), - Some(account_number), - reference.last_used, - stored.seed_from_anchor, - ) - } - }; + let account = self.account_of_reference(anchor_number, origin, reference)?; let principal = delegation::canister_sig_principal( canister_id(), account.calculate_seed_with_salt(salt).to_vec(), From b504a93a75046fe6dbc36a8ded52637c3a11e0de Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:43:27 +0200 Subject: [PATCH 139/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it twice, in prose, for something else entirely. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 2 +- src/internet_identity/src/storage.rs | 50 ++++++++++--------- .../storable/account_reference_list.rs | 9 ++-- src/internet_identity/src/storage/tests.rs | 19 +++---- 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 61f85617a8..7a067ca2be 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -365,7 +365,7 @@ pub async fn prepare_account_delegation( // Stamped before the delegation is signed. On the IC returning `Err` commits // every write that came before it, so propagating a failure from here once the // signature was in the map would report an error for a delegation that has - // already been issued. `Ok(None)` is not a failure: it means the row holds no + // already been issued. `Ok(None)` is not a failure: it means the list holds no // reference to stamp, which is how a default account that is still derived rather // than stored reads. storage_borrow_mut(|storage| { diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 18beb0b630..2eb4c6538c 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1529,10 +1529,10 @@ impl Storage { } /// This identity's account references at `application_number`, or `None` where it - /// has no row there at all. + /// has no account reference list there at all. /// /// The one reader, so no caller has to assemble the list from storage itself. The - /// `None` is only ever "no row": an empty row is a tombstone and means the + /// `None` is only ever "no list": an empty list is a tombstone and means the /// opposite, so the two must not be collapsed by a caller either. fn account_references( &self, @@ -1548,7 +1548,8 @@ impl Storage { /// /// `account_number` names the reference, `None` being the tracked default. /// Answering `None` is the ownership check: an account belongs to whichever - /// identity's row names it, so a caller that finds no reference here has no claim + /// identity's account reference list names it, so a caller that finds no reference + /// here has no claim /// on the account whether or not it exists. fn account_reference( &self, @@ -1561,13 +1562,13 @@ impl Storage { .find(|reference| reference.account_number == account_number) } - /// Applies `f` to one of this identity's account references, and writes the row + /// Applies `f` to one of this identity's account references, and writes the list /// back if it ran. /// /// `account_number` names the reference, `None` being the tracked default. /// /// `Ok(None)` means there was nothing to apply `f` to and nothing was written: - /// the row is absent or a tombstone, or it holds no reference for this account — + /// the list is absent or a tombstone, or it holds no reference for this account — /// see [`Self::account_reference`] for what that last case means. fn with_account_reference_mut( &mut self, @@ -1588,7 +1589,7 @@ impl Storage { .iter_mut() .find(|reference| reference.account_number == account_number) else { - // `f` never ran, so nothing changed, and writing the row back here would + // `f` never ran, so nothing changed, and writing the list back here would // store the bytes it already holds. return Ok(None); }; @@ -1607,7 +1608,7 @@ impl Storage { now: Timestamp, ) -> Result, StorageError> { // An origin nothing has ever been stored under holds no reference to stamp, - // which is the same answer as a row that holds no reference for this account. + // which is the same answer as a list that holds no reference for this account. let Some(application_number) = self.lookup_application_number_with_origin(&origin) else { return Ok(None); }; @@ -1905,8 +1906,9 @@ impl Storage { // last_used will be set once the user signs in with the account. let last_used = None; - // With no row yet the default account reference is created alongside this one, - // because default accounts are never created explicitly. An existing row is + // With no account reference list yet the default account reference is created + // alongside this one, because default accounts are never created explicitly. An + // existing list is // added to as it stands: a tombstone must not regain a default reference, which // is the whole reason it is kept. let mut references = self @@ -1949,7 +1951,7 @@ impl Storage { return vec![Account::synthetic(anchor_number, origin.clone())]; }; - // An empty row is a tombstone: everything here moved away, so not even a + // An empty list is a tombstone: everything here moved away, so not even a // synthetic default is offered — that is what it exists to prevent. Its empty // list falls out of the iteration below. match self.account_references(anchor_number, application_number) { @@ -1992,7 +1994,7 @@ impl Storage { Some(_) => None, }; }; - // No row: nothing has ever happened at this origin. + // No account reference list: nothing has ever happened at this origin. let Some(references) = self.account_references(params.anchor_number, application_number) else { return match params.account_number { @@ -2004,7 +2006,7 @@ impl Storage { match params.account_number { // The tracked default. None => { - // XXX WARNING: an empty row is a tombstone — the default moved away — + // XXX WARNING: an empty list is a tombstone — the default moved away — // so answering with a synthetic one lets its former owner reconstruct // it at the same principal. Kept for now because refusing would lock // out an identity that moved its default away and then reached the @@ -2013,7 +2015,7 @@ impl Storage { return Some(synthetic_default()); } - // A row that names other accounts but not the default means the default + // A list that names other accounts but not the default means the default // was named or moved away; the identity signs in with one of the others. references .iter() @@ -2028,8 +2030,8 @@ impl Storage { ) }) } - // A named account. The stored record carries its name; this identity's row - // naming it is what says the identity owns it. + // A named account. The stored record carries its name; this identity's + // account reference list naming it is what says the identity owns it. Some(account_number) => { let storable_account = self.stable_account_memory.get(&account_number)?; references @@ -2110,7 +2112,7 @@ impl Storage { }; // Only the account record is written. Renaming leaves every reference as it - // was, and the row is a single blob, so writing it back would store the bytes + // was, and the list is a single blob, so writing it back would store the bytes // it already holds. storable_account.name = name.clone(); self.stable_account_memory @@ -2149,7 +2151,7 @@ impl Storage { .and_then(|application_number| { self.account_references(anchor_number, application_number) }) { - // Nothing stored under this origin yet, so the row starts with just this + // Nothing stored under this origin yet, so the list starts with just this // account. Default accounts are never created explicitly. None => None, Some(references) @@ -2159,7 +2161,7 @@ impl Storage { { Some(references) } - // An empty row is a tombstone and holds nothing to name, and a row whose + // An empty list is a tombstone and holds nothing to name, and a list whose // default reference is gone never regains one — it was named or moved away. Some(_) => { return Err(StorageError::MissingAccount { @@ -2469,7 +2471,7 @@ impl Storage { /// Which of the counters derived from a reference list a delta is applied to. /// -/// Each carries what identifies its row, so a refusal points at the counter that +/// Each carries what identifies its list, so a refusal points at the counter that /// diverged rather than only saying that one did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReferenceCounter { @@ -2513,7 +2515,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list row moves the counters derived from it. +/// How one write to an account reference list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. @@ -2529,11 +2531,11 @@ struct ReferenceListDeltas { impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// - /// A row that does not exist and one holding nothing both count as no references, + /// A list that does not exist and one holding nothing both count as no references, /// which is right for these totals: neither contributes any. It is also why - /// retiring a row must not go through here — a tombstone's row is still alive while + /// retiring a list must not go through here — a tombstone's list is still alive while /// holding nothing, so a diff against it would report no change and leave the - /// counters claiming references the removed row no longer has. + /// counters claiming references the removed list no longer has. fn between( previous_references: &[AccountReference], new_references: &[AccountReference], @@ -2570,7 +2572,7 @@ impl ReferenceListDeltas { /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references - /// this application any more", which retires a row other anchors still point at. + /// this application any more", which retires a list other anchors still point at. fn apply( &self, counter: ReferenceCounter, diff --git a/src/internet_identity/src/storage/storable/account_reference_list.rs b/src/internet_identity/src/storage/storable/account_reference_list.rs index 224ebbf0e8..997501dc1f 100644 --- a/src/internet_identity/src/storage/storable/account_reference_list.rs +++ b/src/internet_identity/src/storage/storable/account_reference_list.rs @@ -29,8 +29,9 @@ impl Storable for StorableAccountReferenceList { /// Why a list of account references cannot be stored. /// /// Only ever raised on the way in. Decoding stays infallible, so a rule added here -/// applies to every future write of an existing row as well as to new ones — a stored -/// row that broke one would become unwritable, and for a reference list that means an +/// applies to every future write of an existing list as well as to new ones — a stored +/// list that broke one would become unwritable, and for an account reference list that +/// means an /// identity locked out of the origin. So the rules here are limited to states nothing /// has ever written. #[derive(Debug, Eq, PartialEq)] @@ -59,7 +60,7 @@ impl StorableAccountReferenceList { self.0 } - /// The row a future account move will leave behind, for tests that need one to + /// The list a future account move will leave behind, for tests that need one to /// exist. Test-only because [`Self::try_from`] refuses it, which is the point. #[cfg(test)] pub fn tombstone_for_testing() -> Self { @@ -130,7 +131,7 @@ mod tests { #[test] fn a_list_without_a_tracked_default_is_storable() { - // Not a tombstone: the default was named, so the row legitimately holds only + // Not a tombstone: the default was named, so the list legitimately holds only // numbered references. assert!(StorableAccountReferenceList::try_from(vec![reference(Some(7))]).is_ok()); } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index eeb4e21b35..de8e7bf9a4 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2211,7 +2211,7 @@ mod reference_list_write_path_tests { // Refused before anything was written: the list still holds both references. let references = storage .account_references(anchor_number, application_number) - .expect("the row written above is gone"); + .expect("the list written above is gone"); assert_eq!(references.len(), 2); } @@ -2471,7 +2471,8 @@ mod reference_list_write_path_tests { } } -/// A `(anchor, application)` row can be absent, empty, or hold references, and those +/// A `(anchor, application)` account reference list can be absent, empty, or hold +/// account references, and those /// mean three different things. Absence says a default account is still /// reconstructible; emptiness is a tombstone and says it never can be again. mod account_reference_state_tests { @@ -2495,7 +2496,7 @@ mod account_reference_state_tests { (storage, anchor_number) } - /// Plants the row a future account move would leave behind. The write path cannot + /// Plants the list a future account move would leave behind. The write path cannot /// store one, which is the whole point, so a test that /// needs a tombstone has to write it directly. fn plant_tombstone(storage: &mut Storage, anchor_number: AnchorNumber) { @@ -2684,7 +2685,7 @@ mod account_reference_state_tests { .unwrap(); let references = storage .account_references(anchor_number, application_number) - .expect("naming the default should not have emptied the row"); + .expect("naming the default should not have emptied the list"); // Repointed where it stood, keeping the order accounts are listed in and the // timestamp the reference already carried. assert_eq!( @@ -2721,8 +2722,8 @@ mod account_reference_state_tests { .account_references(anchor_number, application_number) .unwrap(); - // A skipped write is invisible in the stored bytes, since rewriting the row - // would store what it already holds. Retiring the application row makes it + // A skipped write is invisible in the stored bytes, since rewriting the list + // would store what it already holds. Retiring the application makes it // visible: `write_reference_list` refuses without one, so a rename that still // went through it could not succeed here. storage @@ -2763,9 +2764,9 @@ mod account_reference_state_tests { }) .unwrap(); let account_number = account.account_number.unwrap(); - // The other identity has a row of its own at this origin, so what refuses the - // attempts below is the row not naming this account rather than there being no - // row to look in. + // The other identity has a list of its own at this origin, so what refuses the + // attempts below is the list not naming this account rather than there being no + // list to look in. storage .create_additional_account(CreateAccountParams { anchor_number: other, From aa660bd7c3d8eebd87d67af9c158b80ecfae11a3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:27 +0200 Subject: [PATCH 140/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 14 +++++++------- src/internet_identity/src/storage/tests.rs | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 7025d6f2ed..cbeb593d4f 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1526,15 +1526,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's row is retired. Its + /// passed is never offered again even after the application's list is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest row walks it backwards. + /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest list walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the - /// application row and the origin index, so reissuing one would put two origins on - /// a single row and have them share its accounts and counters. + /// application and the origin index, so reissuing one would put two origins on + /// a single list and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -2699,8 +2699,8 @@ pub enum StorageError { ErrorUpdatingAccountCounter, AccountsCounterOverflow, /// No application numbers left to hand out. Refused rather than saturated: the - /// number keys the application row and the origin index, so reissuing one would - /// put two origins on a single row. + /// number keys the application and the origin index, so reissuing one would + /// put two origins on a single list. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, /// The references a write assembled cannot be stored as they stand. diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index a8773f62c3..5c35a63295 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2920,8 +2920,8 @@ mod application_number_allocator_tests { .stable_application_memory .insert(number, application(origin)); } - // The rows now have a hole in them while the counter knows nothing, which is - // the one state a row count gets wrong: it would answer 2, the number + // The lists now have a hole in them while the counter knows nothing, which is + // the one state a list count gets wrong: it would answer 2, the number // `https://c.com` still holds. storage.stable_application_memory.remove(&0); storage.next_application_number_memory.set(0).unwrap(); From 78fbf0000daaa0bc053d0dad6be4a420c8f825ff Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:28 +0200 Subject: [PATCH 141/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 4 +- src/internet_identity/src/storage.rs | 58 +++++++++---------- src/internet_identity/src/storage/account.rs | 2 +- .../src/storage/account/tests.rs | 2 +- src/internet_identity/src/storage/tests.rs | 26 ++++----- 5 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 00112c0cf5..04dca8650f 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -80,7 +80,7 @@ pub fn get_account_for_origin( account_number: Option, ) -> Result { // Including the tracked default, which the storage read answers for: it is the - // identity's only where its row still names it, and no account at all where it was + // identity's only where its list still names it, and no account at all where it was // named or moved away. if let Ok(account) = try_read_account(anchor_number, &origin, account_number) { return Ok(account); @@ -1150,7 +1150,7 @@ fn should_fall_back_to_the_tracked_default_when_the_reservation_is_stale() { use ic_stable_structures::VectorMemory; let mut storage = Storage::new((0, 10000), VectorMemory::default()); - // A write of this row derives account principals on later branches, so the salt has + // A write of this list derives account principals on later branches, so the salt has // to be there for the same test to hold all the way up the stack. storage.update_salt([17u8; 32]); storage_replace(storage); diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 4514a77600..dd48e7e3c3 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1523,15 +1523,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's row is retired. Its + /// passed is never offered again even after the application's list is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest row walks it backwards. + /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest list walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the - /// application row and the origin index, so reissuing one would put two origins on - /// a single row and have them share its accounts and counters. + /// application and the origin index, so reissuing one would put two origins on + /// a single list and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -1576,9 +1576,9 @@ impl Storage { /// This identity's account references at `application_number`. /// - /// An absent row normalises to the derived default: nothing has happened at this + /// An absent list normalises to the derived default: nothing has happened at this /// origin, so the identity still has the default it has always had. A stored empty - /// row is a tombstone and stays empty — everything here moved away and the default + /// list is a tombstone and stays empty — everything here moved away and the default /// must never be derived again. /// /// Absence and emptiness are opposites, and this is the only place that knows it. @@ -1592,7 +1592,7 @@ impl Storage { } /// [`Self::account_references`] for a caller that has an origin rather than an - /// application number. An origin nothing has ever been stored under has no row, so + /// application number. An origin nothing has ever been stored under has no list, so /// it normalises the same way. fn account_references_for_origin( &self, @@ -1614,9 +1614,9 @@ impl Storage { }] } - /// The row as stored, with no default derived for an absent one. + /// The list as stored, with no default derived for an absent one. /// - /// Only the write path may see this. The counters describe stored rows, so a row + /// Only the write path may see this. The counters describe stored lists, so a list /// that never existed must not be diffed against as though it held the default. fn stored_account_references( &self, @@ -1652,10 +1652,10 @@ impl Storage { /// on the IC returning `Err` commits what was written before it, and only a trap /// rolls back, so a refusal here must leave nothing behind. /// - /// `references` is the whole new list. It is diffed against the stored row for the + /// `references` is the whole new list. It is diffed against the stored list for the /// counter deltas, so a caller supplies only what it wants stored and cannot move a /// counter by the default [`Self::account_references`] derived for it. A list equal - /// to the stored row writes nothing: the row is a single blob, so writing it back + /// to the stored list writes nothing: the list is a single blob, so writing it back /// would store the bytes it already holds. fn write_account_state( &mut self, @@ -1667,7 +1667,7 @@ impl Storage { ) -> Result<(), StorageError> { let stored_references = self.stored_account_references(anchor_number, application_number); - // Nothing to say: the row already holds these bytes, so it is not written and + // Nothing to say: the list already holds these bytes, so it is not written and // nothing about it is checked either. This is what lets a rename leave every // reference alone without its caller having to know to skip the write. let list_write = if stored_references.as_deref() == Some(references.as_slice()) { @@ -1686,9 +1686,9 @@ impl Storage { .stable_application_memory .get(&application_number) .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; - // A first row holding nothing but a derived default records nothing: + // A first list holding nothing but a derived default records nothing: // absence already says the identity has its default here, so storing it - // would keep bytes to repeat that. Only an account number is worth a row. + // would keep bytes to repeat that. Only an account number is worth a list. // Checked after the refusals above, so a caller still hears about a list or // an application it had no business passing. let records_nothing = stored_references.is_none() @@ -1918,7 +1918,7 @@ impl Storage { /// One account this identity holds at `key.origin`, or `None` where it holds none. /// /// `key.account_number` names it, `None` being the tracked default. Answering - /// `None` is the ownership check: an account belongs to whichever identity's row + /// `None` is the ownership check: an account belongs to whichever identity's list /// names it, so a caller that finds no reference here has no claim on the account /// whether or not it exists. /// @@ -1999,7 +1999,7 @@ impl Storage { let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; let account_number = self.allocate_account_number()?; - // An absent row normalises to the derived default, which is how the first named + // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. let mut references = self.account_references(anchor_number, application_number); @@ -2033,7 +2033,7 @@ impl Storage { /// /// Renaming one, naming the tracked default, and recording that an account was used /// are the same read-modify-write: the account is the state to store, not a patch - /// over it, so what it carries is what the row ends up holding. + /// over it, so what it carries is what the list ends up holding. /// /// A number no reference names is [`StorageError::AccountNotFound`] and never a /// create. `update_account_for_origin` takes its account number straight from the @@ -2055,7 +2055,7 @@ impl Storage { // Naming the tracked default stores this identity's first account here, so // the origin gets its application number now. (None, Some(_)) => self.lookup_or_insert_application_number_with_origin(&origin)?, - // Everything else writes to a row that already exists, and an origin + // Everything else writes to a list that already exists, and an origin // nothing has been stored under has none. _ => match self.lookup_application_number_with_origin(&origin) { Some(application_number) => application_number, @@ -2084,7 +2084,7 @@ impl Storage { .position(|reference| reference.account_number == account_number) else { // Holding a reference is what grants access, so a miss means this identity - // does not have the account. For the tracked default it means the row is a + // does not have the account. For the tracked default it means the list is a // tombstone or the default was named and is no longer numberless — neither // can be reconstructed from the origin. return Err(match account_number { @@ -2128,7 +2128,7 @@ impl Storage { ) } // The tracked default, unnamed: nothing to store but the use of a - // reference the row already holds. + // reference the list already holds. (None, None) => { self.write_account_state( anchor_number, @@ -2438,7 +2438,7 @@ impl Storage { /// Which of the counters derived from a reference list a delta is applied to. /// -/// Each carries what identifies its row, so a refusal points at the counter that +/// Each carries what identifies its list, so a refusal points at the counter that /// diverged rather than only saying that one did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReferenceCounter { @@ -2482,7 +2482,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list row moves the counters derived from it. +/// How one write to a reference-list list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. @@ -2498,11 +2498,11 @@ struct ReferenceListDeltas { impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// - /// A row that does not exist and one holding nothing both count as no references, + /// A list that does not exist and one holding nothing both count as no references, /// which is right for these totals: neither contributes any. It is also why - /// retiring a row must not go through here — a tombstone's row is still alive while + /// retiring a list must not go through here — a tombstone's list is still alive while /// holding nothing, so a diff against it would report no change and leave the - /// counters claiming references the removed row no longer has. + /// counters claiming references the removed list no longer has. fn between( previous_references: &[AccountReference], new_references: &[AccountReference], @@ -2539,7 +2539,7 @@ impl ReferenceListDeltas { /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references - /// this application any more", which retires a row other anchors still point at. + /// this application any more", which retires a list other anchors still point at. fn apply( &self, counter: ReferenceCounter, @@ -2612,8 +2612,8 @@ pub enum StorageError { ErrorUpdatingAccountCounter, AccountsCounterOverflow, /// No application numbers left to hand out. Refused rather than saturated: the - /// number keys the application row and the origin index, so reissuing one would - /// put two origins on a single row. + /// number keys the application and the origin index, so reissuing one would + /// put two origins on a single list. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, /// The references a write assembled cannot be stored as they stand. diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 6c2d155e95..ebaa86907f 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -57,7 +57,7 @@ impl Account { /// /// Test-only. In production every account comes out of /// [`crate::storage::Storage::read_account`], which builds this one only where the - /// identity's row still names it — a derived default handed out without that check + /// identity's list still names it — a derived default handed out without that check /// would sign for an origin the identity may have moved every account away from. #[cfg(test)] pub fn synthetic(anchor_number: AnchorNumber, origin: FrontendHostname) -> Self { diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 82a0773497..ecabe384ee 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -507,7 +507,7 @@ fn should_read_a_synthetic_default_account_when_no_reference_list_exists() { let anchor_number: AnchorNumber = 10_000; let origin: FrontendHostname = "https://some.origin".to_string(); - // The origin is known, but this identity has no row under it. + // The origin is known, but this identity has no list under it. storage .lookup_or_insert_application_number_with_origin(&origin) .unwrap(); diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 162b57992d..77be057857 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -468,7 +468,7 @@ fn should_not_store_a_row_to_record_use_of_a_derived_default() { storage.write_account(account).unwrap(); // Nothing is stored at this origin, so the default here is still derived from it. - // A row saying only that it was used would keep bytes to repeat what absence + // A list saying only that it was used would keep bytes to repeat what absence // already says, so none is written and the read is unchanged. assert_eq!(storage.read_account(&key).unwrap().last_used, None); assert!(storage @@ -486,7 +486,7 @@ fn should_record_that_a_tracked_default_was_used() { let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // A named account gives the origin a row, which the default is tracked in. + // A named account gives the origin a list, which the default is tracked in. storage .create_account(anchor_number, origin.clone(), "Test Account".to_string()) .unwrap(); @@ -2204,7 +2204,7 @@ mod reference_list_write_path_tests { // Refused before anything was written: the list still holds both references. let references = storage .stored_account_references(anchor_number, application_number) - .expect("the row written above is gone"); + .expect("the list written above is gone"); assert_eq!(references.len(), 2); } @@ -2508,7 +2508,7 @@ mod reference_list_write_path_tests { } } -/// A `(anchor, application)` row can be absent, empty, or hold references, and those +/// A `(anchor, application)` list can be absent, empty, or hold references, and those /// mean three different things. Absence says a default account is still /// reconstructible; emptiness is a tombstone and says it never can be again. mod account_reference_state_tests { @@ -2530,7 +2530,7 @@ mod account_reference_state_tests { (storage, anchor_number) } - /// Plants the row a future account move would leave behind. The write path cannot + /// Plants the list a future account move would leave behind. The write path cannot /// store one, which is the whole point, so a test that /// needs a tombstone has to write it directly. fn plant_tombstone(storage: &mut Storage, anchor_number: AnchorNumber) { @@ -2709,7 +2709,7 @@ mod account_reference_state_tests { .unwrap(); let references = storage .stored_account_references(anchor_number, application_number) - .expect("naming the default should not have emptied the row"); + .expect("naming the default should not have emptied the list"); // Repointed where it stood, keeping the order accounts are listed in and the // timestamp the reference already carried. assert_eq!( @@ -2742,8 +2742,8 @@ mod account_reference_state_tests { .stored_account_references(anchor_number, application_number) .unwrap(); - // A skipped write is invisible in the stored bytes, since rewriting the row - // would store what it already holds. Retiring the application row makes it + // A skipped write is invisible in the stored bytes, since rewriting the list + // would store what it already holds. Retiring the application makes it // visible: `write_account_state` refuses without one, so a rename that still // wrote the list could not succeed here. storage @@ -2781,9 +2781,9 @@ mod account_reference_state_tests { .create_account(owner, origin.clone(), "named".to_string()) .unwrap(); let account_number = account.account_number.unwrap(); - // The other identity has a row of its own at this origin, so what refuses the - // attempts below is the row not naming this account rather than there being no - // row to look in. + // The other identity has a list of its own at this origin, so what refuses the + // attempts below is the list not naming this account rather than there being no + // list to look in. storage .create_account(other, origin.clone(), "mine".to_string()) .unwrap(); @@ -2902,8 +2902,8 @@ mod application_number_allocator_tests { .stable_application_memory .insert(number, application(origin)); } - // The rows now have a hole in them while the counter knows nothing, which is - // the one state a row count gets wrong: it would answer 2, the number + // The lists now have a hole in them while the counter knows nothing, which is + // the one state a list count gets wrong: it would answer 2, the number // `https://c.com` still holds. storage.stable_application_memory.remove(&0); storage.next_application_number_memory.set(0).unwrap(); From 52e4728dad8eaff1fcc548dfdfe190a7654655ed Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:28 +0200 Subject: [PATCH 142/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 88 +++++++++---------- .../src/storage/storable/application.rs | 2 +- src/internet_identity/src/storage/tests.rs | 50 +++++------ 3 files changed, 70 insertions(+), 70 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 9807c32a8f..a11712804d 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -299,7 +299,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list rows that hold nothing but a tracked default +/// Per-anchor cap on reference-list lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -1535,15 +1535,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's row is retired. Its + /// passed is never offered again even after the application's list is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest row walks it backwards. + /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest list walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the - /// application row and the origin index, so reissuing one would put two origins on - /// a single row and have them share its accounts and counters. + /// application and the origin index, so reissuing one would put two origins on + /// a single list and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -1588,9 +1588,9 @@ impl Storage { /// This identity's account references at `application_number`. /// - /// An absent row normalises to the derived default: nothing has happened at this + /// An absent list normalises to the derived default: nothing has happened at this /// origin, so the identity still has the default it has always had. A stored empty - /// row is a tombstone and stays empty — everything here moved away and the default + /// list is a tombstone and stays empty — everything here moved away and the default /// must never be derived again. /// /// Absence and emptiness are opposites, and this is the only place that knows it. @@ -1604,7 +1604,7 @@ impl Storage { } /// [`Self::account_references`] for a caller that has an origin rather than an - /// application number. An origin nothing has ever been stored under has no row, so + /// application number. An origin nothing has ever been stored under has no list, so /// it normalises the same way. fn account_references_for_origin( &self, @@ -1626,9 +1626,9 @@ impl Storage { }] } - /// The row as stored, with no default derived for an absent one. + /// The list as stored, with no default derived for an absent one. /// - /// Only the write path may see this. The counters describe stored rows, so a row + /// Only the write path may see this. The counters describe stored lists, so a list /// that never existed must not be diffed against as though it held the default. fn stored_account_references( &self, @@ -1640,20 +1640,20 @@ impl Storage { .map(Vec::::from) } - /// Removes a reference-list row and everything derived from it. + /// Removes a reference-list list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, ) -> Result<(), StorageError> { - // A row is retired only when a live tracked default is all it holds. Nothing + // A list is retired only when a live tracked default is all it holds. Nothing // else may be pruned, and the rule sits here rather than only in the caller // that picks victims, because this is the irreversible step: // - // - an absent row has nothing to remove; - // - an empty row is a tombstone, and taking it away would make the default it + // - an absent list has nothing to remove; + // - an empty list is a tombstone, and taking it away would make the default it // stands for reconstructible again; - // - a row holding named accounts, or whose default was named or moved away, + // - a list holding named accounts, or whose default was named or moved away, // would lose references that nothing else records. let previous = match self .stored_account_references(anchor_number, application_number) @@ -1705,7 +1705,7 @@ impl Storage { .collect() } - /// Upper bound on an anchor's evictable rows, from counters that already exist. + /// Upper bound on an anchor's evictable lists, from counters that already exist. fn tracked_default_account_upper_bound(&self, anchor_number: AnchorNumber) -> u64 { let counter = self.get_account_counter(anchor_number); counter @@ -1770,10 +1770,10 @@ impl Storage { /// on the IC returning `Err` commits what was written before it, and only a trap /// rolls back, so a refusal here must leave nothing behind. /// - /// `references` is the whole new list. It is diffed against the stored row for the + /// `references` is the whole new list. It is diffed against the stored list for the /// counter deltas, so a caller supplies only what it wants stored and cannot move a /// counter by the default [`Self::account_references`] derived for it. A list equal - /// to the stored row writes nothing: the row is a single blob, so writing it back + /// to the stored list writes nothing: the list is a single blob, so writing it back /// would store the bytes it already holds. fn write_account_state( &mut self, @@ -1785,7 +1785,7 @@ impl Storage { ) -> Result<(), StorageError> { let stored_references = self.stored_account_references(anchor_number, application_number); - // Nothing to say: the row already holds these bytes, so it is not written and + // Nothing to say: the list already holds these bytes, so it is not written and // nothing about it is checked either. This is what lets a rename leave every // reference alone without its caller having to know to skip the write. let list_write = if stored_references.as_deref() == Some(references.as_slice()) { @@ -1834,10 +1834,10 @@ impl Storage { Ok(()) } - /// [`Self::write_account_state`] for a row the tracked default is the reason for, + /// [`Self::write_account_state`] for a list the tracked default is the reason for, /// reaping idle ones where this took the anchor over the cap. /// - /// Only a row that did not exist can take it over: stamping or repointing one + /// Only a list that did not exist can take it over: stamping or repointing one /// leaves the count where it was, so there would be nothing to reap. fn write_tracked_default( &mut self, @@ -1920,7 +1920,7 @@ impl Storage { }, ); // A zero here now means what it says. The delta refuses rather than clamping, so - // the row is only retired when no anchor references it, not when a counter that + // the list is only retired when no anchor references it, not when a counter that // had already drifted was pulled below zero. // // Tombstones count too, and they are the reason references alone are not enough: @@ -2080,7 +2080,7 @@ impl Storage { /// One account this identity holds at `key.origin`, or `None` where it holds none. /// /// `key.account_number` names it, `None` being the tracked default. Answering - /// `None` is the ownership check: an account belongs to whichever identity's row + /// `None` is the ownership check: an account belongs to whichever identity's list /// names it, so a caller that finds no reference here has no claim on the account /// whether or not it exists. /// @@ -2161,7 +2161,7 @@ impl Storage { let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; let account_number = self.allocate_account_number()?; - // An absent row normalises to the derived default, which is how the first named + // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. let mut references = self.account_references(anchor_number, application_number); @@ -2195,7 +2195,7 @@ impl Storage { /// /// Renaming one, naming the tracked default, and recording that an account was used /// are the same read-modify-write: the account is the state to store, not a patch - /// over it, so what it carries is what the row ends up holding. + /// over it, so what it carries is what the list ends up holding. /// /// A number no reference names is [`StorageError::AccountNotFound`] and never a /// create. `update_account_for_origin` takes its account number straight from the @@ -2217,7 +2217,7 @@ impl Storage { // The tracked default is stored the first time it is named or used, so its // origin gets an application number on either. None => self.lookup_or_insert_application_number_with_origin(&origin)?, - // A stored account writes to a row that already exists, and an origin + // A stored account writes to a list that already exists, and an origin // nothing has been stored under has none. Some(account_number) => self .lookup_application_number_with_origin(&origin) @@ -2230,7 +2230,7 @@ impl Storage { .position(|reference| reference.account_number == account_number) else { // Holding a reference is what grants access, so a miss means this identity - // does not have the account. For the tracked default it means the row is a + // does not have the account. For the tracked default it means the list is a // tombstone or the default was named and is no longer numberless — neither // can be reconstructed from the origin. return Err(match account_number { @@ -2274,7 +2274,7 @@ impl Storage { ) } // The tracked default, unnamed: nothing to store but the use of a - // reference the row already holds. + // reference the list already holds. (None, None) => { self.write_tracked_default(anchor_number, application_number, references, None)?; return Ok(Account::new_with_last_used( @@ -2577,7 +2577,7 @@ impl Storage { /// Which of the counters derived from a reference list a delta is applied to. /// -/// Each carries what identifies its row, so a refusal points at the counter that +/// Each carries what identifies its list, so a refusal points at the counter that /// diverged rather than only saying that one did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReferenceCounter { @@ -2624,7 +2624,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list row moves the counters derived from it. +/// How one write to a reference-list list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. @@ -2635,19 +2635,19 @@ struct ReferenceListDeltas { accounts: i64, /// Change in references, named and tracked-default alike. references: i64, - /// Change in rows that exist while holding no reference. Only ever -1, 0 or 1: one - /// write touches one row. + /// Change in lists that exist while holding no reference. Only ever -1, 0 or 1: one + /// write touches one list. tombstones: i64, } impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// - /// A row that does not exist and one holding nothing both count as no references, + /// A list that does not exist and one holding nothing both count as no references, /// which is right for these totals: neither contributes any. It is also why - /// retiring a row must not go through here — a tombstone's row is still alive while + /// retiring a list must not go through here — a tombstone's list is still alive while /// holding nothing, so a diff against it would report no change and leave the - /// counters claiming references the removed row no longer has. + /// counters claiming references the removed list no longer has. fn between( previous_references: Option<&[AccountReference]>, new_references: &[AccountReference], @@ -2670,7 +2670,7 @@ impl ReferenceListDeltas { let (previous_named, previous_total) = counts(previous_references.unwrap_or_default()); let (new_named, new_total) = counts(new_references); - // A row that does not exist is not a tombstone — a tombstone is a row someone + // A list that does not exist is not a tombstone — a tombstone is a list someone // stored, and absence is what normalisation reads as "derive the default". let was_tombstone = previous_references.is_some_and(<[_]>::is_empty); let is_tombstone = new_references.is_empty(); @@ -2687,19 +2687,19 @@ impl ReferenceListDeltas { } } - /// What retiring a row holding `previous` does to the counters. + /// What retiring a list holding `previous` does to the counters. /// /// Separate from [`Self::between`] rather than a write of an empty list, because - /// an empty list cannot be written at all: a row holding nothing is a tombstone + /// an empty list cannot be written at all: a list holding nothing is a tombstone /// and stays, so only an outright removal gets to zero these out. fn removing(previous: &[AccountReference]) -> Self { let removed = Self::between(Some(&[]), previous); Self { accounts: removed.accounts.saturating_neg(), references: removed.references.saturating_neg(), - // The row is gone, so a tombstone goes with it. Not the negation of what + // The list is gone, so a tombstone goes with it. Not the negation of what // `between` reported: that describes writing this list, and this describes - // removing the row it was in. + // removing the list it was in. tombstones: if previous.is_empty() { -1 } else { 0 }, } } @@ -2712,7 +2712,7 @@ impl ReferenceListDeltas { /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references - /// this application any more", which retires a row other anchors still point at. + /// this application any more", which retires a list other anchors still point at. fn apply( &self, counter: ReferenceCounter, @@ -2786,8 +2786,8 @@ pub enum StorageError { ErrorUpdatingAccountCounter, AccountsCounterOverflow, /// No application numbers left to hand out. Refused rather than saturated: the - /// number keys the application row and the origin index, so reissuing one would - /// put two origins on a single row. + /// number keys the application and the origin index, so reissuing one would + /// put two origins on a single list. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, /// The references a write assembled cannot be stored as they stand. diff --git a/src/internet_identity/src/storage/storable/application.rs b/src/internet_identity/src/storage/storable/application.rs index a996a08290..e2da18e453 100644 --- a/src/internet_identity/src/storage/storable/application.rs +++ b/src/internet_identity/src/storage/storable/application.rs @@ -19,7 +19,7 @@ pub struct StorableApplication { pub stored_account_references: u64, /// Rows that exist here while holding no reference at all. /// - /// A row holding nothing is a tombstone: it says every account an identity had at + /// A list holding nothing is a tombstone: it says every account an identity had at /// this origin was moved away and its default must never be derived again. It /// contributes nothing to `stored_account_references`, so without counting it /// separately this application would look unreferenced and be retired — and the next diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f37b2161fd..fd7499ddc1 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -39,7 +39,7 @@ fn record_use( const HEADER_SIZE: usize = 58; -/// The references a row holds, for assertions that go on to index them. +/// The references a list holds, for assertions that go on to index them. fn held_references( storage: &Storage, anchor_number: AnchorNumber, @@ -47,7 +47,7 @@ fn held_references( ) -> Vec { storage .stored_account_references(anchor_number, application_number) - .expect("expected a row holding references, found none") + .expect("expected a list holding references, found none") } #[test] @@ -501,7 +501,7 @@ fn should_track_the_default_account_on_first_use() { storage.write_account(account).unwrap(); // Nothing was stored at this origin, so recording the use is what gives the - // default a row: the timestamp has nowhere else to live. + // default a list: the timestamp has nowhere else to live. assert_eq!( storage.read_account(&key).unwrap().last_used, Some(timestamp) @@ -528,7 +528,7 @@ fn should_record_that_a_tracked_default_was_used() { let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); - // A named account gives the origin a row, which the default is tracked in. + // A named account gives the origin a list, which the default is tracked in. storage .create_account(anchor_number, origin.clone(), "Test Account".to_string()) .unwrap(); @@ -2289,7 +2289,7 @@ mod reference_list_write_path_tests { // Refused before anything was written: the list still holds both references. let references = storage .stored_account_references(anchor_number, application_number) - .expect("the row written above is gone"); + .expect("the list written above is gone"); assert_eq!(references.len(), 2); } @@ -2593,7 +2593,7 @@ mod reference_list_write_path_tests { } } -/// A `(anchor, application)` row can be absent, empty, or hold references, and those +/// A `(anchor, application)` list can be absent, empty, or hold references, and those /// mean three different things. Absence says a default account is still /// reconstructible; emptiness is a tombstone and says it never can be again. mod account_reference_state_tests { @@ -2616,7 +2616,7 @@ mod account_reference_state_tests { (storage, anchor_number) } - /// Plants the row a future account move would leave behind. The write path cannot + /// Plants the list a future account move would leave behind. The write path cannot /// store one, which is the whole point, so a test that /// needs a tombstone has to write it directly. fn plant_tombstone(storage: &mut Storage, anchor_number: AnchorNumber) { @@ -2628,7 +2628,7 @@ mod account_reference_state_tests { (anchor_number, application_number), StorableAccountReferenceList::tombstone_for_testing(), ); - // The counter goes up with the row, as the move that will one day leave a + // The counter goes up with the list, as the move that will one day leave a // tombstone behind has to do: a stored tombstone the application does not count // is a divergence, and the write path refuses those rather than papering over // them. @@ -2810,7 +2810,7 @@ mod account_reference_state_tests { .unwrap(); let references = storage .stored_account_references(anchor_number, application_number) - .expect("naming the default should not have emptied the row"); + .expect("naming the default should not have emptied the list"); // Repointed where it stood, keeping the order accounts are listed in and the // timestamp the reference already carried. assert_eq!( @@ -2843,8 +2843,8 @@ mod account_reference_state_tests { .stored_account_references(anchor_number, application_number) .unwrap(); - // A skipped write is invisible in the stored bytes, since rewriting the row - // would store what it already holds. Retiring the application row makes it + // A skipped write is invisible in the stored bytes, since rewriting the list + // would store what it already holds. Retiring the application makes it // visible: `write_account_state` refuses without one, so a rename that still // wrote the list could not succeed here. storage @@ -2882,9 +2882,9 @@ mod account_reference_state_tests { .create_account(owner, origin.clone(), "named".to_string()) .unwrap(); let account_number = account.account_number.unwrap(); - // The other identity has a row of its own at this origin, so what refuses the - // attempts below is the row not naming this account rather than there being no - // row to look in. + // The other identity has a list of its own at this origin, so what refuses the + // attempts below is the list not naming this account rather than there being no + // list to look in. storage .create_account(other, origin.clone(), "mine".to_string()) .unwrap(); @@ -3005,8 +3005,8 @@ mod application_number_allocator_tests { .stable_application_memory .insert(number, application(origin)); } - // The rows now have a hole in them while the counter knows nothing, which is - // the one state a row count gets wrong: it would answer 2, the number + // The lists now have a hole in them while the counter knows nothing, which is + // the one state a list count gets wrong: it would answer 2, the number // `https://c.com` still holds. storage.stable_application_memory.remove(&0); storage.next_application_number_memory.set(0).unwrap(); @@ -3428,8 +3428,8 @@ mod tracked_default_eviction_tests { sign_in_at(&mut storage, anchor_number, index); } - let rows = storage.evictable_default_rows(anchor_number).len() as u64; - assert!(rows <= MAX_EVICTABLE_DEFAULT_ACCOUNTS); + let lists = storage.evictable_default_rows(anchor_number).len() as u64; + assert!(lists <= MAX_EVICTABLE_DEFAULT_ACCOUNTS); let newest = storage .lookup_application_number_with_origin(&origin_of( MAX_EVICTABLE_DEFAULT_ACCOUNTS * 2 - 1, @@ -3761,7 +3761,7 @@ mod application_removal_tests { .unwrap(); plant_tombstone(&mut storage, anchor_number, application_number); - // The move back: the tombstoned row gains a reference again. + // The move back: the tombstoned list gains a reference again. storage .write_account_state( anchor_number, @@ -3791,7 +3791,7 @@ mod application_removal_tests { .is_none()); } - /// Moves every account out of a row, leaving the tombstone a future account move + /// Moves every account out of a list, leaving the tombstone a future account move /// will. The write path refuses to store an empty list, which is what makes a /// tombstone a thing only a move can create, so it is written here directly — with /// the application's counters moved as that move will have to move them. @@ -3802,7 +3802,7 @@ mod application_removal_tests { ) { let moved_away = storage .stored_account_references(anchor_number, application_number) - .expect("a row has to exist before it can be emptied"); + .expect("a list has to exist before it can be emptied"); let named = moved_away .iter() .filter(|reference| reference.account_number.is_some()) @@ -3975,7 +3975,7 @@ mod application_removal_tests { fn only_a_lone_tracked_default_may_be_pruned() { let (mut storage, anchor_number, _) = storage_with_anchors(); let origin = "https://example.com".to_string(); - // A default alongside a named account. Retiring the row would drop a reference + // A default alongside a named account. Retiring the list would drop a reference // nothing else records, so it is refused even though the caller asked. storage .create_account(anchor_number, origin.clone(), "named".to_string()) @@ -4004,7 +4004,7 @@ mod application_removal_tests { let application_number = storage .lookup_or_insert_application_number_with_origin(&origin) .unwrap(); - // Taking the row away would make the moved-away default reconstructible again, + // Taking the list away would make the moved-away default reconstructible again, // which is the one thing the tombstone exists to prevent. storage.stable_account_reference_list_memory.insert( (anchor_number, application_number), @@ -4028,8 +4028,8 @@ mod application_removal_tests { let application_number = storage .lookup_or_insert_application_number_with_origin(&origin) .unwrap(); - // A lone tracked default, which is the only thing a row may be retired for, - // and the config row that goes with it. + // A lone tracked default, which is the only thing a list may be retired for, + // and the config list that goes with it. storage .set_default_account(anchor_number, origin.clone(), None) .unwrap(); From dcfe31c9fc776149db97e32d918a62d31849d5c5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:29 +0200 Subject: [PATCH 143/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 2 +- src/internet_identity/src/storage.rs | 38 +++++++++++----------- src/internet_identity/src/storage/tests.rs | 14 ++++---- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 21935248d1..c67896d862 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -848,7 +848,7 @@ thread_local! { /// Returns `(indexed_entries, skipped_rows, is_done)` so monitoring can track the sweep. /// -/// A non-zero skip count is not progress: it is reference-list rows whose application +/// A non-zero skip count is not progress: it is reference-list lists whose application /// is gone, which the sweep cannot derive a principal for. A run that reports nothing /// indexed and nothing skipped had nothing to do; one that reports skips did not. #[query(hidden = true)] diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 1345ae7d30..16893c429a 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1931,14 +1931,14 @@ impl Storage { Ok(()) } - /// Indexes one batch of existing reference-list rows. Entries are only inserted, + /// Indexes one batch of existing reference-list lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// - /// `batch_size` bounds **derivations**, not rows. One row is an identity's references + /// `batch_size` bounds **derivations**, not lists. One list is an identity's references /// at one origin and holds up to [`MAX_ANCHOR_ACCOUNTS`] of them, each costing a seed - /// hash, a principal derivation and a stable write — so a row-bounded batch is only - /// bounded in the shape of data that happens to be common. A batch stops mid-row and - /// the cursor says where, which is why it carries an offset into the row. + /// hash, a principal derivation and a stable write — so a list-bounded batch is only + /// bounded in the shape of data that happens to be common. A batch stops mid-list and + /// the cursor says where, which is why it carries an offset into the list. pub fn backfill_account_principal_index_batch( &mut self, cursor: Option, @@ -1950,26 +1950,26 @@ impl Storage { }; // Examining nothing is not finishing. Reporting completion here would stop a - // sweep that has not read a single row, and a lookup miss would then be taken as + // sweep that has not read a single list, and a lookup miss would then be taken as // proof no account has that principal. if batch_size == 0 { return outcome; } use std::ops::Bound as RangeBound; - // Inclusive of the cursor's own row: a batch may have stopped part-way through + // Inclusive of the cursor's own list: a batch may have stopped part-way through // it, and the offset says how far it got. let range = match cursor { - Some(cursor) => (RangeBound::Included(cursor.row()), RangeBound::Unbounded), + Some(cursor) => (RangeBound::Included(cursor.list()), RangeBound::Unbounded), None => (RangeBound::Unbounded, RangeBound::Unbounded), }; - // Read far enough ahead to spend the budget and no further, so the rows behind + // Read far enough ahead to spend the budget and no further, so the lists behind // this batch are never materialised. The borrow ends here, which is what lets the // indexing below write. let mut outstanding = batch_size; let mut ran_out = false; - let mut rows: Vec<( + let mut lists: Vec<( AnchorNumber, ApplicationNumber, Vec, @@ -1978,11 +1978,11 @@ impl Storage { for (key, list) in self.stable_account_reference_list_memory.range(range) { let references = Vec::::from(list); let already_done = match cursor { - Some(cursor) if cursor.row() == key => cursor.references_done, + Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; let left_in_row = references.len().saturating_sub(already_done) as u64; - rows.push((key.0, key.1, references, already_done)); + lists.push((key.0, key.1, references, already_done)); if left_in_row >= outstanding { ran_out = true; break; @@ -1992,9 +1992,9 @@ impl Storage { // Nothing left to index, whatever else is true of this canister. Checked before // the salt, because a fresh install has no salt until its first sign-in and no - // rows either — and a sweep that waits for the salt there never reports done and + // lists either — and a sweep that waits for the salt there never reports done and // ticks its timer for the life of the canister. - if rows.is_empty() { + if lists.is_empty() { outcome.is_done = true; return outcome; } @@ -2008,7 +2008,7 @@ impl Storage { outcome.is_done = !ran_out; let mut budget = batch_size; - for (anchor_number, application_number, references, already_done) in rows { + for (anchor_number, application_number, references, already_done) in lists { let Some(origin) = self .stable_application_memory .get(&application_number) @@ -2858,8 +2858,8 @@ impl Storage { } } -/// How far the sweep has got: which row, and how many of that row's references are -/// already indexed. The offset is what lets a batch stop inside a row that holds more +/// How far the sweep has got: which list, and how many of that list's references are +/// already indexed. The offset is what lets a batch stop inside a list that holds more /// references than one message can derive principals for. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct AccountPrincipalIndexBackfillCursor { @@ -2869,7 +2869,7 @@ pub struct AccountPrincipalIndexBackfillCursor { } impl AccountPrincipalIndexBackfillCursor { - fn row(&self) -> (AnchorNumber, ApplicationNumber) { + fn list(&self) -> (AnchorNumber, ApplicationNumber) { (self.anchor_number, self.application_number) } } @@ -2878,7 +2878,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A row + /// Rows whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 290b7a90f7..252c580d51 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4386,11 +4386,11 @@ mod account_principal_index_backfill_tests { const SALT: [u8; 32] = [17u8; 32]; - fn storage_with_rows(rows: u64) -> (Storage, Vec) { + fn storage_with_rows(lists: u64) -> (Storage, Vec) { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); storage.update_salt(SALT); let mut anchors = vec![]; - for index in 0..rows { + for index in 0..lists { let anchor = storage.allocate_anchor(0).unwrap(); let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); @@ -4510,7 +4510,7 @@ mod account_principal_index_backfill_tests { assert_eq!(outcome.indexed, 0); } - /// A canister that has never been signed in to has no salt and no rows, and the sweep + /// A canister that has never been signed in to has no salt and no lists, and the sweep /// has to finish on the second of those. Waiting for the salt would leave its timer /// running for the life of the canister. #[test] @@ -4536,9 +4536,9 @@ mod account_principal_index_backfill_tests { assert_eq!(outcome.indexed, 0); } - /// A row can hold up to `MAX_ANCHOR_ACCOUNTS` references, so a batch that stopped only - /// on row boundaries would derive that many principals in one message however small - /// the batch. It stops inside the row and the cursor says where. + /// A list can hold up to `MAX_ANCHOR_ACCOUNTS` references, so a batch that stopped only + /// on list boundaries would derive that many principals in one message however small + /// the batch. It stops inside the list and the cursor says where. #[test] fn a_batch_stops_inside_a_row_too_big_to_finish() { let (mut storage, anchors) = storage_with_rows(1); @@ -4566,7 +4566,7 @@ mod account_principal_index_backfill_tests { assert_eq!( first.next_cursor.map(|cursor| cursor.references_done), Some(2), - "the cursor should point inside the row, not past it" + "the cursor should point inside the list, not past it" ); assert_eq!(storage.lookup_account_with_principal_memory.len(), 2); From b00b2ebf112175935d8a2d3db2d3478dbd5499c5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:29 +0200 Subject: [PATCH 144/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 0d7ff7b5a7..682ea56b24 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4626,7 +4626,7 @@ mod session_record_tests { assert_eq!(StorableAccountReference::from(reference).sessions, None); } - /// A row is evictable on its shape alone. Sparing one because it holds a live session + /// A list is evictable on its shape alone. Sparing one because it holds a live session /// would leave the user with access that settings cannot show them, and a session /// nobody can find is a session nobody can revoke. #[test] From 91cf4e88bd232183db349bc14803451965450faf Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:29 +0200 Subject: [PATCH 145/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 6 +++--- src/internet_identity/src/storage/storable/account_key.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 769e3d0d4d..8a2fa57c27 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1686,7 +1686,7 @@ impl Storage { .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; // Every reference goes, so every principal it derived goes with it — resolved - // before the first removal so a missing salt refuses with the row intact. + // before the first removal so a missing salt refuses with the list intact. let salt = *self.salt().ok_or(StorageError::SaltNotSet)?; let origin = application.origin.clone(); @@ -1872,7 +1872,7 @@ impl Storage { )?; // Nothing below this line can fail. The record goes in before the index, - // because a principal is derived from an account's stored row and one that + // because a principal is derived from an account's stored list and one that // is not in yet derives nothing — a new account would get no entry. if let Some((account_number, storable_account)) = record.take() { self.stable_account_memory @@ -1971,7 +1971,7 @@ impl Storage { } } - /// The principals a set of references derives to. A reference whose account row is + /// The principals a set of references derives to. A reference whose account list is /// gone derives nothing and is skipped. fn account_principals( &self, diff --git a/src/internet_identity/src/storage/storable/account_key.rs b/src/internet_identity/src/storage/storable/account_key.rs index 1bac974162..c888e5a01f 100644 --- a/src/internet_identity/src/storage/storable/account_key.rs +++ b/src/internet_identity/src/storage/storable/account_key.rs @@ -9,7 +9,7 @@ use std::borrow::Cow; /// The stored form of an [`crate::storage::account::AccountKey`], with the origin /// interned to an application number. /// -/// The number rather than the origin, because a row per principal would otherwise +/// The number rather than the origin, because a list per principal would otherwise /// carry a copy of the origin string, and interning it is what application numbers are /// for. Which is also why the two types stay apart: the number is storage's own, and /// what leaves is the `AccountKey` it maps to. Absent account number means the tracked From e2e5ee3938d63cac4547adb623b62d8e18af0db3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:29 +0200 Subject: [PATCH 146/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/anchor.rs | 4 ++-- src/internet_identity/src/storage/anchor/tests.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 56b1bc6dd3..ea93314fca 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -71,7 +71,7 @@ pub enum SessionDeviceError { /// so it is still proving with the key it announced a successor for. The answer is for /// the browser to promote its own successor and present that — it is the only party /// holding both keys. Registering it as a new browser instead would turn every dropped - /// response into a second row for one browser, and accepting it would leave a leaked + /// response into a second list for one browser, and accepting it would leave a leaked /// key useful for longer than the one sign-in rotation allows it. StaleDeviceKey, } @@ -742,7 +742,7 @@ impl Anchor { /// has already retired is refused with [`SessionDeviceError::StaleDeviceKey`] rather /// than accepted or registered afresh, so a key is good for exactly one sign-in and a /// browser that lost a response is told to promote its own successor instead of - /// becoming a second row. A key no entry holds at all registers a new browser. + /// becoming a second list. A key no entry holds at all registers a new browser. /// /// At the cap the least recently used records are dropped, and their ids returned so /// the caller can end their sessions too. diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index ac68a0e831..774a7c7448 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -1635,7 +1635,7 @@ mod session_device_tests { } /// A response that never reached the browser leaves it proving with the key the entry - /// has already retired. That is refused rather than registered: a second row for one + /// has already retired. That is refused rather than registered: a second list for one /// browser is exactly what a dropped response must not cost, and the browser holds the /// successor that does resolve. #[test] From f5e6d495b55b90637e06aff6e6098c4f174cb6d4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:30 +0200 Subject: [PATCH 147/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 158 +++++++++---------- src/internet_identity/src/storage/account.rs | 2 +- src/internet_identity/src/storage/tests.rs | 10 +- 3 files changed, 85 insertions(+), 85 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 56cde2e25f..e0238782ad 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -319,7 +319,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list rows that hold nothing but a tracked default +/// Per-anchor cap on reference-list lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -1583,15 +1583,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's row is retired. Its + /// passed is never offered again even after the application's list is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest row walks it backwards. + /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest list walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the - /// application row and the origin index, so reissuing one would put two origins on - /// a single row and have them share its accounts and counters. + /// application and the origin index, so reissuing one would put two origins on + /// a single list and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -1653,9 +1653,9 @@ impl Storage { /// This identity's account references at `application_number`. /// - /// An absent row normalises to the derived default: nothing has happened at this + /// An absent list normalises to the derived default: nothing has happened at this /// origin, so the identity still has the default it has always had. A stored empty - /// row is a tombstone and stays empty — everything here moved away and the default + /// list is a tombstone and stays empty — everything here moved away and the default /// must never be derived again. /// /// Absence and emptiness are opposites, and this is the only place that knows it. @@ -1669,7 +1669,7 @@ impl Storage { } /// [`Self::account_references`] for a caller that has an origin rather than an - /// application number. An origin nothing has ever been stored under has no row, so + /// application number. An origin nothing has ever been stored under has no list, so /// it normalises the same way. fn account_references_for_origin( &self, @@ -1688,9 +1688,9 @@ impl Storage { vec![AccountReference::new(None, None)] } - /// The row as stored, with no default derived for an absent one. + /// The list as stored, with no default derived for an absent one. /// - /// Only the write path may see this. The counters describe stored rows, so a row + /// Only the write path may see this. The counters describe stored lists, so a list /// that never existed must not be diffed against as though it held the default. fn stored_account_references( &self, @@ -1727,7 +1727,7 @@ impl Storage { }) .collect(); - // One anchor write for the whole sweep rather than one per application: each row + // One anchor write for the whole sweep rather than one per application: each list // reports what it did to the count, and the total is applied once at the end. let mut delta = 0i64; for (application_number, mut references) in affected { @@ -1799,7 +1799,7 @@ impl Storage { .max(MIN_SESSION_IDLE_NS) .min(granted); - // The row this session lands in has to exist first, but an existing one must not be + // The list 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) @@ -1866,7 +1866,7 @@ impl Storage { }; reference.sessions.push(session.clone()); - // The whole row, not just the reference being written: this row is about to be + // The whole list, not just the reference being written: this list 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() { @@ -1880,7 +1880,7 @@ impl Storage { }); } - // The row is the whole of it: the index entries for the session created here and + // The list is the whole of it: the index entries for the session created here and // for the ones pruned above, and the identity's session count, all follow from // the list this writes. self.write_account_state(anchor_number, application_number, references, None, None)?; @@ -1919,20 +1919,20 @@ impl Storage { )) } - /// Removes a reference-list row and everything derived from it. + /// Removes a reference-list list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, ) -> Result<(), StorageError> { - // A row is retired only when a live tracked default is all it holds. Nothing + // A list is retired only when a live tracked default is all it holds. Nothing // else may be pruned, and the rule sits here rather than only in the caller // that picks victims, because this is the irreversible step: // - // - an absent row has nothing to remove; - // - an empty row is a tombstone, and taking it away would make the default it + // - an absent list has nothing to remove; + // - an empty list is a tombstone, and taking it away would make the default it // stands for reconstructible again; - // - a row holding named accounts, or whose default was named or moved away, + // - a list holding named accounts, or whose default was named or moved away, // would lose references that nothing else records. let previous = match self .stored_account_references(anchor_number, application_number) @@ -1947,12 +1947,12 @@ impl Storage { .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; // Every reference goes, so every principal it derived goes with it — resolved - // before the first removal so a missing salt refuses with the row intact. + // before the first removal so a missing salt refuses with the list intact. let salt = *self.salt().ok_or(StorageError::SaltNotSet)?; let origin = application.origin.clone(); - // Removing the row rather than writing one, so this is the one place that says + // Removing the list rather than writing one, so this is the one place that says // what leaves outside the gate — and it says it the same way, by diffing what the - // row held against nothing. + // list held against nothing. let delta = self.sync_session_index( anchor_number, application_number, @@ -2008,7 +2008,7 @@ impl Storage { .collect() } - /// Upper bound on an anchor's evictable rows, from counters that already exist. + /// Upper bound on an anchor's evictable lists, from counters that already exist. fn tracked_default_account_upper_bound(&self, anchor_number: AnchorNumber) -> u64 { let counter = self.get_account_counter(anchor_number); counter @@ -2073,10 +2073,10 @@ impl Storage { /// on the IC returning `Err` commits what was written before it, and only a trap /// rolls back, so a refusal here must leave nothing behind. /// - /// `references` is the whole new list. It is diffed against the stored row for the + /// `references` is the whole new list. It is diffed against the stored list for the /// counter deltas, so a caller supplies only what it wants stored and cannot move a /// counter by the default [`Self::account_references`] derived for it. A list equal - /// to the stored row writes nothing: the row is a single blob, so writing it back + /// to the stored list writes nothing: the list is a single blob, so writing it back /// would store the bytes it already holds. fn write_account_state( &mut self, @@ -2100,8 +2100,8 @@ impl Storage { /// count rather than applying it. /// /// The delta is still *derived* here — no caller says what it is. What a caller may - /// choose is when to apply it, so an operation spanning rows moves the anchor once - /// instead of once per row. + /// choose is when to apply it, so an operation spanning lists moves the anchor once + /// instead of once per list. fn write_account_state_deferring_count( &mut self, anchor_number: AnchorNumber, @@ -2112,7 +2112,7 @@ impl Storage { ) -> Result { let stored_references = self.stored_account_references(anchor_number, application_number); - // Nothing to say: the row already holds these bytes, so it is not written and + // Nothing to say: the list already holds these bytes, so it is not written and // nothing about it is checked either. This is what lets a rename leave every // reference alone without its caller having to know to skip the write. let list_write = if stored_references.as_deref() == Some(references.as_slice()) { @@ -2190,7 +2190,7 @@ impl Storage { )?; // Nothing below this line can fail. The record goes in before the index, - // because a principal is derived from an account's stored row and one that + // because a principal is derived from an account's stored list and one that // is not in yet derives nothing — a new account would get no entry. if let Some((account_number, storable_account)) = record.take() { self.stable_account_memory @@ -2237,7 +2237,7 @@ impl Storage { Ok(session_delta) } - /// Moves the identity's session count by what a write to its rows implied. + /// Moves the identity's session count by what a write to its lists implied. /// /// Cannot report a failure: the caller has already written the anchor to get here, so /// the read resolves, and writing it back with one `u32` changed leaves every field @@ -2272,10 +2272,10 @@ impl Storage { ids } - /// [`Self::write_account_state`] for a row the tracked default is the reason for, + /// [`Self::write_account_state`] for a list the tracked default is the reason for, /// reaping idle ones where this took the anchor over the cap. /// - /// Only a row that did not exist can take it over: stamping or repointing one + /// Only a list that did not exist can take it over: stamping or repointing one /// leaves the count where it was, so there would be nothing to reap. fn write_tracked_default( &mut self, @@ -2297,14 +2297,14 @@ impl Storage { Ok(()) } - /// Indexes one batch of existing reference-list rows. Entries are only inserted, + /// Indexes one batch of existing reference-list lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// - /// `batch_size` bounds **derivations**, not rows. One row is an identity's references + /// `batch_size` bounds **derivations**, not lists. One list is an identity's references /// at one origin and holds up to [`MAX_ANCHOR_ACCOUNTS`] of them, each costing a seed - /// hash, a principal derivation and a stable write — so a row-bounded batch is only - /// bounded in the shape of data that happens to be common. A batch stops mid-row and - /// the cursor says where, which is why it carries an offset into the row. + /// hash, a principal derivation and a stable write — so a list-bounded batch is only + /// bounded in the shape of data that happens to be common. A batch stops mid-list and + /// the cursor says where, which is why it carries an offset into the list. pub fn backfill_account_principal_index_batch( &mut self, cursor: Option, @@ -2316,26 +2316,26 @@ impl Storage { }; // Examining nothing is not finishing. Reporting completion here would stop a - // sweep that has not read a single row, and a lookup miss would then be taken as + // sweep that has not read a single list, and a lookup miss would then be taken as // proof no account has that principal. if batch_size == 0 { return outcome; } use std::ops::Bound as RangeBound; - // Inclusive of the cursor's own row: a batch may have stopped part-way through + // Inclusive of the cursor's own list: a batch may have stopped part-way through // it, and the offset says how far it got. let range = match cursor { - Some(cursor) => (RangeBound::Included(cursor.row()), RangeBound::Unbounded), + Some(cursor) => (RangeBound::Included(cursor.list()), RangeBound::Unbounded), None => (RangeBound::Unbounded, RangeBound::Unbounded), }; - // Read far enough ahead to spend the budget and no further, so the rows behind + // Read far enough ahead to spend the budget and no further, so the lists behind // this batch are never materialised. The borrow ends here, which is what lets the // indexing below write. let mut outstanding = batch_size; let mut ran_out = false; - let mut rows: Vec<( + let mut lists: Vec<( AnchorNumber, ApplicationNumber, Vec, @@ -2344,11 +2344,11 @@ impl Storage { for (key, list) in self.stable_account_reference_list_memory.range(range) { let references = Vec::::from(list); let already_done = match cursor { - Some(cursor) if cursor.row() == key => cursor.references_done, + Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; let left_in_row = references.len().saturating_sub(already_done) as u64; - rows.push((key.0, key.1, references, already_done)); + lists.push((key.0, key.1, references, already_done)); if left_in_row >= outstanding { ran_out = true; break; @@ -2358,9 +2358,9 @@ impl Storage { // Nothing left to index, whatever else is true of this canister. Checked before // the salt, because a fresh install has no salt until its first sign-in and no - // rows either — and a sweep that waits for the salt there never reports done and + // lists either — and a sweep that waits for the salt there never reports done and // ticks its timer for the life of the canister. - if rows.is_empty() { + if lists.is_empty() { outcome.is_done = true; return outcome; } @@ -2374,7 +2374,7 @@ impl Storage { outcome.is_done = !ran_out; let mut budget = batch_size; - for (anchor_number, application_number, references, already_done) in rows { + for (anchor_number, application_number, references, already_done) in lists { let Some(origin) = self .stable_application_memory .get(&application_number) @@ -2461,7 +2461,7 @@ impl Storage { } } - /// The principals a set of references derives to. A reference whose account row is + /// The principals a set of references derives to. A reference whose account list is /// gone derives nothing and is skipped. /// The account one reference names, built from the reference and the record it /// points at. @@ -2536,7 +2536,7 @@ impl Storage { /// /// Sessions live on the reference, so a reference that goes takes its sessions with /// it and this sees them as removed without any caller saying so. That is the point: - /// the row and everything derived from it move together, in the one place holding + /// the list and everything derived from it move together, in the one place holding /// both versions of it. fn sync_session_index( &mut self, @@ -2561,9 +2561,9 @@ impl Storage { continue; } // The account's entry goes in with the session's. A handle names its account - // by principal, and that index gains entries only where a row's set of - // account numbers changes or when the backfill reaches the row — neither of - // which a sign-in does. Without this a session at a row that predates the + // by principal, and that index gains entries only where a list's set of + // account numbers changes or when the backfill reaches the list — neither of + // which a sign-in does. Without this a session at a list that predates the // index resolves to nothing until the sweep happens to arrive. self.lookup_account_with_principal_memory.insert( Principal::from_slice(&handle.account_principal), @@ -2665,7 +2665,7 @@ impl Storage { }, ); // A zero here now means what it says. The delta refuses rather than clamping, so - // the row is only retired when no anchor references it, not when a counter that + // the list is only retired when no anchor references it, not when a counter that // had already drifted was pulled below zero. // // Tombstones count too, and they are the reason references alone are not enough: @@ -2825,7 +2825,7 @@ impl Storage { /// One account this identity holds at `key.origin`, or `None` where it holds none. /// /// `key.account_number` names it, `None` being the tracked default. Answering - /// `None` is the ownership check: an account belongs to whichever identity's row + /// `None` is the ownership check: an account belongs to whichever identity's list /// names it, so a caller that finds no reference here has no claim on the account /// whether or not it exists. /// @@ -2906,7 +2906,7 @@ impl Storage { let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; let account_number = self.allocate_account_number()?; - // An absent row normalises to the derived default, which is how the first named + // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. let mut references = self.account_references(anchor_number, application_number); @@ -2937,7 +2937,7 @@ impl Storage { /// /// Renaming one, naming the tracked default, and recording that an account was used /// are the same read-modify-write: the account is the state to store, not a patch - /// over it, so what it carries is what the row ends up holding. + /// over it, so what it carries is what the list ends up holding. /// /// A number no reference names is [`StorageError::AccountNotFound`] and never a /// create. `update_account_for_origin` takes its account number straight from the @@ -2959,7 +2959,7 @@ impl Storage { // The tracked default is stored the first time it is named or used, so its // origin gets an application number on either. None => self.lookup_or_insert_application_number_with_origin(&origin)?, - // A stored account writes to a row that already exists, and an origin + // A stored account writes to a list that already exists, and an origin // nothing has been stored under has none. Some(account_number) => self .lookup_application_number_with_origin(&origin) @@ -2972,7 +2972,7 @@ impl Storage { .position(|reference| reference.account_number == account_number) else { // Holding a reference is what grants access, so a miss means this identity - // does not have the account. For the tracked default it means the row is a + // does not have the account. For the tracked default it means the list is a // tombstone or the default was named and is no longer numberless — neither // can be reconstructed from the origin. return Err(match account_number { @@ -3016,7 +3016,7 @@ impl Storage { ) } // The tracked default, unnamed: nothing to store but the use of a - // reference the row already holds. + // reference the list already holds. (None, None) => { self.write_tracked_default(anchor_number, application_number, references, None)?; return Ok(Account::new_with_last_used( @@ -3334,8 +3334,8 @@ pub struct CreateSessionParams { pub now_ns: Timestamp, } -/// How far the sweep has got: which row, and how many of that row's references are -/// already indexed. The offset is what lets a batch stop inside a row that holds more +/// How far the sweep has got: which list, and how many of that list's references are +/// already indexed. The offset is what lets a batch stop inside a list that holds more /// references than one message can derive principals for. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct AccountPrincipalIndexBackfillCursor { @@ -3345,7 +3345,7 @@ pub struct AccountPrincipalIndexBackfillCursor { } impl AccountPrincipalIndexBackfillCursor { - fn row(&self) -> (AnchorNumber, ApplicationNumber) { + fn list(&self) -> (AnchorNumber, ApplicationNumber) { (self.anchor_number, self.application_number) } } @@ -3354,7 +3354,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A row + /// Rows whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, @@ -3374,7 +3374,7 @@ fn canister_id() -> Principal { } /// Which of the counters derived from a reference list a delta is applied to. /// -/// Each carries what identifies its row, so a refusal points at the counter that +/// Each carries what identifies its list, so a refusal points at the counter that /// diverged rather than only saying that one did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReferenceCounter { @@ -3421,7 +3421,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list row moves the counters derived from it. +/// How one write to a reference-list list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. @@ -3432,19 +3432,19 @@ struct ReferenceListDeltas { accounts: i64, /// Change in references, named and tracked-default alike. references: i64, - /// Change in rows that exist while holding no reference. Only ever -1, 0 or 1: one - /// write touches one row. + /// Change in lists that exist while holding no reference. Only ever -1, 0 or 1: one + /// write touches one list. tombstones: i64, } impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// - /// A row that does not exist and one holding nothing both count as no references, + /// A list that does not exist and one holding nothing both count as no references, /// which is right for these totals: neither contributes any. It is also why - /// retiring a row must not go through here — a tombstone's row is still alive while + /// retiring a list must not go through here — a tombstone's list is still alive while /// holding nothing, so a diff against it would report no change and leave the - /// counters claiming references the removed row no longer has. + /// counters claiming references the removed list no longer has. fn between( previous_references: Option<&[AccountReference]>, new_references: &[AccountReference], @@ -3467,7 +3467,7 @@ impl ReferenceListDeltas { let (previous_named, previous_total) = counts(previous_references.unwrap_or_default()); let (new_named, new_total) = counts(new_references); - // A row that does not exist is not a tombstone — a tombstone is a row someone + // A list that does not exist is not a tombstone — a tombstone is a list someone // stored, and absence is what normalisation reads as "derive the default". let was_tombstone = previous_references.is_some_and(<[_]>::is_empty); let is_tombstone = new_references.is_empty(); @@ -3484,19 +3484,19 @@ impl ReferenceListDeltas { } } - /// What retiring a row holding `previous` does to the counters. + /// What retiring a list holding `previous` does to the counters. /// /// Separate from [`Self::between`] rather than a write of an empty list, because - /// an empty list cannot be written at all: a row holding nothing is a tombstone + /// an empty list cannot be written at all: a list holding nothing is a tombstone /// and stays, so only an outright removal gets to zero these out. fn removing(previous: &[AccountReference]) -> Self { let removed = Self::between(Some(&[]), previous); Self { accounts: removed.accounts.saturating_neg(), references: removed.references.saturating_neg(), - // The row is gone, so a tombstone goes with it. Not the negation of what + // The list is gone, so a tombstone goes with it. Not the negation of what // `between` reported: that describes writing this list, and this describes - // removing the row it was in. + // removing the list it was in. tombstones: if previous.is_empty() { -1 } else { 0 }, } } @@ -3509,7 +3509,7 @@ impl ReferenceListDeltas { /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references - /// this application any more", which retires a row other anchors still point at. + /// this application any more", which retires a list other anchors still point at. fn apply( &self, counter: ReferenceCounter, @@ -3584,8 +3584,8 @@ pub enum StorageError { SaltNotSet, AccountsCounterOverflow, /// No application numbers left to hand out. Refused rather than saturated: the - /// number keys the application row and the origin index, so reissuing one would - /// put two origins on a single row. + /// number keys the application and the origin index, so reissuing one would + /// put two origins on a single list. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, /// No session ids left to hand out. Refused rather than saturated: the id is an diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index b949ee5b43..87e10ec4cd 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -71,7 +71,7 @@ pub const DEFAULT_SESSION_IDLE_NS: u64 = 7 * crate::DAY_NS; /// Where one session is stored, and which session it is. /// -/// The account addresses the row; `session_id` picks the record out of it. The id is +/// The account addresses the list; `session_id` picks the record out of it. The id is /// unique on its own, so every operation is compare-and-act: a key for a session that /// was replaced reads as `None` and revokes nothing, instead of landing on its /// successor. diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 03b36be79c..a20f990d35 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4718,8 +4718,8 @@ 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. + /// Eviction orders on the list's `last_used`, which every refresh stamps, so a session + /// in use keeps its list 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(); @@ -4883,9 +4883,9 @@ mod session_creation_tests { } } - /// A row that predates the principal index, which is every row an existing user has: - /// the index is written only where a row's set of account numbers changes, and by the - /// backfill sweep. Emptied here to stand in for a row the sweep has not reached. + /// A list that predates the principal index, which is every list an existing user has: + /// the index is written only where a list's set of account numbers changes, and by the + /// backfill sweep. Emptied here to stand in for a list the sweep has not reached. fn forget_account_principals(storage: &mut Storage) { let principals: Vec<_> = storage .lookup_account_with_principal_memory From fce16f2e93679d9f3f13c0d327c2580ce2d89193 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:30 +0200 Subject: [PATCH 148/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 34 +++++++++++----------- src/internet_identity/src/storage/tests.rs | 28 +++++++++--------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index b21001bfb5..16a5d62e9e 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -338,7 +338,7 @@ const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS /// 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. +/// lists 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. @@ -1768,7 +1768,7 @@ impl Storage { /// /// 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 + /// reaches the cap this recounts what the lists 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, @@ -1795,15 +1795,15 @@ impl Storage { self.write(anchor) } - /// Walks the anchor's rows once and reclaims down to the watermark, taking sessions in + /// Walks the anchor's lists 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 + /// Returns what the lists 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. + /// cap, and bounded by the same list limit account eviction uses. fn reclaim_sessions( &mut self, anchor_number: AnchorNumber, @@ -1811,16 +1811,16 @@ impl Storage { ) -> Result { struct Candidate { order: (bool, Timestamp, SessionId), - row: usize, + list: usize, session_id: SessionId, } - // Every row, not a bounded prefix of them: the number this returns is what the cap is + // Every list, 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 + // undercount, and let the stored set climb past the cap from there. An identity's lists + // are already bounded — the list 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 + let mut lists: Vec<(ApplicationNumber, Vec)> = self .stable_account_reference_list_memory .range( (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), @@ -1831,12 +1831,12 @@ impl Storage { .collect(); let mut candidates: Vec = vec![]; - for (row, (_, references)) in rows.iter().enumerate() { + for (list, (_, references)) in lists.iter().enumerate() { for reference in references.iter() { for session in &reference.sessions { candidates.push(Candidate { order: session.reclaim_order(now), - row, + list, session_id: session.session_id, }); } @@ -1852,17 +1852,17 @@ impl Storage { return Ok(stored); } - // One write per row rather than one per victim: the row is a single blob, so + // One write per list rather than one per victim: the list 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(); + let mut touched: Vec = victims.iter().map(|victim| victim.list).collect(); touched.sort_unstable(); touched.dedup(); - // One anchor write for the pass rather than one per row: each row reports what it + // One anchor write for the pass rather than one per list: each list reports what it // did to the count, and the recount below is what the cap is enforced against. let mut dropped_total = 0i64; - for row in touched { - let (application_number, references) = &mut rows[row]; + for list in touched { + let (application_number, references) = &mut lists[list]; let application_number = *application_number; for reference in references.iter_mut() { reference.sessions.retain(|session| { diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index ec13accf8e..b4c02c2b07 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5202,7 +5202,7 @@ mod session_creation_tests { assert_eq!( storage.read(anchor_number).unwrap().session_count as usize, stored, - "the counter parted ways with the rows after {device_id} sign-ins" + "the counter parted ways with the lists after {device_id} sign-ins" ); } } @@ -5215,7 +5215,7 @@ mod session_creation_tests { .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. + // enforced against what the lists hold, not against the drift. let mut anchor = storage.read(anchor_number).unwrap(); anchor.session_count = MAX_SESSIONS_PER_ANCHOR; storage.write(anchor).unwrap(); @@ -5228,19 +5228,19 @@ mod session_creation_tests { assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); } - /// Two rows, both holding a default account, and both holding sessions for the same + /// Two lists, 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. + // Two lists of this size put the identity two over the watermark, so the pass selects + // exactly two victims — one in each list. const PER_ROW: u32 = SESSIONS_WATERMARK_PER_ANCHOR / 2 + 1; - // The browser ids repeat across the rows; the session ids do not, because no two + // The browser ids repeat across the lists; the session ids do not, because no two // sessions ever share one. - let row = |id_base: u64, expired_device: u32| -> Vec { + let list = |id_base: u64, expired_device: u32| -> Vec { let sessions = (0..PER_ROW) .map(|device_id| { let session_id = id_base + device_id as u64; @@ -5281,10 +5281,10 @@ mod session_creation_tests { .lookup_or_insert_application_number_with_origin(&OTHER_ORIGIN.to_string()) .unwrap(); storage - .write_account_state(anchor_number, first, row(1_000, 0), None, None) + .write_account_state(anchor_number, first, list(1_000, 0), None, None) .unwrap(); storage - .write_account_state(anchor_number, second, row(2_000, 1), None, None) + .write_account_state(anchor_number, second, list(2_000, 1), None, None) .unwrap(); let mut anchor = storage.read(anchor_number).unwrap(); @@ -5310,24 +5310,24 @@ mod session_creation_tests { assert!( !first_devices.contains(&0), - "the expired session selected in the first row should be gone" + "the expired session selected in the first list should be gone" ); assert!( !second_devices.contains(&1), - "the expired session selected in the second row should be gone" + "the expired session selected in the second list should be gone" ); assert!( first_devices.contains(&1), - "the first row's live session for browser 1 was not selected and must survive" + "the first list'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 second list'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 + /// session the user has actually kept alive survives a list 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() { From 33f85b65c0e1903864753b770d56b0f134c93bcd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:31 +0200 Subject: [PATCH 149/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 2 +- src/internet_identity/src/storage.rs | 192 +++++++++++++------------- 2 files changed, 97 insertions(+), 97 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 93e94e9e79..3fb5864132 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -292,7 +292,7 @@ fn session_identity( /// 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. +/// `seed_from_anchor`, which only the stored list carries. fn account_principal( anchor_number: AnchorNumber, origin: &FrontendHostname, diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 3f2cd4e63e..97d68ba833 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -319,7 +319,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list rows that hold nothing but a tracked default +/// Per-anchor cap on reference-list lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -338,7 +338,7 @@ const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS /// 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. +/// lists 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. @@ -1598,15 +1598,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's row is retired. Its + /// passed is never offered again even after the application's list is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest row walks it backwards. + /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest list walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the - /// application row and the origin index, so reissuing one would put two origins on - /// a single row and have them share its accounts and counters. + /// application and the origin index, so reissuing one would put two origins on + /// a single list and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -1668,9 +1668,9 @@ impl Storage { /// This identity's account references at `application_number`. /// - /// An absent row normalises to the derived default: nothing has happened at this + /// An absent list normalises to the derived default: nothing has happened at this /// origin, so the identity still has the default it has always had. A stored empty - /// row is a tombstone and stays empty — everything here moved away and the default + /// list is a tombstone and stays empty — everything here moved away and the default /// must never be derived again. /// /// Absence and emptiness are opposites, and this is the only place that knows it. @@ -1684,7 +1684,7 @@ impl Storage { } /// [`Self::account_references`] for a caller that has an origin rather than an - /// application number. An origin nothing has ever been stored under has no row, so + /// application number. An origin nothing has ever been stored under has no list, so /// it normalises the same way. fn account_references_for_origin( &self, @@ -1703,9 +1703,9 @@ impl Storage { vec![AccountReference::new(None, None)] } - /// The row as stored, with no default derived for an absent one. + /// The list as stored, with no default derived for an absent one. /// - /// Only the write path may see this. The counters describe stored rows, so a row + /// Only the write path may see this. The counters describe stored lists, so a list /// that never existed must not be diffed against as though it held the default. fn stored_account_references( &self, @@ -1740,7 +1740,7 @@ impl Storage { }) .collect(); - // One anchor write for the whole sweep rather than one per application: each row + // One anchor write for the whole sweep rather than one per application: each list // reports what it did to the count, and the total is applied once at the end. let mut delta = 0i64; for (application_number, mut references) in affected { @@ -1766,7 +1766,7 @@ impl Storage { /// /// 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 + /// reaches the cap this recounts what the lists 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, @@ -1793,15 +1793,15 @@ impl Storage { self.write(anchor) } - /// Walks the anchor's rows once and reclaims down to the watermark, taking sessions in + /// Walks the anchor's lists 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 + /// Returns what the lists 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. + /// cap, and bounded by the same list limit account eviction uses. fn reclaim_sessions( &mut self, anchor_number: AnchorNumber, @@ -1809,16 +1809,16 @@ impl Storage { ) -> Result { struct Candidate { order: (bool, Timestamp, SessionId), - row: usize, + list: usize, session_id: SessionId, } - // Every row, not a bounded prefix of them: the number this returns is what the cap is + // Every list, 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 + // undercount, and let the stored set climb past the cap from there. An identity's lists + // are already bounded — the list 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 + let mut lists: Vec<(ApplicationNumber, Vec)> = self .stable_account_reference_list_memory .range( (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), @@ -1829,12 +1829,12 @@ impl Storage { .collect(); let mut candidates: Vec = vec![]; - for (row, (_, references)) in rows.iter().enumerate() { + for (list, (_, references)) in lists.iter().enumerate() { for reference in references.iter() { for session in &reference.sessions { candidates.push(Candidate { order: session.reclaim_order(now), - row, + list, session_id: session.session_id, }); } @@ -1850,17 +1850,17 @@ impl Storage { return Ok(stored); } - // One write per row rather than one per victim: the row is a single blob, so + // One write per list rather than one per victim: the list 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(); + let mut touched: Vec = victims.iter().map(|victim| victim.list).collect(); touched.sort_unstable(); touched.dedup(); - // One anchor write for the pass rather than one per row: each row reports what it + // One anchor write for the pass rather than one per list: each list reports what it // did to the count, and the recount below is what the cap is enforced against. let mut dropped_total = 0i64; - for row in touched { - let (application_number, references) = &mut rows[row]; + for list in touched { + let (application_number, references) = &mut lists[list]; let application_number = *application_number; for reference in references.iter_mut() { reference.sessions.retain(|session| { @@ -1929,7 +1929,7 @@ impl Storage { .max(MIN_SESSION_IDLE_NS) .min(granted); - // The row this session lands in has to exist first, but an existing one must not be + // The list 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) @@ -2002,7 +2002,7 @@ impl Storage { }; reference.sessions.push(session.clone()); - // The whole row, not just the reference being written: this row is about to be + // The whole list, not just the reference being written: this list 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() { @@ -2016,7 +2016,7 @@ impl Storage { }); } - // The row is the whole of it: the index entries for the session created here and + // The list is the whole of it: the index entries for the session created here and // for the ones pruned above, and the identity's session count, all follow from // the list this writes. self.write_account_state(anchor_number, application_number, references, None, None)?; @@ -2030,20 +2030,20 @@ impl Storage { Ok((key, session)) } - /// Removes a reference-list row and everything derived from it. + /// Removes a reference-list list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, ) -> Result<(), StorageError> { - // A row is retired only when a live tracked default is all it holds. Nothing + // A list is retired only when a live tracked default is all it holds. Nothing // else may be pruned, and the rule sits here rather than only in the caller // that picks victims, because this is the irreversible step: // - // - an absent row has nothing to remove; - // - an empty row is a tombstone, and taking it away would make the default it + // - an absent list has nothing to remove; + // - an empty list is a tombstone, and taking it away would make the default it // stands for reconstructible again; - // - a row holding named accounts, or whose default was named or moved away, + // - a list holding named accounts, or whose default was named or moved away, // would lose references that nothing else records. let previous = match self .stored_account_references(anchor_number, application_number) @@ -2058,12 +2058,12 @@ impl Storage { .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; // Every reference goes, so every principal it derived goes with it — resolved - // before the first removal so a missing salt refuses with the row intact. + // before the first removal so a missing salt refuses with the list intact. let salt = *self.salt().ok_or(StorageError::SaltNotSet)?; let origin = application.origin.clone(); - // Removing the row rather than writing one, so this is the one place that says + // Removing the list rather than writing one, so this is the one place that says // what leaves outside the gate — and it says it the same way, by diffing what the - // row held against nothing. + // list held against nothing. let delta = self.sync_session_index( anchor_number, application_number, @@ -2119,7 +2119,7 @@ impl Storage { .collect() } - /// Upper bound on an anchor's evictable rows, from counters that already exist. + /// Upper bound on an anchor's evictable lists, from counters that already exist. fn tracked_default_account_upper_bound(&self, anchor_number: AnchorNumber) -> u64 { let counter = self.get_account_counter(anchor_number); counter @@ -2184,10 +2184,10 @@ impl Storage { /// on the IC returning `Err` commits what was written before it, and only a trap /// rolls back, so a refusal here must leave nothing behind. /// - /// `references` is the whole new list. It is diffed against the stored row for the + /// `references` is the whole new list. It is diffed against the stored list for the /// counter deltas, so a caller supplies only what it wants stored and cannot move a /// counter by the default [`Self::account_references`] derived for it. A list equal - /// to the stored row writes nothing: the row is a single blob, so writing it back + /// to the stored list writes nothing: the list is a single blob, so writing it back /// would store the bytes it already holds. fn write_account_state( &mut self, @@ -2211,8 +2211,8 @@ impl Storage { /// count rather than applying it. /// /// The delta is still *derived* here — no caller says what it is. What a caller may - /// choose is when to apply it, so an operation spanning rows moves the anchor once - /// instead of once per row. + /// choose is when to apply it, so an operation spanning lists moves the anchor once + /// instead of once per list. fn write_account_state_deferring_count( &mut self, anchor_number: AnchorNumber, @@ -2223,7 +2223,7 @@ impl Storage { ) -> Result { let stored_references = self.stored_account_references(anchor_number, application_number); - // Nothing to say: the row already holds these bytes, so it is not written and + // Nothing to say: the list already holds these bytes, so it is not written and // nothing about it is checked either. This is what lets a rename leave every // reference alone without its caller having to know to skip the write. let list_write = if stored_references.as_deref() == Some(references.as_slice()) { @@ -2301,7 +2301,7 @@ impl Storage { )?; // Nothing below this line can fail. The record goes in before the index, - // because a principal is derived from an account's stored row and one that + // because a principal is derived from an account's stored list and one that // is not in yet derives nothing — a new account would get no entry. if let Some((account_number, storable_account)) = record.take() { self.stable_account_memory @@ -2348,7 +2348,7 @@ impl Storage { Ok(session_delta) } - /// Moves the identity's session count by what a write to its rows implied. + /// Moves the identity's session count by what a write to its lists implied. /// /// Cannot report a failure: the caller has already written the anchor to get here, so /// the read resolves, and writing it back with one `u32` changed leaves every field @@ -2383,10 +2383,10 @@ impl Storage { ids } - /// [`Self::write_account_state`] for a row the tracked default is the reason for, + /// [`Self::write_account_state`] for a list the tracked default is the reason for, /// reaping idle ones where this took the anchor over the cap. /// - /// Only a row that did not exist can take it over: stamping or repointing one + /// Only a list that did not exist can take it over: stamping or repointing one /// leaves the count where it was, so there would be nothing to reap. fn write_tracked_default( &mut self, @@ -2408,14 +2408,14 @@ impl Storage { Ok(()) } - /// Indexes one batch of existing reference-list rows. Entries are only inserted, + /// Indexes one batch of existing reference-list lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// - /// `batch_size` bounds **derivations**, not rows. One row is an identity's references + /// `batch_size` bounds **derivations**, not lists. One list is an identity's references /// at one origin and holds up to [`MAX_ANCHOR_ACCOUNTS`] of them, each costing a seed - /// hash, a principal derivation and a stable write — so a row-bounded batch is only - /// bounded in the shape of data that happens to be common. A batch stops mid-row and - /// the cursor says where, which is why it carries an offset into the row. + /// hash, a principal derivation and a stable write — so a list-bounded batch is only + /// bounded in the shape of data that happens to be common. A batch stops mid-list and + /// the cursor says where, which is why it carries an offset into the list. pub fn backfill_account_principal_index_batch( &mut self, cursor: Option, @@ -2427,26 +2427,26 @@ impl Storage { }; // Examining nothing is not finishing. Reporting completion here would stop a - // sweep that has not read a single row, and a lookup miss would then be taken as + // sweep that has not read a single list, and a lookup miss would then be taken as // proof no account has that principal. if batch_size == 0 { return outcome; } use std::ops::Bound as RangeBound; - // Inclusive of the cursor's own row: a batch may have stopped part-way through + // Inclusive of the cursor's own list: a batch may have stopped part-way through // it, and the offset says how far it got. let range = match cursor { - Some(cursor) => (RangeBound::Included(cursor.row()), RangeBound::Unbounded), + Some(cursor) => (RangeBound::Included(cursor.list()), RangeBound::Unbounded), None => (RangeBound::Unbounded, RangeBound::Unbounded), }; - // Read far enough ahead to spend the budget and no further, so the rows behind + // Read far enough ahead to spend the budget and no further, so the lists behind // this batch are never materialised. The borrow ends here, which is what lets the // indexing below write. let mut outstanding = batch_size; let mut ran_out = false; - let mut rows: Vec<( + let mut lists: Vec<( AnchorNumber, ApplicationNumber, Vec, @@ -2455,11 +2455,11 @@ impl Storage { for (key, list) in self.stable_account_reference_list_memory.range(range) { let references = Vec::::from(list); let already_done = match cursor { - Some(cursor) if cursor.row() == key => cursor.references_done, + Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; let left_in_row = references.len().saturating_sub(already_done) as u64; - rows.push((key.0, key.1, references, already_done)); + lists.push((key.0, key.1, references, already_done)); if left_in_row >= outstanding { ran_out = true; break; @@ -2469,9 +2469,9 @@ impl Storage { // Nothing left to index, whatever else is true of this canister. Checked before // the salt, because a fresh install has no salt until its first sign-in and no - // rows either — and a sweep that waits for the salt there never reports done and + // lists either — and a sweep that waits for the salt there never reports done and // ticks its timer for the life of the canister. - if rows.is_empty() { + if lists.is_empty() { outcome.is_done = true; return outcome; } @@ -2485,7 +2485,7 @@ impl Storage { outcome.is_done = !ran_out; let mut budget = batch_size; - for (anchor_number, application_number, references, already_done) in rows { + for (anchor_number, application_number, references, already_done) in lists { let Some(origin) = self .stable_application_memory .get(&application_number) @@ -2572,7 +2572,7 @@ impl Storage { } } - /// The principals a set of references derives to. A reference whose account row is + /// The principals a set of references derives to. A reference whose account list is /// gone derives nothing and is skipped. /// The account one reference names, built from the reference and the record it /// points at. @@ -2647,7 +2647,7 @@ impl Storage { /// /// Sessions live on the reference, so a reference that goes takes its sessions with /// it and this sees them as removed without any caller saying so. That is the point: - /// the row and everything derived from it move together, in the one place holding + /// the list and everything derived from it move together, in the one place holding /// both versions of it. fn sync_session_index( &mut self, @@ -2672,9 +2672,9 @@ impl Storage { continue; } // The account's entry goes in with the session's. A handle names its account - // by principal, and that index gains entries only where a row's set of - // account numbers changes or when the backfill reaches the row — neither of - // which a sign-in does. Without this a session at a row that predates the + // by principal, and that index gains entries only where a list's set of + // account numbers changes or when the backfill reaches the list — neither of + // which a sign-in does. Without this a session at a list that predates the // index resolves to nothing until the sweep happens to arrive. self.lookup_account_with_principal_memory.insert( Principal::from_slice(&handle.account_principal), @@ -2776,7 +2776,7 @@ impl Storage { }, ); // A zero here now means what it says. The delta refuses rather than clamping, so - // the row is only retired when no anchor references it, not when a counter that + // the list is only retired when no anchor references it, not when a counter that // had already drifted was pulled below zero. // // Tombstones count too, and they are the reason references alone are not enough: @@ -2936,7 +2936,7 @@ impl Storage { /// One account this identity holds at `key.origin`, or `None` where it holds none. /// /// `key.account_number` names it, `None` being the tracked default. Answering - /// `None` is the ownership check: an account belongs to whichever identity's row + /// `None` is the ownership check: an account belongs to whichever identity's list /// names it, so a caller that finds no reference here has no claim on the account /// whether or not it exists. /// @@ -3017,7 +3017,7 @@ impl Storage { let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; let account_number = self.allocate_account_number()?; - // An absent row normalises to the derived default, which is how the first named + // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. let mut references = self.account_references(anchor_number, application_number); @@ -3048,7 +3048,7 @@ impl Storage { /// /// Renaming one, naming the tracked default, and recording that an account was used /// are the same read-modify-write: the account is the state to store, not a patch - /// over it, so what it carries is what the row ends up holding. + /// over it, so what it carries is what the list ends up holding. /// /// A number no reference names is [`StorageError::AccountNotFound`] and never a /// create. `update_account_for_origin` takes its account number straight from the @@ -3070,7 +3070,7 @@ impl Storage { // The tracked default is stored the first time it is named or used, so its // origin gets an application number on either. None => self.lookup_or_insert_application_number_with_origin(&origin)?, - // A stored account writes to a row that already exists, and an origin + // A stored account writes to a list that already exists, and an origin // nothing has been stored under has none. Some(account_number) => self .lookup_application_number_with_origin(&origin) @@ -3083,7 +3083,7 @@ impl Storage { .position(|reference| reference.account_number == account_number) else { // Holding a reference is what grants access, so a miss means this identity - // does not have the account. For the tracked default it means the row is a + // does not have the account. For the tracked default it means the list is a // tombstone or the default was named and is no longer numberless — neither // can be reconstructed from the origin. return Err(match account_number { @@ -3127,7 +3127,7 @@ impl Storage { ) } // The tracked default, unnamed: nothing to store but the use of a - // reference the row already holds. + // reference the list already holds. (None, None) => { self.write_tracked_default(anchor_number, application_number, references, None)?; return Ok(Account::new_with_last_used( @@ -3443,8 +3443,8 @@ pub struct CreateSessionParams { pub now_ns: Timestamp, } -/// How far the sweep has got: which row, and how many of that row's references are -/// already indexed. The offset is what lets a batch stop inside a row that holds more +/// How far the sweep has got: which list, and how many of that list's references are +/// already indexed. The offset is what lets a batch stop inside a list that holds more /// references than one message can derive principals for. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct AccountPrincipalIndexBackfillCursor { @@ -3454,7 +3454,7 @@ pub struct AccountPrincipalIndexBackfillCursor { } impl AccountPrincipalIndexBackfillCursor { - fn row(&self) -> (AnchorNumber, ApplicationNumber) { + fn list(&self) -> (AnchorNumber, ApplicationNumber) { (self.anchor_number, self.application_number) } } @@ -3463,7 +3463,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A row + /// Rows whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, @@ -3483,7 +3483,7 @@ fn canister_id() -> Principal { } /// Which of the counters derived from a reference list a delta is applied to. /// -/// Each carries what identifies its row, so a refusal points at the counter that +/// Each carries what identifies its list, so a refusal points at the counter that /// diverged rather than only saying that one did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReferenceCounter { @@ -3530,7 +3530,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list row moves the counters derived from it. +/// How one write to a reference-list list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. @@ -3541,19 +3541,19 @@ struct ReferenceListDeltas { accounts: i64, /// Change in references, named and tracked-default alike. references: i64, - /// Change in rows that exist while holding no reference. Only ever -1, 0 or 1: one - /// write touches one row. + /// Change in lists that exist while holding no reference. Only ever -1, 0 or 1: one + /// write touches one list. tombstones: i64, } impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// - /// A row that does not exist and one holding nothing both count as no references, + /// A list that does not exist and one holding nothing both count as no references, /// which is right for these totals: neither contributes any. It is also why - /// retiring a row must not go through here — a tombstone's row is still alive while + /// retiring a list must not go through here — a tombstone's list is still alive while /// holding nothing, so a diff against it would report no change and leave the - /// counters claiming references the removed row no longer has. + /// counters claiming references the removed list no longer has. fn between( previous_references: Option<&[AccountReference]>, new_references: &[AccountReference], @@ -3576,7 +3576,7 @@ impl ReferenceListDeltas { let (previous_named, previous_total) = counts(previous_references.unwrap_or_default()); let (new_named, new_total) = counts(new_references); - // A row that does not exist is not a tombstone — a tombstone is a row someone + // A list that does not exist is not a tombstone — a tombstone is a list someone // stored, and absence is what normalisation reads as "derive the default". let was_tombstone = previous_references.is_some_and(<[_]>::is_empty); let is_tombstone = new_references.is_empty(); @@ -3593,19 +3593,19 @@ impl ReferenceListDeltas { } } - /// What retiring a row holding `previous` does to the counters. + /// What retiring a list holding `previous` does to the counters. /// /// Separate from [`Self::between`] rather than a write of an empty list, because - /// an empty list cannot be written at all: a row holding nothing is a tombstone + /// an empty list cannot be written at all: a list holding nothing is a tombstone /// and stays, so only an outright removal gets to zero these out. fn removing(previous: &[AccountReference]) -> Self { let removed = Self::between(Some(&[]), previous); Self { accounts: removed.accounts.saturating_neg(), references: removed.references.saturating_neg(), - // The row is gone, so a tombstone goes with it. Not the negation of what + // The list is gone, so a tombstone goes with it. Not the negation of what // `between` reported: that describes writing this list, and this describes - // removing the row it was in. + // removing the list it was in. tombstones: if previous.is_empty() { -1 } else { 0 }, } } @@ -3618,7 +3618,7 @@ impl ReferenceListDeltas { /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references - /// this application any more", which retires a row other anchors still point at. + /// this application any more", which retires a list other anchors still point at. fn apply( &self, counter: ReferenceCounter, @@ -3693,8 +3693,8 @@ pub enum StorageError { SaltNotSet, AccountsCounterOverflow, /// No application numbers left to hand out. Refused rather than saturated: the - /// number keys the application row and the origin index, so reissuing one would - /// put two origins on a single row. + /// number keys the application and the origin index, so reissuing one would + /// put two origins on a single list. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, /// No session ids left to hand out. Refused rather than saturated: the id is an From 647ea1478e96969c33118dabe232c965df7d2aea Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:31 +0200 Subject: [PATCH 150/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 2 +- src/internet_identity/src/storage/tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 4d9d09fc04..574492a302 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1772,7 +1772,7 @@ impl Storage { /// A stored account address resolved to the one callers use. /// - /// `None` where the application is gone, which leaves the stored row naming + /// `None` where the application is gone, which leaves the stored list naming /// nothing. Not a `From`, because the origin the number stands for comes out of /// storage. fn account_key_of(&self, stored: &StorableAccountKey) -> Option { diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 8426c5f99c..d4967b9015 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5406,7 +5406,7 @@ mod session_creation_tests { assert!( storage.lookup_session_with_principal(principal).is_none(), - "an evicted row left its sessions resolvable" + "an evicted list left its sessions resolvable" ); assert_eq!(storage.read(anchor_number).unwrap().session_count, 0); } From 7475c3d8d15121b0bbf7da200e433fa729700fdb Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:32 +0200 Subject: [PATCH 151/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 4 ++-- src/internet_identity/src/storage/tests.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 7b3c153da8..4e0b2f84fb 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1965,9 +1965,9 @@ impl Storage { let device_id = session.device_id; reference.last_used = Some(now); - // This row is being rewritten anyway, so its dead sessions go now. It costs one + // This list 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. + // list anyone still uses stays clean without anything having to sweep for it. for reference in references.iter_mut() { reference.sessions.retain(|session| !session.is_over(now)); } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 2af6388925..539a08b676 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5811,7 +5811,7 @@ mod session_refresh_stamp_tests { } } - /// The row is rewritten anyway, so the refresh is where a dead sibling is collected — + /// The list 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() { From e1e3920df9f848f2fe60ca8bc4f5d3935eb79561 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:32 +0200 Subject: [PATCH 152/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 198 +++++++++++++-------------- 1 file changed, 99 insertions(+), 99 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index f1ad83be7f..79af823872 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -319,7 +319,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list rows that hold nothing but a tracked default +/// Per-anchor cap on reference-list lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -338,7 +338,7 @@ const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS /// 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. +/// lists 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. @@ -1598,15 +1598,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's row is retired. Its + /// passed is never offered again even after the application's list is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest row walks it backwards. + /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest list walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the - /// application row and the origin index, so reissuing one would put two origins on - /// a single row and have them share its accounts and counters. + /// application and the origin index, so reissuing one would put two origins on + /// a single list and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -1668,9 +1668,9 @@ impl Storage { /// This identity's account references at `application_number`. /// - /// An absent row normalises to the derived default: nothing has happened at this + /// An absent list normalises to the derived default: nothing has happened at this /// origin, so the identity still has the default it has always had. A stored empty - /// row is a tombstone and stays empty — everything here moved away and the default + /// list is a tombstone and stays empty — everything here moved away and the default /// must never be derived again. /// /// Absence and emptiness are opposites, and this is the only place that knows it. @@ -1684,7 +1684,7 @@ impl Storage { } /// [`Self::account_references`] for a caller that has an origin rather than an - /// application number. An origin nothing has ever been stored under has no row, so + /// application number. An origin nothing has ever been stored under has no list, so /// it normalises the same way. fn account_references_for_origin( &self, @@ -1703,9 +1703,9 @@ impl Storage { vec![AccountReference::new(None, None)] } - /// The row as stored, with no default derived for an absent one. + /// The list as stored, with no default derived for an absent one. /// - /// Only the write path may see this. The counters describe stored rows, so a row + /// Only the write path may see this. The counters describe stored lists, so a list /// that never existed must not be diffed against as though it held the default. fn stored_account_references( &self, @@ -1740,7 +1740,7 @@ impl Storage { }) .collect(); - // One anchor write for the whole sweep rather than one per application: each row + // One anchor write for the whole sweep rather than one per application: each list // reports what it did to the count, and the total is applied once at the end. let mut delta = 0i64; for (application_number, mut references) in affected { @@ -1772,7 +1772,7 @@ impl Storage { /// A stored account address resolved to the one callers use. /// - /// `None` where the application is gone, which leaves the stored row naming + /// `None` where the application is gone, which leaves the stored list naming /// nothing. Not a `From`, because the origin the number stands for comes out of /// storage. fn account_key_of(&self, stored: &StorableAccountKey) -> Option { @@ -1809,7 +1809,7 @@ impl Storage { /// /// 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 + /// reaches the cap this recounts what the lists 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, @@ -1858,15 +1858,15 @@ impl Storage { ) } - /// Walks the anchor's rows once and reclaims down to the watermark, taking sessions in + /// Walks the anchor's lists 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 + /// Returns what the lists 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. + /// cap, and bounded by the same list limit account eviction uses. fn reclaim_sessions( &mut self, anchor_number: AnchorNumber, @@ -1874,16 +1874,16 @@ impl Storage { ) -> Result { struct Candidate { order: (bool, Timestamp, SessionId), - row: usize, + list: usize, session_id: SessionId, } - // Every row, not a bounded prefix of them: the number this returns is what the cap is + // Every list, 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 + // undercount, and let the stored set climb past the cap from there. An identity's lists + // are already bounded — the list 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 + let mut lists: Vec<(ApplicationNumber, Vec)> = self .stable_account_reference_list_memory .range( (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), @@ -1894,12 +1894,12 @@ impl Storage { .collect(); let mut candidates: Vec = vec![]; - for (row, (_, references)) in rows.iter().enumerate() { + for (list, (_, references)) in lists.iter().enumerate() { for reference in references.iter() { for session in &reference.sessions { candidates.push(Candidate { order: session.reclaim_order(now), - row, + list, session_id: session.session_id, }); } @@ -1915,17 +1915,17 @@ impl Storage { return Ok(stored); } - // One write per row rather than one per victim: the row is a single blob, so + // One write per list rather than one per victim: the list 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(); + let mut touched: Vec = victims.iter().map(|victim| victim.list).collect(); touched.sort_unstable(); touched.dedup(); - // One anchor write for the pass rather than one per row: each row reports what it + // One anchor write for the pass rather than one per list: each list reports what it // did to the count, and the recount below is what the cap is enforced against. let mut dropped_total = 0i64; - for row in touched { - let (application_number, references) = &mut rows[row]; + for list in touched { + let (application_number, references) = &mut lists[list]; let application_number = *application_number; for reference in references.iter_mut() { reference.sessions.retain(|session| { @@ -1987,9 +1987,9 @@ impl Storage { let device_id = session.device_id; reference.last_used = Some(now); - // This row is being rewritten anyway, so its dead sessions go now. It costs one + // This list 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. + // list anyone still uses stays clean without anything having to sweep for it. for reference in references.iter_mut() { reference.sessions.retain(|session| !session.is_over(now)); } @@ -2090,7 +2090,7 @@ impl Storage { .max(MIN_SESSION_IDLE_NS) .min(granted); - // The row this session lands in has to exist first, but an existing one must not be + // The list 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) @@ -2163,7 +2163,7 @@ impl Storage { }; reference.sessions.push(session.clone()); - // The whole row, not just the reference being written: this row is about to be + // The whole list, not just the reference being written: this list 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() { @@ -2177,7 +2177,7 @@ impl Storage { }); } - // The row is the whole of it: the index entries for the session created here and + // The list is the whole of it: the index entries for the session created here and // for the ones pruned above, and the identity's session count, all follow from // the list this writes. self.write_account_state(anchor_number, application_number, references, None, None)?; @@ -2191,20 +2191,20 @@ impl Storage { Ok((key, session)) } - /// Removes a reference-list row and everything derived from it. + /// Removes a reference-list list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, ) -> Result<(), StorageError> { - // A row is retired only when a live tracked default is all it holds. Nothing + // A list is retired only when a live tracked default is all it holds. Nothing // else may be pruned, and the rule sits here rather than only in the caller // that picks victims, because this is the irreversible step: // - // - an absent row has nothing to remove; - // - an empty row is a tombstone, and taking it away would make the default it + // - an absent list has nothing to remove; + // - an empty list is a tombstone, and taking it away would make the default it // stands for reconstructible again; - // - a row holding named accounts, or whose default was named or moved away, + // - a list holding named accounts, or whose default was named or moved away, // would lose references that nothing else records. let previous = match self .stored_account_references(anchor_number, application_number) @@ -2219,12 +2219,12 @@ impl Storage { .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; // Every reference goes, so every principal it derived goes with it — resolved - // before the first removal so a missing salt refuses with the row intact. + // before the first removal so a missing salt refuses with the list intact. let salt = *self.salt().ok_or(StorageError::SaltNotSet)?; let origin = application.origin.clone(); - // Removing the row rather than writing one, so this is the one place that says + // Removing the list rather than writing one, so this is the one place that says // what leaves outside the gate — and it says it the same way, by diffing what the - // row held against nothing. + // list held against nothing. let delta = self.sync_session_index( anchor_number, application_number, @@ -2280,7 +2280,7 @@ impl Storage { .collect() } - /// Upper bound on an anchor's evictable rows, from counters that already exist. + /// Upper bound on an anchor's evictable lists, from counters that already exist. fn tracked_default_account_upper_bound(&self, anchor_number: AnchorNumber) -> u64 { let counter = self.get_account_counter(anchor_number); counter @@ -2345,10 +2345,10 @@ impl Storage { /// on the IC returning `Err` commits what was written before it, and only a trap /// rolls back, so a refusal here must leave nothing behind. /// - /// `references` is the whole new list. It is diffed against the stored row for the + /// `references` is the whole new list. It is diffed against the stored list for the /// counter deltas, so a caller supplies only what it wants stored and cannot move a /// counter by the default [`Self::account_references`] derived for it. A list equal - /// to the stored row writes nothing: the row is a single blob, so writing it back + /// to the stored list writes nothing: the list is a single blob, so writing it back /// would store the bytes it already holds. fn write_account_state( &mut self, @@ -2372,8 +2372,8 @@ impl Storage { /// count rather than applying it. /// /// The delta is still *derived* here — no caller says what it is. What a caller may - /// choose is when to apply it, so an operation spanning rows moves the anchor once - /// instead of once per row. + /// choose is when to apply it, so an operation spanning lists moves the anchor once + /// instead of once per list. fn write_account_state_deferring_count( &mut self, anchor_number: AnchorNumber, @@ -2384,7 +2384,7 @@ impl Storage { ) -> Result { let stored_references = self.stored_account_references(anchor_number, application_number); - // Nothing to say: the row already holds these bytes, so it is not written and + // Nothing to say: the list already holds these bytes, so it is not written and // nothing about it is checked either. This is what lets a rename leave every // reference alone without its caller having to know to skip the write. let list_write = if stored_references.as_deref() == Some(references.as_slice()) { @@ -2462,7 +2462,7 @@ impl Storage { )?; // Nothing below this line can fail. The record goes in before the index, - // because a principal is derived from an account's stored row and one that + // because a principal is derived from an account's stored list and one that // is not in yet derives nothing — a new account would get no entry. if let Some((account_number, storable_account)) = record.take() { self.stable_account_memory @@ -2509,7 +2509,7 @@ impl Storage { Ok(session_delta) } - /// Moves the identity's session count by what a write to its rows implied. + /// Moves the identity's session count by what a write to its lists implied. /// /// Cannot report a failure: the caller has already written the anchor to get here, so /// the read resolves, and writing it back with one `u32` changed leaves every field @@ -2544,10 +2544,10 @@ impl Storage { ids } - /// [`Self::write_account_state`] for a row the tracked default is the reason for, + /// [`Self::write_account_state`] for a list the tracked default is the reason for, /// reaping idle ones where this took the anchor over the cap. /// - /// Only a row that did not exist can take it over: stamping or repointing one + /// Only a list that did not exist can take it over: stamping or repointing one /// leaves the count where it was, so there would be nothing to reap. fn write_tracked_default( &mut self, @@ -2569,14 +2569,14 @@ impl Storage { Ok(()) } - /// Indexes one batch of existing reference-list rows. Entries are only inserted, + /// Indexes one batch of existing reference-list lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// - /// `batch_size` bounds **derivations**, not rows. One row is an identity's references + /// `batch_size` bounds **derivations**, not lists. One list is an identity's references /// at one origin and holds up to [`MAX_ANCHOR_ACCOUNTS`] of them, each costing a seed - /// hash, a principal derivation and a stable write — so a row-bounded batch is only - /// bounded in the shape of data that happens to be common. A batch stops mid-row and - /// the cursor says where, which is why it carries an offset into the row. + /// hash, a principal derivation and a stable write — so a list-bounded batch is only + /// bounded in the shape of data that happens to be common. A batch stops mid-list and + /// the cursor says where, which is why it carries an offset into the list. pub fn backfill_account_principal_index_batch( &mut self, cursor: Option, @@ -2588,26 +2588,26 @@ impl Storage { }; // Examining nothing is not finishing. Reporting completion here would stop a - // sweep that has not read a single row, and a lookup miss would then be taken as + // sweep that has not read a single list, and a lookup miss would then be taken as // proof no account has that principal. if batch_size == 0 { return outcome; } use std::ops::Bound as RangeBound; - // Inclusive of the cursor's own row: a batch may have stopped part-way through + // Inclusive of the cursor's own list: a batch may have stopped part-way through // it, and the offset says how far it got. let range = match cursor { - Some(cursor) => (RangeBound::Included(cursor.row()), RangeBound::Unbounded), + Some(cursor) => (RangeBound::Included(cursor.list()), RangeBound::Unbounded), None => (RangeBound::Unbounded, RangeBound::Unbounded), }; - // Read far enough ahead to spend the budget and no further, so the rows behind + // Read far enough ahead to spend the budget and no further, so the lists behind // this batch are never materialised. The borrow ends here, which is what lets the // indexing below write. let mut outstanding = batch_size; let mut ran_out = false; - let mut rows: Vec<( + let mut lists: Vec<( AnchorNumber, ApplicationNumber, Vec, @@ -2616,11 +2616,11 @@ impl Storage { for (key, list) in self.stable_account_reference_list_memory.range(range) { let references = Vec::::from(list); let already_done = match cursor { - Some(cursor) if cursor.row() == key => cursor.references_done, + Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; let left_in_row = references.len().saturating_sub(already_done) as u64; - rows.push((key.0, key.1, references, already_done)); + lists.push((key.0, key.1, references, already_done)); if left_in_row >= outstanding { ran_out = true; break; @@ -2630,9 +2630,9 @@ impl Storage { // Nothing left to index, whatever else is true of this canister. Checked before // the salt, because a fresh install has no salt until its first sign-in and no - // rows either — and a sweep that waits for the salt there never reports done and + // lists either — and a sweep that waits for the salt there never reports done and // ticks its timer for the life of the canister. - if rows.is_empty() { + if lists.is_empty() { outcome.is_done = true; return outcome; } @@ -2646,7 +2646,7 @@ impl Storage { outcome.is_done = !ran_out; let mut budget = batch_size; - for (anchor_number, application_number, references, already_done) in rows { + for (anchor_number, application_number, references, already_done) in lists { let Some(origin) = self .stable_application_memory .get(&application_number) @@ -2733,7 +2733,7 @@ impl Storage { } } - /// The principals a set of references derives to. A reference whose account row is + /// The principals a set of references derives to. A reference whose account list is /// gone derives nothing and is skipped. /// The account one reference names, built from the reference and the record it /// points at. @@ -2808,7 +2808,7 @@ impl Storage { /// /// Sessions live on the reference, so a reference that goes takes its sessions with /// it and this sees them as removed without any caller saying so. That is the point: - /// the row and everything derived from it move together, in the one place holding + /// the list and everything derived from it move together, in the one place holding /// both versions of it. fn sync_session_index( &mut self, @@ -2833,9 +2833,9 @@ impl Storage { continue; } // The account's entry goes in with the session's. A handle names its account - // by principal, and that index gains entries only where a row's set of - // account numbers changes or when the backfill reaches the row — neither of - // which a sign-in does. Without this a session at a row that predates the + // by principal, and that index gains entries only where a list's set of + // account numbers changes or when the backfill reaches the list — neither of + // which a sign-in does. Without this a session at a list that predates the // index resolves to nothing until the sweep happens to arrive. self.lookup_account_with_principal_memory.insert( Principal::from_slice(&handle.account_principal), @@ -2937,7 +2937,7 @@ impl Storage { }, ); // A zero here now means what it says. The delta refuses rather than clamping, so - // the row is only retired when no anchor references it, not when a counter that + // the list is only retired when no anchor references it, not when a counter that // had already drifted was pulled below zero. // // Tombstones count too, and they are the reason references alone are not enough: @@ -3097,7 +3097,7 @@ impl Storage { /// One account this identity holds at `key.origin`, or `None` where it holds none. /// /// `key.account_number` names it, `None` being the tracked default. Answering - /// `None` is the ownership check: an account belongs to whichever identity's row + /// `None` is the ownership check: an account belongs to whichever identity's list /// names it, so a caller that finds no reference here has no claim on the account /// whether or not it exists. /// @@ -3178,7 +3178,7 @@ impl Storage { let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; let account_number = self.allocate_account_number()?; - // An absent row normalises to the derived default, which is how the first named + // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. let mut references = self.account_references(anchor_number, application_number); @@ -3209,7 +3209,7 @@ impl Storage { /// /// Renaming one, naming the tracked default, and recording that an account was used /// are the same read-modify-write: the account is the state to store, not a patch - /// over it, so what it carries is what the row ends up holding. + /// over it, so what it carries is what the list ends up holding. /// /// A number no reference names is [`StorageError::AccountNotFound`] and never a /// create. `update_account_for_origin` takes its account number straight from the @@ -3231,7 +3231,7 @@ impl Storage { // The tracked default is stored the first time it is named or used, so its // origin gets an application number on either. None => self.lookup_or_insert_application_number_with_origin(&origin)?, - // A stored account writes to a row that already exists, and an origin + // A stored account writes to a list that already exists, and an origin // nothing has been stored under has none. Some(account_number) => self .lookup_application_number_with_origin(&origin) @@ -3244,7 +3244,7 @@ impl Storage { .position(|reference| reference.account_number == account_number) else { // Holding a reference is what grants access, so a miss means this identity - // does not have the account. For the tracked default it means the row is a + // does not have the account. For the tracked default it means the list is a // tombstone or the default was named and is no longer numberless — neither // can be reconstructed from the origin. return Err(match account_number { @@ -3288,7 +3288,7 @@ impl Storage { ) } // The tracked default, unnamed: nothing to store but the use of a - // reference the row already holds. + // reference the list already holds. (None, None) => { self.write_tracked_default(anchor_number, application_number, references, None)?; return Ok(Account::new_with_last_used( @@ -3604,8 +3604,8 @@ pub struct CreateSessionParams { pub now_ns: Timestamp, } -/// How far the sweep has got: which row, and how many of that row's references are -/// already indexed. The offset is what lets a batch stop inside a row that holds more +/// How far the sweep has got: which list, and how many of that list's references are +/// already indexed. The offset is what lets a batch stop inside a list that holds more /// references than one message can derive principals for. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct AccountPrincipalIndexBackfillCursor { @@ -3615,7 +3615,7 @@ pub struct AccountPrincipalIndexBackfillCursor { } impl AccountPrincipalIndexBackfillCursor { - fn row(&self) -> (AnchorNumber, ApplicationNumber) { + fn list(&self) -> (AnchorNumber, ApplicationNumber) { (self.anchor_number, self.application_number) } } @@ -3624,7 +3624,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A row + /// Rows whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, @@ -3644,7 +3644,7 @@ fn canister_id() -> Principal { } /// Which of the counters derived from a reference list a delta is applied to. /// -/// Each carries what identifies its row, so a refusal points at the counter that +/// Each carries what identifies its list, so a refusal points at the counter that /// diverged rather than only saying that one did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReferenceCounter { @@ -3691,7 +3691,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list row moves the counters derived from it. +/// How one write to a reference-list list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. @@ -3702,19 +3702,19 @@ struct ReferenceListDeltas { accounts: i64, /// Change in references, named and tracked-default alike. references: i64, - /// Change in rows that exist while holding no reference. Only ever -1, 0 or 1: one - /// write touches one row. + /// Change in lists that exist while holding no reference. Only ever -1, 0 or 1: one + /// write touches one list. tombstones: i64, } impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// - /// A row that does not exist and one holding nothing both count as no references, + /// A list that does not exist and one holding nothing both count as no references, /// which is right for these totals: neither contributes any. It is also why - /// retiring a row must not go through here — a tombstone's row is still alive while + /// retiring a list must not go through here — a tombstone's list is still alive while /// holding nothing, so a diff against it would report no change and leave the - /// counters claiming references the removed row no longer has. + /// counters claiming references the removed list no longer has. fn between( previous_references: Option<&[AccountReference]>, new_references: &[AccountReference], @@ -3737,7 +3737,7 @@ impl ReferenceListDeltas { let (previous_named, previous_total) = counts(previous_references.unwrap_or_default()); let (new_named, new_total) = counts(new_references); - // A row that does not exist is not a tombstone — a tombstone is a row someone + // A list that does not exist is not a tombstone — a tombstone is a list someone // stored, and absence is what normalisation reads as "derive the default". let was_tombstone = previous_references.is_some_and(<[_]>::is_empty); let is_tombstone = new_references.is_empty(); @@ -3754,19 +3754,19 @@ impl ReferenceListDeltas { } } - /// What retiring a row holding `previous` does to the counters. + /// What retiring a list holding `previous` does to the counters. /// /// Separate from [`Self::between`] rather than a write of an empty list, because - /// an empty list cannot be written at all: a row holding nothing is a tombstone + /// an empty list cannot be written at all: a list holding nothing is a tombstone /// and stays, so only an outright removal gets to zero these out. fn removing(previous: &[AccountReference]) -> Self { let removed = Self::between(Some(&[]), previous); Self { accounts: removed.accounts.saturating_neg(), references: removed.references.saturating_neg(), - // The row is gone, so a tombstone goes with it. Not the negation of what + // The list is gone, so a tombstone goes with it. Not the negation of what // `between` reported: that describes writing this list, and this describes - // removing the row it was in. + // removing the list it was in. tombstones: if previous.is_empty() { -1 } else { 0 }, } } @@ -3779,7 +3779,7 @@ impl ReferenceListDeltas { /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references - /// this application any more", which retires a row other anchors still point at. + /// this application any more", which retires a list other anchors still point at. fn apply( &self, counter: ReferenceCounter, @@ -3854,8 +3854,8 @@ pub enum StorageError { SaltNotSet, AccountsCounterOverflow, /// No application numbers left to hand out. Refused rather than saturated: the - /// number keys the application row and the origin index, so reissuing one would - /// put two origins on a single row. + /// number keys the application and the origin index, so reissuing one would + /// put two origins on a single list. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, /// No session ids left to hand out. Refused rather than saturated: the id is an From 7f7ae7682bdfff1f71ca2598b4e60537a1441edd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:45:33 +0200 Subject: [PATCH 153/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 198 +++++++++++++-------------- 1 file changed, 99 insertions(+), 99 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index d923e89db1..bb9107d3f5 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -319,7 +319,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list rows that hold nothing but a tracked default +/// Per-anchor cap on reference-list lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -338,7 +338,7 @@ const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS /// 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. +/// lists 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. @@ -1598,15 +1598,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's row is retired. Its + /// passed is never offered again even after the application's list is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a row count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest row walks it backwards. + /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest list walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the - /// application row and the origin index, so reissuing one would put two origins on - /// a single row and have them share its accounts and counters. + /// application and the origin index, so reissuing one would put two origins on + /// a single list and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -1668,9 +1668,9 @@ impl Storage { /// This identity's account references at `application_number`. /// - /// An absent row normalises to the derived default: nothing has happened at this + /// An absent list normalises to the derived default: nothing has happened at this /// origin, so the identity still has the default it has always had. A stored empty - /// row is a tombstone and stays empty — everything here moved away and the default + /// list is a tombstone and stays empty — everything here moved away and the default /// must never be derived again. /// /// Absence and emptiness are opposites, and this is the only place that knows it. @@ -1684,7 +1684,7 @@ impl Storage { } /// [`Self::account_references`] for a caller that has an origin rather than an - /// application number. An origin nothing has ever been stored under has no row, so + /// application number. An origin nothing has ever been stored under has no list, so /// it normalises the same way. fn account_references_for_origin( &self, @@ -1703,9 +1703,9 @@ impl Storage { vec![AccountReference::new(None, None)] } - /// The row as stored, with no default derived for an absent one. + /// The list as stored, with no default derived for an absent one. /// - /// Only the write path may see this. The counters describe stored rows, so a row + /// Only the write path may see this. The counters describe stored lists, so a list /// that never existed must not be diffed against as though it held the default. fn stored_account_references( &self, @@ -1762,7 +1762,7 @@ impl Storage { } } - // One anchor write for the whole sweep rather than one per application: each row + // One anchor write for the whole sweep rather than one per application: each list // reports what it did to the count, and the total is applied once at the end. let mut delta = 0i64; for (application_number, mut references) in affected { @@ -1794,7 +1794,7 @@ impl Storage { /// A stored account address resolved to the one callers use. /// - /// `None` where the application is gone, which leaves the stored row naming + /// `None` where the application is gone, which leaves the stored list naming /// nothing. Not a `From`, because the origin the number stands for comes out of /// storage. fn account_key_of(&self, stored: &StorableAccountKey) -> Option { @@ -1831,7 +1831,7 @@ impl Storage { /// /// 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 + /// reaches the cap this recounts what the lists 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, @@ -1880,15 +1880,15 @@ impl Storage { ) } - /// Walks the anchor's rows once and reclaims down to the watermark, taking sessions in + /// Walks the anchor's lists 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 + /// Returns what the lists 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. + /// cap, and bounded by the same list limit account eviction uses. fn reclaim_sessions( &mut self, anchor_number: AnchorNumber, @@ -1896,16 +1896,16 @@ impl Storage { ) -> Result { struct Candidate { order: (bool, Timestamp, SessionId), - row: usize, + list: usize, session_id: SessionId, } - // Every row, not a bounded prefix of them: the number this returns is what the cap is + // Every list, 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 + // undercount, and let the stored set climb past the cap from there. An identity's lists + // are already bounded — the list 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 + let mut lists: Vec<(ApplicationNumber, Vec)> = self .stable_account_reference_list_memory .range( (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), @@ -1916,12 +1916,12 @@ impl Storage { .collect(); let mut candidates: Vec = vec![]; - for (row, (_, references)) in rows.iter().enumerate() { + for (list, (_, references)) in lists.iter().enumerate() { for reference in references.iter() { for session in &reference.sessions { candidates.push(Candidate { order: session.reclaim_order(now), - row, + list, session_id: session.session_id, }); } @@ -1937,17 +1937,17 @@ impl Storage { return Ok(stored); } - // One write per row rather than one per victim: the row is a single blob, so + // One write per list rather than one per victim: the list 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(); + let mut touched: Vec = victims.iter().map(|victim| victim.list).collect(); touched.sort_unstable(); touched.dedup(); - // One anchor write for the pass rather than one per row: each row reports what it + // One anchor write for the pass rather than one per list: each list reports what it // did to the count, and the recount below is what the cap is enforced against. let mut dropped_total = 0i64; - for row in touched { - let (application_number, references) = &mut rows[row]; + for list in touched { + let (application_number, references) = &mut lists[list]; let application_number = *application_number; for reference in references.iter_mut() { reference.sessions.retain(|session| { @@ -2009,9 +2009,9 @@ impl Storage { let device_id = session.device_id; reference.last_used = Some(now); - // This row is being rewritten anyway, so its dead sessions go now. It costs one + // This list 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. + // list anyone still uses stays clean without anything having to sweep for it. for reference in references.iter_mut() { reference.sessions.retain(|session| !session.is_over(now)); } @@ -2112,7 +2112,7 @@ impl Storage { .max(MIN_SESSION_IDLE_NS) .min(granted); - // The row this session lands in has to exist first, but an existing one must not be + // The list 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) @@ -2185,7 +2185,7 @@ impl Storage { }; reference.sessions.push(session.clone()); - // The whole row, not just the reference being written: this row is about to be + // The whole list, not just the reference being written: this list 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() { @@ -2199,7 +2199,7 @@ impl Storage { }); } - // The row is the whole of it: the index entries for the session created here and + // The list is the whole of it: the index entries for the session created here and // for the ones pruned above, and the identity's session count, all follow from // the list this writes. self.write_account_state(anchor_number, application_number, references, None, None)?; @@ -2213,20 +2213,20 @@ impl Storage { Ok((key, session)) } - /// Removes a reference-list row and everything derived from it. + /// Removes a reference-list list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, application_number: ApplicationNumber, ) -> Result<(), StorageError> { - // A row is retired only when a live tracked default is all it holds. Nothing + // A list is retired only when a live tracked default is all it holds. Nothing // else may be pruned, and the rule sits here rather than only in the caller // that picks victims, because this is the irreversible step: // - // - an absent row has nothing to remove; - // - an empty row is a tombstone, and taking it away would make the default it + // - an absent list has nothing to remove; + // - an empty list is a tombstone, and taking it away would make the default it // stands for reconstructible again; - // - a row holding named accounts, or whose default was named or moved away, + // - a list holding named accounts, or whose default was named or moved away, // would lose references that nothing else records. let previous = match self .stored_account_references(anchor_number, application_number) @@ -2241,12 +2241,12 @@ impl Storage { .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; // Every reference goes, so every principal it derived goes with it — resolved - // before the first removal so a missing salt refuses with the row intact. + // before the first removal so a missing salt refuses with the list intact. let salt = *self.salt().ok_or(StorageError::SaltNotSet)?; let origin = application.origin.clone(); - // Removing the row rather than writing one, so this is the one place that says + // Removing the list rather than writing one, so this is the one place that says // what leaves outside the gate — and it says it the same way, by diffing what the - // row held against nothing. + // list held against nothing. let delta = self.sync_session_index( anchor_number, application_number, @@ -2302,7 +2302,7 @@ impl Storage { .collect() } - /// Upper bound on an anchor's evictable rows, from counters that already exist. + /// Upper bound on an anchor's evictable lists, from counters that already exist. fn tracked_default_account_upper_bound(&self, anchor_number: AnchorNumber) -> u64 { let counter = self.get_account_counter(anchor_number); counter @@ -2367,10 +2367,10 @@ impl Storage { /// on the IC returning `Err` commits what was written before it, and only a trap /// rolls back, so a refusal here must leave nothing behind. /// - /// `references` is the whole new list. It is diffed against the stored row for the + /// `references` is the whole new list. It is diffed against the stored list for the /// counter deltas, so a caller supplies only what it wants stored and cannot move a /// counter by the default [`Self::account_references`] derived for it. A list equal - /// to the stored row writes nothing: the row is a single blob, so writing it back + /// to the stored list writes nothing: the list is a single blob, so writing it back /// would store the bytes it already holds. fn write_account_state( &mut self, @@ -2394,8 +2394,8 @@ impl Storage { /// count rather than applying it. /// /// The delta is still *derived* here — no caller says what it is. What a caller may - /// choose is when to apply it, so an operation spanning rows moves the anchor once - /// instead of once per row. + /// choose is when to apply it, so an operation spanning lists moves the anchor once + /// instead of once per list. fn write_account_state_deferring_count( &mut self, anchor_number: AnchorNumber, @@ -2406,7 +2406,7 @@ impl Storage { ) -> Result { let stored_references = self.stored_account_references(anchor_number, application_number); - // Nothing to say: the row already holds these bytes, so it is not written and + // Nothing to say: the list already holds these bytes, so it is not written and // nothing about it is checked either. This is what lets a rename leave every // reference alone without its caller having to know to skip the write. let list_write = if stored_references.as_deref() == Some(references.as_slice()) { @@ -2484,7 +2484,7 @@ impl Storage { )?; // Nothing below this line can fail. The record goes in before the index, - // because a principal is derived from an account's stored row and one that + // because a principal is derived from an account's stored list and one that // is not in yet derives nothing — a new account would get no entry. if let Some((account_number, storable_account)) = record.take() { self.stable_account_memory @@ -2531,7 +2531,7 @@ impl Storage { Ok(session_delta) } - /// Moves the identity's session count by what a write to its rows implied. + /// Moves the identity's session count by what a write to its lists implied. /// /// Cannot report a failure: the caller has already written the anchor to get here, so /// the read resolves, and writing it back with one `u32` changed leaves every field @@ -2566,10 +2566,10 @@ impl Storage { ids } - /// [`Self::write_account_state`] for a row the tracked default is the reason for, + /// [`Self::write_account_state`] for a list the tracked default is the reason for, /// reaping idle ones where this took the anchor over the cap. /// - /// Only a row that did not exist can take it over: stamping or repointing one + /// Only a list that did not exist can take it over: stamping or repointing one /// leaves the count where it was, so there would be nothing to reap. fn write_tracked_default( &mut self, @@ -2591,14 +2591,14 @@ impl Storage { Ok(()) } - /// Indexes one batch of existing reference-list rows. Entries are only inserted, + /// Indexes one batch of existing reference-list lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// - /// `batch_size` bounds **derivations**, not rows. One row is an identity's references + /// `batch_size` bounds **derivations**, not lists. One list is an identity's references /// at one origin and holds up to [`MAX_ANCHOR_ACCOUNTS`] of them, each costing a seed - /// hash, a principal derivation and a stable write — so a row-bounded batch is only - /// bounded in the shape of data that happens to be common. A batch stops mid-row and - /// the cursor says where, which is why it carries an offset into the row. + /// hash, a principal derivation and a stable write — so a list-bounded batch is only + /// bounded in the shape of data that happens to be common. A batch stops mid-list and + /// the cursor says where, which is why it carries an offset into the list. pub fn backfill_account_principal_index_batch( &mut self, cursor: Option, @@ -2610,26 +2610,26 @@ impl Storage { }; // Examining nothing is not finishing. Reporting completion here would stop a - // sweep that has not read a single row, and a lookup miss would then be taken as + // sweep that has not read a single list, and a lookup miss would then be taken as // proof no account has that principal. if batch_size == 0 { return outcome; } use std::ops::Bound as RangeBound; - // Inclusive of the cursor's own row: a batch may have stopped part-way through + // Inclusive of the cursor's own list: a batch may have stopped part-way through // it, and the offset says how far it got. let range = match cursor { - Some(cursor) => (RangeBound::Included(cursor.row()), RangeBound::Unbounded), + Some(cursor) => (RangeBound::Included(cursor.list()), RangeBound::Unbounded), None => (RangeBound::Unbounded, RangeBound::Unbounded), }; - // Read far enough ahead to spend the budget and no further, so the rows behind + // Read far enough ahead to spend the budget and no further, so the lists behind // this batch are never materialised. The borrow ends here, which is what lets the // indexing below write. let mut outstanding = batch_size; let mut ran_out = false; - let mut rows: Vec<( + let mut lists: Vec<( AnchorNumber, ApplicationNumber, Vec, @@ -2638,11 +2638,11 @@ impl Storage { for (key, list) in self.stable_account_reference_list_memory.range(range) { let references = Vec::::from(list); let already_done = match cursor { - Some(cursor) if cursor.row() == key => cursor.references_done, + Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; let left_in_row = references.len().saturating_sub(already_done) as u64; - rows.push((key.0, key.1, references, already_done)); + lists.push((key.0, key.1, references, already_done)); if left_in_row >= outstanding { ran_out = true; break; @@ -2652,9 +2652,9 @@ impl Storage { // Nothing left to index, whatever else is true of this canister. Checked before // the salt, because a fresh install has no salt until its first sign-in and no - // rows either — and a sweep that waits for the salt there never reports done and + // lists either — and a sweep that waits for the salt there never reports done and // ticks its timer for the life of the canister. - if rows.is_empty() { + if lists.is_empty() { outcome.is_done = true; return outcome; } @@ -2668,7 +2668,7 @@ impl Storage { outcome.is_done = !ran_out; let mut budget = batch_size; - for (anchor_number, application_number, references, already_done) in rows { + for (anchor_number, application_number, references, already_done) in lists { let Some(origin) = self .stable_application_memory .get(&application_number) @@ -2755,7 +2755,7 @@ impl Storage { } } - /// The principals a set of references derives to. A reference whose account row is + /// The principals a set of references derives to. A reference whose account list is /// gone derives nothing and is skipped. /// The account one reference names, built from the reference and the record it /// points at. @@ -2830,7 +2830,7 @@ impl Storage { /// /// Sessions live on the reference, so a reference that goes takes its sessions with /// it and this sees them as removed without any caller saying so. That is the point: - /// the row and everything derived from it move together, in the one place holding + /// the list and everything derived from it move together, in the one place holding /// both versions of it. fn sync_session_index( &mut self, @@ -2855,9 +2855,9 @@ impl Storage { continue; } // The account's entry goes in with the session's. A handle names its account - // by principal, and that index gains entries only where a row's set of - // account numbers changes or when the backfill reaches the row — neither of - // which a sign-in does. Without this a session at a row that predates the + // by principal, and that index gains entries only where a list's set of + // account numbers changes or when the backfill reaches the list — neither of + // which a sign-in does. Without this a session at a list that predates the // index resolves to nothing until the sweep happens to arrive. self.lookup_account_with_principal_memory.insert( Principal::from_slice(&handle.account_principal), @@ -2959,7 +2959,7 @@ impl Storage { }, ); // A zero here now means what it says. The delta refuses rather than clamping, so - // the row is only retired when no anchor references it, not when a counter that + // the list is only retired when no anchor references it, not when a counter that // had already drifted was pulled below zero. // // Tombstones count too, and they are the reason references alone are not enough: @@ -3119,7 +3119,7 @@ impl Storage { /// One account this identity holds at `key.origin`, or `None` where it holds none. /// /// `key.account_number` names it, `None` being the tracked default. Answering - /// `None` is the ownership check: an account belongs to whichever identity's row + /// `None` is the ownership check: an account belongs to whichever identity's list /// names it, so a caller that finds no reference here has no claim on the account /// whether or not it exists. /// @@ -3200,7 +3200,7 @@ impl Storage { let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; let account_number = self.allocate_account_number()?; - // An absent row normalises to the derived default, which is how the first named + // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. let mut references = self.account_references(anchor_number, application_number); @@ -3231,7 +3231,7 @@ impl Storage { /// /// Renaming one, naming the tracked default, and recording that an account was used /// are the same read-modify-write: the account is the state to store, not a patch - /// over it, so what it carries is what the row ends up holding. + /// over it, so what it carries is what the list ends up holding. /// /// A number no reference names is [`StorageError::AccountNotFound`] and never a /// create. `update_account_for_origin` takes its account number straight from the @@ -3253,7 +3253,7 @@ impl Storage { // The tracked default is stored the first time it is named or used, so its // origin gets an application number on either. None => self.lookup_or_insert_application_number_with_origin(&origin)?, - // A stored account writes to a row that already exists, and an origin + // A stored account writes to a list that already exists, and an origin // nothing has been stored under has none. Some(account_number) => self .lookup_application_number_with_origin(&origin) @@ -3266,7 +3266,7 @@ impl Storage { .position(|reference| reference.account_number == account_number) else { // Holding a reference is what grants access, so a miss means this identity - // does not have the account. For the tracked default it means the row is a + // does not have the account. For the tracked default it means the list is a // tombstone or the default was named and is no longer numberless — neither // can be reconstructed from the origin. return Err(match account_number { @@ -3310,7 +3310,7 @@ impl Storage { ) } // The tracked default, unnamed: nothing to store but the use of a - // reference the row already holds. + // reference the list already holds. (None, None) => { self.write_tracked_default(anchor_number, application_number, references, None)?; return Ok(Account::new_with_last_used( @@ -3626,8 +3626,8 @@ pub struct CreateSessionParams { pub now_ns: Timestamp, } -/// How far the sweep has got: which row, and how many of that row's references are -/// already indexed. The offset is what lets a batch stop inside a row that holds more +/// How far the sweep has got: which list, and how many of that list's references are +/// already indexed. The offset is what lets a batch stop inside a list that holds more /// references than one message can derive principals for. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct AccountPrincipalIndexBackfillCursor { @@ -3637,7 +3637,7 @@ pub struct AccountPrincipalIndexBackfillCursor { } impl AccountPrincipalIndexBackfillCursor { - fn row(&self) -> (AnchorNumber, ApplicationNumber) { + fn list(&self) -> (AnchorNumber, ApplicationNumber) { (self.anchor_number, self.application_number) } } @@ -3646,7 +3646,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A row + /// Rows whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, @@ -3666,7 +3666,7 @@ fn canister_id() -> Principal { } /// Which of the counters derived from a reference list a delta is applied to. /// -/// Each carries what identifies its row, so a refusal points at the counter that +/// Each carries what identifies its list, so a refusal points at the counter that /// diverged rather than only saying that one did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ReferenceCounter { @@ -3713,7 +3713,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list row moves the counters derived from it. +/// How one write to a reference-list list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. @@ -3724,19 +3724,19 @@ struct ReferenceListDeltas { accounts: i64, /// Change in references, named and tracked-default alike. references: i64, - /// Change in rows that exist while holding no reference. Only ever -1, 0 or 1: one - /// write touches one row. + /// Change in lists that exist while holding no reference. Only ever -1, 0 or 1: one + /// write touches one list. tombstones: i64, } impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// - /// A row that does not exist and one holding nothing both count as no references, + /// A list that does not exist and one holding nothing both count as no references, /// which is right for these totals: neither contributes any. It is also why - /// retiring a row must not go through here — a tombstone's row is still alive while + /// retiring a list must not go through here — a tombstone's list is still alive while /// holding nothing, so a diff against it would report no change and leave the - /// counters claiming references the removed row no longer has. + /// counters claiming references the removed list no longer has. fn between( previous_references: Option<&[AccountReference]>, new_references: &[AccountReference], @@ -3759,7 +3759,7 @@ impl ReferenceListDeltas { let (previous_named, previous_total) = counts(previous_references.unwrap_or_default()); let (new_named, new_total) = counts(new_references); - // A row that does not exist is not a tombstone — a tombstone is a row someone + // A list that does not exist is not a tombstone — a tombstone is a list someone // stored, and absence is what normalisation reads as "derive the default". let was_tombstone = previous_references.is_some_and(<[_]>::is_empty); let is_tombstone = new_references.is_empty(); @@ -3776,19 +3776,19 @@ impl ReferenceListDeltas { } } - /// What retiring a row holding `previous` does to the counters. + /// What retiring a list holding `previous` does to the counters. /// /// Separate from [`Self::between`] rather than a write of an empty list, because - /// an empty list cannot be written at all: a row holding nothing is a tombstone + /// an empty list cannot be written at all: a list holding nothing is a tombstone /// and stays, so only an outright removal gets to zero these out. fn removing(previous: &[AccountReference]) -> Self { let removed = Self::between(Some(&[]), previous); Self { accounts: removed.accounts.saturating_neg(), references: removed.references.saturating_neg(), - // The row is gone, so a tombstone goes with it. Not the negation of what + // The list is gone, so a tombstone goes with it. Not the negation of what // `between` reported: that describes writing this list, and this describes - // removing the row it was in. + // removing the list it was in. tombstones: if previous.is_empty() { -1 } else { 0 }, } } @@ -3801,7 +3801,7 @@ impl ReferenceListDeltas { /// /// Refuses rather than clamping: an under-run means the counters and the stored /// lists have already diverged, and a clamped zero reads as "no anchor references - /// this application any more", which retires a row other anchors still point at. + /// this application any more", which retires a list other anchors still point at. fn apply( &self, counter: ReferenceCounter, @@ -3876,8 +3876,8 @@ pub enum StorageError { SaltNotSet, AccountsCounterOverflow, /// No application numbers left to hand out. Refused rather than saturated: the - /// number keys the application row and the origin index, so reissuing one would - /// put two origins on a single row. + /// number keys the application and the origin index, so reissuing one would + /// put two origins on a single list. ApplicationsCounterOverflow, ErrorUpdatingApplicationNumberAllocator, /// No session ids left to hand out. Refused rather than saturated: the id is an From 7197c170496f4d8c2f5eb987916ebee13b531072 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:44 +0200 Subject: [PATCH 154/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it twice, in prose, for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the identifiers and comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index de8e7bf9a4..55cba450db 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2531,7 +2531,7 @@ mod account_reference_state_tests { } #[test] - fn an_untouched_row_offers_a_reconstructible_default() { + fn an_untouched_list_offers_a_reconstructible_default() { let (storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); @@ -2542,7 +2542,7 @@ mod account_reference_state_tests { } #[test] - fn a_tombstoned_row_lists_nothing_but_still_answers_the_default() { + fn a_tombstoned_list_holds_nothing_but_still_answers_the_default() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); @@ -2555,7 +2555,7 @@ mod account_reference_state_tests { } #[test] - fn a_row_that_names_no_default_has_no_default_to_read() { + fn a_list_that_names_no_default_has_no_default_to_read() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage @@ -2590,7 +2590,7 @@ mod account_reference_state_tests { } #[test] - fn a_named_account_added_to_a_tombstoned_row_does_not_revive_the_default() { + fn a_named_account_added_to_a_tombstoned_list_does_not_revive_the_default() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); From 6c2e88a101fb70d7c346409809cb62bb46740c2f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:49 +0200 Subject: [PATCH 155/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index cbeb593d4f..b8cc917b91 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1526,15 +1526,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's list is retired. Its + /// passed is never offered again even after the application is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest list walks it backwards. + /// exact, unlike an application count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest application walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the /// application and the origin index, so reissuing one would put two origins on - /// a single list and have them share its accounts and counters. + /// a single application and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest From 65de5b06f4320a39c834a88f888fd6f4cfb22687 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:50 +0200 Subject: [PATCH 156/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 2 +- src/internet_identity/src/storage/tests.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 856bf234c1..6dc4fda401 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2482,7 +2482,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list list moves the counters derived from it. +/// How one write to a account reference list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 77be057857..029fb7d597 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -448,7 +448,7 @@ fn should_record_that_a_named_account_was_used() { } #[test] -fn should_not_store_a_row_to_record_use_of_a_derived_default() { +fn should_not_store_a_list_to_record_use_of_a_derived_default() { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory); let origin = "https://example.com".to_string(); @@ -2317,7 +2317,7 @@ mod reference_list_write_path_tests { } #[test] - fn writing_the_list_the_row_already_holds_touches_nothing() { + fn writing_the_list_the_list_already_holds_touches_nothing() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -2566,7 +2566,7 @@ mod account_reference_state_tests { } #[test] - fn an_untouched_row_offers_a_reconstructible_default() { + fn an_untouched_list_offers_a_reconstructible_default() { let (storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); @@ -2577,7 +2577,7 @@ mod account_reference_state_tests { } #[test] - fn a_tombstoned_row_has_nothing_to_sign_in_as() { + fn a_tombstoned_list_has_nothing_to_sign_in_as() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); @@ -2590,7 +2590,7 @@ mod account_reference_state_tests { } #[test] - fn a_row_that_names_no_default_has_no_default_to_read() { + fn a_list_that_names_no_default_has_no_default_to_read() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage @@ -2623,7 +2623,7 @@ mod account_reference_state_tests { } #[test] - fn a_named_account_added_to_a_tombstoned_row_does_not_revive_the_default() { + fn a_named_account_added_to_a_tombstoned_list_does_not_revive_the_default() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); From 8a5a5cdd63a071f1d8539f7980c7089417ec8d42 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:50 +0200 Subject: [PATCH 157/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 16 +++---- .../src/storage/storable/application.rs | 2 +- src/internet_identity/src/storage/tests.rs | 42 +++++++++---------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 9cc28356c9..9a04760aff 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -299,7 +299,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list lists that hold nothing but a tracked default +/// Per-anchor cap on account reference lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -1640,7 +1640,7 @@ impl Storage { .map(Vec::::from) } - /// Removes a reference-list list and everything derived from it. + /// Removes a account reference list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, @@ -1684,8 +1684,8 @@ impl Storage { Ok(()) } - /// Rows whose only reference is a tracked default. - fn evictable_default_rows( + /// Lists whose only reference is a tracked default. + fn evictable_default_lists( &self, anchor_number: AnchorNumber, ) -> Vec<(ApplicationNumber, Option)> { @@ -1725,7 +1725,7 @@ impl Storage { } let mut candidates: Vec<_> = self - .evictable_default_rows(anchor_number) + .evictable_default_lists(anchor_number) .into_iter() .filter(|(application_number, _)| *application_number != just_written) .collect(); @@ -1846,13 +1846,13 @@ impl Storage { references: Vec, config: Option, ) -> Result<(), StorageError> { - let is_new_row = self + let is_new_list = self .stored_account_references(anchor_number, application_number) .is_none(); self.write_account_state(anchor_number, application_number, references, None, config)?; - if is_new_row { + if is_new_list { self.evict_idle_tracked_defaults(anchor_number, application_number)?; } @@ -2610,7 +2610,7 @@ pub enum ReferenceCount { Accounts, /// References, named and tracked-default alike. References, - /// Rows that exist while holding no reference. + /// Lists that exist while holding no reference. Tombstones, } diff --git a/src/internet_identity/src/storage/storable/application.rs b/src/internet_identity/src/storage/storable/application.rs index e2da18e453..76c4653c31 100644 --- a/src/internet_identity/src/storage/storable/application.rs +++ b/src/internet_identity/src/storage/storable/application.rs @@ -17,7 +17,7 @@ pub struct StorableApplication { pub stored_accounts: u64, #[n(2)] pub stored_account_references: u64, - /// Rows that exist here while holding no reference at all. + /// Lists that exist here while holding no reference at all. /// /// A list holding nothing is a tombstone: it says every account an identity had at /// this origin was moved away and its default must never be derived again. It diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index fd7499ddc1..636fd5af00 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2402,7 +2402,7 @@ mod reference_list_write_path_tests { } #[test] - fn writing_the_list_the_row_already_holds_touches_nothing() { + fn writing_the_list_the_list_already_holds_touches_nothing() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -2667,7 +2667,7 @@ mod account_reference_state_tests { } #[test] - fn an_untouched_row_offers_a_reconstructible_default() { + fn an_untouched_list_offers_a_reconstructible_default() { let (storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); @@ -2678,7 +2678,7 @@ mod account_reference_state_tests { } #[test] - fn a_tombstoned_row_has_nothing_to_sign_in_as() { + fn a_tombstoned_list_has_nothing_to_sign_in_as() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); @@ -2691,7 +2691,7 @@ mod account_reference_state_tests { } #[test] - fn a_row_that_names_no_default_has_no_default_to_read() { + fn a_list_that_names_no_default_has_no_default_to_read() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage @@ -2724,7 +2724,7 @@ mod account_reference_state_tests { } #[test] - fn a_named_account_added_to_a_tombstoned_row_does_not_revive_the_default() { + fn a_named_account_added_to_a_tombstoned_list_does_not_revive_the_default() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); @@ -3252,7 +3252,7 @@ mod default_account_tracking_tests { } #[test] - fn a_config_row_implies_a_reference_list_row() { + fn a_config_list_implies_an_account_reference_list() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -3330,7 +3330,7 @@ mod tracked_default_eviction_tests { let evicted = MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; assert_eq!( - storage.evictable_default_rows(anchor_number).len() as u64, + storage.evictable_default_lists(anchor_number).len() as u64, MAX_EVICTABLE_DEFAULT_ACCOUNTS - evicted ); @@ -3361,13 +3361,13 @@ mod tracked_default_eviction_tests { } assert!( - storage.evictable_default_rows(anchor_number).len() as u64 + storage.evictable_default_lists(anchor_number).len() as u64 <= MAX_EVICTABLE_DEFAULT_ACCOUNTS ); } #[test] - fn the_row_a_sign_in_just_wrote_is_never_its_own_victim() { + fn the_list_a_sign_in_just_wrote_is_never_its_own_victim() { let (mut storage, anchor_number) = storage_with_anchor(); for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 { record_use(&mut storage, anchor_number, origin_of(index), None, 1).unwrap(); @@ -3405,7 +3405,7 @@ mod tracked_default_eviction_tests { ) .unwrap(); } - let before = storage.evictable_default_rows(anchor_number).len() as u64; + let before = storage.evictable_default_lists(anchor_number).len() as u64; record_use( &mut storage, @@ -3416,7 +3416,7 @@ mod tracked_default_eviction_tests { ) .unwrap(); - let after = storage.evictable_default_rows(anchor_number).len() as u64; + let after = storage.evictable_default_lists(anchor_number).len() as u64; assert_eq!(before + 1 - after, MAX_EVICTIONS_PER_CALL); } @@ -3428,7 +3428,7 @@ mod tracked_default_eviction_tests { sign_in_at(&mut storage, anchor_number, index); } - let lists = storage.evictable_default_rows(anchor_number).len() as u64; + let lists = storage.evictable_default_lists(anchor_number).len() as u64; assert!(lists <= MAX_EVICTABLE_DEFAULT_ACCOUNTS); let newest = storage .lookup_application_number_with_origin(&origin_of( @@ -3463,7 +3463,7 @@ mod tracked_default_eviction_tests { } #[test] - fn a_default_sharing_a_row_with_a_named_account_is_not_evictable() { + fn a_default_sharing_a_list_with_a_named_account_is_not_evictable() { let (mut storage, anchor_number) = storage_with_anchor(); let shared_origin = "https://has-a-named-account.com".to_string(); storage @@ -3483,7 +3483,7 @@ mod tracked_default_eviction_tests { } #[test] - fn eviction_removes_the_config_row_and_the_counters_follow() { + fn eviction_removes_the_config_list_and_the_counters_follow() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -3558,7 +3558,7 @@ mod tracked_default_eviction_tests { } #[test] - fn removing_a_row_that_does_not_exist_is_a_no_op() { + fn removing_a_list_that_does_not_exist_is_a_no_op() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -3578,19 +3578,19 @@ mod tracked_default_eviction_tests { } #[test] - fn eviction_never_leaves_an_empty_row_behind() { + fn eviction_never_leaves_an_empty_list_behind() { let (mut storage, anchor_number) = storage_with_anchor(); for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { sign_in_at(&mut storage, anchor_number, index); } - let empty_rows = storage + let empty_lists = storage .stable_account_reference_list_memory .range((anchor_number, 0)..=(anchor_number, u64::MAX)) .filter(|(_, list)| list.clone().into_vec().is_empty()) .count(); - assert_eq!(empty_rows, 0); + assert_eq!(empty_lists, 0); } #[test] @@ -3613,7 +3613,7 @@ mod tracked_default_eviction_tests { storage.tracked_default_account_upper_bound(anchor_number), 4 ); - assert_eq!(storage.evictable_default_rows(anchor_number).len(), 1); + assert_eq!(storage.evictable_default_lists(anchor_number).len(), 1); } #[test] @@ -3657,7 +3657,7 @@ mod tracked_default_eviction_tests { ) .unwrap(); - assert_eq!(storage.evictable_default_rows(anchor_number).len(), 0); + assert_eq!(storage.evictable_default_lists(anchor_number).len(), 0); } } @@ -4022,7 +4022,7 @@ mod application_removal_tests { } #[test] - fn removal_leaves_no_config_row_behind() { + fn removal_leaves_no_config_list_behind() { let (mut storage, anchor_number, _) = storage_with_anchors(); let origin = "https://example.com".to_string(); let application_number = storage From f1bc5adc1d6dbb50617534f1e96ca6f8e7b21565 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:51 +0200 Subject: [PATCH 158/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 4 ++-- src/internet_identity/src/storage.rs | 10 +++++----- src/internet_identity/src/storage/tests.rs | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index c67896d862..3275344af0 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -846,9 +846,9 @@ thread_local! { static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID: RefCell> = const { RefCell::new(None) }; } -/// Returns `(indexed_entries, skipped_rows, is_done)` so monitoring can track the sweep. +/// Returns `(indexed_entries, skipped_lists, is_done)` so monitoring can track the sweep. /// -/// A non-zero skip count is not progress: it is reference-list lists whose application +/// A non-zero skip count is not progress: it is account reference lists whose application /// is gone, which the sweep cannot derive a principal for. A run that reports nothing /// indexed and nothing skipped had nothing to do; one that reports skips did not. #[query(hidden = true)] diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 830bc37ab8..532726582f 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1931,7 +1931,7 @@ impl Storage { Ok(()) } - /// Indexes one batch of existing reference-list lists. Entries are only inserted, + /// Indexes one batch of existing account reference lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// /// `batch_size` bounds **derivations**, not lists. One list is an identity's references @@ -1981,13 +1981,13 @@ impl Storage { Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; - let left_in_row = references.len().saturating_sub(already_done) as u64; + let left_in_list = references.len().saturating_sub(already_done) as u64; lists.push((key.0, key.1, references, already_done)); - if left_in_row >= outstanding { + if left_in_list >= outstanding { ran_out = true; break; } - outstanding -= left_in_row; + outstanding -= left_in_list; } // Nothing left to index, whatever else is true of this canister. Checked before @@ -2878,7 +2878,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A list + /// Lists whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index b686aad946..4eaccbfa5c 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4386,7 +4386,7 @@ mod account_principal_index_backfill_tests { const SALT: [u8; 32] = [17u8; 32]; - fn storage_with_rows(lists: u64) -> (Storage, Vec) { + fn storage_with_lists(lists: u64) -> (Storage, Vec) { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); storage.update_salt(SALT); let mut anchors = vec![]; @@ -4426,8 +4426,8 @@ mod account_principal_index_backfill_tests { } #[test] - fn a_sweep_indexes_every_pre_existing_row() { - let (mut storage, anchors) = storage_with_rows(5); + fn a_sweep_indexes_every_pre_existing_list() { + let (mut storage, anchors) = storage_with_lists(5); clear_index(&mut storage); assert_eq!(storage.lookup_account_with_principal_memory.len(), 0); @@ -4456,7 +4456,7 @@ mod account_principal_index_backfill_tests { #[test] fn a_sweep_resumes_from_its_cursor() { - let (mut storage, _) = storage_with_rows(5); + let (mut storage, _) = storage_with_lists(5); clear_index(&mut storage); let first = storage.backfill_account_principal_index_batch(None, 2); @@ -4475,7 +4475,7 @@ mod account_principal_index_backfill_tests { #[test] fn a_repeated_sweep_writes_nothing_new() { - let (mut storage, _) = storage_with_rows(3); + let (mut storage, _) = storage_with_lists(3); let outcome = storage.backfill_account_principal_index_batch(None, 100); @@ -4527,7 +4527,7 @@ mod account_principal_index_backfill_tests { /// meaningful once the sweep says it is finished. #[test] fn an_empty_batch_size_does_not_report_completion() { - let (mut storage, _) = storage_with_rows(3); + let (mut storage, _) = storage_with_lists(3); clear_index(&mut storage); let outcome = storage.backfill_account_principal_index_batch(None, 0); @@ -4540,8 +4540,8 @@ mod account_principal_index_backfill_tests { /// on list boundaries would derive that many principals in one message however small /// the batch. It stops inside the list and the cursor says where. #[test] - fn a_batch_stops_inside_a_row_too_big_to_finish() { - let (mut storage, anchors) = storage_with_rows(1); + fn a_batch_stops_inside_a_list_too_big_to_finish() { + let (mut storage, anchors) = storage_with_lists(1); let anchor_number = anchors[0]; let origin = "https://d-0.com".to_string(); let mut references = vec![AccountReference { From 8b281c8ba5ba916acf98d64af7163317df0362c7 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:52 +0200 Subject: [PATCH 159/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 62 +++++++++++----------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 682ea56b24..314e43af17 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2387,7 +2387,7 @@ mod reference_list_write_path_tests { } #[test] - fn writing_the_list_the_row_already_holds_touches_nothing() { + fn writing_the_list_the_list_already_holds_touches_nothing() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -2634,7 +2634,7 @@ mod account_reference_state_tests { } #[test] - fn an_untouched_row_offers_a_reconstructible_default() { + fn an_untouched_list_offers_a_reconstructible_default() { let (storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); @@ -2645,7 +2645,7 @@ mod account_reference_state_tests { } #[test] - fn a_tombstoned_row_has_nothing_to_sign_in_as() { + fn a_tombstoned_list_has_nothing_to_sign_in_as() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); @@ -2658,7 +2658,7 @@ mod account_reference_state_tests { } #[test] - fn a_row_that_names_no_default_has_no_default_to_read() { + fn a_list_that_names_no_default_has_no_default_to_read() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage @@ -2688,7 +2688,7 @@ mod account_reference_state_tests { } #[test] - fn a_named_account_added_to_a_tombstoned_row_does_not_revive_the_default() { + fn a_named_account_added_to_a_tombstoned_list_does_not_revive_the_default() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); plant_tombstone(&mut storage, anchor_number); @@ -3202,7 +3202,7 @@ mod default_account_tracking_tests { } #[test] - fn a_config_row_implies_a_reference_list_row() { + fn a_config_list_implies_an_account_reference_list() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -3278,7 +3278,7 @@ mod tracked_default_eviction_tests { let evicted = MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; assert_eq!( - storage.evictable_default_rows(anchor_number).len() as u64, + storage.evictable_default_lists(anchor_number).len() as u64, MAX_EVICTABLE_DEFAULT_ACCOUNTS - evicted ); @@ -3309,13 +3309,13 @@ mod tracked_default_eviction_tests { } assert!( - storage.evictable_default_rows(anchor_number).len() as u64 + storage.evictable_default_lists(anchor_number).len() as u64 <= MAX_EVICTABLE_DEFAULT_ACCOUNTS ); } #[test] - fn the_row_a_sign_in_just_wrote_is_never_its_own_victim() { + fn the_list_a_sign_in_just_wrote_is_never_its_own_victim() { let (mut storage, anchor_number) = storage_with_anchor(); for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 { record_use(&mut storage, anchor_number, origin_of(index), None, 1).unwrap(); @@ -3350,7 +3350,7 @@ mod tracked_default_eviction_tests { ) .unwrap(); } - let before = storage.evictable_default_rows(anchor_number).len() as u64; + let before = storage.evictable_default_lists(anchor_number).len() as u64; record_use( &mut storage, @@ -3362,7 +3362,7 @@ mod tracked_default_eviction_tests { .unwrap(); assert_eq!( - before + 1 - storage.evictable_default_rows(anchor_number).len() as u64, + before + 1 - storage.evictable_default_lists(anchor_number).len() as u64, MAX_EVICTIONS_PER_CALL ); } @@ -3375,7 +3375,7 @@ mod tracked_default_eviction_tests { sign_in_at(&mut storage, anchor_number, index); } - let lists = storage.evictable_default_rows(anchor_number).len() as u64; + let lists = storage.evictable_default_lists(anchor_number).len() as u64; assert!(lists <= MAX_EVICTABLE_DEFAULT_ACCOUNTS); let newest = storage .lookup_application_number_with_origin(&origin_of( @@ -3410,7 +3410,7 @@ mod tracked_default_eviction_tests { } #[test] - fn a_default_sharing_a_row_with_a_named_account_is_not_evictable() { + fn a_default_sharing_a_list_with_a_named_account_is_not_evictable() { let (mut storage, anchor_number) = storage_with_anchor(); let shared_origin = "https://has-a-named-account.com".to_string(); storage @@ -3430,7 +3430,7 @@ mod tracked_default_eviction_tests { } #[test] - fn eviction_removes_the_config_row_and_the_counters_follow() { + fn eviction_removes_the_config_list_and_the_counters_follow() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -3505,7 +3505,7 @@ mod tracked_default_eviction_tests { } #[test] - fn removing_a_row_that_does_not_exist_is_a_no_op() { + fn removing_a_list_that_does_not_exist_is_a_no_op() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); let application_number = storage @@ -3525,19 +3525,19 @@ mod tracked_default_eviction_tests { } #[test] - fn eviction_never_leaves_an_empty_row_behind() { + fn eviction_never_leaves_an_empty_list_behind() { let (mut storage, anchor_number) = storage_with_anchor(); for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { sign_in_at(&mut storage, anchor_number, index); } - let empty_rows = storage + let empty_lists = storage .stable_account_reference_list_memory .range((anchor_number, 0)..=(anchor_number, u64::MAX)) .filter(|(_, list)| list.clone().into_vec().is_empty()) .count(); - assert_eq!(empty_rows, 0); + assert_eq!(empty_lists, 0); } #[test] @@ -3560,7 +3560,7 @@ mod tracked_default_eviction_tests { storage.tracked_default_account_upper_bound(anchor_number), 4 ); - assert_eq!(storage.evictable_default_rows(anchor_number).len(), 1); + assert_eq!(storage.evictable_default_lists(anchor_number).len(), 1); } #[test] @@ -3601,7 +3601,7 @@ mod tracked_default_eviction_tests { ) .unwrap(); - assert_eq!(storage.evictable_default_rows(anchor_number).len(), 0); + assert_eq!(storage.evictable_default_lists(anchor_number).len(), 0); } } @@ -3961,7 +3961,7 @@ mod application_removal_tests { } #[test] - fn removal_leaves_no_config_row_behind() { + fn removal_leaves_no_config_list_behind() { let (mut storage, anchor_number, _) = storage_with_anchors(); let origin = "https://example.com".to_string(); let application_number = storage @@ -4310,7 +4310,7 @@ mod account_principal_index_backfill_tests { const SALT: [u8; 32] = [17u8; 32]; - fn storage_with_rows(lists: u64) -> (Storage, Vec) { + fn storage_with_lists(lists: u64) -> (Storage, Vec) { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); storage.update_salt(SALT); let mut anchors = vec![]; @@ -4347,8 +4347,8 @@ mod account_principal_index_backfill_tests { } #[test] - fn a_sweep_indexes_every_pre_existing_row() { - let (mut storage, anchors) = storage_with_rows(5); + fn a_sweep_indexes_every_pre_existing_list() { + let (mut storage, anchors) = storage_with_lists(5); clear_index(&mut storage); assert_eq!(storage.lookup_account_with_principal_memory.len(), 0); @@ -4377,7 +4377,7 @@ mod account_principal_index_backfill_tests { #[test] fn a_sweep_resumes_from_its_cursor() { - let (mut storage, _) = storage_with_rows(5); + let (mut storage, _) = storage_with_lists(5); clear_index(&mut storage); let first = storage.backfill_account_principal_index_batch(None, 2); @@ -4396,7 +4396,7 @@ mod account_principal_index_backfill_tests { #[test] fn a_repeated_sweep_writes_nothing_new() { - let (mut storage, _) = storage_with_rows(3); + let (mut storage, _) = storage_with_lists(3); let outcome = storage.backfill_account_principal_index_batch(None, 100); @@ -4445,7 +4445,7 @@ mod account_principal_index_backfill_tests { /// meaningful once the sweep says it is finished. #[test] fn an_empty_batch_size_does_not_report_completion() { - let (mut storage, _) = storage_with_rows(3); + let (mut storage, _) = storage_with_lists(3); clear_index(&mut storage); let outcome = storage.backfill_account_principal_index_batch(None, 0); @@ -4458,8 +4458,8 @@ mod account_principal_index_backfill_tests { /// on list boundaries would derive that many principals in one message however small /// the batch. It stops inside the list and the cursor says where. #[test] - fn a_batch_stops_inside_a_row_too_big_to_finish() { - let (mut storage, anchors) = storage_with_rows(1); + fn a_batch_stops_inside_a_list_too_big_to_finish() { + let (mut storage, anchors) = storage_with_lists(1); let anchor_number = anchors[0]; let origin = "https://d-0.com".to_string(); let mut references = vec![AccountReference::new(None, Some(1))]; @@ -4630,7 +4630,7 @@ mod session_record_tests { /// would leave the user with access that settings cannot show them, and a session /// nobody can find is a session nobody can revoke. #[test] - fn a_row_holding_a_session_is_evictable_like_any_other() { + fn a_list_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 @@ -4650,7 +4650,7 @@ mod session_record_tests { ) .unwrap(); - assert_eq!(storage.evictable_default_rows(anchor_number).len(), 1); + assert_eq!(storage.evictable_default_lists(anchor_number).len(), 1); } #[test] From 622ee33d1fd8a178340095f43e4c1c44be479f8b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:53 +0200 Subject: [PATCH 160/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 36 +++++++++++----------- src/internet_identity/src/storage/tests.rs | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index e0238782ad..d0d7ec7e4f 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -319,7 +319,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list lists that hold nothing but a tracked default +/// Per-anchor cap on account reference lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -1583,15 +1583,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's list is retired. Its + /// passed is never offered again even after the application is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest list walks it backwards. + /// exact, unlike an application count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest application walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the /// application and the origin index, so reissuing one would put two origins on - /// a single list and have them share its accounts and counters. + /// a single application and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -1919,7 +1919,7 @@ impl Storage { )) } - /// Removes a reference-list list and everything derived from it. + /// Removes a account reference list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, @@ -1987,8 +1987,8 @@ impl Storage { Ok(()) } - /// Rows whose only reference is a tracked default. - fn evictable_default_rows( + /// Lists whose only reference is a tracked default. + fn evictable_default_lists( &self, anchor_number: AnchorNumber, ) -> Vec<(ApplicationNumber, Option)> { @@ -2028,7 +2028,7 @@ impl Storage { } let mut candidates: Vec<_> = self - .evictable_default_rows(anchor_number) + .evictable_default_lists(anchor_number) .into_iter() .filter(|(application_number, _)| *application_number != just_written) .collect(); @@ -2284,20 +2284,20 @@ impl Storage { references: Vec, config: Option, ) -> Result<(), StorageError> { - let is_new_row = self + let is_new_list = self .stored_account_references(anchor_number, application_number) .is_none(); self.write_account_state(anchor_number, application_number, references, None, config)?; - if is_new_row { + if is_new_list { self.evict_idle_tracked_defaults(anchor_number, application_number)?; } Ok(()) } - /// Indexes one batch of existing reference-list lists. Entries are only inserted, + /// Indexes one batch of existing account reference lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// /// `batch_size` bounds **derivations**, not lists. One list is an identity's references @@ -2347,13 +2347,13 @@ impl Storage { Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; - let left_in_row = references.len().saturating_sub(already_done) as u64; + let left_in_list = references.len().saturating_sub(already_done) as u64; lists.push((key.0, key.1, references, already_done)); - if left_in_row >= outstanding { + if left_in_list >= outstanding { ran_out = true; break; } - outstanding -= left_in_row; + outstanding -= left_in_list; } // Nothing left to index, whatever else is true of this canister. Checked before @@ -3354,7 +3354,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A list + /// Lists whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, @@ -3407,7 +3407,7 @@ pub enum ReferenceCount { Accounts, /// References, named and tracked-default alike. References, - /// Rows that exist while holding no reference. + /// Lists that exist while holding no reference. Tombstones, } @@ -3421,7 +3421,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list list moves the counters derived from it. +/// How one write to a account reference list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 68ee03af32..fdf64fb297 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4721,7 +4721,7 @@ mod session_record_tests { /// Eviction orders on the list's `last_used`, which every refresh stamps, so a session /// in use keeps its list 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() { + fn a_refreshed_session_keeps_its_list_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(); From 3afff90dceaaf2d17c9c1332217109088e5f17a9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:54 +0200 Subject: [PATCH 161/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 36 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 97d68ba833..4d706ff115 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -319,7 +319,7 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on reference-list lists that hold nothing but a tracked default +/// Per-anchor cap on account reference lists that hold nothing but a tracked default /// account. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; @@ -1598,15 +1598,15 @@ impl Storage { /// one ever held. /// /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application's list is retired. Its + /// passed is never offered again even after the application is retired. Its /// value is not the whole answer only because it postdates the applications /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike a list count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest list walks it backwards. + /// exact, unlike an application count, which a retirement leaves undershooting. It cannot be + /// the answer on its own either: removing the highest application walks it backwards. /// /// Refuses at the ceiling rather than saturating. The number keys both the /// application and the origin index, so reissuing one would put two origins on - /// a single list and have them share its accounts and counters. + /// a single application and have them share its accounts and counters. fn allocate_application_number(&mut self) -> Result { let above_highest_stored = match self.stable_application_memory.last_key_value() { Some((highest, _)) => highest @@ -2030,7 +2030,7 @@ impl Storage { Ok((key, session)) } - /// Removes a reference-list list and everything derived from it. + /// Removes a account reference list and everything derived from it. fn remove_reference_list( &mut self, anchor_number: AnchorNumber, @@ -2098,8 +2098,8 @@ impl Storage { Ok(()) } - /// Rows whose only reference is a tracked default. - fn evictable_default_rows( + /// Lists whose only reference is a tracked default. + fn evictable_default_lists( &self, anchor_number: AnchorNumber, ) -> Vec<(ApplicationNumber, Option)> { @@ -2139,7 +2139,7 @@ impl Storage { } let mut candidates: Vec<_> = self - .evictable_default_rows(anchor_number) + .evictable_default_lists(anchor_number) .into_iter() .filter(|(application_number, _)| *application_number != just_written) .collect(); @@ -2395,20 +2395,20 @@ impl Storage { references: Vec, config: Option, ) -> Result<(), StorageError> { - let is_new_row = self + let is_new_list = self .stored_account_references(anchor_number, application_number) .is_none(); self.write_account_state(anchor_number, application_number, references, None, config)?; - if is_new_row { + if is_new_list { self.evict_idle_tracked_defaults(anchor_number, application_number)?; } Ok(()) } - /// Indexes one batch of existing reference-list lists. Entries are only inserted, + /// Indexes one batch of existing account reference lists. Entries are only inserted, /// never removed, so a batch that runs twice writes the same values. /// /// `batch_size` bounds **derivations**, not lists. One list is an identity's references @@ -2458,13 +2458,13 @@ impl Storage { Some(cursor) if cursor.list() == key => cursor.references_done, _ => 0, }; - let left_in_row = references.len().saturating_sub(already_done) as u64; + let left_in_list = references.len().saturating_sub(already_done) as u64; lists.push((key.0, key.1, references, already_done)); - if left_in_row >= outstanding { + if left_in_list >= outstanding { ran_out = true; break; } - outstanding -= left_in_row; + outstanding -= left_in_list; } // Nothing left to index, whatever else is true of this canister. Checked before @@ -3463,7 +3463,7 @@ impl AccountPrincipalIndexBackfillCursor { pub struct AccountPrincipalIndexBackfillOutcome { pub next_cursor: Option, pub indexed: u64, - /// Rows whose application is gone, so no principal can be derived for them. A list + /// Lists whose application is gone, so no principal can be derived for them. A list /// in that state is an inconsistency rather than a normal skip, and a run that /// silently indexes nothing would otherwise look like a run with nothing to do. pub skipped: u64, @@ -3516,7 +3516,7 @@ pub enum ReferenceCount { Accounts, /// References, named and tracked-default alike. References, - /// Rows that exist while holding no reference. + /// Lists that exist while holding no reference. Tombstones, } @@ -3530,7 +3530,7 @@ impl fmt::Display for ReferenceCount { } } -/// How one write to a reference-list list moves the counters derived from it. +/// How one write to a account reference list moves the counters derived from it. /// /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. From 36c01dbc12e50487a5f54c117d35f6e096558937 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 01:48:54 +0200 Subject: [PATCH 162/298] refactor(be): name the account reference list rather than a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Row" was this stack's own word: `origin/main` uses it for something else. The storage layer already names this thing — the account reference list — in its types and functions, so the comments now say what the code says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 3c446e7076..46ed14ef12 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5386,10 +5386,10 @@ mod session_creation_tests { assert_eq!(storage.read(anchor_number).unwrap().session_count, 0); } - /// Row eviction leaves the account's principal untouched, so the same origin comes back + /// List 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() { + fn evicting_a_list_removes_its_sessions_index_entries() { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage .create_session(params(anchor_number, 7, 1_000)) From 916d203b3e22a0901f325aaca7aef71381fd0720 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 02:29:20 +0200 Subject: [PATCH 163/298] refactor(be): one write path for an identity's account state, keyed by origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path took one application at a time, so anything spanning several looped it — and on the IC an `Err` commits what came before it. It also took an application number, which meant the caller had to mint one before the write, so a refusal could leave behind an application no counter would ever retire. It now takes what the identity holds at each origin, validates every origin before storing any of them, and derives the rest: the counters, the application's own totals, its creation, and the account numbers for anything being named. An origin is created only where the write stores something at it, and a refusal mints nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 943 +++++++++++------- .../src/storage/account/tests.rs | 8 +- .../src/storage/storable/account.rs | 2 +- .../storable/anchor_application_config.rs | 2 +- src/internet_identity/src/storage/tests.rs | 768 ++++++++++---- 5 files changed, 1153 insertions(+), 570 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 6dc4fda401..9b080cbde2 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -84,7 +84,7 @@ use candid::{CandidType, Deserialize, Principal}; use ic_cdk::api::stable::WASM_PAGE_SIZE_IN_BYTES; use ic_stable_structures::cell::ValueError; use std::borrow::Cow; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; use std::io::Write; use std::ops::RangeInclusive; @@ -1484,77 +1484,6 @@ impl Storage { .get(&key.clone().into()) } - /// Look up an application number per origin, create entry in applications and lookup table if it doesn't exist - /// - /// Fails only where there is no number left to hand out, which - /// [`Self::allocate_application_number`] refuses rather than reissuing one. - pub fn lookup_or_insert_application_number_with_origin( - &mut self, - origin: &FrontendHostname, - ) -> Result { - let origin_sha256 = StorableOriginSha256::from_origin(origin); - - if let Some(existing_number) = self - .lookup_application_with_origin_memory - .get(&origin_sha256) - { - return Ok(existing_number); - } - - let new_number = self.allocate_application_number()?; - - // Update the source of truth. - self.lookup_application_with_origin_memory - .insert(origin_sha256, new_number); - - let new_application = StorableApplication { - origin: origin.to_string(), - stored_accounts: 0u64, - stored_account_references: 0u64, - }; - - self.stable_application_memory - .insert(new_number, new_application); - - Ok(new_number) - } - - /// Hands out an application number that no live application holds and no retired - /// one ever held. - /// - /// The counter is what guarantees that: it only ever climbs, so a number it has - /// passed is never offered again even after the application is retired. Its - /// value is not the whole answer only because it postdates the applications - /// numbered before it existed, so the highest stored number is taken as a floor — - /// exact, unlike an application count, which a retirement leaves undershooting. It cannot be - /// the answer on its own either: removing the highest application walks it backwards. - /// - /// Refuses at the ceiling rather than saturating. The number keys both the - /// application and the origin index, so reissuing one would put two origins on - /// a single application and have them share its accounts and counters. - fn allocate_application_number(&mut self) -> Result { - let above_highest_stored = match self.stable_application_memory.last_key_value() { - Some((highest, _)) => highest - .checked_add(1) - .ok_or(StorageError::ApplicationsCounterOverflow)?, - None => 0, - }; - let new_number = ApplicationNumber::max( - *self.next_application_number_memory.get(), - above_highest_stored, - ); - - self.next_application_number_memory - .set( - new_number - .checked_add(1) - .ok_or(StorageError::ApplicationsCounterOverflow)?, - ) - .map_err(|_| StorageError::ErrorUpdatingApplicationNumberAllocator)?; - - Ok(new_number) - } - pub fn lookup_application_number_with_origin( &self, origin: &FrontendHostname, @@ -1605,6 +1534,391 @@ impl Storage { } } + /// This identity's account state at one origin, in the shape a write takes it. + /// + /// An origin nothing has been stored under normalises to the derived default, so no + /// caller builds one and the rule that absence means the derived default stays in + /// here. Writing this value back unchanged changes nothing. + fn account_state_for_origin( + &self, + anchor_number: AnchorNumber, + origin: &FrontendHostname, + ) -> AccountReferenceListWrite { + ( + self.account_references_for_origin(anchor_number, origin) + .into_iter() + .map(AccountReferenceWrite::from) + .collect(), + None, + ) + } + + /// Writes this identity's account state, at one origin or several. + /// + /// Every refusal for every origin happens before the first store, so a write that + /// spans origins cannot half-happen. Returning `Err` on the IC commits whatever was + /// written before it and only a trap rolls back, so a refusal here must leave + /// nothing behind. There is deliberately no form of this that writes one origin at a + /// time, because that is the shape that gets it wrong. + /// + /// A write says what the identity holds afterwards rather than patching what it + /// holds now. Each origin's account references are diffed against what is stored to + /// derive the counters and the application's own totals, so no caller states any of + /// them and none can forget one. + /// + /// The gate speaks origins. An application number is storage's handle for an origin + /// and this owns that mapping, so an origin nothing is stored under is created here, + /// application and all, with its counters already right. A refusal cannot leave + /// behind an application no counter will ever retire. + /// + /// An account reference carrying a record but no number is an account being named: a + /// name is what a person gives an account, the number is what storage keys it by, so + /// the number is minted here too and a refusal mints nothing. + /// + /// Returns what the identity now holds — the input with the minted account numbers + /// filled in, each list in the order it was given. + fn write_account_state( + &mut self, + anchor_number: AnchorNumber, + writes: BTreeMap, + ) -> Result, StorageError> { + let validated = self.validate_account_state(anchor_number, writes)?; + Ok(self.apply_account_state(anchor_number, validated)) + } + + /// Everything that can refuse. Reads what is stored, works out what would be minted + /// without minting it, and hands apply something that cannot fail. + fn validate_account_state( + &self, + anchor_number: AnchorNumber, + writes: BTreeMap, + ) -> Result { + let mut minting = MintingState { + next_application_number: self.next_application_number()?, + global: self.stable_account_counter_memory.get().clone(), + }; + + let mut validated = Vec::with_capacity(writes.len()); + for (origin, write) in writes { + validated.push(self.validate_account_reference_list( + anchor_number, + origin, + write, + &mut minting, + )?); + } + + // The anchor's counter and the global reference count are shared by every origin + // in this call, so they are folded here rather than per write. Each delta + // computed against the same stored value and applied on its own would keep only + // the last of them, and deltas that each fit can still sum to one that does not: + // applying them to a running total is what checks the sum rather than the parts. + let stored_anchor = self + .stable_anchor_account_counter_memory + .get(&anchor_number) + .unwrap_or_default(); + let mut anchor_accounts = stored_anchor.stored_accounts; + let mut anchor_references = stored_anchor.stored_account_references; + let mut global_references = minting.global.stored_account_references; + for one in &validated { + let (accounts, references) = one.deltas.apply( + ReferenceCounter::Anchor { anchor_number }, + anchor_accounts, + anchor_references, + )?; + anchor_accounts = accounts; + anchor_references = references; + global_references = one.deltas.apply_one( + ReferenceCounter::Global, + ReferenceCount::References, + global_references, + )?; + } + + Ok(ValidatedAccountStateWrite { + writes: validated, + anchor_counter: StorableAccountsCounter { + stored_accounts: anchor_accounts, + stored_account_references: anchor_references, + }, + // Only the reference count moves here: the global account count is the + // account-number allocator, which minting above has already advanced. + global_counter: StorableAccountsCounter { + stored_accounts: minting.global.stored_accounts, + stored_account_references: global_references, + }, + next_application_number: minting.next_application_number, + }) + } + + /// One origin's worth of validation. + fn validate_account_reference_list( + &self, + anchor_number: AnchorNumber, + origin: FrontendHostname, + (mut writes, config): AccountReferenceListWrite, + minting: &mut MintingState, + ) -> Result { + let stored_number = self.lookup_application_number_with_origin(&origin); + let stored = stored_number.and_then(|application_number| { + self.stored_account_references(anchor_number, application_number) + }); + let previous_holds_tracked_default = match &stored { + Some(references) => references + .iter() + .any(|reference| reference.account_number.is_none()), + // An absent list normalises to the derived default, which is one. + None => true, + }; + + // A record on an account reference with no number is an account being named. + // Minting it here is what keeps the number out of the caller's hands, and what + // makes a refusal cost nothing: the allocator only moves in apply. + let mut minted = Vec::new(); + for write in &mut writes { + if write.account_reference.account_number.is_none() && write.record.is_some() { + let account_number = minting.allocate_account_number()?; + write.account_reference.account_number = Some(account_number); + minted.push(account_number); + } + } + + let references: Vec = writes + .iter() + .map(|write| write.account_reference.clone()) + .collect(); + + // Nothing changed, so nothing is written and nothing about it is checked. Two + // ways of saying the same thing: the stored list already holds these bytes, or + // nothing is stored and this says only what absence already says. The second is + // what keeps a read-change-write that touched nothing from materialising a list. + let unchanged = stored.as_deref() == Some(references.as_slice()); + // An empty list is not "nothing worth storing" — it is a tombstone, which nothing + // may create yet, so it has to reach the refusal below rather than be skipped + // here. `all` on an empty list is true, which is exactly how it would not. + let records_nothing = stored.is_none() + && !references.is_empty() + && references + .iter() + .all(|reference| reference.account_number.is_none()); + let writes_a_list = !(unchanged || records_nothing); + + let records: Vec<(AccountNumber, StorableAccount)> = writes + .iter() + .filter_map(|write| { + let record = write.record.clone()?; + // Every record was given a number above where it lacked one. + let account_number = write.account_reference.account_number?; + Some((account_number, record)) + }) + .collect(); + + // Naming a tracked default is what makes the named account this identity's + // default here: the tracked default is gone and the account that replaced it is + // what the identity signs in with. The caller cannot say so itself, because the + // number was minted above — so it is derived from the write rather than stated. + // Adding a named account beside a default that is still there is not this: the + // list still holds a numberless account reference. + let config = match config { + Some(config) => Some(config), + None if previous_holds_tracked_default + && minted.len() == 1 + && !references + .iter() + .any(|reference| reference.account_number.is_none()) => + { + Some(AnchorApplicationConfig { + default_account_number: Some(minted[0]), + }) + } + None => None, + }; + + // Only a list or a config is stored against an application: a record is keyed by + // its account number and needs none. So a rename, which leaves every account + // reference where it was, does not check the application either — which is what + // keeps it as cheap as it looks. + // + // Nothing to store against one means nothing is checked, and that is what lets a + // write that changed nothing cost nothing: reading the whole of an identity's + // state and writing it straight back is a no-op rather than a sweep of writes. + if !writes_a_list && config.is_none() { + return Ok(ValidatedAccountReferenceListWrite { + written: (writes, None), + origin, + application_number: None, + application: None, + list: None, + records, + config: None, + deltas: ReferenceListDeltas::default(), + }); + } + + // An origin nothing is stored under gets its application here. Creating one for a + // write that left nothing behind would leave an application no counter will ever + // retire, since retirement only ever runs off a write to its account state — + // which is why this sits after the check above rather than before it. + let application_number = match stored_number { + Some(application_number) => application_number, + None => minting.allocate_application_number()?, + }; + + // An origin index pointing at an application that is gone is a broken invariant + // rather than a new origin, so it still refuses rather than quietly creating one. + let application = match ( + stored_number, + self.stable_application_memory.get(&application_number), + ) { + (Some(_), Some(application)) => application, + (Some(_), None) => { + return Err(StorageError::OriginNotFoundForApplicationNumber { application_number }) + } + (None, _) => StorableApplication { + origin: origin.clone(), + stored_accounts: 0, + stored_account_references: 0, + }, + }; + + let (list, deltas) = if writes_a_list { + // Refuses a list this identity may not store — see + // [`StorableAccountReferenceList::try_from`], which is where that is + // enforced and why it is enforced there. + let storable = + StorableAccountReferenceList::try_from(references.clone()).map_err(|error| { + StorageError::UnstorableAccountReferenceList { + anchor_number, + application_number, + error, + } + })?; + let deltas = + ReferenceListDeltas::between(stored.as_deref().unwrap_or_default(), &references); + (Some(storable), deltas) + } else { + (None, ReferenceListDeltas::default()) + }; + + let (application_accounts, application_references) = deltas.apply( + ReferenceCounter::Application { application_number }, + application.stored_accounts, + application.stored_account_references, + )?; + + Ok(ValidatedAccountReferenceListWrite { + written: (writes, config.clone()), + origin, + application_number: Some(application_number), + application: Some(StorableApplication { + origin: application.origin, + stored_accounts: application_accounts, + stored_account_references: application_references, + }), + list, + records, + config, + deltas, + }) + } + + /// Stores what was validated, and everything derived from it. + /// + /// Cannot refuse: every read it needed happened in validate, and the two cells it + /// sets hold fixed-size values that were read out of them, so a failure to set one + /// is a broken invariant rather than a case to report. + fn apply_account_state( + &mut self, + anchor_number: AnchorNumber, + validated: ValidatedAccountStateWrite, + ) -> BTreeMap { + let ValidatedAccountStateWrite { + writes, + anchor_counter, + global_counter, + next_application_number, + } = validated; + + self.stable_account_counter_memory + .set(global_counter) + .expect("the account counter is a fixed-size value read from this cell"); + self.next_application_number_memory + .set(next_application_number) + .expect("the application number allocator is a fixed-size value read from this cell"); + self.stable_anchor_account_counter_memory + .insert(anchor_number, anchor_counter); + + let mut written = BTreeMap::new(); + for one in writes { + let ValidatedAccountReferenceListWrite { + origin, + application_number, + application, + list, + records, + config, + written: result, + .. + } = one; + + let Some((application_number, application)) = application_number.zip(application) + else { + // No list and no config, so nothing is keyed by an application here. A + // record still is — by its own account number — so a rename lands. + for (account_number, record) in records { + self.stable_account_memory.insert(account_number, record); + } + written.insert(origin, result); + continue; + }; + + // The application and its counters go in together, so a list is never stored + // against an application whose totals do not know about it, and an + // application is never stored without something holding it. + self.lookup_application_with_origin_memory.insert( + StorableOriginSha256::from_origin(&origin), + application_number, + ); + self.stable_application_memory + .insert(application_number, application); + + if let Some(list) = list { + self.stable_account_reference_list_memory + .insert((anchor_number, application_number), list); + } + for (account_number, record) in records { + self.stable_account_memory.insert(account_number, record); + } + if let Some(config) = config { + self.stable_anchor_application_config_memory + .insert((anchor_number, application_number), config); + } + + written.insert(origin, result); + } + + written + } + + /// The number the next application would be given, without giving it out. + /// + /// The counter only ever climbs, so a number it has passed is never offered again + /// even after the application is retired. Its value is not the whole answer only + /// because it postdates the applications numbered before it existed, so the highest + /// stored number is taken as a floor. + fn next_application_number(&self) -> Result { + let above_highest_stored = match self.stable_application_memory.last_key_value() { + Some((highest, _)) => highest + .checked_add(1) + .ok_or(StorageError::ApplicationsCounterOverflow)?, + None => 0, + }; + Ok(ApplicationNumber::max( + *self.next_application_number_memory.get(), + above_highest_stored, + )) + } + /// What an identity holds where nothing is stored: the default it has always had, /// derived from the origin rather than kept. fn derived_default_references() -> Vec { @@ -1643,160 +1957,6 @@ impl Storage { AnchorApplicationConfig::default() } - /// The single write path for everything keyed by `(anchor_number, - /// application_number)`: the reference list, an account record, and the config - /// naming the default. - /// - /// The three describe one state and drift apart if written apart, so they are - /// written together or not at all. Every fallible step runs before any of them: - /// on the IC returning `Err` commits what was written before it, and only a trap - /// rolls back, so a refusal here must leave nothing behind. - /// - /// `references` is the whole new list. It is diffed against the stored list for the - /// counter deltas, so a caller supplies only what it wants stored and cannot move a - /// counter by the default [`Self::account_references`] derived for it. A list equal - /// to the stored list writes nothing: the list is a single blob, so writing it back - /// would store the bytes it already holds. - fn write_account_state( - &mut self, - anchor_number: AnchorNumber, - application_number: ApplicationNumber, - references: Vec, - record: Option<(AccountNumber, StorableAccount)>, - config: Option, - ) -> Result<(), StorageError> { - let stored_references = self.stored_account_references(anchor_number, application_number); - - // Nothing to say: the list already holds these bytes, so it is not written and - // nothing about it is checked either. This is what lets a rename leave every - // reference alone without its caller having to know to skip the write. - let list_write = if stored_references.as_deref() == Some(references.as_slice()) { - None - } else { - // Refuses a list this identity may not store — see - // [`StorableAccountReferenceList::try_from`], which is where that is - // enforced and why it is enforced there. - let storable_references = StorableAccountReferenceList::try_from(references.clone()) - .map_err(|error| StorageError::UnstorableAccountReferenceList { - anchor_number, - application_number, - error, - })?; - let application = self - .stable_application_memory - .get(&application_number) - .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; - // A first list holding nothing but a derived default records nothing: - // absence already says the identity has its default here, so storing it - // would keep bytes to repeat that. Only an account number is worth a list. - // Checked after the refusals above, so a caller still hears about a list or - // an application it had no business passing. - let records_nothing = stored_references.is_none() - && references - .iter() - .all(|reference| reference.account_number.is_none()); - - let deltas = ReferenceListDeltas::between( - stored_references.as_deref().unwrap_or_default(), - &references, - ); - (!records_nothing).then_some((storable_references, application, deltas)) - }; - - if let Some((storable_references, application, deltas)) = list_write { - // Counters first: the only step left that can fail, so an out-of-bounds - // delta refuses with nothing stored rather than a list without its counts. - self.apply_reference_counter_deltas( - anchor_number, - application_number, - application, - deltas, - )?; - self.stable_account_reference_list_memory - .insert((anchor_number, application_number), storable_references); - } - - if let Some((account_number, storable_account)) = record { - self.stable_account_memory - .insert(account_number, storable_account); - } - - if let Some(config) = config { - self.stable_anchor_application_config_memory - .insert((anchor_number, application_number), config); - } - - Ok(()) - } - - fn apply_reference_counter_deltas( - &mut self, - anchor_number: AnchorNumber, - application_number: ApplicationNumber, - application: StorableApplication, - deltas: ReferenceListDeltas, - ) -> Result<(), StorageError> { - if deltas.is_empty() { - return Ok(()); - } - - // Every counter is computed before any of them is written, so an out-of-bounds - // delta refuses with all three still holding their old values. - let anchor_counter = self - .stable_anchor_account_counter_memory - .get(&anchor_number) - .unwrap_or_default(); - let (anchor_accounts, anchor_references) = deltas.apply( - ReferenceCounter::Anchor { anchor_number }, - anchor_counter.stored_accounts, - anchor_counter.stored_account_references, - )?; - - // Only the reference count: the global account count is the account-number - // allocator, which `allocate_account_number` owns and this rewrites unchanged. - // Moving it here would refuse a write over a count nothing was going to store. - let global_counter = self.stable_account_counter_memory.get().clone(); - let global_references = deltas.apply_one( - ReferenceCounter::Global, - ReferenceCount::References, - global_counter.stored_account_references, - )?; - - let (application_accounts, application_references) = deltas.apply( - ReferenceCounter::Application { application_number }, - application.stored_accounts, - application.stored_account_references, - )?; - - // The only write here that reports a failure, so it goes before the two that - // cannot: past this line nothing can return an error and leave a partial update. - self.stable_account_counter_memory - .set(StorableAccountsCounter { - stored_accounts: global_counter.stored_accounts, - stored_account_references: global_references, - }) - .map_err(|_| StorageError::ErrorUpdatingAccountCounter)?; - - self.stable_anchor_account_counter_memory.insert( - anchor_number, - StorableAccountsCounter { - stored_accounts: anchor_accounts, - stored_account_references: anchor_references, - }, - ); - - self.stable_application_memory.insert( - application_number, - StorableApplication { - origin: application.origin, - stored_accounts: application_accounts, - stored_account_references: application_references, - }, - ); - - Ok(()) - } - /// This is for testing purposes only, DO NOT use anywhere else! #[cfg(test)] #[allow(dead_code)] @@ -1836,25 +1996,6 @@ impl Storage { self.stable_application_memory.len() } - // Increments the `stable_account_counter_memory` account counter by one and returns the new number. - fn allocate_account_number(&mut self) -> Result { - let account_counter = self.stable_account_counter_memory.get().clone(); - // The counter is also the account number, so it must not wrap or saturate: - // either would re-issue a number that is already in use, and two accounts at - // one origin would derive the same principal. - let next_account_number = account_counter - .stored_accounts - .checked_add(1) - .ok_or(StorageError::AccountsCounterOverflow)?; - self.stable_account_counter_memory - .set(StorableAccountsCounter { - stored_accounts: next_account_number, - ..account_counter - }) - .map_err(|_| StorageError::ErrorUpdatingAccountCounter)?; - Ok(next_account_number) - } - /// Returns all account references associated with a single anchor number, across all applications. pub fn list_identity_account_references( &self, @@ -1993,39 +2134,37 @@ impl Storage { ) -> Result { check_frontend_length(&origin); - // Both fallible, so both before the record is built. An allocated number that - // is never stored only leaves a gap, and the counter is monotonic by design; - // a stored record nothing references would be visible and permanent. - let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; - let account_number = self.allocate_account_number()?; - // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. - let mut references = self.account_references(anchor_number, application_number); - references.push(AccountReference { - account_number: Some(account_number), - // Set when the identity signs in with the account. - last_used: None, + let (mut account_references, config) = + self.account_state_for_origin(anchor_number, &origin); + // Where the write leaves it, and so where its minted number comes back. + let created = account_references.len(); + account_references.push(AccountReferenceWrite { + account_reference: AccountReference { + account_number: None, + // Set when the identity signs in with the account. + last_used: None, + }, + // A record on an account reference with no number is an account being named, + // and naming it is what mints it one. + record: Some(StorableAccount { + name: name.clone(), + seed_from_anchor: None, + }), }); - let storable_account = StorableAccount { - name: name.clone(), - seed_from_anchor: None, - }; - self.write_account_state( + let written = self.write_account_state( anchor_number, - application_number, - references, - Some((account_number, storable_account)), - None, + BTreeMap::from([(origin.clone(), (account_references, config))]), )?; Ok(Account::new( anchor_number, - origin, + origin.clone(), Some(name), - Some(account_number), + written[&origin].0[created].account_reference.account_number, )) } @@ -2051,42 +2190,39 @@ impl Storage { .. } = account; - let application_number = match (account_number, &name) { - // Naming the tracked default stores this identity's first account here, so - // the origin gets its application number now. - (None, Some(_)) => self.lookup_or_insert_application_number_with_origin(&origin)?, - // Everything else writes to a list that already exists, and an origin - // nothing has been stored under has none. - _ => match self.lookup_application_number_with_origin(&origin) { - Some(application_number) => application_number, - None => { - return match account_number { - Some(account_number) => { - Err(StorageError::AccountNotFound { account_number }) - } - // The default here is still derived rather than stored, so - // there is no reference to record its use against. - None => Ok(Account::new_with_last_used( - anchor_number, - origin, - None, - None, - last_used, - )), - }; - } - }, - }; + // An origin nothing has been stored under still holds the derived default, so + // naming that default lands there and creates the application. Anything else has + // no account reference to write against. + let names_the_tracked_default = account_number.is_none() && name.is_some(); + if self + .lookup_application_number_with_origin(&origin) + .is_none() + && !names_the_tracked_default + { + return match account_number { + Some(account_number) => Err(StorageError::AccountNotFound { account_number }), + // The default here is still derived rather than stored, so there is no + // account reference to record its use against. + None => Ok(Account::new_with_last_used( + anchor_number, + origin, + None, + None, + last_used, + )), + }; + } - let mut references = self.account_references(anchor_number, application_number); - let Some(position) = references + let (mut account_references, config) = + self.account_state_for_origin(anchor_number, &origin); + let Some(position) = account_references .iter() - .position(|reference| reference.account_number == account_number) + .position(|write| write.account_reference.account_number == account_number) else { - // Holding a reference is what grants access, so a miss means this identity - // does not have the account. For the tracked default it means the list is a - // tombstone or the default was named and is no longer numberless — neither - // can be reconstructed from the origin. + // Holding an account reference is what grants access, so a miss means this + // identity does not have the account. For the tracked default it means the + // list is a tombstone or the default was named and is no longer numberless — + // neither can be reconstructed from the origin. return Err(match account_number { Some(account_number) => StorageError::AccountNotFound { account_number }, None => StorageError::MissingAccount { @@ -2095,9 +2231,9 @@ impl Storage { }, }); }; - references[position].last_used = last_used; + account_references[position].account_reference.last_used = last_used; - let (account_number, storable_account, config) = match (account_number, name) { + match (account_number, name) { // A stored account, whose record carries the name. Only the tracked default // goes without one, so an account that has a number and no name is not a // state a read can hand back. @@ -2108,63 +2244,47 @@ impl Storage { return Err(StorageError::AccountNotFound { account_number }); }; storable_account.name = name; - (account_number, storable_account, None) + account_references[position].record = Some(storable_account); } - // Naming the tracked default is what stores it, and storing it is what - // mints its number. Its seed stays the anchor's, so the principal this - // identity already signs in with here is preserved. + // Naming the tracked default is what stores it, and the write mints its + // number. Its seed stays the anchor's, so the principal this identity already + // signs in with here is preserved — and the write makes it the default, + // because that is what naming it means. (None, Some(name)) => { - let account_number = self.allocate_account_number()?; - references[position].account_number = Some(account_number); - ( - account_number, - StorableAccount { - name, - seed_from_anchor: Some(anchor_number), - }, - Some(AnchorApplicationConfig { - default_account_number: Some(account_number), - }), - ) + account_references[position].record = Some(StorableAccount { + name, + seed_from_anchor: Some(anchor_number), + }); } - // The tracked default, unnamed: nothing to store but the use of a + // The tracked default, unnamed: nothing to store but the use of an account // reference the list already holds. - (None, None) => { - self.write_account_state( - anchor_number, - application_number, - references, - None, - None, - )?; - return Ok(Account::new_with_last_used( - anchor_number, - origin, - None, - None, - last_used, - )); - } - }; + (None, None) => {} + } - let name = storable_account.name.clone(); - let seed_from_anchor = storable_account.seed_from_anchor; - self.write_account_state( + let written = self.write_account_state( anchor_number, - application_number, - references, - Some((account_number, storable_account)), - config, + BTreeMap::from([(origin.clone(), (account_references, config))]), )?; + let write = &written[&origin].0[position]; + let account_number = write.account_reference.account_number; - Ok(Account::new_full( - anchor_number, - origin, - Some(name), - Some(account_number), - last_used, - seed_from_anchor, - )) + Ok(match &write.record { + Some(record) => Account::new_full( + anchor_number, + origin.clone(), + Some(record.name.clone()), + account_number, + last_used, + record.seed_from_anchor, + ), + None => Account::new_with_last_used( + anchor_number, + origin.clone(), + None, + account_number, + last_used, + ), + }) } /// Points this identity's default at `origin` to `account_number`, or clears it @@ -2180,18 +2300,20 @@ impl Storage { ) -> Result<(), StorageError> { check_frontend_length(&origin); - let application_number = self.lookup_or_insert_application_number_with_origin(&origin)?; - let references = self.account_references(anchor_number, application_number); - + let (account_references, _) = self.account_state_for_origin(anchor_number, &origin); self.write_account_state( anchor_number, - application_number, - references, - None, - Some(AnchorApplicationConfig { - default_account_number: account_number, - }), - ) + BTreeMap::from([( + origin, + ( + account_references, + Some(AnchorApplicationConfig { + default_account_number: account_number, + }), + ), + )]), + )?; + Ok(()) } /// Make sure all the required metadata is recorded to stable memory. @@ -2487,7 +2609,7 @@ impl fmt::Display for ReferenceCount { /// Signed because these are differences rather than totals: a write that drops a /// reference has to move the counters down, and there is no unsigned way to say so. /// Both are applied to `u64` totals by [`ReferenceListDeltas::apply`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] struct ReferenceListDeltas { /// Change in named accounts — references that carry an account number. accounts: i64, @@ -2495,6 +2617,99 @@ struct ReferenceListDeltas { references: i64, } +/// One account as this identity holds it at one application: the account reference by +/// which the identity holds the account, and the account's own stored record where this +/// write touches it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AccountReferenceWrite { + pub account_reference: AccountReference, + /// The account's stored record, where this write touches it. Together with the + /// account reference's number this says what the write means: + /// + /// | number | record | meaning | + /// | --- | --- | --- | + /// | `None` | `None` | the tracked default, and it stays one | + /// | `None` | `Some` | name it: the write mints a number and stores the record | + /// | `Some` | `Some` | rename | + /// | `Some` | `None` | leave the stored record alone | + pub record: Option, +} + +impl From for AccountReferenceWrite { + fn from(account_reference: AccountReference) -> Self { + Self { + account_reference, + record: None, + } + } +} + +/// One application's worth of a write: what the identity holds there afterwards, and the +/// config it holds it under. +/// +/// The config is `None` where the write leaves the stored one alone. +pub type AccountReferenceListWrite = (Vec, Option); + +/// One origin's write, past every refusal it can make on its own. +struct ValidatedAccountReferenceListWrite { + origin: FrontendHostname, + /// `None` where this write stores nothing at this origin and the origin has no + /// application, so none is created for it. + application_number: Option, + /// The application as it will be stored, counters included, whether it existed + /// before this write or is created by it. + application: Option, + /// `None` where the list is not written: it already holds these bytes, or nothing is + /// stored and this says only what absence already says. + list: Option, + records: Vec<(AccountNumber, StorableAccount)>, + config: Option, + deltas: ReferenceListDeltas, + /// What this origin holds afterwards, handed back to the caller. + written: AccountReferenceListWrite, +} + +/// A whole write, past every refusal — including the ones only the whole call can make. +struct ValidatedAccountStateWrite { + writes: Vec, + anchor_counter: StorableAccountsCounter, + global_counter: StorableAccountsCounter, + next_application_number: ApplicationNumber, +} + +/// The numbers a write will hand out, tracked across the whole call so that two origins +/// in one write cannot be given the same one. +/// +/// Nothing here is stored until apply, which is what makes a refusal cost nothing. +struct MintingState { + next_application_number: ApplicationNumber, + /// `stored_accounts` is the account-number allocator as well as the global count. + global: StorableAccountsCounter, +} + +impl MintingState { + fn allocate_application_number(&mut self) -> Result { + let application_number = self.next_application_number; + self.next_application_number = application_number + .checked_add(1) + .ok_or(StorageError::ApplicationsCounterOverflow)?; + Ok(application_number) + } + + /// The counter is also the account number, so it must not wrap or saturate: either + /// would re-issue a number already in use, and two accounts at one origin would + /// derive the same principal. + fn allocate_account_number(&mut self) -> Result { + let account_number = self + .global + .stored_accounts + .checked_add(1) + .ok_or(StorageError::AccountsCounterOverflow)?; + self.global.stored_accounts = account_number; + Ok(account_number) + } +} + impl ReferenceListDeltas { /// What writing `new_references` over `previous_references` does to the counters. /// @@ -2531,10 +2746,6 @@ impl ReferenceListDeltas { } } - fn is_empty(&self) -> bool { - self.accounts == 0 && self.references == 0 - } - /// Both counts of `counter`, moved by this delta. /// /// Refuses rather than clamping: an under-run means the counters and the stored diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index ecabe384ee..6328b021d1 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -482,9 +482,7 @@ fn should_not_read_a_default_account_from_an_empty_reference_list() { let origin: FrontendHostname = "https://some.origin".to_string(); // 2. Create application but with empty account reference list - let app_num = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let app_num = crate::storage::tests::application_number_for(&mut storage, &origin); storage.stable_account_reference_list_memory.insert( (anchor_number, app_num), StorableAccountReferenceList::tombstone_for_testing(), @@ -508,9 +506,7 @@ fn should_read_a_synthetic_default_account_when_no_reference_list_exists() { let anchor_number: AnchorNumber = 10_000; let origin: FrontendHostname = "https://some.origin".to_string(); // The origin is known, but this identity has no list under it. - storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + crate::storage::tests::application_number_for(&mut storage, &origin); let default_account = storage .read_account(&AccountKey { diff --git a/src/internet_identity/src/storage/storable/account.rs b/src/internet_identity/src/storage/storable/account.rs index 698c6e3f88..370b079f4f 100644 --- a/src/internet_identity/src/storage/storable/account.rs +++ b/src/internet_identity/src/storage/storable/account.rs @@ -4,7 +4,7 @@ use ic_stable_structures::Storable; use minicbor::{Decode, Encode}; use std::borrow::Cow; -#[derive(Encode, Decode, Clone)] +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq)] #[cbor(map)] pub struct StorableAccount { #[n(0)] diff --git a/src/internet_identity/src/storage/storable/anchor_application_config.rs b/src/internet_identity/src/storage/storable/anchor_application_config.rs index 2aa3991312..3bc0623378 100644 --- a/src/internet_identity/src/storage/storable/anchor_application_config.rs +++ b/src/internet_identity/src/storage/storable/anchor_application_config.rs @@ -4,7 +4,7 @@ use ic_stable_structures::Storable; use minicbor::{Decode, Encode}; use std::borrow::Cow; -#[derive(Encode, Decode, Default, Clone, Ord, Eq, PartialEq, PartialOrd)] +#[derive(Encode, Decode, Default, Clone, Ord, Eq, PartialEq, PartialOrd, Debug)] #[cbor(map)] pub struct AnchorApplicationConfig { #[n(0)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 029fb7d597..de7258475b 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3,21 +3,93 @@ use crate::openid::OpenIdCredential; use crate::state::PersistentState; use crate::stats::activity_stats::activity_counter::active_anchor_counter::ActiveAnchorCounter; use crate::stats::activity_stats::{ActivityStats, CompletedActivityStats, OngoingActivityStats}; +use crate::storage::account::AccountReference; use crate::storage::account::{Account, AccountKey}; use crate::storage::anchor::{Anchor, Device}; +use crate::storage::storable::account::StorableAccount; +use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; +use crate::storage::{AccountReferenceListWrite, AccountReferenceWrite}; use crate::storage::{Header, StorageError, MAX_ENTRIES}; use crate::Storage; use candid::Principal; use ic_stable_structures::{Memory, VectorMemory}; +use internet_identity_interface::internet_identity::types::{ + AccountNumber, ApplicationNumber, FrontendHostname, +}; use internet_identity_interface::internet_identity::types::{ ArchiveConfig, DeviceProtection, KeyType, Purpose, }; use pretty_assertions::assert_eq; use serde_bytes::ByteBuf; +use std::collections::BTreeMap; use std::collections::HashMap; const HEADER_SIZE: usize = 58; +/// One origin's worth of a write, in the shape the gate takes it. +pub(crate) fn write_at( + origin: &FrontendHostname, + account_references: Vec, + config: Option, +) -> BTreeMap { + BTreeMap::from([( + origin.clone(), + ( + account_references + .into_iter() + .map(AccountReferenceWrite::from) + .collect(), + config, + ), + )]) +} + +/// The same, with a record attached to the account reference carrying `account_number` — +/// or, where that is `None`, to the tracked default, which is what names it. +pub(crate) fn write_at_with_record( + origin: &FrontendHostname, + account_references: Vec, + account_number: Option, + record: StorableAccount, + config: Option, +) -> BTreeMap { + let mut writes = write_at(origin, account_references, config); + let (account_references, _) = writes.get_mut(origin).expect("just built"); + let write = account_references + .iter_mut() + .find(|write| write.account_reference.account_number == account_number) + .expect("the account reference the record belongs to is in the list"); + write.record = Some(record); + writes +} + +/// Creates the application for `origin` the way production does — by writing account +/// state at it — and hands back its number. +/// +/// There is no allocator to call on its own any more: an application exists because +/// something holds it, so a test that wants one writes something. The config write is +/// the smallest thing that stores anything at an origin without giving the identity +/// an account there. +pub(crate) fn application_number_for( + storage: &mut Storage, + origin: &FrontendHostname, +) -> ApplicationNumber { + let anchor_number = storage.assigned_anchor_number_range().0; + let (account_references, _) = storage.account_state_for_origin(anchor_number, origin); + storage + .write_account_state( + anchor_number, + BTreeMap::from([( + origin.clone(), + (account_references, Some(AnchorApplicationConfig::default())), + )]), + ) + .expect("writing a config at an origin creates its application"); + storage + .lookup_application_number_with_origin(origin) + .expect("the write above created the application") +} + #[test] fn should_match_actual_header_size() { // if this test fails, make sure the change was intentional and upgrade as well as rollback still work! @@ -636,6 +708,8 @@ fn sample_persistent_state() -> PersistentState { #[cfg(test)] mod application_lookup_tests { + use super::application_number_for; + use super::*; use crate::storage::storable::application::StorableOriginSha256; use ic_stable_structures::VectorMemory; @@ -671,9 +745,7 @@ mod application_lookup_tests { let mut storage = Storage::new((10, 20), VectorMemory::default()); let origin = "https://example.com".to_string(); - let app_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let app_number = application_number_for(&mut storage, &origin); // Should create application number 0 for first application assert_eq!(app_number, 0); @@ -696,14 +768,10 @@ mod application_lookup_tests { let origin = "https://example.com".to_string(); // Create application first time - let app_number1 = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let app_number1 = application_number_for(&mut storage, &origin); // Should return same application number on second call - let app_number2 = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let app_number2 = application_number_for(&mut storage, &origin); assert_eq!(app_number1, app_number2); assert_eq!(app_number1, 0); @@ -717,15 +785,9 @@ mod application_lookup_tests { let origin2 = "https://different.com".to_string(); let origin3 = "https://another.org".to_string(); - let app_num1 = storage - .lookup_or_insert_application_number_with_origin(&origin1) - .unwrap(); - let app_num2 = storage - .lookup_or_insert_application_number_with_origin(&origin2) - .unwrap(); - let app_num3 = storage - .lookup_or_insert_application_number_with_origin(&origin3) - .unwrap(); + let app_num1 = application_number_for(&mut storage, &origin1); + let app_num2 = application_number_for(&mut storage, &origin2); + let app_num3 = application_number_for(&mut storage, &origin3); assert_eq!(app_num1, 0); assert_eq!(app_num2, 1); @@ -749,9 +811,7 @@ mod application_lookup_tests { let long_origin = format!("https://{}.com", "a".repeat(20_000)); - let app_number = storage - .lookup_or_insert_application_number_with_origin(&long_origin) - .unwrap(); + let app_number = application_number_for(&mut storage, &long_origin); assert_eq!(app_number, 0); // Should be findable in both maps @@ -773,9 +833,7 @@ mod application_lookup_tests { ]; for (i, origin) in origins.iter().enumerate() { - let app_number = storage - .lookup_or_insert_application_number_with_origin(origin) - .unwrap(); + let app_number = application_number_for(&mut storage, origin); assert_eq!(app_number, i as u64); // Total application count should increment @@ -791,9 +849,7 @@ mod application_lookup_tests { // Create storage and add application { let mut storage = Storage::new((10, 20), memory.clone()); - let app_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let app_number = application_number_for(&mut storage, &origin); assert_eq!(app_number, 0); } @@ -2113,13 +2169,19 @@ fn test_anchor_storage_migration_round_trip() { } mod reference_list_write_path_tests { + use super::application_number_for; + use super::{write_at, write_at_with_record}; use crate::storage::account::AccountReference; + use crate::storage::storable::account::StorableAccount; use crate::storage::storable::accounts_counter::StorableAccountsCounter; + use crate::storage::AccountReferenceWrite; use crate::storage::{ReferenceCount, ReferenceCounter, StorageError}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; + use internet_identity_interface::internet_identity::types::FrontendHostname; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; fn storage_with_anchor() -> (Storage, AnchorNumber) { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); @@ -2129,6 +2191,387 @@ mod reference_list_write_path_tests { (storage, anchor_number) } + /// Everything a write derives, rebuilt from the account reference lists alone. + /// + /// A gate whose job is deriving values is only as good as a check that does the + /// deriving a second way, so this is the shape the counter assertions below take. + fn derived_counters( + storage: &Storage, + anchor_number: AnchorNumber, + ) -> (u64, u64) { + let mut accounts = 0; + let mut references = 0; + for reference in storage.list_identity_account_references(anchor_number) { + references += 1; + if reference.account_number.is_some() { + accounts += 1; + } + } + (accounts, references) + } + + #[test] + fn reading_the_state_and_writing_it_back_changes_nothing() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + storage + .create_account(anchor_number, origin.clone(), "named".to_string()) + .unwrap(); + let before = storage + .stable_anchor_account_counter_memory + .get(&anchor_number); + let stored = storage.stored_account_references(anchor_number, 0); + + let state = storage.account_state_for_origin(anchor_number, &origin); + storage + .write_account_state(anchor_number, BTreeMap::from([(origin.clone(), state)])) + .unwrap(); + + assert_eq!( + storage + .stable_anchor_account_counter_memory + .get(&anchor_number), + before + ); + assert_eq!(storage.stored_account_references(anchor_number, 0), stored); + } + + #[test] + fn writing_back_an_untouched_origin_does_not_give_it_an_application() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://nothing-here.com".to_string(); + + // What the identity holds at an origin nothing is stored under is the derived + // default. Writing that back says only what absence already says, so storing it + // would leave an account reference list — and an application — that nothing asked + // for and no counter would ever retire. + let state = storage.account_state_for_origin(anchor_number, &origin); + storage + .write_account_state(anchor_number, BTreeMap::from([(origin.clone(), state)])) + .unwrap(); + + assert_eq!(storage.lookup_application_number_with_origin(&origin), None); + assert_eq!(storage.get_total_application_count(), 0); + assert_eq!(derived_counters(&storage, anchor_number), (0, 0)); + } + + #[test] + fn a_first_account_at_an_origin_creates_its_application_with_the_counters_already_right() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + + storage + .create_account(anchor_number, origin.clone(), "named".to_string()) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .expect("the write created the application"); + let application = storage + .stable_application_memory + .get(&application_number) + .expect("and stored it"); + // One named account and the tracked default beside it. The application never + // existed holding zero of either: it is written with what holds it. + assert_eq!( + ( + application.stored_accounts, + application.stored_account_references + ), + (1, 2) + ); + assert_eq!(derived_counters(&storage, anchor_number), (1, 2)); + } + + #[test] + fn naming_an_account_mints_its_number_and_hands_it_back_in_place() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + let default_reference = AccountReference { + account_number: None, + last_used: None, + }; + + // A record on an account reference with no number is an account being named. The + // caller never states the number; it comes back where the account reference was. + let written = storage + .write_account_state( + anchor_number, + BTreeMap::from([( + origin.clone(), + ( + vec![ + AccountReferenceWrite::from(default_reference), + AccountReferenceWrite { + account_reference: AccountReference { + account_number: None, + last_used: None, + }, + record: Some(StorableAccount { + name: "named".to_string(), + seed_from_anchor: None, + }), + }, + ], + None, + ), + )]), + ) + .unwrap(); + + let (account_references, _) = &written[&origin]; + assert_eq!(account_references[0].account_reference.account_number, None); + assert_eq!( + account_references[1].account_reference.account_number, + Some(1) + ); + } + + #[test] + fn naming_the_tracked_default_makes_it_the_default() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + + // The tracked default gains a number and a record, and nothing numberless is + // left. What the identity signs in with here is now that account, so the config + // says so — derived from the write, because the caller could not have named a + // number that did not exist yet. + let written = storage + .write_account_state( + anchor_number, + write_at_with_record( + &origin, + vec![AccountReference { + account_number: None, + last_used: None, + }], + None, + StorableAccount { + name: "named".to_string(), + seed_from_anchor: Some(anchor_number), + }, + None, + ), + ) + .unwrap(); + + let account_number = written[&origin].0[0].account_reference.account_number; + assert_eq!(account_number, Some(1)); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + assert_eq!( + storage + .stable_anchor_application_config_memory + .get(&(anchor_number, application_number)) + .map(|config| config.default_account_number), + Some(account_number) + ); + } + + #[test] + fn adding_an_account_beside_a_default_that_stays_does_not_move_the_default() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + + // Also a mint, but the tracked default is still there afterwards, so this is a + // new account rather than the default being named. + storage + .create_account(anchor_number, origin.clone(), "named".to_string()) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + assert_eq!( + storage + .stable_anchor_application_config_memory + .get(&(anchor_number, application_number)), + None + ); + } + + #[test] + fn an_empty_list_is_refused_at_an_origin_nothing_is_stored_under() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + + // A tombstone, which nothing may create yet. An empty list holds no account + // reference with a number, which is also true of a list holding only the derived + // default — so this has to be told apart from the write that stores nothing. + let result = storage.write_account_state(anchor_number, write_at(&origin, vec![], None)); + + assert!(matches!( + result, + Err(StorageError::UnstorableAccountReferenceList { .. }) + )); + assert_eq!(storage.lookup_application_number_with_origin(&origin), None); + } + + #[test] + fn a_refusal_at_one_origin_writes_nothing_at_any_of_them() { + let (mut storage, anchor_number) = storage_with_anchor(); + // Ordered so the write that succeeds is reached first: the batch is keyed by + // origin, so a gate that stored as it went would have already written this one + // by the time the next is refused. Named the other way round the test passes + // whether or not anything is atomic. + let good = "https://a-good.com".to_string(); + let bad = "https://z-bad.com".to_string(); + + let mut writes: BTreeMap = BTreeMap::new(); + writes.extend(write_at_with_record( + &good, + vec![AccountReference { + account_number: None, + last_used: None, + }], + None, + StorableAccount { + name: "named".to_string(), + seed_from_anchor: None, + }, + None, + )); + // Refused: nothing may write a tombstone. + writes.extend(write_at(&bad, vec![], None)); + + let result = storage.write_account_state(anchor_number, writes); + + assert!(result.is_err()); + // Not one of them half-happened: no application, no account number spent, no + // counter moved. + assert_eq!(storage.lookup_application_number_with_origin(&good), None); + assert_eq!(storage.lookup_application_number_with_origin(&bad), None); + assert_eq!(storage.get_total_application_count(), 0); + assert_eq!( + storage.stable_account_counter_memory.get().stored_accounts, + 0 + ); + assert_eq!(derived_counters(&storage, anchor_number), (0, 0)); + } + + #[test] + fn one_write_spanning_two_origins_moves_the_shared_counters_once() { + let (mut storage, anchor_number) = storage_with_anchor(); + let first = "https://first.com".to_string(); + let second = "https://second.com".to_string(); + + let mut writes: BTreeMap = BTreeMap::new(); + for origin in [&first, &second] { + writes.extend(write_at_with_record( + origin, + vec![AccountReference { + account_number: None, + last_used: None, + }], + None, + StorableAccount { + name: "named".to_string(), + seed_from_anchor: None, + }, + None, + )); + } + + storage.write_account_state(anchor_number, writes).unwrap(); + + // Two accounts, two account references, and two distinct numbers: the counters + // shared by both origins are folded across the call rather than each computed + // against the same stored value. + assert_eq!(derived_counters(&storage, anchor_number), (2, 2)); + assert_eq!( + storage + .stable_anchor_account_counter_memory + .get(&anchor_number), + Some(StorableAccountsCounter { + stored_accounts: 2, + stored_account_references: 2, + }) + ); + assert_eq!( + storage.stable_account_counter_memory.get().stored_accounts, + 2 + ); + assert_eq!(storage.get_total_application_count(), 2); + } + + #[test] + fn a_write_refused_by_the_counters_spends_no_application_number() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = "https://example.com".to_string(); + storage + .create_account(anchor_number, origin.clone(), "named".to_string()) + .unwrap(); + // Force the divergence: the stored list holds references the counter no longer + // knows about, so dropping one under-runs it. + storage.set_counters_for_testing(anchor_number, 0, 0); + let next_application_number = *storage.next_application_number_memory.get(); + let allocator = storage.stable_account_counter_memory.get().stored_accounts; + + let result = storage.write_account_state( + anchor_number, + write_at( + &origin, + vec![AccountReference { + account_number: None, + last_used: None, + }], + None, + ), + ); + + assert!(matches!( + result, + Err(StorageError::AccountCounterOutOfBounds { .. }) + )); + // The allocators only move in apply, so a refusal leaves both where they were. + assert_eq!( + *storage.next_application_number_memory.get(), + next_application_number + ); + assert_eq!( + storage.stable_account_counter_memory.get().stored_accounts, + allocator + ); + } + + #[test] + fn a_new_application_is_never_stored_holding_nothing() { + let (mut storage, anchor_number) = storage_with_anchor(); + + // Every application this identity can reach it through, at every point a write + // could have left one behind. + for origin in [ + "https://untouched.com".to_string(), + "https://named.com".to_string(), + ] { + let state = storage.account_state_for_origin(anchor_number, &origin); + storage + .write_account_state(anchor_number, BTreeMap::from([(origin.clone(), state)])) + .unwrap(); + } + storage + .create_account( + anchor_number, + "https://named.com".to_string(), + "named".to_string(), + ) + .unwrap(); + + for (number, application) in storage.stable_application_memory.iter() { + let held = storage + .stored_account_references(anchor_number, number) + .map(|references| references.len() as u64) + .unwrap_or_default(); + assert!( + held > 0, + "application {number} at {} is stored holding nothing", + application.origin + ); + assert_eq!(application.stored_account_references, held); + } + } + #[test] fn allocating_past_the_last_account_number_is_refused() { let (mut storage, anchor_number) = storage_with_anchor(); @@ -2156,9 +2599,7 @@ mod reference_list_write_path_tests { fn refuses_a_counter_delta_that_would_underflow_without_writing_anything() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let application_number = application_number_for(&mut storage, &origin); let default_reference = AccountReference { account_number: None, last_used: None, @@ -2170,10 +2611,11 @@ mod reference_list_write_path_tests { storage .write_account_state( anchor_number, - application_number, - vec![default_reference.clone(), named_reference], - None, - None, + write_at( + &origin, + vec![default_reference.clone(), named_reference], + None, + ), ) .unwrap(); @@ -2183,10 +2625,7 @@ mod reference_list_write_path_tests { let result = storage.write_account_state( anchor_number, - application_number, - vec![default_reference], - None, - None, + write_at(&origin, vec![default_reference], None), ); // The refusal names what diverged: this identity's account count, what it held, @@ -2212,9 +2651,6 @@ mod reference_list_write_path_tests { fn the_two_counts_move_independently_and_the_refusal_says_which_one_failed() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); let default_reference = AccountReference { account_number: None, last_used: None, @@ -2226,10 +2662,11 @@ mod reference_list_write_path_tests { storage .write_account_state( anchor_number, - application_number, - vec![default_reference, named_reference.clone()], - None, - None, + write_at( + &origin, + vec![default_reference, named_reference.clone()], + None, + ), ) .unwrap(); storage.set_counters_for_testing(anchor_number, 0, 0); @@ -2239,10 +2676,7 @@ mod reference_list_write_path_tests { // under-run here, and the refusal has to name that one rather than the other. let result = storage.write_account_state( anchor_number, - application_number, - vec![named_reference], - None, - None, + write_at(&origin, vec![named_reference], None), ); assert_eq!( @@ -2263,12 +2697,9 @@ mod reference_list_write_path_tests { // however the caller assembled it. let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let application_number = application_number_for(&mut storage, &origin); - let result = - storage.write_account_state(anchor_number, application_number, vec![], None, None); + let result = storage.write_account_state(anchor_number, write_at(&origin, vec![], None)); assert!(matches!( result, @@ -2281,19 +2712,28 @@ mod reference_list_write_path_tests { } #[test] - fn rejects_writing_for_an_unknown_application_without_writing_anything() { + fn refuses_an_origin_whose_application_is_gone_without_writing_anything() { let (mut storage, anchor_number) = storage_with_anchor(); - let unknown_application_number = 42u64; + let origin = "https://example.com".to_string(); + let application_number = application_number_for(&mut storage, &origin); + + // The origin index still resolves, but the application it names is gone. That is + // a broken invariant rather than an origin nobody has stored anything under, so + // the write refuses instead of quietly creating a second application for it. + storage + .stable_application_memory + .remove(&application_number); let result = storage.write_account_state( anchor_number, - unknown_application_number, - vec![AccountReference { - account_number: None, - last_used: None, - }], - None, - None, + write_at( + &origin, + vec![AccountReference { + account_number: Some(1), + last_used: None, + }], + None, + ), ); assert!(matches!( @@ -2301,40 +2741,22 @@ mod reference_list_write_path_tests { Err(StorageError::OriginNotFoundForApplicationNumber { .. }) )); assert_eq!( - storage.stored_account_references(anchor_number, unknown_application_number), + storage.stored_account_references(anchor_number, application_number), None ); - assert_eq!( - storage.get_account_counter(anchor_number), - crate::storage::account::AccountsCounter::default() - ); - assert_eq!( - storage - .get_total_accounts_counter() - .stored_account_references, - 0 - ); } #[test] fn writing_the_list_the_list_already_holds_touches_nothing() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let application_number = application_number_for(&mut storage, &origin); let references = vec![AccountReference { account_number: Some(1), last_used: None, }]; storage - .write_account_state( - anchor_number, - application_number, - references.clone(), - None, - None, - ) + .write_account_state(anchor_number, write_at(&origin, references.clone(), None)) .unwrap(); // Retiring the application makes a write visible: the write path refuses // without one, so a write that still went through it could not succeed here. @@ -2343,13 +2765,7 @@ mod reference_list_write_path_tests { .remove(&application_number); storage - .write_account_state( - anchor_number, - application_number, - references.clone(), - None, - None, - ) + .write_account_state(anchor_number, write_at(&origin, references.clone(), None)) .unwrap(); assert_eq!( @@ -2362,26 +2778,24 @@ mod reference_list_write_path_tests { fn derives_counters_from_added_references() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); storage .write_account_state( anchor_number, - application_number, - vec![ - AccountReference { - account_number: None, - last_used: None, - }, - AccountReference { - account_number: Some(7), - last_used: None, - }, - ], - None, - None, + write_at( + &origin, + vec![ + AccountReference { + account_number: None, + last_used: None, + }, + AccountReference { + account_number: Some(7), + last_used: None, + }, + ], + None, + ), ) .unwrap(); @@ -2405,32 +2819,31 @@ mod reference_list_write_path_tests { fn materializing_a_default_moves_only_the_account_counter() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); storage .write_account_state( anchor_number, - application_number, - vec![AccountReference { - account_number: None, - last_used: None, - }], - None, - None, + write_at( + &origin, + vec![AccountReference { + account_number: None, + last_used: None, + }], + None, + ), ) .unwrap(); storage .write_account_state( anchor_number, - application_number, - vec![AccountReference { - account_number: Some(3), - last_used: None, - }], - None, - None, + write_at( + &origin, + vec![AccountReference { + account_number: Some(3), + last_used: None, + }], + None, + ), ) .unwrap(); @@ -2447,35 +2860,27 @@ mod reference_list_write_path_tests { fn rewriting_an_unchanged_list_leaves_counters_alone() { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); let references = vec![AccountReference { account_number: Some(1), last_used: None, }]; storage - .write_account_state( - anchor_number, - application_number, - references.clone(), - None, - None, - ) + .write_account_state(anchor_number, write_at(&origin, references.clone(), None)) .unwrap(); let after_first_write = storage.get_account_counter(anchor_number); storage .write_account_state( anchor_number, - application_number, - vec![AccountReference { - account_number: Some(1), - last_used: Some(123), - }], - None, - None, + write_at( + &origin, + vec![AccountReference { + account_number: Some(1), + last_used: Some(123), + }], + None, + ), ) .unwrap(); @@ -2512,6 +2917,8 @@ mod reference_list_write_path_tests { /// mean three different things. Absence says a default account is still /// reconstructible; emptiness is a tombstone and says it never can be again. mod account_reference_state_tests { + use super::application_number_for; + use super::write_at; use crate::storage::account::{Account, AccountKey, AccountReference}; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::StorageError; @@ -2535,9 +2942,7 @@ mod account_reference_state_tests { /// needs a tombstone has to write it directly. fn plant_tombstone(storage: &mut Storage, anchor_number: AnchorNumber) { let origin = ORIGIN.to_string(); - let application_number = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let application_number = application_number_for(storage, &origin); storage.stable_account_reference_list_memory.insert( (anchor_number, application_number), StorableAccountReferenceList::tombstone_for_testing(), @@ -2597,21 +3002,19 @@ mod account_reference_state_tests { .create_account(anchor_number, origin.clone(), "named".to_string()) .unwrap(); let account_number = account.account_number.unwrap(); - let application_number = storage - .lookup_application_number_with_origin(&origin) - .unwrap(); // Drop just the default reference, as moving it away would. storage .write_account_state( anchor_number, - application_number, - vec![AccountReference { - account_number: Some(account_number), - last_used: None, - }], - None, - None, + write_at( + &origin, + vec![AccountReference { + account_number: Some(account_number), + last_used: None, + }], + None, + ), ) .unwrap(); @@ -2819,6 +3222,7 @@ mod account_reference_state_tests { } mod application_number_allocator_tests { + use super::application_number_for; use crate::storage::storable::application::StorableApplication; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -2836,15 +3240,9 @@ mod application_number_allocator_tests { fn allocates_dense_numbers_from_zero() { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); - let first = storage - .lookup_or_insert_application_number_with_origin(&"https://a.com".into()) - .unwrap(); - let second = storage - .lookup_or_insert_application_number_with_origin(&"https://b.com".into()) - .unwrap(); - let third = storage - .lookup_or_insert_application_number_with_origin(&"https://c.com".into()) - .unwrap(); + let first = application_number_for(&mut storage, &"https://a.com".into()); + let second = application_number_for(&mut storage, &"https://b.com".into()); + let third = application_number_for(&mut storage, &"https://c.com".into()); assert_eq!((first, second, third), (0, 1, 2)); } @@ -2854,12 +3252,8 @@ mod application_number_allocator_tests { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); let origin = "https://a.com".to_string(); - let first = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); - let again = storage - .lookup_or_insert_application_number_with_origin(&origin) - .unwrap(); + let first = application_number_for(&mut storage, &origin); + let again = application_number_for(&mut storage, &origin); assert_eq!(first, again); assert_eq!(storage.get_total_application_count(), 1); @@ -2882,9 +3276,7 @@ mod application_number_allocator_tests { storage.flush(); let mut storage = Storage::from_memory(memory); - let next = storage - .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) - .unwrap(); + let next = application_number_for(&mut storage, &"https://d.com".into()); assert_eq!(next, 3); } @@ -2910,9 +3302,7 @@ mod application_number_allocator_tests { storage.flush(); let mut storage = Storage::from_memory(memory); - let next = storage - .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) - .unwrap(); + let next = application_number_for(&mut storage, &"https://d.com".into()); assert_eq!(next, 3); assert_eq!( @@ -2925,16 +3315,12 @@ mod application_number_allocator_tests { fn never_reissues_the_number_of_a_removed_application() { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); for origin in ["https://a.com", "https://b.com", "https://c.com"] { - storage - .lookup_or_insert_application_number_with_origin(&origin.into()) - .unwrap(); + application_number_for(&mut storage, &origin.into()); } storage.stable_application_memory.remove(&1); - let next = storage - .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) - .unwrap(); + let next = application_number_for(&mut storage, &"https://d.com".into()); assert_eq!(next, 3); assert!(storage.stable_application_memory.get(&2).is_some()); @@ -2950,9 +3336,7 @@ mod application_number_allocator_tests { "https://c.com", "https://d.com", ] { - storage - .lookup_or_insert_application_number_with_origin(&origin.into()) - .unwrap(); + application_number_for(&mut storage, &origin.into()); } storage.flush(); storage.stable_application_memory.remove(&1); @@ -2960,9 +3344,7 @@ mod application_number_allocator_tests { assert_eq!(storage.stable_application_memory.len(), 2); let mut storage = Storage::from_memory(memory.clone()); - let next = storage - .lookup_or_insert_application_number_with_origin(&"https://e.com".into()) - .unwrap(); + let next = application_number_for(&mut storage, &"https://e.com".into()); assert_eq!(next, 4); assert_eq!( @@ -2972,9 +3354,7 @@ mod application_number_allocator_tests { let mut storage = Storage::from_memory(memory); assert_eq!( - storage - .lookup_or_insert_application_number_with_origin(&"https://f.com".into()) - .unwrap(), + application_number_for(&mut storage, &"https://f.com".into()), 5 ); } @@ -2984,9 +3364,7 @@ mod application_number_allocator_tests { let memory = VectorMemory::default(); let mut storage = Storage::new((10_000, 3_784_873), memory.clone()); for origin in ["https://a.com", "https://b.com", "https://c.com"] { - storage - .lookup_or_insert_application_number_with_origin(&origin.into()) - .unwrap(); + application_number_for(&mut storage, &origin.into()); } storage.next_application_number_memory.set(0).unwrap(); storage.flush(); @@ -2994,9 +3372,7 @@ mod application_number_allocator_tests { let mut storage = Storage::from_memory(memory); storage.stable_application_memory.remove(&0); - let next = storage - .lookup_or_insert_application_number_with_origin(&"https://d.com".into()) - .unwrap(); + let next = application_number_for(&mut storage, &"https://d.com".into()); assert_eq!(next, 3); assert_eq!( From e654aa0df09e4f9a2e5a3fb414fbfda6f9a58b2c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 03:38:59 +0200 Subject: [PATCH 164/298] refactor(be): drop the account counter's repair path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebuild existed because the counter was maintained by hand at each write site and could therefore drift. The write path derives it now, so there is nothing left to repair — and it only ever covered one of the four counters, firing from a cap check, while the ones whose drift is unrecoverable had no repair at all. What a drift would have cost is written down in the test that replaces it: the identity cannot name another account. That is not worth a repair path for a state that can no longer arise. The discrepancy counter went with it, since rebuilds were the only thing it counted. The stable cell and its metric stay, because removing them is a migration rather than a deletion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 148 ++++-------------- src/internet_identity/src/storage.rs | 44 +----- .../storage/storable/discrepancy_counter.rs | 17 +- src/internet_identity/src/storage/tests.rs | 15 +- 4 files changed, 48 insertions(+), 176 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 7a067ca2be..94d9eee575 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -196,13 +196,8 @@ pub fn create_account_for_origin( ) -> Result { validate_account_name(&name).map_err(Into::::into)?; let created_account = storage_borrow_mut(|storage| { - check_or_rebuild_max_anchor_accounts( - storage, - anchor_number, - MAX_ANCHOR_ACCOUNTS as u64, - true, - ) - .map_err(Into::::into)?; + check_max_anchor_accounts(storage, anchor_number, MAX_ANCHOR_ACCOUNTS as u64) + .map_err(Into::::into)?; storage .create_additional_account(CreateAccountParams { @@ -239,12 +234,7 @@ pub fn update_account_for_origin( // Check if we have reached account limit // Because editing a default account turns it into a stored account if account_number.is_none() { - check_or_rebuild_max_anchor_accounts( - storage, - anchor_number, - MAX_ANCHOR_ACCOUNTS as u64, - true, - ) + check_max_anchor_accounts(storage, anchor_number, MAX_ANCHOR_ACCOUNTS as u64) .map_err(Into::::into)? } @@ -440,13 +430,15 @@ pub fn get_account_delegation( }) } -/// Checks whether the stored number of accounts as per the counter exceeds the maximum permitted number. -/// If it does, it rebuilds the counter. If it still exceeds, it will return an error. -fn check_or_rebuild_max_anchor_accounts( - storage: &mut Storage, +/// Refuses where this identity already holds as many accounts as it may. +/// +/// Counted rather than repaired. There used to be a rebuild here for a counter that could +/// drift, because it was maintained by hand at each write site; the write path derives it +/// now, so there is nothing to repair and no reason to look twice. +fn check_max_anchor_accounts( + storage: &Storage, anchor_number: AnchorNumber, max_anchor_accounts: u64, - first_time: bool, // required for safe recursion ) -> Result<(), CheckMaxAccountError> { let AccountsCounter { stored_accounts, @@ -454,18 +446,7 @@ fn check_or_rebuild_max_anchor_accounts( } = storage.get_account_counter(anchor_number); if stored_accounts >= max_anchor_accounts { - // check whether we actually have reached the number - if first_time { - storage.rebuild_identity_account_counters(anchor_number); - return check_or_rebuild_max_anchor_accounts( - storage, - anchor_number, - max_anchor_accounts, - false, - ); - } else { - return Err(CheckMaxAccountError::AccountLimitReached); - } + return Err(CheckMaxAccountError::AccountLimitReached); } Ok(()) } @@ -813,7 +794,7 @@ fn should_update_default_account_for_origin() { } #[test] -// This test is to make sure that the check_or_rebuild_max_anchor_accounts function correctly errors +// This test is to make sure that the check_max_anchor_accounts function correctly errors // It should error when the counters are at or above max and argument 'first_time' is false fn should_fail_check_or_rebuild_when_not_first_time() { use crate::state::{storage_borrow_mut, storage_replace}; @@ -830,27 +811,27 @@ fn should_fail_check_or_rebuild_when_not_first_time() { MAX_ANCHOR_ACCOUNTS as u64, MAX_ANCHOR_ACCOUNTS as u64, ); - let res = check_or_rebuild_max_anchor_accounts( - storage, - anchor.anchor_number(), - MAX_ANCHOR_ACCOUNTS as u64, - false, - ); + let res = + check_max_anchor_accounts(storage, anchor.anchor_number(), MAX_ANCHOR_ACCOUNTS as u64); assert!(res.is_err()) }); } #[test] -fn should_properly_recalculate_faulty_account_counter() { +fn a_drifted_account_counter_is_not_repaired_and_costs_the_identity_its_limit() { use crate::state::{storage_borrow_mut, storage_replace}; use crate::storage::Storage; use ic_stable_structures::VectorMemory; storage_replace(Storage::new((0, 10000), VectorMemory::default())); - let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); + let anchor = storage_borrow_mut(|storage| { + let anchor = storage.allocate_anchor(0).unwrap(); + storage.write(anchor.clone()).unwrap(); + anchor + }); let name = "Alice".to_string(); - // create faulty counter entries + // A counter that says this identity is at its limit when it holds nothing. storage_borrow_mut(|storage| { storage.set_counters_for_testing( anchor.anchor_number(), @@ -859,84 +840,19 @@ fn should_properly_recalculate_faulty_account_counter() { ) }); - for i in 0..=MAX_ANCHOR_ACCOUNTS { - let origin = format!("https://example-{i}.com"); - let result = - create_account_for_origin(anchor.anchor_number(), origin.clone(), name.clone()); - if i == MAX_ANCHOR_ACCOUNTS { - assert_eq!(result, Err(CreateAccountError::AccountLimitReached)) - } else { - assert!(result.is_ok()) - } - } -} - -#[test] -fn should_properly_recalculate_faulty_account_counter_when_updating() { - use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - - storage_replace(Storage::new((0, 10000), VectorMemory::default())); - let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); - - // create faulty counter entries - storage_borrow_mut(|storage| { - storage.set_counters_for_testing( - anchor.anchor_number(), - MAX_ANCHOR_ACCOUNTS as u64, - MAX_ANCHOR_ACCOUNTS as u64, - ) - }); - - let result = update_account_for_origin( - anchor.anchor_number(), - None, - "https://example-1.com".to_string(), - AccountUpdate { - name: Some("Gabriel".to_string()), - }, - ); - assert!(result.is_ok()) -} - -#[test] -fn should_increment_discrepancy_counter() { - use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; - use ic_stable_structures::VectorMemory; - - storage_replace(Storage::new((0, 10000), VectorMemory::default())); - let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); - - // create faulty counter entries - storage_borrow_mut(|storage| { - storage.set_counters_for_testing( + // There used to be a rebuild here that noticed and corrected it. The write path + // derives the counter now, so nothing maintains it by hand and nothing can drift it — + // and carrying a repair path for a state that can no longer arise is not worth the one + // counter of four it covered. The accepted cost, written down so it is not rediscovered + // as a bug: an identity whose counter ever did drift high cannot name another account. + assert_eq!( + create_account_for_origin( anchor.anchor_number(), - MAX_ANCHOR_ACCOUNTS as u64, - MAX_ANCHOR_ACCOUNTS as u64, - ) - }); - - storage_borrow(|storage| { - let discrepancy_counter_before = storage.get_discrepancy_counter(); - assert_eq!(discrepancy_counter_before.account_counter_rebuilds, 0); - }); - - let result = update_account_for_origin( - anchor.anchor_number(), - None, - "https://example-1.com".to_string(), - AccountUpdate { - name: Some("Gabriel".to_string()), - }, + "https://example.com".to_string(), + name, + ), + Err(CreateAccountError::AccountLimitReached) ); - assert!(result.is_ok()); - - storage_borrow(|storage| { - let discrepancy_counter_after = storage.get_discrepancy_counter(); - assert_eq!(discrepancy_counter_after.account_counter_rebuilds, 1); - }); } #[test] diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 2eb4c6538c..cb3b86e978 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -85,7 +85,6 @@ use account::{ }; use candid::{CandidType, Deserialize, Principal}; use ic_cdk::api::stable::WASM_PAGE_SIZE_IN_BYTES; -use ic_stable_structures::cell::ValueError; use std::borrow::Cow; use std::collections::{BTreeSet, HashMap}; use std::fmt; @@ -128,7 +127,7 @@ use storable::anchor::StorableAnchor; use storable::anchor_number::StorableAnchorNumber; use storable::application::StorableApplication; use storable::credential_id::StorableCredentialId; -use storable::discrepancy_counter::{DiscrepancyType, StorableDiscrepancyCounter}; +use storable::discrepancy_counter::StorableDiscrepancyCounter; use storable::email_recovery_address_hash::StorableEmailRecoveryAddressHash; use storable::fixed_anchor::StorableFixedAnchor; use storable::mcp_config::StorableMcpConfig; @@ -1821,6 +1820,7 @@ impl Storage { } /// Returns all account references associated with a single anchor number, across all applications. + #[cfg(test)] pub fn list_identity_account_references( &self, anchor_number: AnchorNumber, @@ -1835,46 +1835,6 @@ impl Storage { .collect() } - /// Rebuilds the account and account reference counters for a given identity - pub fn rebuild_identity_account_counters(&mut self, anchor_number: AnchorNumber) { - // increment metrics - let _ = self.increment_discrepancy_counter(&DiscrepancyType::AccountRebuild); - - // get actual list of stored references and accounts - let acc_ref_list = self.list_identity_account_references(anchor_number); - - let mut stored_accounts = 0; - let mut stored_account_references = 0; - - acc_ref_list.iter().for_each(|acc_ref| { - // for every reference, we increment the account references counter - stored_account_references += 1; - // if the account reference has an account number and is thus stored, also increment the stored accounts counter - if acc_ref.account_number.is_some() { - stored_accounts += 1; - } - }); - - self.stable_anchor_account_counter_memory.insert( - anchor_number, - StorableAccountsCounter { - stored_accounts, - stored_account_references, - }, - ); - } - - /// Increments the discrepancy counter (this is so we can ascertain correctness of our counters - ideally, this is never actually called) - fn increment_discrepancy_counter( - &mut self, - discrepancy_type: &DiscrepancyType, - ) -> Result { - let counters = self.stable_account_counter_discrepancy_counter_memory.get(); - - self.stable_account_counter_discrepancy_counter_memory - .set(counters.increment(discrepancy_type)) - } - /// Retrieves the discrepancy counter pub fn get_discrepancy_counter(&self) -> &StorableDiscrepancyCounter { self.stable_account_counter_discrepancy_counter_memory.get() diff --git a/src/internet_identity/src/storage/storable/discrepancy_counter.rs b/src/internet_identity/src/storage/storable/discrepancy_counter.rs index 02d5a35863..a4ea019df3 100644 --- a/src/internet_identity/src/storage/storable/discrepancy_counter.rs +++ b/src/internet_identity/src/storage/storable/discrepancy_counter.rs @@ -24,19 +24,4 @@ impl Storable for StorableDiscrepancyCounter { const BOUND: Bound = Bound::Unbounded; } -impl StorableDiscrepancyCounter { - pub fn increment(&self, discrepancy_type: &DiscrepancyType) -> Self { - match discrepancy_type { - DiscrepancyType::AccountRebuild => Self { - account_counter_rebuilds: self - .account_counter_rebuilds - .checked_add(1) - .expect("overflow in account_counter_rebuilds"), - }, - } - } -} - -pub enum DiscrepancyType { - AccountRebuild, -} +impl StorableDiscrepancyCounter {} diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 55cba450db..a0911d0abe 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2462,10 +2462,21 @@ mod reference_list_write_path_tests { .unwrap(); } + // Counted a second way: what the write path derived, against what the account + // reference lists actually hold. There is no repair path any more, so this is the + // check that the derivation is right. let written = storage.get_account_counter(anchor_number); - storage.rebuild_identity_account_counters(anchor_number); + let mut accounts = 0; + let mut references = 0; + for reference in storage.list_identity_account_references(anchor_number) { + references += 1; + if reference.account_number.is_some() { + accounts += 1; + } + } - assert_eq!(storage.get_account_counter(anchor_number), written); + assert_eq!(written.stored_account_references, references); + assert_eq!(written.stored_accounts, accounts); assert_eq!(written.stored_account_references, 9); assert_eq!(written.stored_accounts, 6); } From 2e1d9fb090587bd9f159c3a67cdec44ac23dc407 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 10:46:48 +0200 Subject: [PATCH 165/298] refactor(be): the account cap is a rule about what the write leaves behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a pre-check: the caller read a counter the write path owns and refused before writing. That is the shape this whole path exists to remove — a check in front of a write is only necessary while the write cannot be taken back, and validate stores nothing. So the rule moves to where the counter is derived, and `account_management` stops asking. Naming a tracked default reaches it the same way, because naming one is what turns it into a stored account. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 98 ++++++++----------- src/internet_identity/src/storage.rs | 24 +++++ 2 files changed, 66 insertions(+), 56 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index dad742b893..52ab8e88eb 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -9,30 +9,27 @@ use crate::{ state::{self, storage_borrow, storage_borrow_mut}, storage::{ account::{ - validate_account_name, Account, AccountDelegationError, AccountKey, AccountsCounter, + validate_account_name, Account, AccountDelegationError, AccountKey, PrepareAccountDelegation, }, - Storage, + StorageError, }, update_root_hash, }; use ic_canister_sig_creation::{signature_map::CanisterSigInputs, DELEGATION_SIG_DOMAIN}; use ic_cdk::{api::time, caller}; -use ic_stable_structures::DefaultMemoryImpl; use internet_identity_interface::{ archive::types::{Operation, Private}, internet_identity::types::{ - AccountInfo, AccountNumber, AccountUpdate, AnchorNumber, CheckMaxAccountError, - CreateAccountError, Delegation, FrontendHostname, GetAccountError, GetDefaultAccountError, - SessionKey, SetDefaultAccountError, SignedDelegation, Timestamp, UpdateAccountError, + AccountInfo, AccountNumber, AccountUpdate, AnchorNumber, CreateAccountError, Delegation, + FrontendHostname, GetAccountError, GetDefaultAccountError, SessionKey, + SetDefaultAccountError, SignedDelegation, Timestamp, UpdateAccountError, }, }; #[cfg(test)] use pretty_assertions::assert_eq; use serde_bytes::ByteBuf; -const MAX_ANCHOR_ACCOUNTS: usize = 500; - pub fn get_accounts_for_origin( anchor_number: AnchorNumber, origin: &FrontendHostname, @@ -166,12 +163,15 @@ pub fn create_account_for_origin( ) -> Result { validate_account_name(&name).map_err(Into::::into)?; let created_account = storage_borrow_mut(|storage| { - check_max_anchor_accounts(storage, anchor_number, MAX_ANCHOR_ACCOUNTS as u64) - .map_err(Into::::into)?; - storage .create_account(anchor_number, origin, name.clone()) - .map_err(|err| CreateAccountError::InternalCanisterError(format!("{err}"))) + .map_err(|err| match err { + // The cap is the write path's rule, so it says so rather than the caller + // asking first — and naming a tracked default reaches it the same way, + // because naming one is what makes it a stored account. + StorageError::AccountLimitReached { .. } => CreateAccountError::AccountLimitReached, + err => CreateAccountError::InternalCanisterError(format!("{err}")), + }) })?; post_account_operation_bookkeeping( @@ -196,14 +196,6 @@ pub fn update_account_for_origin( let (updated_account, old_account_name) = // Type annotation was necessary for the compiler to infer the correct type storage_borrow_mut(|storage| -> Result<(Account, Option), UpdateAccountError> { - // If the account to be updated is a default account - // Check if we have reached account limit - // Because editing a default account turns it into a stored account - if account_number.is_none() { - check_max_anchor_accounts(storage, anchor_number, MAX_ANCHOR_ACCOUNTS as u64) - .map_err(Into::::into)? - } - // A caller reaches this with nothing readable in two ways: the // account belongs to another identity, or the tracked default has // already been named and so is no longer numberless. Both are the @@ -221,7 +213,12 @@ pub fn update_account_for_origin( renamed_account.name = Some(new_name.clone()); let updated_account = storage .write_account(renamed_account) - .map_err(|err| UpdateAccountError::InternalCanisterError(err.to_string()))?; + .map_err(|err| match err { + StorageError::AccountLimitReached { .. } => { + UpdateAccountError::AccountLimitReached + } + err => UpdateAccountError::InternalCanisterError(err.to_string()), + })?; Ok((updated_account, old_account.name)) })?; @@ -390,27 +387,6 @@ pub fn get_account_delegation( }) } -/// Refuses where this identity already holds as many accounts as it may. -/// -/// Counted rather than repaired. There used to be a rebuild here for a counter that could -/// drift, because it was maintained by hand at each write site; the write path derives it -/// now, so there is nothing to repair and no reason to look twice. -fn check_max_anchor_accounts( - storage: &Storage, - anchor_number: AnchorNumber, - max_anchor_accounts: u64, -) -> Result<(), CheckMaxAccountError> { - let AccountsCounter { - stored_accounts, - stored_account_references: _, - } = storage.get_account_counter(anchor_number); - - if stored_accounts >= max_anchor_accounts { - return Err(CheckMaxAccountError::AccountLimitReached); - } - Ok(()) -} - #[cfg(not(test))] #[allow(dead_code)] fn post_account_operation_bookkeeping(anchor_number: AnchorNumber, operation: Operation) { @@ -449,6 +425,7 @@ fn should_create_account_for_origin() { fn should_fail_to_create_accounts_above_max() { use crate::state::{storage_borrow_mut, storage_replace}; use crate::storage::Storage; + use crate::storage::MAX_ANCHOR_ACCOUNTS; use ic_stable_structures::VectorMemory; storage_replace(Storage::new((0, 10000), VectorMemory::default())); @@ -470,6 +447,7 @@ fn should_fail_to_create_accounts_above_max() { fn should_fail_to_update_default_accounts_above_max() { use crate::state::{storage_borrow_mut, storage_replace}; use crate::storage::Storage; + use crate::storage::MAX_ANCHOR_ACCOUNTS; use ic_stable_structures::VectorMemory; storage_replace(Storage::new((0, 10000), VectorMemory::default())); @@ -754,33 +732,41 @@ fn should_update_default_account_for_origin() { } #[test] -// This test is to make sure that the check_max_anchor_accounts function correctly errors -// It should error when the counters are at or above max and argument 'first_time' is false -fn should_fail_check_or_rebuild_when_not_first_time() { +fn naming_a_tracked_default_at_the_account_limit_is_refused() { use crate::state::{storage_borrow_mut, storage_replace}; - use crate::storage::Storage; + use crate::storage::{Storage, MAX_ANCHOR_ACCOUNTS}; use ic_stable_structures::VectorMemory; storage_replace(Storage::new((0, 10000), VectorMemory::default())); - let anchor = storage_borrow_mut(|storage| storage.allocate_anchor(0).unwrap()); + let anchor = storage_borrow_mut(|storage| { + let anchor = storage.allocate_anchor(0).unwrap(); + storage.write(anchor.clone()).unwrap(); + anchor + }); + let origin = "https://example.com".to_string(); + create_account_for_origin(anchor.anchor_number(), origin.clone(), "first".to_string()).unwrap(); - // create faulty counter entries + // At the limit, and naming another account is the one thing that cannot be done — + // said by the write itself rather than by a caller asking first. storage_borrow_mut(|storage| { storage.set_counters_for_testing( anchor.anchor_number(), - MAX_ANCHOR_ACCOUNTS as u64, - MAX_ANCHOR_ACCOUNTS as u64, - ); - let res = - check_max_anchor_accounts(storage, anchor.anchor_number(), MAX_ANCHOR_ACCOUNTS as u64); - assert!(res.is_err()) + MAX_ANCHOR_ACCOUNTS, + MAX_ANCHOR_ACCOUNTS, + ) }); + + assert_eq!( + create_account_for_origin(anchor.anchor_number(), origin, "second".to_string()), + Err(CreateAccountError::AccountLimitReached) + ); } #[test] fn a_drifted_account_counter_is_not_repaired_and_costs_the_identity_its_limit() { use crate::state::{storage_borrow_mut, storage_replace}; use crate::storage::Storage; + use crate::storage::MAX_ANCHOR_ACCOUNTS; use ic_stable_structures::VectorMemory; storage_replace(Storage::new((0, 10000), VectorMemory::default())); @@ -795,8 +781,8 @@ fn a_drifted_account_counter_is_not_repaired_and_costs_the_identity_its_limit() storage_borrow_mut(|storage| { storage.set_counters_for_testing( anchor.anchor_number(), - MAX_ANCHOR_ACCOUNTS as u64, - MAX_ANCHOR_ACCOUNTS as u64, + MAX_ANCHOR_ACCOUNTS, + MAX_ANCHOR_ACCOUNTS, ) }); diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index b4f69cb92f..6637c3a6fa 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -294,6 +294,12 @@ const NEXT_APPLICATION_NUMBER_MEMORY_ID: MemoryId = // multiple virtual memories for smaller amounts of data. // This value results in 256 GB of total managed memory, which should be enough // for the foreseeable future. +/// Named accounts one identity may hold, across every origin. +/// +/// Each costs a stored record and a seed, so this is what bounds an identity's share of +/// the canister. +pub const MAX_ANCHOR_ACCOUNTS: u64 = 500; + const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; @@ -1634,6 +1640,14 @@ impl Storage { )?; } + // The account cap is a rule about the state this write leaves the identity in, + // not a question for a caller to ask first. Refusing here costs nothing, because + // nothing has been stored — which is the only reason a rule can live at the end of + // a write rather than in front of it. + if anchor_accounts > MAX_ANCHOR_ACCOUNTS { + return Err(StorageError::AccountLimitReached { anchor_number }); + } + Ok(ValidatedAccountStateWrite { writes: validated, anchor_counter: StorableAccountsCounter { @@ -1974,6 +1988,9 @@ impl Storage { ); } + // Read by tests only: the caps are the write path's rules now, so nothing in + // production asks a counter what it may do. + #[cfg(test)] /// Returns the account counter for a given anchor number. pub fn get_account_counter(&self, anchor_number: AnchorNumber) -> AccountsCounter { self.stable_anchor_account_counter_memory @@ -2752,6 +2769,9 @@ impl ReferenceListDeltas { #[derive(Debug)] pub enum StorageError { + AccountLimitReached { + anchor_number: AnchorNumber, + }, AnchorNumberOutOfRange { anchor_number: AnchorNumber, range: (AnchorNumber, AnchorNumber), @@ -2836,6 +2856,10 @@ impl fmt::Display for StorageError { "attempted to store an entry of size {space_required} \ which is larger then the max allowed entry size {space_available}" ), + Self::AccountLimitReached { anchor_number } => write!( + f, + "identity {anchor_number} already holds as many named accounts as it may" + ), Self::AnchorNotFound { anchor_number } => { write!( f, From d13a71f64758eda45a221398384ee46c512e41b4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 10:51:01 +0200 Subject: [PATCH 166/298] refactor(be): evicting idle tracked defaults is part of the write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eviction rode on a second call made once the first had returned, so a write that pushed an identity over the cap was two atomic units — and an `Err` from the second committed the first. It also only rode on writes that *created* a list, so an identity could drift well past the cap through writes that did not. Victims are selected in validate now, against the state the write is about to leave behind rather than the state read back afterwards: the counters plus this call's deltas, with every origin the write touches excluded so a list it is in the middle of changing is never also a victim of it. The removals go in the same batch. `write_tracked_default`, `evict_idle_tracked_defaults` and `tracked_default_account_upper_bound` go with it — a caller no longer has to know the cap exists. Two tests had to plant their over-cap state rather than write it, because the write path can no longer produce one. That is the cap holding rather than a test getting harder: the shape they now describe is what an upgrade leaves behind from before it existed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 204 ++++++++++----------- src/internet_identity/src/storage/tests.rs | 113 +++++++++--- 2 files changed, 189 insertions(+), 128 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 00aea46a36..d43b8b38c2 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1602,40 +1602,6 @@ impl Storage { Ok(self.apply_account_state(anchor_number, validated)) } - /// A write that may store this identity's first tracked default at an origin, and - /// evicts idle ones where it did. - /// - /// The sweep rides on the write that created the list rather than running on its own, - /// because the cap it enforces is a cap on stored tracked defaults and that is the - /// only write that can push past it. - fn write_tracked_default( - &mut self, - anchor_number: AnchorNumber, - origin: FrontendHostname, - account_references: Vec, - config: Option, - ) -> Result, StorageError> { - let is_new_list = self - .lookup_application_number_with_origin(&origin) - .and_then(|application_number| { - self.stored_account_references(anchor_number, application_number) - }) - .is_none(); - - let written = self.write_account_state( - anchor_number, - BTreeMap::from([(origin.clone(), Some((account_references, config)))]), - )?; - - if is_new_list { - if let Some(application_number) = self.lookup_application_number_with_origin(&origin) { - self.evict_idle_tracked_defaults(anchor_number, application_number)?; - } - } - - Ok(written) - } - /// Everything that can refuse. Reads what is stored, works out what would be minted /// without minting it, and hands apply something that cannot fail. fn validate_account_state( @@ -1648,6 +1614,7 @@ impl Storage { global: self.stable_account_counter_memory.get().clone(), }; + let written_origins: BTreeSet = writes.keys().cloned().collect(); let mut validated = Vec::with_capacity(writes.len()); for (origin, write) in writes { validated.push(self.validate_account_reference_list( @@ -1658,15 +1625,32 @@ impl Storage { )?); } + // Evicting idle tracked defaults belongs here rather than to a caller, for the + // same reason the counters do: it is a consequence of the write, and a caller that + // has to remember it is a caller that can forget. + // + // It also has to be *this* write. Eviction used to run as a second call once the + // first had returned, so a write that pushed the identity over the cap was two + // atomic units — and an `Err` from the second committed the first. + let stored_anchor = self + .stable_anchor_account_counter_memory + .get(&anchor_number) + .unwrap_or_default(); + for (origin, application_number) in + self.evictable_after(anchor_number, &stored_anchor, &validated, &written_origins) + { + validated.push(self.validate_removal( + anchor_number, + origin, + Some(application_number), + )?); + } + // The anchor's counter and the global reference count are shared by every origin // in this call, so they are folded here rather than per write. Each delta // computed against the same stored value and applied on its own would keep only // the last of them, and deltas that each fit can still sum to one that does not: // applying them to a running total is what checks the sum rather than the parts. - let stored_anchor = self - .stable_anchor_account_counter_memory - .get(&anchor_number) - .unwrap_or_default(); let mut anchor_accounts = stored_anchor.stored_accounts; let mut anchor_references = stored_anchor.stored_account_references; let mut global_references = minting.global.stored_account_references; @@ -1709,6 +1693,69 @@ impl Storage { }) } + /// The tracked defaults this write leaves over the cap, as origins to remove. + /// + /// Selected against the state the write is about to produce rather than the state on + /// disk: the deltas it carries are added to the counters here, and every origin the + /// write touches is excluded, so a list it is in the middle of changing is never also + /// a victim of it. + fn evictable_after( + &self, + anchor_number: AnchorNumber, + stored_anchor: &StorableAccountsCounter, + validated: &[ValidatedAccountReferenceListWrite], + written_origins: &BTreeSet, + ) -> Vec<(FrontendHostname, ApplicationNumber)> { + let (accounts, references) = validated.iter().fold( + ( + stored_anchor.stored_accounts as i64, + stored_anchor.stored_account_references as i64, + ), + |(accounts, references), one| { + ( + accounts + one.deltas.accounts, + references + one.deltas.references, + ) + }, + ); + // Numberless account references, bounded from counters rather than by looking: + // every account reference that is not a named account is a tracked default. + let upper_bound = references.saturating_sub(accounts).max(0) as u64; + if upper_bound < MAX_EVICTABLE_DEFAULT_ACCOUNTS { + return Vec::new(); + } + + let mut candidates: Vec<_> = self + .evictable_default_lists(anchor_number) + .into_iter() + .filter_map(|(application_number, last_used)| { + // An origin this write is already changing is not a victim of it, and one + // whose application is gone would refuse the whole call — housekeeping + // does not get to fail the write it is riding on. + let application = self.stable_application_memory.get(&application_number)?; + (!written_origins.contains(&application.origin)).then_some(( + last_used, + application_number, + application.origin, + )) + }) + .collect(); + if candidates.len() as u64 <= EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK { + return Vec::new(); + } + + candidates.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1))); + let victims = u64::min( + candidates.len() as u64 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK, + MAX_EVICTIONS_PER_CALL, + ); + candidates + .into_iter() + .take(victims as usize) + .map(|(_, application_number, origin)| (origin, application_number)) + .collect() + } + /// One origin's worth of validation. fn validate_account_reference_list( &self, @@ -2165,59 +2212,6 @@ impl Storage { .collect() } - /// Upper bound on an anchor's evictable lists, from counters that already exist. - fn tracked_default_account_upper_bound(&self, anchor_number: AnchorNumber) -> u64 { - let counter = self.get_account_counter(anchor_number); - counter - .stored_account_references - .saturating_sub(counter.stored_accounts) - } - - /// Drops the least recently used evictable defaults once the anchor is at the cap. - fn evict_idle_tracked_defaults( - &mut self, - anchor_number: AnchorNumber, - just_written: ApplicationNumber, - ) -> Result<(), StorageError> { - if self.tracked_default_account_upper_bound(anchor_number) < MAX_EVICTABLE_DEFAULT_ACCOUNTS - { - return Ok(()); - } - - let mut candidates: Vec<_> = self - .evictable_default_lists(anchor_number) - .into_iter() - .filter(|(application_number, _)| *application_number != just_written) - .collect(); - if candidates.len() as u64 <= EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK { - return Ok(()); - } - - candidates.sort_by_key(|(application_number, last_used)| (*last_used, *application_number)); - - let victims = u64::min( - candidates.len() as u64 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK, - MAX_EVICTIONS_PER_CALL, - ); - // One call holding every victim rather than one call each: an `Err` on the IC - // commits what came before it, so a sweep that wrote as it went would sign the - // identity out of some applications and report failure. - let removals: BTreeMap = candidates - .into_iter() - .take(victims as usize) - .filter_map(|(application_number, _)| { - // A victim whose application is gone would refuse the whole call, and - // this runs alongside a sign-in. Housekeeping does not get to fail that. - let application = self.stable_application_memory.get(&application_number)?; - Some((application.origin, None)) - }) - .collect(); - - self.write_account_state(anchor_number, removals)?; - - Ok(()) - } - pub fn lookup_anchor_application_config( &self, anchor_number: AnchorNumber, @@ -2511,14 +2505,10 @@ impl Storage { (None, None) => {} } - let written = if account_number.is_none() { - self.write_tracked_default(anchor_number, origin.clone(), account_references, config)? - } else { - self.write_account_state( - anchor_number, - BTreeMap::from([(origin.clone(), Some((account_references, config)))]), - )? - }; + let written = self.write_account_state( + anchor_number, + BTreeMap::from([(origin.clone(), Some((account_references, config)))]), + )?; let write = &written[&origin] .as_ref() .expect("a write that holds something is handed back holding it") @@ -2558,13 +2548,17 @@ impl Storage { check_frontend_length(&origin); let (account_references, _) = self.account_state_for_origin(anchor_number, &origin); - self.write_tracked_default( + self.write_account_state( anchor_number, - origin, - account_references, - Some(AnchorApplicationConfig { - default_account_number: account_number, - }), + BTreeMap::from([( + origin, + Some(( + account_references, + Some(AnchorApplicationConfig { + default_account_number: account_number, + }), + )), + )]), )?; Ok(()) } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 6983e9ad48..44341fd18f 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3708,6 +3708,9 @@ mod tracked_default_eviction_tests { use super::remove_at; use super::write_at; use crate::storage::account::{AccountKey, AccountReference}; + use crate::storage::storable::account_reference_list::StorableAccountReferenceList; + use crate::storage::storable::accounts_counter::StorableAccountsCounter; + use crate::storage::storable::application::StorableApplication; use crate::storage::{ EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK, MAX_EVICTABLE_DEFAULT_ACCOUNTS, MAX_EVICTIONS_PER_CALL, @@ -3798,24 +3801,88 @@ mod tracked_default_eviction_tests { ); } + /// Puts an identity over the tracked-default cap without going through the write path, + /// which is the one thing that can no longer produce this state. + fn plant_over_the_cap( + storage: &mut Storage, + anchor_number: AnchorNumber, + lists: u64, + ) { + for index in 0..lists { + let origin = origin_of(index); + let application_number = index; + storage.lookup_application_with_origin_memory.insert( + crate::storage::StorableOriginSha256::from_origin(&origin), + application_number, + ); + // Counters that match what is planted, or removing one under-runs them. + storage.stable_application_memory.insert( + application_number, + StorableApplication { + origin, + stored_accounts: 0, + stored_account_references: 1, + tombstones: 0, + }, + ); + storage.stable_account_reference_list_memory.insert( + (anchor_number, application_number), + StorableAccountReferenceList::try_from(vec![AccountReference { + account_number: None, + last_used: Some(index + 1), + }]) + .unwrap(), + ); + } + storage.set_counters_for_testing(anchor_number, 0, lists); + storage + .stable_account_counter_memory + .set(StorableAccountsCounter { + stored_accounts: 0, + stored_account_references: lists, + }) + .unwrap(); + } + + /// Eviction is a consequence of the write, so it follows any write that leaves the + /// identity over the cap — not only the one that created a list, which is what used to + /// carry it. + #[test] + fn a_write_to_an_origin_that_already_has_a_list_evicts_too() { + let (mut storage, anchor_number) = storage_with_anchor(); + let lists = MAX_EVICTABLE_DEFAULT_ACCOUNTS + 100; + plant_over_the_cap(&mut storage, anchor_number, lists); + + // An origin that already has a list, so this write creates nothing — which is + // exactly the case the old order skipped the sweep for. + record_use( + &mut storage, + anchor_number, + origin_of(lists - 1), + None, + 1_000_000, + ) + .unwrap(); + + assert_eq!( + lists - storage.evictable_default_lists(anchor_number).len() as u64, + MAX_EVICTIONS_PER_CALL, + "a write that created nothing still evicted" + ); + } + #[test] fn one_call_evicts_at_most_a_bounded_batch() { let (mut storage, anchor_number) = storage_with_anchor(); - for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS * 3 { - let _application_number = storage - .write_account_state( - anchor_number, - write_at( - &origin_of(index), - vec![AccountReference { - account_number: None, - last_used: Some(index + 1), - }], - None, - ), - ) - .unwrap(); - } + // Planted rather than written. An identity can no longer *reach* far over the cap + // through the write path — eviction now runs on the write that would take it + // there — so this is the state an upgrade leaves behind from before the cap + // existed, which is the only way the batch bound is still the binding one. + plant_over_the_cap( + &mut storage, + anchor_number, + MAX_EVICTABLE_DEFAULT_ACCOUNTS * 3, + ); let before = storage.evictable_default_lists(anchor_number).len() as u64; record_use( @@ -4007,17 +4074,17 @@ mod tracked_default_eviction_tests { .unwrap(); } - assert_eq!( - storage.tracked_default_account_upper_bound(anchor_number), - 3 - ); + // The bound eviction triggers on is every account reference that is not a named + // account, taken from counters rather than by looking at the lists. + let tracked_defaults = |storage: &Storage| { + let counter = storage.get_account_counter(anchor_number); + counter.stored_account_references - counter.stored_accounts + }; + assert_eq!(tracked_defaults(&storage), 3); sign_in_at(&mut storage, anchor_number, 100); - assert_eq!( - storage.tracked_default_account_upper_bound(anchor_number), - 4 - ); + assert_eq!(tracked_defaults(&storage), 4); assert_eq!(storage.evictable_default_lists(anchor_number).len(), 1); } From 8915093b58546f394075a55d2b0bead6fc83193d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 11:59:14 +0200 Subject: [PATCH 167/298] test(be): pin that an account write needs an identity that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking the anchor rather than its number means the write refuses where there is nothing to read. That started as a consequence of the borrow — a caller must not hold a copy of the anchor across the write — but it is right on its own terms: the counters, the account reference lists and the session count all key on a record that would not be there, so a write against an identity that was never allocated leaves rows nothing will ever prune. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 7 +++++-- src/internet_identity/src/storage/tests.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 7600824565..84484a2b98 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3125,8 +3125,11 @@ impl Storage { // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. - // Read once and handed to the write: the gate moves this identity's session - // count and must not be handed a copy that has already gone stale. + // Read once and handed to the write, for two reasons: the gate moves this + // identity's session count and must not be handed a copy that has already gone + // stale, and an identity that does not exist has nothing to hold what is about to + // be written — the counters, the account reference lists and the session count all + // key on a record that would not be there. let mut anchor = self.read(anchor_number)?; let (mut account_references, config) = self.account_state_for_origin(anchor_number, &origin); diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index dda2a22590..61244581c9 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -2346,6 +2346,24 @@ mod reference_list_write_path_tests { (storage, anchor_number) } + /// The write path takes the identity, not its number, so there is nothing to write + /// against when the identity does not exist. Writing anyway would leave counters, + /// account reference lists and an application keyed on an owner that never existed, + /// and nothing would ever prune them. + #[test] + fn an_account_write_for_an_identity_that_does_not_exist_is_refused() { + let (mut storage, anchor_number) = storage_with_anchor(); + let never_allocated = anchor_number + 1; + let origin = "https://example.com".to_string(); + + let result = storage.create_account(never_allocated, origin.clone(), "named".to_string()); + + assert!(matches!(result, Err(StorageError::BadAnchorNumber(_)))); + // Refused before anything was written, the application included. + assert_eq!(storage.lookup_application_number_with_origin(&origin), None); + assert_eq!(storage.get_total_application_count(), 0); + } + /// Everything a write derives, rebuilt from the account reference lists alone. /// /// A gate whose job is deriving values is only as good as a check that does the From 0d20623091e68227be8d02ab90d5a86b2482e62e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 12:07:00 +0200 Subject: [PATCH 168/298] test(be): every derivation, checked against the lists it came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path's job is deriving values — the counters, both principal indices, the session count — from the pair of account reference lists a write holds. Every defect the review of this stack turned up was one of those maintained by hand and forgotten at one write site. A test per operation catches the site it names; this catches the ones nobody thought to name. An arbitrary sequence of writes against one identity, deterministic from a seed so a failure is reproducible, with everything rebuilt from the lists a second way after every step. The indices are checked as a round trip rather than by deriving the principal again, so it catches an index the write path forgot to add to as well as one it forgot to remove from. This is what stands where the counter repair path used to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 315 +++++++++++++++++++++ 1 file changed, 315 insertions(+) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 486e506eb6..50be793889 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6590,3 +6590,318 @@ mod session_revocation_tests { ); } } + +/// Everything the write path derives, checked against the account reference lists it +/// derived them from, after an arbitrary sequence of writes. +/// +/// This is the check that stands where the counter repair path used to. The gate's whole +/// job is deriving values — the counters, both principal indices, the session count — from +/// the pair of lists a write holds, and every defect the review of this stack turned up was +/// one of those maintained by hand and forgotten at one write site. A test per operation +/// catches the site it names; this catches the ones nobody thought to name. +mod write_path_property_tests { + use super::record_use; + use crate::storage::account::{AccountKey, AccountReference}; + use crate::storage::{CreateSessionParams, Storage}; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::AnchorNumber; + use pretty_assertions::assert_eq; + + /// Deterministic, so a failure is reproducible from the seed the loop prints. + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 >> 33 + } + + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + } + + fn storage_with_anchor() -> (Storage, AnchorNumber) { + let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); + storage.update_salt([23u8; 32]); + let anchor = storage.allocate_anchor(0).unwrap(); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).unwrap(); + (storage, anchor_number) + } + + fn origin_of(index: u64) -> String { + format!("https://app-{index}.com") + } + + /// One write, chosen at random. Refusals are fine and are part of the point: a write + /// the gate turned down must leave everything it touched exactly as it was, which the + /// invariants below are checked against either way. + fn arbitrary_write( + storage: &mut Storage, + anchor_number: AnchorNumber, + rng: &mut Rng, + now: u64, + ) { + // A small set of origins, so writes collide and lists grow rather than every write + // landing somewhere fresh. + let origin = origin_of(rng.below(6)); + match rng.below(7) { + 0 => { + let _ = storage.create_account( + anchor_number, + origin, + format!("account-{}", rng.next()), + ); + } + 1 => { + // Naming the tracked default, or renaming whatever is there. + let account_number = pick_account(storage, anchor_number, &origin, rng); + if let Some(mut account) = storage.read_account(&AccountKey { + anchor_number, + origin, + account_number, + }) { + account.name = Some(format!("renamed-{}", rng.next())); + let _ = storage.write_account(account); + } + } + 2 => { + let account_number = pick_account(storage, anchor_number, &origin, rng); + let _ = storage.set_default_account(anchor_number, origin, account_number); + } + 3 => { + let _ = record_use(storage, anchor_number, origin, None, now); + } + 4 => { + let account_number = pick_account(storage, anchor_number, &origin, rng); + let _ = storage.create_session(CreateSessionParams { + anchor_number, + origin, + account_number, + device_id: rng.below(4) as u32, + valid_till_ns: now + 1 + rng.below(20_000), + max_idle_ns: None, + read_only: false, + now_ns: now, + }); + } + 5 => { + if let Some(key) = pick_session(storage, anchor_number, &origin, rng) { + let _ = storage.revoke_session(&key); + } + } + _ => { + let _ = storage.revoke_device_sessions(anchor_number, rng.below(4) as u32); + } + } + } + + /// One of the accounts this identity holds at `origin`, or the tracked default. + fn pick_account( + storage: &Storage, + anchor_number: AnchorNumber, + origin: &str, + rng: &mut Rng, + ) -> Option { + let application_number = + storage.lookup_application_number_with_origin(&origin.to_string())?; + let references = storage + .stored_account_references(anchor_number, application_number) + .unwrap_or_default(); + if references.is_empty() { + return None; + } + references[rng.below(references.len() as u64) as usize].account_number + } + + fn pick_session( + storage: &Storage, + anchor_number: AnchorNumber, + origin: &str, + rng: &mut Rng, + ) -> Option { + let application_number = + storage.lookup_application_number_with_origin(&origin.to_string())?; + let references = storage.stored_account_references(anchor_number, application_number)?; + let sessions: Vec<_> = references + .iter() + .flat_map(|reference| { + reference + .sessions + .iter() + .map(move |session| (reference.account_number, session.session_id)) + }) + .collect(); + if sessions.is_empty() { + return None; + } + let (account_number, session_id) = sessions[rng.below(sessions.len() as u64) as usize]; + Some(crate::storage::account::SessionRecordKey { + anchor_number, + origin: origin.to_string(), + account_number, + session_id, + }) + } + + /// Every derived value, rebuilt from the lists a second way. + fn check(storage: &Storage, anchor_number: AnchorNumber, where_: &str) { + let mut accounts = 0u64; + let mut references = 0u64; + let mut sessions = 0u32; + let mut lists = Vec::new(); + + for (key, list) in storage.stable_account_reference_list_memory.iter() { + if key.0 != anchor_number { + continue; + } + let held = Vec::::from(list); + let named = held + .iter() + .filter(|reference| reference.account_number.is_some()) + .count() as u64; + accounts += named; + references += held.len() as u64; + sessions += held + .iter() + .map(|reference| reference.sessions.len() as u32) + .sum::(); + lists.push((key.1, held)); + } + + // The counters say what the lists hold. + let anchor_counter = storage.get_account_counter(anchor_number); + assert_eq!( + ( + anchor_counter.stored_accounts, + anchor_counter.stored_account_references + ), + (accounts, references), + "{where_}: the anchor counter and the lists disagree" + ); + assert_eq!( + storage + .stable_account_counter_memory + .get() + .stored_account_references, + references, + "{where_}: the global reference count and the lists disagree" + ); + assert_eq!( + storage.read(anchor_number).unwrap().session_count, + sessions, + "{where_}: the session count and the lists disagree" + ); + + // Each application's own totals say what its list holds, and an application with + // nothing holding it is retired rather than left behind. + for (application_number, held) in &lists { + let application = storage + .stable_application_memory + .get(application_number) + .unwrap_or_else(|| { + panic!("{where_}: list {application_number} names an application that is gone") + }); + let named = held + .iter() + .filter(|reference| reference.account_number.is_some()) + .count() as u64; + assert_eq!( + ( + application.stored_accounts, + application.stored_account_references + ), + (named, held.len() as u64), + "{where_}: application {application_number}'s counters and its list disagree" + ); + } + + // Every account this identity holds resolves from its principal, and every entry + // that resolves to it is one it still holds. Checked as a round trip rather than by + // deriving the principal again, so it catches an index the write path forgot to + // add to and one it forgot to remove from. + let mut held_accounts = std::collections::BTreeSet::new(); + for (application_number, held) in &lists { + let Some(application) = storage.stable_application_memory.get(application_number) + else { + continue; + }; + for reference in held { + // The tracked default is indexed too: it has a derived principal, which is + // what an identity signs in with at an origin before naming anything. + held_accounts.insert((application.origin.clone(), reference.account_number)); + } + } + let mut indexed_accounts = std::collections::BTreeSet::new(); + for (principal, _) in storage.lookup_account_with_principal_memory.iter() { + let Some(key) = storage.lookup_account_with_principal(principal) else { + panic!("{where_}: an account index entry resolves to nothing"); + }; + if key.anchor_number != anchor_number { + continue; + } + assert!( + held_accounts.contains(&(key.origin.clone(), key.account_number)), + "{where_}: the account index names an account this identity does not hold" + ); + indexed_accounts.insert((key.origin, key.account_number)); + } + assert_eq!( + held_accounts, indexed_accounts, + "{where_}: an account this identity holds is missing from the index" + ); + + // The same, for sessions. + let mut held_sessions = std::collections::BTreeSet::new(); + for (application_number, held) in &lists { + let Some(application) = storage.stable_application_memory.get(application_number) + else { + continue; + }; + for reference in held { + for session in &reference.sessions { + held_sessions.insert((application.origin.clone(), session.session_id)); + } + } + } + let mut indexed_sessions = std::collections::BTreeSet::new(); + for (principal, _) in storage.lookup_session_with_principal_memory.iter() { + let Some(key) = storage.lookup_session_with_principal(principal) else { + panic!("{where_}: a session index entry resolves to nothing"); + }; + if key.anchor_number != anchor_number { + continue; + } + assert!( + held_sessions.contains(&(key.origin.clone(), key.session_id)), + "{where_}: the session index names a session this identity does not hold" + ); + indexed_sessions.insert((key.origin, key.session_id)); + } + assert_eq!( + held_sessions, indexed_sessions, + "{where_}: a session this identity holds is missing from the index" + ); + } + + #[test] + fn every_derivation_survives_an_arbitrary_sequence_of_writes() { + for seed in 0..16u64 { + let (mut storage, anchor_number) = storage_with_anchor(); + let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + for step in 0..40u64 { + let now = 1_000 + step * 500; + arbitrary_write(&mut storage, anchor_number, &mut rng, now); + check( + &storage, + anchor_number, + &format!("seed {seed}, step {step}"), + ); + } + } + } +} From ff9d611942f4d96191a64370cc6e81e9088590f3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 12:11:31 +0200 Subject: [PATCH 169/298] test(be): evicting a list takes its sessions' index entries with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing caught this. Making the removal path skip the session index passed all 846 tests: the property test cannot reach removal, because removal is only reachable through eviction and eviction needs five hundred origins, and no targeted test covered it either. Not an authorisation hole — the entry resolves to nothing, because the account entry does go — but an orphan nothing will ever collect, which is the class of defect the whole write path exists to make impossible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 50be793889..0c49498ac5 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3742,6 +3742,7 @@ mod tracked_default_eviction_tests { use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::storable::accounts_counter::StorableAccountsCounter; use crate::storage::storable::application::StorableApplication; + use crate::storage::CreateSessionParams; use crate::storage::{ EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK, MAX_EVICTABLE_DEFAULT_ACCOUNTS, MAX_EVICTIONS_PER_CALL, @@ -3768,6 +3769,72 @@ mod tracked_default_eviction_tests { record_use(storage, anchor_number, origin_of(index), None, index + 1).unwrap(); } + /// Evicting a list takes its sessions' index entries with it, not just its accounts'. + /// + /// The property test cannot reach this: removal is only reachable through eviction, and + /// eviction needs five hundred origins. Without this, an index entry for a session on an + /// evicted list is left behind — it resolves to nothing, because the account entry does + /// go, so it grants nothing; but it is an orphan nothing will ever collect. + #[test] + fn evicting_a_list_takes_its_sessions_index_entries_with_it() { + let (mut storage, anchor_number) = storage_with_anchor(); + + // A real session, so it is really indexed. Signed in first, so it is the stalest + // list and therefore the first thing eviction gives up. + let doomed = origin_of(0); + let (key, _) = storage + .create_session(CreateSessionParams { + anchor_number, + origin: doomed.clone(), + account_number: None, + device_id: 1, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1, + }) + .expect("signing in at a fresh origin"); + let session_principals: Vec<_> = storage + .lookup_session_with_principal_memory + .iter() + .map(|(principal, _)| principal) + .collect(); + assert_eq!( + session_principals.len(), + 1, + "the session should be indexed to begin with" + ); + + // Everything else more recently used, so the session's list is what goes. + for index in 1..=MAX_EVICTABLE_DEFAULT_ACCOUNTS { + record_use( + &mut storage, + anchor_number, + origin_of(index), + None, + 1_000 + index, + ) + .unwrap(); + } + + assert_eq!( + storage.lookup_application_number_with_origin(&doomed), + None, + "the stalest list should have been evicted" + ); + assert_eq!( + storage.lookup_session_with_principal(session_principals[0]), + None, + "its session must not still resolve" + ); + assert_eq!( + storage.lookup_session_with_principal_memory.len(), + 0, + "and must not be left behind in the index either" + ); + assert!(storage.read_session(&key).is_none()); + } + #[test] fn evicting_drops_the_least_recently_used_down_to_the_watermark() { let (mut storage, anchor_number) = storage_with_anchor(); From 920652297ddb3e364f3f6fceb6ac03d61e71f5f1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:31 +0200 Subject: [PATCH 170/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken and immovable: it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. Two notions of "device" in one file is worse than the name we had. The word this wants is the one its own doc comment already used — "a browser this anchor has signed in from" — and the one the frontend uses throughout. It is also broader than "session device" in the direction this is going: a browser is what signs in, whether that is to an application or to the identity itself. `SessionDevice` becomes `Browser`, its id, error, storable and info types follow, and `revoke_device_sessions` becomes `revoke_browser_sessions`. `device_key` and `device_name` stay where they refer to the passkey device; only the browser's own `current_device_key` and `next_device_key` are renamed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/generated/internet_identity_idl.js | 4 +- .../generated/internet_identity_types.d.ts | 4 +- .../(home)/smartActions.test.ts | 2 +- src/internet_identity/internet_identity.did | 4 +- .../src/email_recovery/remove.rs | 4 +- src/internet_identity/src/main.rs | 12 +- src/internet_identity/src/storage.rs | 4 +- src/internet_identity/src/storage/account.rs | 12 +- src/internet_identity/src/storage/anchor.rs | 149 ++++++------ .../src/storage/anchor/tests.rs | 227 +++++++----------- src/internet_identity/src/storage/storable.rs | 4 +- .../src/storage/storable/anchor.rs | 12 +- .../{session_device.rs => browser.rs} | 16 +- .../src/storage/storable/browser_id.rs | 1 + .../src/storage/storable/session_device_id.rs | 1 - .../src/storage/storable/session_record.rs | 8 +- src/internet_identity/src/storage/tests.rs | 88 +++---- .../src/verified_emails/remove.rs | 4 +- .../tests/integration/upgrade.rs | 6 +- .../src/internet_identity/types.rs | 2 +- .../src/internet_identity/types/api_v2.rs | 8 +- 21 files changed, 261 insertions(+), 311 deletions(-) rename src/internet_identity/src/storage/storable/{session_device.rs => browser.rs} (73%) create mode 100644 src/internet_identity/src/storage/storable/browser_id.rs delete mode 100644 src/internet_identity/src/storage/storable/session_device_id.rs diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 55988f7be0..c6bcfb9e57 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -559,7 +559,7 @@ export const idlFactory = ({ IDL }) => { 'address' : IDL.Text, 'last_used' : IDL.Opt(Timestamp), }); - const SessionDeviceInfo = IDL.Record({ + const BrowserInfo = IDL.Record({ 'id' : IDL.Nat32, 'name' : IDL.Text, 'created_at' : Timestamp, @@ -581,7 +581,7 @@ export const idlFactory = ({ IDL }) => { 'name' : IDL.Opt(IDL.Text), 'email_recovery' : IDL.Opt(IDL.Vec(EmailRecoveryCredential)), 'created_at' : IDL.Opt(Timestamp), - 'session_devices' : IDL.Opt(IDL.Vec(SessionDeviceInfo)), + 'browsers' : IDL.Opt(IDL.Vec(BrowserInfo)), 'mcp_config' : IDL.Opt(McpConfig), 'authn_method_registration' : IDL.Opt(AuthnMethodRegistrationInfo), 'openid_credentials' : IDL.Opt(IDL.Vec(OpenIdCredential)), 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 5aacaa52da..d8cd1b3069 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -947,7 +947,7 @@ export interface IdentityInfo { * created a session), so the Settings UI can offer "sign this browser * out" without a separate call. */ - 'session_devices' : [] | [Array], + 'browsers' : [] | [Array], /** * The anchor's synced trusted-MCP-server config (absent when the * anchor never wrote one). Carried here rather than read from the @@ -1539,7 +1539,7 @@ export type SessionDelegationError = { 'NoSuchDelegation' : null } | * client, so it is a label for the user rather than evidence about where a * session came from. */ -export interface SessionDeviceInfo { +export interface BrowserInfo { 'id' : number, 'name' : string, 'created_at' : Timestamp, diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts index bb411b3ece..b9381ced84 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(home)/smartActions.test.ts @@ -40,7 +40,7 @@ const baseIdentityInfo: IdentityInfo = { created_at: [], authn_method_registration: [], openid_credentials: [], - session_devices: [], + browsers: [], mcp_config: [], }; diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 7c97b7f522..70461d4c52 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1009,7 +1009,7 @@ type IdentityAuthnInfo = record { // A browser an anchor has signed in from. The name is self-reported by the // client, so it is a label for the user rather than evidence about where a // session came from. -type SessionDeviceInfo = record { +type BrowserInfo = record { id : nat32; name : text; created_at : Timestamp; @@ -1040,7 +1040,7 @@ type IdentityInfo = record { // Browsers this anchor has signed in from (absent when it has never // created a session), so the Settings UI can offer "sign this browser // out" without a separate call. - session_devices : opt vec SessionDeviceInfo; + browsers : opt vec BrowserInfo; // The anchor's synced trusted-MCP-server config (absent when the // anchor never wrote one). Carried here rather than read from the // mcp_get_config query so the Settings UI has a certified value to diff --git a/src/internet_identity/src/email_recovery/remove.rs b/src/internet_identity/src/email_recovery/remove.rs index e4cde76364..50d7551bd5 100644 --- a/src/internet_identity/src/email_recovery/remove.rs +++ b/src/internet_identity/src/email_recovery/remove.rs @@ -75,8 +75,8 @@ mod tests { fn anchor_with(address: Option<&str>) -> Anchor { let mut a = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 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 05be8859f6..bdcadd345d 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -1164,20 +1164,20 @@ mod v2_api { Some(stored_verified_emails) }; - let stored_session_devices: Vec = state::anchor(identity_number) - .session_devices() + let stored_browsers: Vec = state::anchor(identity_number) + .browsers() .iter() - .map(|device| SessionDeviceInfo { + .map(|device| BrowserInfo { id: device.id, name: device.name.clone(), created_at: device.created_at, last_used: device.last_used, }) .collect(); - let session_devices = if stored_session_devices.is_empty() { + let browsers = if stored_browsers.is_empty() { None } else { - Some(stored_session_devices) + Some(stored_browsers) }; let identity_info = IdentityInfo { @@ -1195,7 +1195,7 @@ mod v2_api { created_at: anchor_info.created_at, email_recovery, verified_emails, - session_devices, + browsers, // The same config `mcp_get_config` serves, but certified: this is // an update call, so the Settings UI can render the trusted server // — and base the config it writes back — on a value no single node diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index cee615aadf..0a33114d7b 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -874,8 +874,8 @@ impl Storage { created_at_ns: _, name: _, verified_emails: _, - session_devices: _, - next_session_device_id: _, + browsers: _, + next_browser_id: _, }) = previous_anchor_maybe { ( diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 595a1302f5..eca9e1846c 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -7,8 +7,8 @@ use crate::{ use ic_cdk::trap; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ - AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, FrontendHostname, - SessionDeviceId, Timestamp, UserKey, + AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, BrowserId, + FrontendHostname, Timestamp, UserKey, }; use serde::{Deserialize, Serialize}; @@ -61,7 +61,7 @@ pub struct SessionRecord { pub valid_till_ns: Timestamp, pub max_idle_ns: u64, pub last_refreshed_ns: Option, - pub device_id: SessionDeviceId, + pub browser_id: BrowserId, pub read_only: bool, } @@ -96,13 +96,13 @@ impl SessionRecord { /// /// The extension is what separates an app in weekly use from one opened once and /// abandoned, which recency alone gets backwards — the abandoned one was touched more - /// recently. `device_id` only makes the order total. - pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, SessionDeviceId) { + /// recently. `browser_id` only makes the order total. + pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, BrowserId) { let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); ( !self.is_over(now), last_used.saturating_add(self.demonstrated_use()), - self.device_id, + self.browser_id, ) } } diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index ea93314fca..9059fb1148 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -2,11 +2,11 @@ use crate::email_inbound::MAX_VERIFIED_EMAILS_PER_ANCHOR; use crate::ii_domain::IIDomain; use crate::openid::{OpenIdCredential, OpenIdCredentialKey}; use crate::storage::storable::anchor::StorableAnchor; +use crate::storage::storable::browser::StorableBrowser; use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCredential; use crate::storage::storable::fixed_anchor::StorableFixedAnchor; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; -use crate::storage::storable::session_device::StorableSessionDevice; use crate::storage::storable::special_device_migration::SpecialDeviceMigration; use crate::storage::storable::verified_email::StorableVerifiedEmail; use crate::{IC0_APP_ORIGIN, ID_AI_ORIGIN, INTERNETCOMPUTER_ORG_ORIGIN}; @@ -39,20 +39,20 @@ pub struct Anchor { pub(crate) email_recovery: Vec, /// Capped by `MAX_VERIFIED_EMAILS_PER_ANCHOR`. pub(crate) verified_emails: Vec, - /// Capped by `MAX_SESSION_DEVICES`. - pub(crate) session_devices: Vec, - pub(crate) next_session_device_id: SessionDeviceId, + /// Capped by `MAX_BROWSERS`. + pub(crate) browsers: Vec, + pub(crate) next_browser_id: BrowserId, pub(crate) metadata: Option>, pub(crate) name: Option, pub(crate) created_at: Option, } /// Bounds the device list, which rides on the anchor blob. -pub const MAX_SESSION_DEVICES: usize = 20; +pub const MAX_BROWSERS: usize = 20; /// Why a browser's presented keys cannot be resolved. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SessionDeviceError { +pub enum BrowserError { /// The announced successor is a key another browser of this anchor already holds. /// /// Presented keys are visible on the wire, so without this a caller could announce a @@ -78,23 +78,23 @@ pub enum SessionDeviceError { /// A browser this anchor has signed in from. The name is self-reported by the client. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SessionDevice { - pub id: SessionDeviceId, +pub struct Browser { + pub id: BrowserId, /// The browser's own public key, DER-encoded. What the entry is looked up by. - pub current_device_key: PublicKey, + pub current_browser_key: PublicKey, /// The successor the browser announced at its last sign-in, also accepted as a proof. - pub next_device_key: PublicKey, + pub next_browser_key: PublicKey, pub name: String, pub created_at: Timestamp, pub last_used: Timestamp, } -impl From for SessionDevice { - fn from(value: StorableSessionDevice) -> Self { - SessionDevice { +impl From for Browser { + fn from(value: StorableBrowser) -> Self { + Browser { id: value.id, - current_device_key: ByteBuf::from(value.current_device_key), - next_device_key: ByteBuf::from(value.next_device_key), + current_browser_key: ByteBuf::from(value.current_browser_key), + next_browser_key: ByteBuf::from(value.next_browser_key), name: value.name, created_at: value.created_at, last_used: value.last_used, @@ -102,12 +102,12 @@ impl From for SessionDevice { } } -impl From for StorableSessionDevice { - fn from(value: SessionDevice) -> Self { - StorableSessionDevice { +impl From for StorableBrowser { + fn from(value: Browser) -> Self { + StorableBrowser { id: value.id, - current_device_key: value.current_device_key.into_vec(), - next_device_key: value.next_device_key.into_vec(), + current_browser_key: value.current_browser_key.into_vec(), + next_browser_key: value.next_browser_key.into_vec(), name: value.name, created_at: value.created_at, last_used: value.last_used, @@ -247,8 +247,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { openid_credentials, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, metadata, name, created_at, @@ -268,13 +268,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { .map(StorableVerifiedEmail::from) .collect(), ); - let next_session_device_id = Some(next_session_device_id); - let session_devices = Some( - session_devices - .into_iter() - .map(StorableSessionDevice::from) - .collect(), - ); + let next_browser_id = Some(next_browser_id); + let browsers = Some(browsers.into_iter().map(StorableBrowser::from).collect()); let (mut passkey_credentials, mut recovery_keys, mut recovery_devices) = (vec![], vec![], vec![]); @@ -514,8 +509,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { recovery_keys, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, }, ) } @@ -531,8 +526,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { recovery_keys, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, } = storable_anchor; let name = name.clone(); @@ -551,12 +546,12 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { .into_iter() .map(VerifiedEmail::from) .collect(); - let session_devices = session_devices + let browsers = browsers .unwrap_or_default() .into_iter() - .map(SessionDevice::from) + .map(Browser::from) .collect(); - let next_session_device_id = next_session_device_id.unwrap_or_default(); + let next_browser_id = next_browser_id.unwrap_or_default(); let mut devices = passkey_credentials .unwrap_or_default() @@ -651,8 +646,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { openid_credentials, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, devices, metadata, } @@ -679,8 +674,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number, devices, metadata, @@ -707,11 +702,11 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho .into_iter() .map(VerifiedEmail::from) .collect(); - let session_devices = storable_anchor - .session_devices + let browsers = storable_anchor + .browsers .unwrap_or_default() .into_iter() - .map(SessionDevice::from) + .map(Browser::from) .collect(); Anchor { @@ -720,8 +715,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials, email_recovery, verified_emails, - session_devices, - next_session_device_id: storable_anchor.next_session_device_id.unwrap_or_default(), + browsers, + next_browser_id: storable_anchor.next_browser_id.unwrap_or_default(), metadata, name, created_at, @@ -730,91 +725,91 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho } impl Anchor { - pub fn session_devices(&self) -> &[SessionDevice] { - &self.session_devices + pub fn browsers(&self) -> &[Browser] { + &self.browsers } /// Resolves the browser a sign-in came from by the public key it proved possession of. /// /// An entry is reached only by the successor it announced. Presenting it promotes that /// successor, retires the key it replaces, and leaves the entry awaiting - /// `next_device_key` — what the browser presents at its next sign-in. A key some entry - /// has already retired is refused with [`SessionDeviceError::StaleDeviceKey`] rather + /// `next_browser_key` — what the browser presents at its next sign-in. A key some entry + /// has already retired is refused with [`BrowserError::StaleDeviceKey`] rather /// than accepted or registered afresh, so a key is good for exactly one sign-in and a /// browser that lost a response is told to promote its own successor instead of /// becoming a second list. A key no entry holds at all registers a new browser. /// /// At the cap the least recently used records are dropped, and their ids returned so /// the caller can end their sessions too. - pub fn resolve_session_device( + pub fn resolve_browser( &mut self, - current_device_key: PublicKey, - next_device_key: PublicKey, + current_browser_key: PublicKey, + next_browser_key: PublicKey, name: String, now: Timestamp, - ) -> Result<(SessionDeviceId, Vec), SessionDeviceError> { - if current_device_key == next_device_key { - return Err(SessionDeviceError::SuccessorMatchesCurrent); + ) -> Result<(BrowserId, Vec), BrowserError> { + if current_browser_key == next_browser_key { + return Err(BrowserError::SuccessorMatchesCurrent); } // Two questions, and they are not the same one. An entry is *advanced* only by the // successor it is waiting for; an entry *holds* a key in either slot, which is what // a successor must not collide with. let entry_awaiting = |candidate: &PublicKey| { - self.session_devices + self.browsers .iter() - .position(|device| device.next_device_key == *candidate) + .position(|device| device.next_browser_key == *candidate) }; let entry_holding = |candidate: &PublicKey| { - self.session_devices.iter().position(|device| { - device.current_device_key == *candidate || device.next_device_key == *candidate + self.browsers.iter().position(|device| { + device.current_browser_key == *candidate || device.next_browser_key == *candidate }) }; - let advances = entry_awaiting(¤t_device_key); + let advances = entry_awaiting(¤t_browser_key); // The entry this request belongs to, which is not always one it can advance: a // browser retrying a lost sign-in still belongs to the entry that retired its key, // and re-announcing the successor it announced then is not stealing anyone's key. - let owner = entry_holding(¤t_device_key); - let successor_holder = entry_holding(&next_device_key); + let owner = entry_holding(¤t_browser_key); + let successor_holder = entry_holding(&next_browser_key); if successor_holder.is_some() && successor_holder != owner { - return Err(SessionDeviceError::SuccessorAlreadyInUse); + return Err(BrowserError::SuccessorAlreadyInUse); } if let Some(index) = advances { - let device = &mut self.session_devices[index]; - device.current_device_key = current_device_key; - device.next_device_key = next_device_key; + let device = &mut self.browsers[index]; + device.current_browser_key = current_browser_key; + device.next_browser_key = next_browser_key; device.last_used = now; return Ok((device.id, vec![])); } if owner.is_some() { - return Err(SessionDeviceError::StaleDeviceKey); + return Err(BrowserError::StaleDeviceKey); } - let id = self.next_session_device_id; - self.next_session_device_id = self.next_session_device_id.saturating_add(1); - self.session_devices.push(SessionDevice { + let id = self.next_browser_id; + self.next_browser_id = self.next_browser_id.saturating_add(1); + self.browsers.push(Browser { id, - current_device_key, - next_device_key, + current_browser_key, + next_browser_key, name, created_at: now, last_used: now, }); let mut dropped = vec![]; - while self.session_devices.len() > MAX_SESSION_DEVICES { + while self.browsers.len() > MAX_BROWSERS { let least_recently_used = self - .session_devices + .browsers .iter() .enumerate() .min_by_key(|(_, device)| (device.last_used, device.id)) .map(|(index, _)| index); match least_recently_used { Some(index) => { - dropped.push(self.session_devices.remove(index).id); + dropped.push(self.browsers.remove(index).id); } None => break, } @@ -833,8 +828,8 @@ impl Anchor { openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, metadata: None, name: None, } diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index 774a7c7448..9c25208846 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -223,8 +223,8 @@ fn should_allow_protection_only_on_recovery_phrases() { fn should_prevent_mutation_when_invariants_are_violated() { let mut device1 = recovery_phrase(1, DeviceProtection::Unprotected); let mut anchor = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ device1.clone(), @@ -247,8 +247,8 @@ fn should_prevent_mutation_when_invariants_are_violated() { #[test] fn should_prevent_addition_when_invariants_are_violated() { let mut anchor = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ recovery_phrase(1, DeviceProtection::Unprotected), @@ -271,8 +271,8 @@ fn should_prevent_addition_when_invariants_are_violated() { fn should_allow_removal_when_invariants_are_violated() { let device1 = recovery_phrase(1, DeviceProtection::Unprotected); let mut anchor = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ device1.clone(), @@ -1267,9 +1267,9 @@ mod mirror_verified_email_tests { } } -mod session_device_tests { +mod browser_tests { use super::*; - use crate::storage::anchor::{SessionDeviceError, MAX_SESSION_DEVICES}; + use crate::storage::anchor::{BrowserError, MAX_BROWSERS}; use internet_identity_interface::internet_identity::types::PublicKey; fn anchor() -> Anchor { @@ -1298,7 +1298,7 @@ mod session_device_tests { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome on MacBook".to_string(), @@ -1307,20 +1307,17 @@ mod session_device_tests { .unwrap(); assert_eq!(id, 0); - assert_eq!(anchor.session_devices().len(), 1); - assert_eq!(anchor.session_devices()[0].name, "Chrome on MacBook"); - assert_eq!( - anchor.session_devices()[0].current_device_key, - browser_key(1) - ); - assert_eq!(anchor.session_devices()[0].created_at, 1_000); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!(anchor.browsers()[0].name, "Chrome on MacBook"); + assert_eq!(anchor.browsers()[0].current_browser_key, browser_key(1)); + assert_eq!(anchor.browsers()[0].created_at, 1_000); } #[test] fn a_browser_that_rotates_reuses_the_device_and_leaves_its_name_alone() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome on MacBook".to_string(), @@ -1329,7 +1326,7 @@ mod session_device_tests { .unwrap(); let (again, _) = anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Something else".to_string(), @@ -1338,8 +1335,8 @@ mod session_device_tests { .unwrap(); assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); - assert_eq!(anchor.session_devices()[0].name, "Chrome on MacBook"); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!(anchor.browsers()[0].name, "Chrome on MacBook"); } #[test] @@ -1347,7 +1344,7 @@ mod session_device_tests { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1355,15 +1352,15 @@ mod session_device_tests { ) .unwrap(); - assert_eq!(anchor.session_devices()[0].created_at, 1_000); - assert_eq!(anchor.session_devices()[0].last_used, 1_000); + assert_eq!(anchor.browsers()[0].created_at, 1_000); + assert_eq!(anchor.browsers()[0].last_used, 1_000); } #[test] fn reuse_advances_last_used_and_leaves_created_at_alone() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1372,7 +1369,7 @@ mod session_device_tests { .unwrap(); anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Chrome".to_string(), @@ -1380,15 +1377,15 @@ mod session_device_tests { ) .unwrap(); - assert_eq!(anchor.session_devices()[0].created_at, 1_000); - assert_eq!(anchor.session_devices()[0].last_used, 5_000); + assert_eq!(anchor.browsers()[0].created_at, 1_000); + assert_eq!(anchor.browsers()[0].last_used, 5_000); } #[test] fn a_key_this_anchor_has_not_seen_registers_a_fresh_device() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1397,7 +1394,7 @@ mod session_device_tests { .unwrap(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(2), successor_key(2), "Firefox".to_string(), @@ -1406,24 +1403,24 @@ mod session_device_tests { .unwrap(); assert_eq!(id, 1); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } #[test] fn ids_are_never_reused() { let mut anchor = anchor(); let (first, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), 1_000, ) .unwrap(); - anchor.session_devices.clear(); + anchor.browsers.clear(); let (second, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1438,9 +1435,9 @@ mod session_device_tests { #[test] fn registering_past_the_cap_drops_the_least_recently_used_and_never_fails() { let mut anchor = anchor(); - for index in 0..MAX_SESSION_DEVICES { + for index in 0..MAX_BROWSERS { anchor - .resolve_session_device( + .resolve_browser( browser_key(index as u8), successor_key(index as u8), format!("device-{index}"), @@ -1450,7 +1447,7 @@ mod session_device_tests { } let (newest, dropped) = anchor - .resolve_session_device( + .resolve_browser( browser_key(200), successor_key(200), "newest".to_string(), @@ -1458,16 +1455,10 @@ mod session_device_tests { ) .unwrap(); - assert_eq!(anchor.session_devices().len(), MAX_SESSION_DEVICES); - assert!(anchor.session_devices().iter().any(|d| d.id == newest)); - assert!(!anchor - .session_devices() - .iter() - .any(|d| d.name == "device-0")); - assert!(anchor - .session_devices() - .iter() - .any(|d| d.name == "device-1")); + assert_eq!(anchor.browsers().len(), MAX_BROWSERS); + assert!(anchor.browsers().iter().any(|d| d.id == newest)); + assert!(!anchor.browsers().iter().any(|d| d.name == "device-0")); + assert!(anchor.browsers().iter().any(|d| d.name == "device-1")); assert_eq!(dropped, vec![0]); } @@ -1475,11 +1466,11 @@ mod session_device_tests { fn the_cap_evicts_on_use_rather_than_on_enrolment() { let mut anchor = anchor(); let (first, _) = anchor - .resolve_session_device(browser_key(0), successor_key(0), "oldest".to_string(), 1) + .resolve_browser(browser_key(0), successor_key(0), "oldest".to_string(), 1) .unwrap(); - for index in 1..MAX_SESSION_DEVICES { + for index in 1..MAX_BROWSERS { anchor - .resolve_session_device( + .resolve_browser( browser_key(index as u8), successor_key(index as u8), format!("device-{index}"), @@ -1488,7 +1479,7 @@ mod session_device_tests { .unwrap(); } anchor - .resolve_session_device( + .resolve_browser( successor_key(0), rotating_key(0, 200), "oldest".to_string(), @@ -1497,7 +1488,7 @@ mod session_device_tests { .unwrap(); let (_, dropped) = anchor - .resolve_session_device( + .resolve_browser( browser_key(200), successor_key(200), "newest".to_string(), @@ -1506,10 +1497,7 @@ mod session_device_tests { .unwrap(); assert_eq!(dropped, vec![1]); - assert!(anchor - .session_devices() - .iter() - .any(|device| device.id == first)); + assert!(anchor.browsers().iter().any(|device| device.id == first)); } #[test] @@ -1517,11 +1505,11 @@ mod session_device_tests { let mut anchor = anchor(); // The phone rotates on every sign-in, as a browser that kept its storage does. let (kept, _) = anchor - .resolve_session_device(browser_key(0), rotating_key(0, 1), "phone".to_string(), 1) + .resolve_browser(browser_key(0), rotating_key(0, 1), "phone".to_string(), 1) .unwrap(); - for wipe in 0..MAX_SESSION_DEVICES as u64 { + for wipe in 0..MAX_BROWSERS as u64 { anchor - .resolve_session_device( + .resolve_browser( rotating_key(0, wipe as u8 + 1), rotating_key(0, wipe as u8 + 2), "phone".to_string(), @@ -1531,7 +1519,7 @@ mod session_device_tests { // A wiped browser has no key to promote, so each pass is a browser this // anchor has never seen. anchor - .resolve_session_device( + .resolve_browser( browser_key(wipe as u8 + 1), successor_key(wipe as u8 + 1), format!("wiped-{wipe}"), @@ -1540,17 +1528,14 @@ mod session_device_tests { .unwrap(); } - assert!(anchor - .session_devices() - .iter() - .any(|device| device.id == kept)); + assert!(anchor.browsers().iter().any(|device| device.id == kept)); } #[test] fn a_wiped_browser_presenting_a_fresh_key_is_a_new_device() { let mut anchor = anchor(); let (before, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1559,7 +1544,7 @@ mod session_device_tests { .unwrap(); let (after, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(2), successor_key(2), "Chrome".to_string(), @@ -1568,14 +1553,14 @@ mod session_device_tests { .unwrap(); assert_ne!(after, before); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } #[test] fn a_successor_is_accepted_and_takes_over_from_the_key_it_replaces() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1584,7 +1569,7 @@ mod session_device_tests { .unwrap(); let (again, _) = anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Chrome".to_string(), @@ -1593,19 +1578,16 @@ mod session_device_tests { .unwrap(); assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); - assert_eq!( - anchor.session_devices()[0].current_device_key, - successor_key(1) - ); - assert_eq!(anchor.session_devices()[0].next_device_key, browser_key(2)); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!(anchor.browsers()[0].current_browser_key, successor_key(1)); + assert_eq!(anchor.browsers()[0].next_browser_key, browser_key(2)); } #[test] fn the_key_a_successor_replaced_is_retired() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1613,7 +1595,7 @@ mod session_device_tests { ) .unwrap(); anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Chrome".to_string(), @@ -1622,7 +1604,7 @@ mod session_device_tests { .unwrap(); let (after, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(3), "Chrome".to_string(), @@ -1631,7 +1613,7 @@ mod session_device_tests { .unwrap(); assert_ne!(after, id); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } /// A response that never reached the browser leaves it proving with the key the entry @@ -1642,7 +1624,7 @@ mod session_device_tests { fn a_retired_key_is_refused_rather_than_registered() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1652,19 +1634,16 @@ mod session_device_tests { // The successor it re-announces is the one this entry is already waiting for, which // is the shape a retry actually takes: the browser has not moved on either. - let retried = anchor.resolve_session_device( + let retried = anchor.resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), 2_000, ); - assert_eq!(retried, Err(SessionDeviceError::StaleDeviceKey)); - assert_eq!(anchor.session_devices().len(), 1); - assert_eq!( - anchor.session_devices()[0].next_device_key, - successor_key(1) - ); + assert_eq!(retried, Err(BrowserError::StaleDeviceKey)); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!(anchor.browsers()[0].next_browser_key, successor_key(1)); } /// The other half of the same rule, from the browser's side: promoting the successor @@ -1673,7 +1652,7 @@ mod session_device_tests { fn promoting_the_announced_successor_resolves_the_same_browser() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1682,7 +1661,7 @@ mod session_device_tests { .unwrap(); let (again, _) = anchor - .resolve_session_device( + .resolve_browser( successor_key(1), successor_key(2), "Chrome".to_string(), @@ -1691,27 +1670,21 @@ mod session_device_tests { .unwrap(); assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); - assert_eq!( - anchor.session_devices()[0].current_device_key, - successor_key(1) - ); - assert_eq!( - anchor.session_devices()[0].next_device_key, - successor_key(2) - ); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!(anchor.browsers()[0].current_browser_key, successor_key(1)); + assert_eq!(anchor.browsers()[0].next_browser_key, successor_key(2)); } #[test] fn rotating_repeatedly_keeps_the_same_browser() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device(browser_key(0), browser_key(1), "Chrome".to_string(), 1) + .resolve_browser(browser_key(0), browser_key(1), "Chrome".to_string(), 1) .unwrap(); for step in 1..10u8 { let (again, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(step), browser_key(step + 1), "Chrome".to_string(), @@ -1721,7 +1694,7 @@ mod session_device_tests { assert_eq!(again, id); } - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.browsers().len(), 1); } /// Presented keys are visible on the wire, so announcing one another browser is about to @@ -1730,7 +1703,7 @@ mod session_device_tests { fn a_successor_another_browser_holds_is_refused() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1738,28 +1711,21 @@ mod session_device_tests { ) .unwrap(); - let stealing_the_key = anchor.resolve_session_device( - browser_key(2), - browser_key(1), - "Firefox".to_string(), - 2_000, - ); - let stealing_the_successor = anchor.resolve_session_device( + let stealing_the_key = + anchor.resolve_browser(browser_key(2), browser_key(1), "Firefox".to_string(), 2_000); + let stealing_the_successor = anchor.resolve_browser( browser_key(2), successor_key(1), "Firefox".to_string(), 2_000, ); - assert_eq!( - stealing_the_key, - Err(SessionDeviceError::SuccessorAlreadyInUse) - ); + assert_eq!(stealing_the_key, Err(BrowserError::SuccessorAlreadyInUse)); assert_eq!( stealing_the_successor, - Err(SessionDeviceError::SuccessorAlreadyInUse) + Err(BrowserError::SuccessorAlreadyInUse) ); - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.browsers().len(), 1); } /// The browser that already holds it is re-announcing, which a retry does. @@ -1767,7 +1733,7 @@ mod session_device_tests { fn a_second_sign_in_from_a_key_a_browser_never_announced_is_a_new_browser() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1777,7 +1743,7 @@ mod session_device_tests { // Neither slot holds it, so there is nothing to say this is the same browser. let (other, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(7), successor_key(7), "Chrome".to_string(), @@ -1786,7 +1752,7 @@ mod session_device_tests { .unwrap(); assert_ne!(other, id); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } #[test] @@ -1797,22 +1763,17 @@ mod session_device_tests { // announced the key it is presenting would keep it alive for as long as it kept // asking, and so would whoever leaked it. assert_eq!( - anchor.resolve_session_device( - browser_key(1), - browser_key(1), - "Chrome".to_string(), - 1_000 - ), - Err(SessionDeviceError::SuccessorMatchesCurrent) + anchor.resolve_browser(browser_key(1), browser_key(1), "Chrome".to_string(), 1_000), + Err(BrowserError::SuccessorMatchesCurrent) ); - assert!(anchor.session_devices().is_empty()); + assert!(anchor.browsers().is_empty()); } #[test] fn a_registered_browser_cannot_stop_rotating_either() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1821,22 +1782,16 @@ mod session_device_tests { .unwrap(); assert_eq!( - anchor.resolve_session_device( + anchor.resolve_browser( successor_key(1), successor_key(1), "Chrome".to_string(), 2_000 ), - Err(SessionDeviceError::SuccessorMatchesCurrent) + Err(BrowserError::SuccessorMatchesCurrent) ); // The entry is left as it was, still awaiting a successor it has not seen. - assert_eq!( - anchor.session_devices()[0].current_device_key, - browser_key(1) - ); - assert_eq!( - anchor.session_devices()[0].next_device_key, - successor_key(1) - ); + assert_eq!(anchor.browsers()[0].current_browser_key, browser_key(1)); + assert_eq!(anchor.browsers()[0].next_browser_key, successor_key(1)); } } diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index e5c0acc772..4183dc9746 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -10,6 +10,8 @@ pub mod anchor_number; pub mod anchor_number_list; pub mod application; pub mod application_number; +pub mod browser; +pub mod browser_id; pub mod credential_id; pub mod discrepancy_counter; pub mod duration; @@ -25,8 +27,6 @@ pub mod openid_credential_key; pub mod openid_jwks; pub mod passkey_credential; pub mod recovery_key; -pub mod session_device; -pub mod session_device_id; 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 af496b3ab0..cff73754b3 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -1,9 +1,9 @@ +use crate::storage::storable::browser::StorableBrowser; +use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCredential; use crate::storage::storable::openid_credential::StorableOpenIdCredential; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; -use crate::storage::storable::session_device::StorableSessionDevice; -use crate::storage::storable::session_device_id::StorableSessionDeviceId; use crate::storage::storable::verified_email::StorableVerifiedEmail; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; @@ -34,12 +34,12 @@ pub struct StorableAnchor { /// `Option` so pre-existing anchors decode cleanly. #[n(6)] pub verified_emails: Option>, - /// Browsers this anchor has signed in from. Capped at `MAX_SESSION_DEVICES`. + /// Browsers this anchor has signed in from. Capped at `MAX_BROWSERS`. #[n(7)] - pub session_devices: Option>, - /// Monotonic per-anchor allocator for `session_devices`. Ids are never reused. + pub browsers: Option>, + /// Monotonic per-anchor allocator for `browsers`. Ids are never reused. #[n(8)] - pub next_session_device_id: Option, + pub next_browser_id: Option, } impl Storable for StorableAnchor { diff --git a/src/internet_identity/src/storage/storable/session_device.rs b/src/internet_identity/src/storage/storable/browser.rs similarity index 73% rename from src/internet_identity/src/storage/storable/session_device.rs rename to src/internet_identity/src/storage/storable/browser.rs index 841a4140e4..0beeb19edf 100644 --- a/src/internet_identity/src/storage/storable/session_device.rs +++ b/src/internet_identity/src/storage/storable/browser.rs @@ -1,4 +1,4 @@ -use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use crate::storage::storable::browser_id::StorableBrowserId; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use internet_identity_interface::internet_identity::types::Timestamp; @@ -7,9 +7,9 @@ use std::borrow::Cow; #[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[cbor(map)] -pub struct StorableSessionDevice { +pub struct StorableBrowser { #[n(0)] - pub id: StorableSessionDeviceId, + pub id: StorableBrowserId, #[n(1)] pub name: String, #[n(2)] @@ -17,20 +17,20 @@ pub struct StorableSessionDevice { #[n(3)] pub last_used: Timestamp, #[cbor(n(4), with = "minicbor::bytes")] - pub current_device_key: Vec, + pub current_browser_key: Vec, #[cbor(n(5), with = "minicbor::bytes")] - pub next_device_key: Vec, + pub next_browser_key: Vec, } -impl Storable for StorableSessionDevice { +impl Storable for StorableBrowser { fn to_bytes(&self) -> Cow<'_, [u8]> { let mut buffer = Vec::new(); - minicbor::encode(self, &mut buffer).expect("failed to encode StorableSessionDevice"); + minicbor::encode(self, &mut buffer).expect("failed to encode StorableBrowser"); Cow::Owned(buffer) } fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { - minicbor::decode(&bytes).expect("failed to decode StorableSessionDevice") + minicbor::decode(&bytes).expect("failed to decode StorableBrowser") } const BOUND: Bound = Bound::Unbounded; diff --git a/src/internet_identity/src/storage/storable/browser_id.rs b/src/internet_identity/src/storage/storable/browser_id.rs new file mode 100644 index 0000000000..2d83877047 --- /dev/null +++ b/src/internet_identity/src/storage/storable/browser_id.rs @@ -0,0 +1 @@ +pub type StorableBrowserId = u32; diff --git a/src/internet_identity/src/storage/storable/session_device_id.rs b/src/internet_identity/src/storage/storable/session_device_id.rs deleted file mode 100644 index 1da8905dd8..0000000000 --- a/src/internet_identity/src/storage/storable/session_device_id.rs +++ /dev/null @@ -1 +0,0 @@ -pub type StorableSessionDeviceId = u32; diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index 0feda7eef6..aa6c647947 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -1,6 +1,6 @@ use crate::storage::account::SessionRecord; +use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::duration::StorableDuration; -use crate::storage::storable::session_device_id::StorableSessionDeviceId; use crate::storage::storable::timestamp::StorableTimestamp; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; @@ -19,7 +19,7 @@ pub struct StorableSessionRecord { #[n(3)] pub last_refreshed_ns: Option, #[n(4)] - pub device_id: StorableSessionDeviceId, + pub browser_id: StorableBrowserId, #[n(5)] pub read_only: bool, } @@ -45,7 +45,7 @@ impl From for SessionRecord { valid_till_ns: value.valid_till_ns, last_refreshed_ns: value.last_refreshed_ns, max_idle_ns: value.max_idle_ns, - device_id: value.device_id, + browser_id: value.browser_id, read_only: value.read_only, } } @@ -58,7 +58,7 @@ impl From for StorableSessionRecord { valid_till_ns: value.valid_till_ns, last_refreshed_ns: value.last_refreshed_ns, max_idle_ns: value.max_idle_ns, - device_id: value.device_id, + browser_id: value.browser_id, read_only: value.read_only, } } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 96b09516fa..dcbd7c1941 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1436,8 +1436,8 @@ fn test_anchor_storage_migration_round_trip() { "empty anchor", storage.allocate_anchor(now).unwrap(), Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 0, devices: vec![], openid_credentials: vec![], @@ -1470,8 +1470,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 1, devices: vec![Device { pubkey: ByteBuf::from("recovery_key_pubkey"), @@ -1515,8 +1515,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 2, devices: vec![Device { pubkey: ByteBuf::from("passkey_pubkey"), @@ -1560,8 +1560,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 3, devices: vec![Device { pubkey: ByteBuf::from("passkey_no_origin"), @@ -1605,8 +1605,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 4, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey"), @@ -1650,8 +1650,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 5, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey_no_origin"), @@ -1695,8 +1695,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 6, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_auth"), @@ -1740,8 +1740,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 7, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_recovery"), @@ -1799,8 +1799,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 8, devices: vec![ Device { @@ -1845,8 +1845,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 9, devices: vec![], openid_credentials: vec![openid_credential(1)], @@ -1866,8 +1866,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 10, devices: vec![], openid_credentials: vec![], @@ -1900,8 +1900,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 11, devices: vec![Device { pubkey: ByteBuf::from("unknown_keytype_passkey"), @@ -1952,8 +1952,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 12, devices: vec![Device { pubkey: ByteBuf::from("device_with_metadata"), @@ -1991,8 +1991,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 13, devices: vec![], openid_credentials: vec![], @@ -2025,8 +2025,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 14, devices: vec![Device { pubkey: ByteBuf::from("protected_recovery_key"), @@ -2073,8 +2073,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 15, devices: vec![Device { pubkey: ByteBuf::from("protected_passkey"), @@ -2120,8 +2120,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 16, devices: vec![Device { pubkey: ByteBuf::from("unusual_device"), @@ -2165,8 +2165,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 17, devices: vec![Device { pubkey: ByteBuf::from("recovery_phrase_custom_alias"), @@ -2210,8 +2210,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 18, devices: vec![Device { pubkey: ByteBuf::from("platform_passkey"), @@ -2255,8 +2255,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 19, devices: vec![Device { pubkey: ByteBuf::from("unknown_keytype_passkey_2"), @@ -5018,7 +5018,7 @@ mod session_record_tests { valid_till_ns, max_idle_ns: NEVER_IDLE, last_refreshed_ns: None, - device_id: 1, + browser_id: 1, read_only: false, } } @@ -5046,7 +5046,7 @@ mod session_record_tests { valid_till_ns: 22, max_idle_ns: 33, last_refreshed_ns: Some(44), - device_id: 55, + browser_id: 55, read_only: false, }, SessionRecord { @@ -5054,7 +5054,7 @@ mod session_record_tests { valid_till_ns: 77, max_idle_ns: 88, last_refreshed_ns: None, - device_id: 99, + browser_id: 99, read_only: true, }, ], @@ -5198,7 +5198,7 @@ mod session_record_tests { // order would protect it. let flood: Vec = (0..500) .map(|index| SessionRecord { - device_id: index, + browser_id: index, ..session(now - 1, now + DAY_NS) }) .collect(); diff --git a/src/internet_identity/src/verified_emails/remove.rs b/src/internet_identity/src/verified_emails/remove.rs index 6f39a75f94..691aabfb04 100644 --- a/src/internet_identity/src/verified_emails/remove.rs +++ b/src/internet_identity/src/verified_emails/remove.rs @@ -31,8 +31,8 @@ mod tests { fn anchor_with(addresses: &[&str]) -> Anchor { let mut a = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number: 1, devices: vec![], openid_credentials: vec![], diff --git a/src/internet_identity/tests/integration/upgrade.rs b/src/internet_identity/tests/integration/upgrade.rs index 4385bbcb04..d734d5e859 100644 --- a/src/internet_identity/tests/integration/upgrade.rs +++ b/src/internet_identity/tests/integration/upgrade.rs @@ -147,8 +147,8 @@ fn should_not_allow_user_range_exceeding_capacity() { /// Verifies that an anchor stored before the session-device registry existed decodes /// after an upgrade, and reports no devices rather than failing. #[test] -fn should_report_no_session_devices_for_an_anchor_from_the_previous_release( -) -> Result<(), RejectResponse> { +fn should_report_no_browsers_for_an_anchor_from_the_previous_release() -> Result<(), RejectResponse> +{ let env = env(); let canister_id = install_ii_canister(&env, II_WASM_PREVIOUS.clone()); let identity_number = flows::register_anchor(&env, canister_id); @@ -157,7 +157,7 @@ fn should_report_no_session_devices_for_an_anchor_from_the_previous_release( let info = api::api_v2::identity_info(&env, canister_id, principal_1(), identity_number)?.unwrap(); - assert_eq!(info.session_devices, None); + assert_eq!(info.browsers, None); assert_eq!(info.authn_methods.len(), 1); Ok(()) diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 4dcf0b549a..8f2f160fc2 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -15,7 +15,7 @@ pub type FrontendHostname = String; pub type ApplicationNumber = u64; pub type Timestamp = u64; // in nanos since epoch /// Per-anchor label for one browser, so a browser's sessions can be revoked together. -pub type SessionDeviceId = u32; +pub type BrowserId = u32; pub type Signature = ByteBuf; pub type DeviceConfirmationCode = String; pub type FailedAttemptsCounter = u8; diff --git a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs index e062f3317a..af2ddcd254 100644 --- a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs +++ b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs @@ -1,5 +1,5 @@ use crate::internet_identity::types::openid::OpenIdCredentialData; -use crate::internet_identity::types::{CredentialId, PublicKey, SessionDeviceId, Timestamp}; +use crate::internet_identity::types::{BrowserId, CredentialId, PublicKey, Timestamp}; use candid::{CandidType, Deserialize, Principal}; use serde_bytes::ByteBuf; use std::collections::HashMap; @@ -79,8 +79,8 @@ pub struct IdentityAuthnInfo { /// A browser this anchor has signed in from. The name is self-reported by the client. #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] -pub struct SessionDeviceInfo { - pub id: SessionDeviceId, +pub struct BrowserInfo { + pub id: BrowserId, pub name: String, pub created_at: Timestamp, pub last_used: Timestamp, @@ -109,7 +109,7 @@ pub struct IdentityInfo { pub verified_emails: Option>, /// Browsers this anchor has signed in from. `None` if it has never created a session. - pub session_devices: Option>, + pub browsers: Option>, /// The anchor's synced trusted-MCP-server config (master toggle + /// trusted server URL). `None` for an anchor that never wrote one. /// From e480f9dbe81c4d7d1199a3dbf7b2114cef94f041 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:51 +0200 Subject: [PATCH 171/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions/device_key.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/sessions/device_key.rs b/src/internet_identity/src/sessions/device_key.rs index ae9433f63a..ebacc9b1ae 100644 --- a/src/internet_identity/src/sessions/device_key.rs +++ b/src/internet_identity/src/sessions/device_key.rs @@ -25,17 +25,17 @@ const DEVICE_KEY_SIGNATURE_BYTES: usize = 64; pub fn verify_device_keys( device_key: &PublicKey, device_key_signature: &[u8], - next_device_key: &PublicKey, - next_device_key_signature: &[u8], + next_browser_key: &PublicKey, + next_browser_key_signature: &[u8], session_key: &SessionKey, ) -> bool { verify( device_key, device_key_signature, - &signed_message(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_device_key), + &signed_message(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key), ) && verify( - next_device_key, - next_device_key_signature, + next_browser_key, + next_browser_key_signature, &signed_message(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, device_key), ) } From c125ffc4d5455b066e5c553a8f027a30c84f62a0 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:52 +0200 Subject: [PATCH 172/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/email_recovery/remove.rs | 4 +- src/internet_identity/src/storage.rs | 18 +- src/internet_identity/src/storage/account.rs | 4 +- src/internet_identity/src/storage/anchor.rs | 148 +++++++------- .../src/storage/anchor/tests.rs | 180 +++++++++--------- src/internet_identity/src/storage/storable.rs | 4 +- .../src/storage/storable/anchor.rs | 12 +- .../src/storage/storable/session_record.rs | 8 +- src/internet_identity/src/storage/tests.rs | 120 ++++++------ .../src/verified_emails/remove.rs | 4 +- .../src/internet_identity/types.rs | 2 +- 11 files changed, 252 insertions(+), 252 deletions(-) diff --git a/src/internet_identity/src/email_recovery/remove.rs b/src/internet_identity/src/email_recovery/remove.rs index 669d5b32e0..1ee52f55e5 100644 --- a/src/internet_identity/src/email_recovery/remove.rs +++ b/src/internet_identity/src/email_recovery/remove.rs @@ -75,8 +75,8 @@ mod tests { fn anchor_with(address: Option<&str>) -> Anchor { let mut a = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 1, devices: vec![], diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 84484a2b98..68e4adc6ef 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -901,8 +901,8 @@ impl Storage { created_at_ns: _, name: _, verified_emails: _, - session_devices: _, - next_session_device_id: _, + browsers: _, + next_browser_id: _, session_count: _, }) = previous_anchor_maybe { @@ -2707,7 +2707,7 @@ impl Storage { anchor_number, origin, account_number, - device_id, + browser_id, valid_till_ns, max_idle_ns, read_only, @@ -2764,7 +2764,7 @@ impl Storage { // at its expiry. let mut dropped: Vec<(Option, SessionRecord)> = vec![]; reference.sessions.retain(|session| { - if session.device_id == device_id { + if session.browser_id == browser_id { dropped.push((account_number, session.clone())); return false; } @@ -2781,7 +2781,7 @@ impl Storage { valid_till_ns, max_idle_ns, last_refreshed_ns: None, - device_id, + browser_id, read_only, }; reference.sessions.push(session.clone()); @@ -2836,10 +2836,10 @@ impl Storage { // Called by the sign-in ceremony, which lands two PRs up. #[allow(dead_code)] /// Signs one browser out of everything, in a single message. - pub fn revoke_device_sessions( + pub fn revoke_browser_sessions( &mut self, anchor_number: AnchorNumber, - device_id: SessionDeviceId, + browser_id: BrowserId, ) -> Result { // Read what the identity holds, take the browser's sessions out of it, write it // back. Nothing here ranges over storage itself and no application number reaches @@ -2855,7 +2855,7 @@ impl Storage { }; for write in account_references.iter_mut() { write.account_reference.sessions.retain(|session| { - let keep = session.device_id != device_id; + let keep = session.browser_id != browser_id; if !keep { revoked += 1; } @@ -3557,7 +3557,7 @@ pub struct CreateSessionParams { pub anchor_number: AnchorNumber, pub origin: FrontendHostname, pub account_number: Option, - pub device_id: SessionDeviceId, + pub browser_id: BrowserId, pub valid_till_ns: Timestamp, pub max_idle_ns: Option, pub read_only: bool, diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 87e10ec4cd..0833b81683 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -8,7 +8,7 @@ use ic_cdk::trap; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, FrontendHostname, - SessionDeviceId, SessionId, Timestamp, UserKey, + BrowserId, SessionId, Timestamp, UserKey, }; use serde::{Deserialize, Serialize}; @@ -107,7 +107,7 @@ pub struct SessionRecord { pub valid_till_ns: Timestamp, pub max_idle_ns: u64, pub last_refreshed_ns: Option, - pub device_id: SessionDeviceId, + pub browser_id: BrowserId, pub read_only: bool, pub session_id: SessionId, } diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index c7600e270d..f7a4427a66 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -6,7 +6,7 @@ use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCr use crate::storage::storable::fixed_anchor::StorableFixedAnchor; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; -use crate::storage::storable::session_device::StorableSessionDevice; +use crate::storage::storable::browser::StorableBrowser; use crate::storage::storable::special_device_migration::SpecialDeviceMigration; use crate::storage::storable::verified_email::StorableVerifiedEmail; use crate::{IC0_APP_ORIGIN, ID_AI_ORIGIN, INTERNETCOMPUTER_ORG_ORIGIN}; @@ -39,9 +39,9 @@ pub struct Anchor { pub(crate) email_recovery: Vec, /// Capped by `MAX_VERIFIED_EMAILS_PER_ANCHOR`. pub(crate) verified_emails: Vec, - /// Capped by `MAX_SESSION_DEVICES`. - pub(crate) session_devices: Vec, - pub(crate) next_session_device_id: SessionDeviceId, + /// Capped by `MAX_BROWSERS`. + pub(crate) browsers: Vec, + pub(crate) next_browser_id: BrowserId, pub(crate) session_count: u32, pub(crate) metadata: Option>, pub(crate) name: Option, @@ -49,11 +49,11 @@ pub struct Anchor { } /// Bounds the device list, which rides on the anchor blob. -pub const MAX_SESSION_DEVICES: usize = 20; +pub const MAX_BROWSERS: usize = 20; /// Why a browser's presented keys cannot be resolved. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SessionDeviceError { +pub enum BrowserError { /// The announced successor is a key another browser of this anchor already holds. /// /// Presented keys are visible on the wire, so without this a caller could announce a @@ -79,23 +79,23 @@ pub enum SessionDeviceError { /// A browser this anchor has signed in from. The name is self-reported by the client. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SessionDevice { - pub id: SessionDeviceId, +pub struct Browser { + pub id: BrowserId, /// The browser's own public key, DER-encoded. What the entry is looked up by. - pub current_device_key: PublicKey, + pub current_browser_key: PublicKey, /// The successor the browser announced at its last sign-in, also accepted as a proof. - pub next_device_key: PublicKey, + pub next_browser_key: PublicKey, pub name: String, pub created_at: Timestamp, pub last_used: Timestamp, } -impl From for SessionDevice { - fn from(value: StorableSessionDevice) -> Self { - SessionDevice { +impl From for Browser { + fn from(value: StorableBrowser) -> Self { + Browser { id: value.id, - current_device_key: ByteBuf::from(value.current_device_key), - next_device_key: ByteBuf::from(value.next_device_key), + current_browser_key: ByteBuf::from(value.current_browser_key), + next_browser_key: ByteBuf::from(value.next_browser_key), name: value.name, created_at: value.created_at, last_used: value.last_used, @@ -103,12 +103,12 @@ impl From for SessionDevice { } } -impl From for StorableSessionDevice { - fn from(value: SessionDevice) -> Self { - StorableSessionDevice { +impl From for StorableBrowser { + fn from(value: Browser) -> Self { + StorableBrowser { id: value.id, - current_device_key: value.current_device_key.into_vec(), - next_device_key: value.next_device_key.into_vec(), + current_browser_key: value.current_browser_key.into_vec(), + next_browser_key: value.next_browser_key.into_vec(), name: value.name, created_at: value.created_at, last_used: value.last_used, @@ -248,8 +248,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { openid_credentials, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, session_count, metadata, name, @@ -270,11 +270,11 @@ impl From for (StorableFixedAnchor, StorableAnchor) { .map(StorableVerifiedEmail::from) .collect(), ); - let next_session_device_id = Some(next_session_device_id); - let session_devices = Some( - session_devices + let next_browser_id = Some(next_browser_id); + let browsers = Some( + browsers .into_iter() - .map(StorableSessionDevice::from) + .map(StorableBrowser::from) .collect(), ); @@ -516,8 +516,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { recovery_keys, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, session_count: Some(session_count), }, ) @@ -534,8 +534,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { recovery_keys, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, session_count, } = storable_anchor; @@ -555,12 +555,12 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { .into_iter() .map(VerifiedEmail::from) .collect(); - let session_devices = session_devices + let browsers = browsers .unwrap_or_default() .into_iter() - .map(SessionDevice::from) + .map(Browser::from) .collect(); - let next_session_device_id = next_session_device_id.unwrap_or_default(); + let next_browser_id = next_browser_id.unwrap_or_default(); let mut devices = passkey_credentials .unwrap_or_default() @@ -655,8 +655,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { openid_credentials, email_recovery, verified_emails, - session_devices, - next_session_device_id, + browsers, + next_browser_id, session_count: session_count.unwrap_or_default(), devices, metadata, @@ -685,8 +685,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, anchor_number, devices, metadata, @@ -713,11 +713,11 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho .into_iter() .map(VerifiedEmail::from) .collect(); - let session_devices = storable_anchor - .session_devices + let browsers = storable_anchor + .browsers .unwrap_or_default() .into_iter() - .map(SessionDevice::from) + .map(Browser::from) .collect(); Anchor { @@ -726,8 +726,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials, email_recovery, verified_emails, - session_devices, - next_session_device_id: storable_anchor.next_session_device_id.unwrap_or_default(), + browsers, + next_browser_id: storable_anchor.next_browser_id.unwrap_or_default(), session_count: storable_anchor.session_count.unwrap_or_default(), metadata, name, @@ -737,91 +737,91 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho } impl Anchor { - pub fn session_devices(&self) -> &[SessionDevice] { - &self.session_devices + pub fn browsers(&self) -> &[Browser] { + &self.browsers } /// Resolves the browser a sign-in came from by the public key it proved possession of. /// /// An entry is reached only by the successor it announced. Presenting it promotes that /// successor, retires the key it replaces, and leaves the entry awaiting - /// `next_device_key` — what the browser presents at its next sign-in. A key some entry - /// has already retired is refused with [`SessionDeviceError::StaleDeviceKey`] rather + /// `next_browser_key` — what the browser presents at its next sign-in. A key some entry + /// has already retired is refused with [`BrowserError::StaleDeviceKey`] rather /// than accepted or registered afresh, so a key is good for exactly one sign-in and a /// browser that lost a response is told to promote its own successor instead of /// becoming a second list. A key no entry holds at all registers a new browser. /// /// At the cap the least recently used records are dropped, and their ids returned so /// the caller can end their sessions too. - pub fn resolve_session_device( + pub fn resolve_browser( &mut self, - current_device_key: PublicKey, - next_device_key: PublicKey, + current_browser_key: PublicKey, + next_browser_key: PublicKey, name: String, now: Timestamp, - ) -> Result<(SessionDeviceId, Vec), SessionDeviceError> { - if current_device_key == next_device_key { - return Err(SessionDeviceError::SuccessorMatchesCurrent); + ) -> Result<(BrowserId, Vec), BrowserError> { + if current_browser_key == next_browser_key { + return Err(BrowserError::SuccessorMatchesCurrent); } // Two questions, and they are not the same one. An entry is *advanced* only by the // successor it is waiting for; an entry *holds* a key in either slot, which is what // a successor must not collide with. let entry_awaiting = |candidate: &PublicKey| { - self.session_devices + self.browsers .iter() - .position(|device| device.next_device_key == *candidate) + .position(|device| device.next_browser_key == *candidate) }; let entry_holding = |candidate: &PublicKey| { - self.session_devices.iter().position(|device| { - device.current_device_key == *candidate || device.next_device_key == *candidate + self.browsers.iter().position(|device| { + device.current_browser_key == *candidate || device.next_browser_key == *candidate }) }; - let advances = entry_awaiting(¤t_device_key); + let advances = entry_awaiting(¤t_browser_key); // The entry this request belongs to, which is not always one it can advance: a // browser retrying a lost sign-in still belongs to the entry that retired its key, // and re-announcing the successor it announced then is not stealing anyone's key. - let owner = entry_holding(¤t_device_key); - let successor_holder = entry_holding(&next_device_key); + let owner = entry_holding(¤t_browser_key); + let successor_holder = entry_holding(&next_browser_key); if successor_holder.is_some() && successor_holder != owner { - return Err(SessionDeviceError::SuccessorAlreadyInUse); + return Err(BrowserError::SuccessorAlreadyInUse); } if let Some(index) = advances { - let device = &mut self.session_devices[index]; - device.current_device_key = current_device_key; - device.next_device_key = next_device_key; + let device = &mut self.browsers[index]; + device.current_browser_key = current_browser_key; + device.next_browser_key = next_browser_key; device.last_used = now; return Ok((device.id, vec![])); } if owner.is_some() { - return Err(SessionDeviceError::StaleDeviceKey); + return Err(BrowserError::StaleDeviceKey); } - let id = self.next_session_device_id; - self.next_session_device_id = self.next_session_device_id.saturating_add(1); - self.session_devices.push(SessionDevice { + let id = self.next_browser_id; + self.next_browser_id = self.next_browser_id.saturating_add(1); + self.browsers.push(Browser { id, - current_device_key, - next_device_key, + current_browser_key, + next_browser_key, name, created_at: now, last_used: now, }); let mut dropped = vec![]; - while self.session_devices.len() > MAX_SESSION_DEVICES { + while self.browsers.len() > MAX_BROWSERS { let least_recently_used = self - .session_devices + .browsers .iter() .enumerate() .min_by_key(|(_, device)| (device.last_used, device.id)) .map(|(index, _)| index); match least_recently_used { Some(index) => { - dropped.push(self.session_devices.remove(index).id); + dropped.push(self.browsers.remove(index).id); } None => break, } @@ -841,8 +841,8 @@ impl Anchor { openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, metadata: None, name: None, } diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index 77f001604e..af1e959de8 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -223,8 +223,8 @@ fn should_allow_protection_only_on_recovery_phrases() { fn should_prevent_mutation_when_invariants_are_violated() { let mut device1 = recovery_phrase(1, DeviceProtection::Unprotected); let mut anchor = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ @@ -248,8 +248,8 @@ fn should_prevent_mutation_when_invariants_are_violated() { #[test] fn should_prevent_addition_when_invariants_are_violated() { let mut anchor = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ @@ -273,8 +273,8 @@ fn should_prevent_addition_when_invariants_are_violated() { fn should_allow_removal_when_invariants_are_violated() { let device1 = recovery_phrase(1, DeviceProtection::Unprotected); let mut anchor = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ @@ -1270,9 +1270,9 @@ mod mirror_verified_email_tests { } } -mod session_device_tests { +mod browser_tests { use super::*; - use crate::storage::anchor::{SessionDeviceError, MAX_SESSION_DEVICES}; + use crate::storage::anchor::{BrowserError, MAX_BROWSERS}; use internet_identity_interface::internet_identity::types::PublicKey; fn anchor() -> Anchor { @@ -1301,7 +1301,7 @@ mod session_device_tests { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome on MacBook".to_string(), @@ -1310,20 +1310,20 @@ mod session_device_tests { .unwrap(); assert_eq!(id, 0); - assert_eq!(anchor.session_devices().len(), 1); - assert_eq!(anchor.session_devices()[0].name, "Chrome on MacBook"); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!(anchor.browsers()[0].name, "Chrome on MacBook"); assert_eq!( - anchor.session_devices()[0].current_device_key, + anchor.browsers()[0].current_browser_key, browser_key(1) ); - assert_eq!(anchor.session_devices()[0].created_at, 1_000); + assert_eq!(anchor.browsers()[0].created_at, 1_000); } #[test] fn a_browser_that_rotates_reuses_the_device_and_leaves_its_name_alone() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome on MacBook".to_string(), @@ -1332,7 +1332,7 @@ mod session_device_tests { .unwrap(); let (again, _) = anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Something else".to_string(), @@ -1341,8 +1341,8 @@ mod session_device_tests { .unwrap(); assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); - assert_eq!(anchor.session_devices()[0].name, "Chrome on MacBook"); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!(anchor.browsers()[0].name, "Chrome on MacBook"); } #[test] @@ -1350,7 +1350,7 @@ mod session_device_tests { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1358,15 +1358,15 @@ mod session_device_tests { ) .unwrap(); - assert_eq!(anchor.session_devices()[0].created_at, 1_000); - assert_eq!(anchor.session_devices()[0].last_used, 1_000); + assert_eq!(anchor.browsers()[0].created_at, 1_000); + assert_eq!(anchor.browsers()[0].last_used, 1_000); } #[test] fn reuse_advances_last_used_and_leaves_created_at_alone() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1375,7 +1375,7 @@ mod session_device_tests { .unwrap(); anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Chrome".to_string(), @@ -1383,15 +1383,15 @@ mod session_device_tests { ) .unwrap(); - assert_eq!(anchor.session_devices()[0].created_at, 1_000); - assert_eq!(anchor.session_devices()[0].last_used, 5_000); + assert_eq!(anchor.browsers()[0].created_at, 1_000); + assert_eq!(anchor.browsers()[0].last_used, 5_000); } #[test] fn a_key_this_anchor_has_not_seen_registers_a_fresh_device() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1400,7 +1400,7 @@ mod session_device_tests { .unwrap(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(2), successor_key(2), "Firefox".to_string(), @@ -1409,24 +1409,24 @@ mod session_device_tests { .unwrap(); assert_eq!(id, 1); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } #[test] fn ids_are_never_reused() { let mut anchor = anchor(); let (first, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), 1_000, ) .unwrap(); - anchor.session_devices.clear(); + anchor.browsers.clear(); let (second, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1441,9 +1441,9 @@ mod session_device_tests { #[test] fn registering_past_the_cap_drops_the_least_recently_used_and_never_fails() { let mut anchor = anchor(); - for index in 0..MAX_SESSION_DEVICES { + for index in 0..MAX_BROWSERS { anchor - .resolve_session_device( + .resolve_browser( browser_key(index as u8), successor_key(index as u8), format!("device-{index}"), @@ -1453,7 +1453,7 @@ mod session_device_tests { } let (newest, dropped) = anchor - .resolve_session_device( + .resolve_browser( browser_key(200), successor_key(200), "newest".to_string(), @@ -1461,14 +1461,14 @@ mod session_device_tests { ) .unwrap(); - assert_eq!(anchor.session_devices().len(), MAX_SESSION_DEVICES); - assert!(anchor.session_devices().iter().any(|d| d.id == newest)); + assert_eq!(anchor.browsers().len(), MAX_BROWSERS); + assert!(anchor.browsers().iter().any(|d| d.id == newest)); assert!(!anchor - .session_devices() + .browsers() .iter() .any(|d| d.name == "device-0")); assert!(anchor - .session_devices() + .browsers() .iter() .any(|d| d.name == "device-1")); assert_eq!(dropped, vec![0]); @@ -1478,11 +1478,11 @@ mod session_device_tests { fn the_cap_evicts_on_use_rather_than_on_enrolment() { let mut anchor = anchor(); let (first, _) = anchor - .resolve_session_device(browser_key(0), successor_key(0), "oldest".to_string(), 1) + .resolve_browser(browser_key(0), successor_key(0), "oldest".to_string(), 1) .unwrap(); - for index in 1..MAX_SESSION_DEVICES { + for index in 1..MAX_BROWSERS { anchor - .resolve_session_device( + .resolve_browser( browser_key(index as u8), successor_key(index as u8), format!("device-{index}"), @@ -1491,7 +1491,7 @@ mod session_device_tests { .unwrap(); } anchor - .resolve_session_device( + .resolve_browser( successor_key(0), rotating_key(0, 200), "oldest".to_string(), @@ -1500,7 +1500,7 @@ mod session_device_tests { .unwrap(); let (_, dropped) = anchor - .resolve_session_device( + .resolve_browser( browser_key(200), successor_key(200), "newest".to_string(), @@ -1510,7 +1510,7 @@ mod session_device_tests { assert_eq!(dropped, vec![1]); assert!(anchor - .session_devices() + .browsers() .iter() .any(|device| device.id == first)); } @@ -1520,11 +1520,11 @@ mod session_device_tests { let mut anchor = anchor(); // The phone rotates on every sign-in, as a browser that kept its storage does. let (kept, _) = anchor - .resolve_session_device(browser_key(0), rotating_key(0, 1), "phone".to_string(), 1) + .resolve_browser(browser_key(0), rotating_key(0, 1), "phone".to_string(), 1) .unwrap(); - for wipe in 0..MAX_SESSION_DEVICES as u64 { + for wipe in 0..MAX_BROWSERS as u64 { anchor - .resolve_session_device( + .resolve_browser( rotating_key(0, wipe as u8 + 1), rotating_key(0, wipe as u8 + 2), "phone".to_string(), @@ -1534,7 +1534,7 @@ mod session_device_tests { // A wiped browser has no key to promote, so each pass is a browser this // anchor has never seen. anchor - .resolve_session_device( + .resolve_browser( browser_key(wipe as u8 + 1), successor_key(wipe as u8 + 1), format!("wiped-{wipe}"), @@ -1544,7 +1544,7 @@ mod session_device_tests { } assert!(anchor - .session_devices() + .browsers() .iter() .any(|device| device.id == kept)); } @@ -1553,7 +1553,7 @@ mod session_device_tests { fn a_wiped_browser_presenting_a_fresh_key_is_a_new_device() { let mut anchor = anchor(); let (before, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1562,7 +1562,7 @@ mod session_device_tests { .unwrap(); let (after, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(2), successor_key(2), "Chrome".to_string(), @@ -1571,14 +1571,14 @@ mod session_device_tests { .unwrap(); assert_ne!(after, before); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } #[test] fn a_successor_is_accepted_and_takes_over_from_the_key_it_replaces() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1587,7 +1587,7 @@ mod session_device_tests { .unwrap(); let (again, _) = anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Chrome".to_string(), @@ -1596,19 +1596,19 @@ mod session_device_tests { .unwrap(); assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.browsers().len(), 1); assert_eq!( - anchor.session_devices()[0].current_device_key, + anchor.browsers()[0].current_browser_key, successor_key(1) ); - assert_eq!(anchor.session_devices()[0].next_device_key, browser_key(2)); + assert_eq!(anchor.browsers()[0].next_browser_key, browser_key(2)); } #[test] fn the_key_a_successor_replaced_is_retired() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1616,7 +1616,7 @@ mod session_device_tests { ) .unwrap(); anchor - .resolve_session_device( + .resolve_browser( successor_key(1), browser_key(2), "Chrome".to_string(), @@ -1625,7 +1625,7 @@ mod session_device_tests { .unwrap(); let (after, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(3), "Chrome".to_string(), @@ -1634,7 +1634,7 @@ mod session_device_tests { .unwrap(); assert_ne!(after, id); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } /// A response that never reached the browser leaves it proving with the key the entry @@ -1645,7 +1645,7 @@ mod session_device_tests { fn a_retired_key_is_refused_rather_than_registered() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1655,17 +1655,17 @@ mod session_device_tests { // The successor it re-announces is the one this entry is already waiting for, which // is the shape a retry actually takes: the browser has not moved on either. - let retried = anchor.resolve_session_device( + let retried = anchor.resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), 2_000, ); - assert_eq!(retried, Err(SessionDeviceError::StaleDeviceKey)); - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(retried, Err(BrowserError::StaleDeviceKey)); + assert_eq!(anchor.browsers().len(), 1); assert_eq!( - anchor.session_devices()[0].next_device_key, + anchor.browsers()[0].next_browser_key, successor_key(1) ); } @@ -1676,7 +1676,7 @@ mod session_device_tests { fn promoting_the_announced_successor_resolves_the_same_browser() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1685,7 +1685,7 @@ mod session_device_tests { .unwrap(); let (again, _) = anchor - .resolve_session_device( + .resolve_browser( successor_key(1), successor_key(2), "Chrome".to_string(), @@ -1694,13 +1694,13 @@ mod session_device_tests { .unwrap(); assert_eq!(again, id); - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.browsers().len(), 1); assert_eq!( - anchor.session_devices()[0].current_device_key, + anchor.browsers()[0].current_browser_key, successor_key(1) ); assert_eq!( - anchor.session_devices()[0].next_device_key, + anchor.browsers()[0].next_browser_key, successor_key(2) ); } @@ -1709,12 +1709,12 @@ mod session_device_tests { fn rotating_repeatedly_keeps_the_same_browser() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device(browser_key(0), browser_key(1), "Chrome".to_string(), 1) + .resolve_browser(browser_key(0), browser_key(1), "Chrome".to_string(), 1) .unwrap(); for step in 1..10u8 { let (again, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(step), browser_key(step + 1), "Chrome".to_string(), @@ -1724,7 +1724,7 @@ mod session_device_tests { assert_eq!(again, id); } - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.browsers().len(), 1); } /// Presented keys are visible on the wire, so announcing one another browser is about to @@ -1733,7 +1733,7 @@ mod session_device_tests { fn a_successor_another_browser_holds_is_refused() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1741,13 +1741,13 @@ mod session_device_tests { ) .unwrap(); - let stealing_the_key = anchor.resolve_session_device( + let stealing_the_key = anchor.resolve_browser( browser_key(2), browser_key(1), "Firefox".to_string(), 2_000, ); - let stealing_the_successor = anchor.resolve_session_device( + let stealing_the_successor = anchor.resolve_browser( browser_key(2), successor_key(1), "Firefox".to_string(), @@ -1756,13 +1756,13 @@ mod session_device_tests { assert_eq!( stealing_the_key, - Err(SessionDeviceError::SuccessorAlreadyInUse) + Err(BrowserError::SuccessorAlreadyInUse) ); assert_eq!( stealing_the_successor, - Err(SessionDeviceError::SuccessorAlreadyInUse) + Err(BrowserError::SuccessorAlreadyInUse) ); - assert_eq!(anchor.session_devices().len(), 1); + assert_eq!(anchor.browsers().len(), 1); } /// The browser that already holds it is re-announcing, which a retry does. @@ -1770,7 +1770,7 @@ mod session_device_tests { fn a_second_sign_in_from_a_key_a_browser_never_announced_is_a_new_browser() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1780,7 +1780,7 @@ mod session_device_tests { // Neither slot holds it, so there is nothing to say this is the same browser. let (other, _) = anchor - .resolve_session_device( + .resolve_browser( browser_key(7), successor_key(7), "Chrome".to_string(), @@ -1789,7 +1789,7 @@ mod session_device_tests { .unwrap(); assert_ne!(other, id); - assert_eq!(anchor.session_devices().len(), 2); + assert_eq!(anchor.browsers().len(), 2); } #[test] @@ -1800,22 +1800,22 @@ mod session_device_tests { // announced the key it is presenting would keep it alive for as long as it kept // asking, and so would whoever leaked it. assert_eq!( - anchor.resolve_session_device( + anchor.resolve_browser( browser_key(1), browser_key(1), "Chrome".to_string(), 1_000 ), - Err(SessionDeviceError::SuccessorMatchesCurrent) + Err(BrowserError::SuccessorMatchesCurrent) ); - assert!(anchor.session_devices().is_empty()); + assert!(anchor.browsers().is_empty()); } #[test] fn a_registered_browser_cannot_stop_rotating_either() { let mut anchor = anchor(); anchor - .resolve_session_device( + .resolve_browser( browser_key(1), successor_key(1), "Chrome".to_string(), @@ -1824,21 +1824,21 @@ mod session_device_tests { .unwrap(); assert_eq!( - anchor.resolve_session_device( + anchor.resolve_browser( successor_key(1), successor_key(1), "Chrome".to_string(), 2_000 ), - Err(SessionDeviceError::SuccessorMatchesCurrent) + Err(BrowserError::SuccessorMatchesCurrent) ); // The entry is left as it was, still awaiting a successor it has not seen. assert_eq!( - anchor.session_devices()[0].current_device_key, + anchor.browsers()[0].current_browser_key, browser_key(1) ); assert_eq!( - anchor.session_devices()[0].next_device_key, + anchor.browsers()[0].next_browser_key, successor_key(1) ); } diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index f7b33d8ac5..ba0c6f8f9c 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -25,8 +25,8 @@ pub mod openid_credential_key; pub mod openid_jwks; pub mod passkey_credential; pub mod recovery_key; -pub mod session_device; -pub mod session_device_id; +pub mod browser; +pub mod browser_id; pub mod session_handle; pub mod session_id; pub mod session_record; diff --git a/src/internet_identity/src/storage/storable/anchor.rs b/src/internet_identity/src/storage/storable/anchor.rs index 0f01cd6b32..6e58582594 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -2,8 +2,8 @@ use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCr use crate::storage::storable::openid_credential::StorableOpenIdCredential; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; -use crate::storage::storable::session_device::StorableSessionDevice; -use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use crate::storage::storable::browser::StorableBrowser; +use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::verified_email::StorableVerifiedEmail; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; @@ -34,12 +34,12 @@ pub struct StorableAnchor { /// `Option` so pre-existing anchors decode cleanly. #[n(6)] pub verified_emails: Option>, - /// Browsers this anchor has signed in from. Capped at `MAX_SESSION_DEVICES`. + /// Browsers this anchor has signed in from. Capped at `MAX_BROWSERS`. #[n(7)] - pub session_devices: Option>, - /// Monotonic per-anchor allocator for `session_devices`. Ids are never reused. + pub browsers: Option>, + /// Monotonic per-anchor allocator for `browsers`. Ids are never reused. #[n(8)] - pub next_session_device_id: Option, + pub next_browser_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. diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index c53689d78c..a964e2a44c 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -1,6 +1,6 @@ use crate::storage::account::SessionRecord; use crate::storage::storable::duration::StorableDuration; -use crate::storage::storable::session_device_id::StorableSessionDeviceId; +use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::session_id::StorableSessionId; use crate::storage::storable::timestamp::StorableTimestamp; use ic_stable_structures::storable::Bound; @@ -20,7 +20,7 @@ pub struct StorableSessionRecord { #[n(3)] pub last_refreshed_ns: Option, #[n(4)] - pub device_id: StorableSessionDeviceId, + pub browser_id: StorableBrowserId, #[n(5)] pub read_only: bool, #[n(6)] @@ -48,7 +48,7 @@ impl From for SessionRecord { valid_till_ns: value.valid_till_ns, max_idle_ns: value.max_idle_ns, last_refreshed_ns: value.last_refreshed_ns, - device_id: value.device_id, + browser_id: value.browser_id, read_only: value.read_only, session_id: value.session_id, } @@ -62,7 +62,7 @@ impl From for StorableSessionRecord { valid_till_ns: value.valid_till_ns, max_idle_ns: value.max_idle_ns, last_refreshed_ns: value.last_refreshed_ns, - device_id: value.device_id, + browser_id: value.browser_id, read_only: value.read_only, session_id: value.session_id, } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 61244581c9..9da5998858 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1440,8 +1440,8 @@ fn test_anchor_storage_migration_round_trip() { "empty anchor", storage.allocate_anchor(now).unwrap(), Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 0, devices: vec![], @@ -1475,8 +1475,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 1, devices: vec![Device { @@ -1521,8 +1521,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 2, devices: vec![Device { @@ -1567,8 +1567,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 3, devices: vec![Device { @@ -1613,8 +1613,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 4, devices: vec![Device { @@ -1659,8 +1659,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 5, devices: vec![Device { @@ -1705,8 +1705,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 6, devices: vec![Device { @@ -1751,8 +1751,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 7, devices: vec![Device { @@ -1811,8 +1811,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 8, devices: vec![ @@ -1858,8 +1858,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 9, devices: vec![], @@ -1880,8 +1880,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 10, devices: vec![], @@ -1915,8 +1915,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 11, devices: vec![Device { @@ -1968,8 +1968,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 12, devices: vec![Device { @@ -2008,8 +2008,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 13, devices: vec![], @@ -2043,8 +2043,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 14, devices: vec![Device { @@ -2092,8 +2092,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 15, devices: vec![Device { @@ -2140,8 +2140,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 16, devices: vec![Device { @@ -2186,8 +2186,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 17, devices: vec![Device { @@ -2232,8 +2232,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 18, devices: vec![Device { @@ -2278,8 +2278,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 19, devices: vec![Device { @@ -5075,7 +5075,7 @@ mod session_record_tests { valid_till_ns, max_idle_ns: NEVER_IDLE, last_refreshed_ns: None, - device_id: 1, + browser_id: 1, read_only: false, session_id, } @@ -5104,7 +5104,7 @@ mod session_record_tests { valid_till_ns: 22, max_idle_ns: 33, last_refreshed_ns: Some(44), - device_id: 55, + browser_id: 55, read_only: false, session_id: 66, }, @@ -5113,7 +5113,7 @@ mod session_record_tests { valid_till_ns: 88, max_idle_ns: 99, last_refreshed_ns: None, - device_id: 111, + browser_id: 111, read_only: true, session_id: 122, }, @@ -5309,7 +5309,7 @@ mod session_record_tests { // order would protect it. let flood: Vec = (0..500) .map(|index| SessionRecord { - device_id: index, + browser_id: index, ..session(index as u64 + 1, now - 1, now + DAY_NS) }) .collect(); @@ -5364,12 +5364,12 @@ mod session_creation_tests { (storage, anchor_number) } - fn params(anchor_number: AnchorNumber, device_id: u32, now: u64) -> CreateSessionParams { + fn params(anchor_number: AnchorNumber, browser_id: u32, now: u64) -> CreateSessionParams { CreateSessionParams { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id, + browser_id, valid_till_ns: now + 10_000, max_idle_ns: None, read_only: false, @@ -5541,7 +5541,7 @@ mod session_creation_tests { assert_eq!(session.created_at_ns, 1_000); assert_eq!(session.valid_till_ns, 11_000); assert_eq!(session.last_refreshed_ns, None); - assert_eq!(session.device_id, 1); + assert_eq!(session.browser_id, 1); assert_eq!(sessions_of(&storage, anchor_number), vec![session]); } @@ -5581,9 +5581,9 @@ mod session_creation_tests { #[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 { + for browser_id in 0..3 { storage - .create_session(params(anchor_number, device_id, 1_000)) + .create_session(params(anchor_number, browser_id, 1_000)) .unwrap(); } @@ -5593,7 +5593,7 @@ mod session_creation_tests { let sessions = sessions_of(&storage, anchor_number); assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].device_id, 9); + assert_eq!(sessions[0].browser_id, 9); } /// There is no per-reference cap: one browser holds one session per account, so the @@ -5601,15 +5601,15 @@ mod session_creation_tests { #[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); + for browser_id in 0..12u32 { + let mut p = params(anchor_number, browser_id, 1_000); p.valid_till_ns = 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)); + assert!(sessions.iter().any(|s| s.browser_id == 0)); } /// The account-principal index is keyed with one derivation and a session handle names @@ -5636,7 +5636,7 @@ mod session_creation_tests { assert_eq!(locator.anchor_number, anchor_number); assert_eq!(locator.application_number, application_number); - assert_eq!(session.device_id, 7); + assert_eq!(session.browser_id, 7); } #[test] @@ -5681,11 +5681,11 @@ mod session_creation_tests { #[test] fn a_session_replaced_in_the_same_round_does_not_inherit_its_identity() { let (mut storage, anchor_number) = storage_with_anchor(); - let same_round = |device_id| CreateSessionParams { + let same_round = |browser_id| CreateSessionParams { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id, + browser_id, valid_till_ns: 10_000, max_idle_ns: None, read_only: false, @@ -5697,7 +5697,7 @@ mod session_creation_tests { let sibling = storage.create_session(same_round(2)).unwrap().1; assert_eq!(first.created_at_ns, replacement.created_at_ns); - assert_eq!(first.device_id, replacement.device_id); + assert_eq!(first.browser_id, replacement.browser_id); assert_ne!(first.session_id, replacement.session_id); assert_ne!(replacement.session_id, sibling.session_id); } @@ -5711,7 +5711,7 @@ mod session_creation_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 1, + browser_id: 1, valid_till_ns: u64::MAX, max_idle_ns: None, read_only, @@ -5821,7 +5821,7 @@ mod session_consent_change_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 1, + browser_id: 1, valid_till_ns: u64::MAX, max_idle_ns: None, read_only, @@ -5886,7 +5886,7 @@ mod session_consent_change_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 2, + browser_id: 2, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, diff --git a/src/internet_identity/src/verified_emails/remove.rs b/src/internet_identity/src/verified_emails/remove.rs index 845958f4b5..a7685f7275 100644 --- a/src/internet_identity/src/verified_emails/remove.rs +++ b/src/internet_identity/src/verified_emails/remove.rs @@ -31,8 +31,8 @@ mod tests { fn anchor_with(addresses: &[&str]) -> Anchor { let mut a = Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 1, devices: vec![], diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 3111f7b93f..2fabbfd807 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -15,7 +15,7 @@ pub type FrontendHostname = String; pub type ApplicationNumber = u64; pub type Timestamp = u64; // in nanos since epoch /// Per-anchor label for one browser, so a browser's sessions can be revoked together. -pub type SessionDeviceId = u32; +pub type BrowserId = u32; /// Names one session for as long as the canister runs. Allocated from a single /// counter, so no two sessions ever share one, and a revoked session's id is never /// handed out again. From 4d81197c58668c513627f27e5ca59442ea07930c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:53 +0200 Subject: [PATCH 173/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/archive/archive.did | 2 +- src/canister_tests/src/api/archive.rs | 2 +- src/canister_tests/src/framework.rs | 4 +- .../lib/generated/internet_identity_idl.js | 10 +-- .../generated/internet_identity_types.d.ts | 16 ++--- src/internet_identity/internet_identity.did | 16 ++--- src/internet_identity/src/sessions.rs | 38 +++++----- .../src/sessions/device_key.rs | 20 +++--- .../tests/integration/sessions.rs | 70 +++++++++---------- .../src/archive/types.rs | 4 +- .../src/internet_identity/types.rs | 14 ++-- 11 files changed, 98 insertions(+), 98 deletions(-) diff --git a/src/archive/archive.did b/src/archive/archive.did index ea7e632052..3ecc2ba589 100644 --- a/src/archive/archive.did +++ b/src/archive/archive.did @@ -73,7 +73,7 @@ type Operation = variant { 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 { + register_browser : record { name : Private; }; create_account : record { diff --git a/src/canister_tests/src/api/archive.rs b/src/canister_tests/src/api/archive.rs index 7aa1a693d8..205933f105 100644 --- a/src/canister_tests/src/api/archive.rs +++ b/src/canister_tests/src/api/archive.rs @@ -135,7 +135,7 @@ pub mod compat { | Operation::RemoveEmailRecovery | Operation::AddVerifiedEmail | Operation::RemoveVerifiedEmail - | Operation::RegisterSessionDevice { .. } => { + | Operation::RegisterBrowser { .. } => { panic!("not available in compat type") } Operation::CreateAccount { name } => CompatOperation::CreateAccount { name }, diff --git a/src/canister_tests/src/framework.rs b/src/canister_tests/src/framework.rs index 2d1f5a415a..64861b2502 100644 --- a/src/canister_tests/src/framework.rs +++ b/src/canister_tests/src/framework.rs @@ -405,8 +405,8 @@ impl BrowserKey { 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) + pub fn sign(&self, session_key: &SessionKey, next_browser_key: &PublicKey) -> ByteBuf { + self.sign_with(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key) } /// The successor's own signature, proving the browser holds the key it announces. diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 4a1f3099f0..fd98953ee4 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -708,21 +708,21 @@ export const idlFactory = ({ IDL }) => { const PrepareAccountSessionRequest = IDL.Record({ 'permissions' : IDL.Opt(Permissions), 'max_idle' : IDL.Opt(IDL.Nat64), - 'current_device_key' : PublicKey, + 'current_browser_key' : PublicKey, 'session_key' : SessionKey, 'valid_for' : IDL.Opt(IDL.Nat64), 'origin' : FrontendHostname, - 'current_device_key_signature' : IDL.Vec(IDL.Nat8), + 'current_browser_key_signature' : IDL.Vec(IDL.Nat8), 'device_name' : IDL.Text, 'account_number' : IDL.Opt(AccountNumber), 'identity_number' : UserNumber, - 'next_device_key' : PublicKey, - 'next_device_key_signature' : IDL.Vec(IDL.Nat8), + 'next_browser_key' : PublicKey, + 'next_browser_key_signature' : IDL.Vec(IDL.Nat8), }); const PrepareAccountSessionResponse = IDL.Record({ 'user_key' : PublicKey, 'session_id' : IDL.Nat64, - 'device_id' : IDL.Nat32, + 'browser_id' : IDL.Nat32, 'expiration' : Timestamp, 'account_principal' : IDL.Principal, }); 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 1698faa311..4292beb030 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1365,7 +1365,7 @@ export interface PrepareAccountSessionRequest { * 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. */ - 'current_device_key' : PublicKey, + 'current_browser_key' : PublicKey, /** * The II frontend's own key. The app never sees this chain's private key. */ @@ -1376,9 +1376,9 @@ export interface PrepareAccountSessionRequest { 'valid_for' : [] | [bigint], 'origin' : FrontendHostname, /** - * Signature over session_key and next_device_key, verified with current_device_key. + * Signature over session_key and next_browser_key, verified with current_browser_key. */ - 'current_device_key_signature' : Uint8Array | number[], + 'current_browser_key_signature' : Uint8Array | number[], /** * Labels the browser in the user's session list, e.g. "Chrome on MacBook". */ @@ -1387,14 +1387,14 @@ export interface PrepareAccountSessionRequest { 'identity_number' : UserNumber, /** * What the browser rotates to once this sign-in succeeds. Must differ from - * current_device_key: a browser that never rotates keeps a leaked key useful. + * current_browser_key: a browser that never rotates keeps a leaked key useful. */ - 'next_device_key' : PublicKey, + 'next_browser_key' : PublicKey, /** - * Signature by next_device_key over session_key and current_device_key, proving the + * Signature by next_browser_key over session_key and current_browser_key, proving the * browser holds the key it is announcing. */ - 'next_device_key_signature' : Uint8Array | number[], + 'next_browser_key_signature' : Uint8Array | number[], } export interface PrepareAccountSessionResponse { 'user_key' : PublicKey, @@ -1409,7 +1409,7 @@ export interface PrepareAccountSessionResponse { * the user is looking at, and so the browser knows which registration its key now * belongs to. Not a credential: a caller never presents it. */ - 'device_id' : number, + 'browser_id' : number, /** * The session's valid_till. */ diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 452756fb91..bfe8917183 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1027,15 +1027,15 @@ type PrepareAccountSessionRequest = record { 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. - current_device_key : PublicKey; + current_browser_key : PublicKey; // What the browser rotates to once this sign-in succeeds. Must differ from - // current_device_key: a browser that never rotates keeps a leaked key useful. - next_device_key : PublicKey; - // Signature over session_key and next_device_key, verified with current_device_key. - current_device_key_signature : blob; - // Signature by next_device_key over session_key and current_device_key, proving the + // current_browser_key: a browser that never rotates keeps a leaked key useful. + next_browser_key : PublicKey; + // Signature over session_key and next_browser_key, verified with current_browser_key. + current_browser_key_signature : blob; + // Signature by next_browser_key over session_key and current_browser_key, proving the // browser holds the key it is announcing. - next_device_key_signature : blob; + next_browser_key_signature : blob; // The consented access level, fixed for the session's life. permissions : opt Permissions; // Clamped to the session maximum. @@ -1057,7 +1057,7 @@ type PrepareAccountSessionResponse = record { // Which browser this sign-in was attributed to, so the settings list can mark the one // the user is looking at, and so the browser knows which registration its key now // belongs to. Not a credential: a caller never presents it. - device_id : nat32; + browser_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; diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 3fb5864132..45b536c989 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -11,7 +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::{AccountKey, SessionRecord, SessionRecordKey}; -use crate::storage::anchor::SessionDeviceError; +use crate::storage::anchor::BrowserError; use crate::storage::{CreateSessionParams, StorageError}; use crate::{update_root_hash, DAY_NS, MINUTE_NS}; use candid::Principal; @@ -71,10 +71,10 @@ pub async fn prepare_account_session( account_number, session_key, device_name, - current_device_key, - next_device_key, - current_device_key_signature, - next_device_key_signature, + current_browser_key, + next_browser_key, + current_browser_key_signature, + next_browser_key_signature, permissions, valid_for, max_idle, @@ -88,10 +88,10 @@ pub async fn prepare_account_session( )); } if !verify_device_keys( - ¤t_device_key, - ¤t_device_key_signature, - &next_device_key, - &next_device_key_signature, + ¤t_browser_key, + ¤t_browser_key_signature, + &next_browser_key, + &next_browser_key_signature, &session_key, ) { return Err(AccountSessionError::InvalidDeviceKey); @@ -124,16 +124,16 @@ pub async fn prepare_account_session( 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.current_device_key == current_device_key - || device.next_device_key == current_device_key + let known_device = anchor.browsers().iter().any(|device| { + device.current_browser_key == current_browser_key + || device.next_browser_key == current_browser_key }); - let (device_id, dropped_devices) = anchor - .resolve_session_device(current_device_key, next_device_key, device_name, now) + let (browser_id, dropped_devices) = anchor + .resolve_browser(current_browser_key, next_browser_key, device_name, now) .map_err(|error| match error { // Told apart from the rest because the browser can act on it: it is the only // party holding the successor that does resolve. - SessionDeviceError::StaleDeviceKey => AccountSessionError::StaleDeviceKey, + BrowserError::StaleDeviceKey => AccountSessionError::StaleDeviceKey, _ => AccountSessionError::InvalidDeviceKey, })?; storage_borrow_mut(|storage| storage.write(anchor)) @@ -142,14 +142,14 @@ pub async fn prepare_account_session( if !known_device { post_operation_bookkeeping( identity_number, - Operation::RegisterSessionDevice { + Operation::RegisterBrowser { name: Private::Redacted, }, ); } for dropped in dropped_devices { - storage_borrow_mut(|storage| storage.revoke_device_sessions(identity_number, dropped)) + storage_borrow_mut(|storage| storage.revoke_browser_sessions(identity_number, dropped)) .expect("failed to end the sessions of a browser the registry dropped"); } @@ -161,7 +161,7 @@ pub async fn prepare_account_session( anchor_number: identity_number, origin: origin.clone(), account_number, - device_id, + browser_id, valid_till_ns: valid_till, max_idle_ns: max_idle, read_only, @@ -190,7 +190,7 @@ pub async fn prepare_account_session( user_key: ByteBuf::from(der_encode_canister_sig_key(seed.to_vec())), expiration: session.valid_till_ns, session_id: session.session_id, - device_id, + browser_id, account_principal, }) } diff --git a/src/internet_identity/src/sessions/device_key.rs b/src/internet_identity/src/sessions/device_key.rs index 5aafd30f94..c588a95a5c 100644 --- a/src/internet_identity/src/sessions/device_key.rs +++ b/src/internet_identity/src/sessions/device_key.rs @@ -23,23 +23,23 @@ const DEVICE_KEY_SIGNATURE_BYTES: usize = 64; /// hold it — without it, keys read off the wire could be planted as another browser's /// successor and claimed when that browser next presented one. pub fn verify_device_keys( - current_device_key: &PublicKey, - current_device_key_signature: &[u8], - next_device_key: &PublicKey, - next_device_key_signature: &[u8], + current_browser_key: &PublicKey, + current_browser_key_signature: &[u8], + next_browser_key: &PublicKey, + next_browser_key_signature: &[u8], session_key: &SessionKey, ) -> bool { verify( - current_device_key, - current_device_key_signature, - &signed_message(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_device_key), + current_browser_key, + current_browser_key_signature, + &signed_message(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key), ) && verify( - next_device_key, - next_device_key_signature, + next_browser_key, + next_browser_key_signature, &signed_message( SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, - current_device_key, + current_browser_key, ), ) } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 497983cb87..2b0d2b4678 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -30,18 +30,18 @@ fn session_request_from( browser: &BrowserKey, ) -> PrepareAccountSessionRequest { let session_key = ByteBuf::from(vec![1; 32]); - let next_device_key = browser.successor().public_key(); + let next_browser_key = browser.successor().public_key(); PrepareAccountSessionRequest { identity_number, origin: ORIGIN.to_string(), account_number: None, device_name: "Chrome on MacBook".to_string(), - current_device_key: browser.public_key(), - current_device_key_signature: browser.sign(&session_key, &next_device_key), - next_device_key_signature: browser + current_browser_key: browser.public_key(), + current_browser_key_signature: browser.sign(&session_key, &next_browser_key), + next_browser_key_signature: browser .successor() .sign_as_successor(&session_key, &browser.public_key()), - next_device_key, + next_browser_key, session_key, permissions: None, valid_for: None, @@ -171,7 +171,7 @@ fn should_archive_a_browser_registration_with_the_name_redacted() -> Result<(), .filter(|entry| { matches!( entry.operation, - Operation::RegisterSessionDevice { + Operation::RegisterBrowser { name: Private::Redacted } ) @@ -201,7 +201,7 @@ fn should_refuse_an_unknown_account_without_registering_a_browser() -> Result<() assert_eq!( identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices, + .browsers, None ); @@ -217,8 +217,8 @@ fn should_refuse_a_signature_from_another_key() -> Result<(), RejectResponse> { let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.current_device_key_signature = - BrowserKey::new(9).sign(&request.session_key, &request.next_device_key); + request.current_browser_key_signature = + BrowserKey::new(9).sign(&request.session_key, &request.next_browser_key); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); @@ -251,7 +251,7 @@ fn should_refuse_a_key_that_is_not_a_public_key() -> Result<(), RejectResponse> let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.current_device_key = ByteBuf::from(vec![0; 91]); + request.current_browser_key = ByteBuf::from(vec![0; 91]); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); @@ -270,13 +270,13 @@ fn should_register_no_browser_when_the_proof_fails() -> Result<(), RejectRespons let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.current_device_key_signature = ByteBuf::from(vec![0; 64]); + request.current_browser_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, + .browsers, None ); @@ -307,7 +307,7 @@ fn should_register_a_second_browser_for_a_key_it_has_not_seen() -> Result<(), Re let devices = identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .expect("the identity should hold browsers"); assert_eq!(devices.len(), 2); @@ -345,7 +345,7 @@ fn should_register_a_fresh_browser_after_a_storage_wipe() -> Result<(), RejectRe let devices = identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .expect("the identity should hold browsers"); assert_eq!(devices.len(), 2); @@ -380,11 +380,11 @@ fn should_accept_the_successor_a_browser_announced() -> Result<(), RejectRespons )? .unwrap(); - assert_eq!(rotated.device_id, first.device_id); + assert_eq!(rotated.browser_id, first.browser_id); assert_eq!( identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .unwrap() .len(), 1 @@ -422,14 +422,14 @@ fn should_treat_a_retired_key_as_a_new_browser() -> Result<(), RejectResponse> { // 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.current_device_key_signature = - browser.sign(&request.session_key, &request.next_device_key); - request.next_device_key_signature = + request.next_browser_key = fresh.public_key(); + request.current_browser_key_signature = + browser.sign(&request.session_key, &request.next_browser_key); + request.next_browser_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); + assert_ne!(copy.browser_id, first.browser_id); Ok(()) } @@ -507,7 +507,7 @@ fn should_refuse_a_retired_key_and_accept_the_successor() -> Result<(), RejectRe )? .unwrap(); - assert_eq!(retried.device_id, first.device_id); + assert_eq!(retried.browser_id, first.browser_id); Ok(()) } @@ -531,9 +531,9 @@ fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectRespons let attacker = BrowserKey::new(2); let mut request = session_request_from(identity_number, &attacker); - request.next_device_key = victim.successor().public_key(); - request.current_device_key_signature = - attacker.sign(&request.session_key, &request.next_device_key); + request.next_browser_key = victim.successor().public_key(); + request.current_browser_key_signature = + attacker.sign(&request.session_key, &request.next_browser_key); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); @@ -552,7 +552,7 @@ fn should_refuse_a_successor_the_caller_cannot_prove() -> Result<(), RejectRespo 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 = + request.next_browser_key_signature = browser.sign_as_successor(&request.session_key, &browser.public_key()); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; @@ -572,10 +572,10 @@ fn should_refuse_a_successor_equal_to_the_key_presented() -> Result<(), RejectRe let browser = BrowserKey::new(1); let mut request = session_request_from(identity_number, &browser); // Both signatures are real: the caller holds the key it is naming as its own successor. - request.next_device_key = browser.public_key(); - request.current_device_key_signature = - browser.sign(&request.session_key, &request.next_device_key); - request.next_device_key_signature = + request.next_browser_key = browser.public_key(); + request.current_browser_key_signature = + browser.sign(&request.session_key, &request.next_browser_key); + request.next_browser_key_signature = browser.sign_as_successor(&request.session_key, &browser.public_key()); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; @@ -609,7 +609,7 @@ fn should_store_the_requested_idle_bound() -> Result<(), RejectResponse> { identity_number, )? .unwrap() - .session_devices + .browsers .unwrap_or_default(); assert_eq!(devices.len(), 1); @@ -637,10 +637,10 @@ fn should_refuse_a_successor_another_browser_holds_even_when_proven() -> Result< // 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.current_device_key_signature = - attacker.sign(&request.session_key, &request.next_device_key); - request.next_device_key_signature = + request.next_browser_key = victim.public_key(); + request.current_browser_key_signature = + attacker.sign(&request.session_key, &request.next_browser_key); + request.next_browser_key_signature = victim.sign_as_successor(&request.session_key, &attacker.public_key()); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; diff --git a/src/internet_identity_interface/src/archive/types.rs b/src/internet_identity_interface/src/archive/types.rs index 9064eb2cb8..ee50db0e65 100644 --- a/src/internet_identity_interface/src/archive/types.rs +++ b/src/internet_identity_interface/src/archive/types.rs @@ -81,8 +81,8 @@ pub enum Operation { // 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 }, + #[serde(rename = "register_browser")] + RegisterBrowser { 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 95170ca2a3..aaf6f10ff2 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -771,14 +771,14 @@ pub struct PrepareAccountSessionRequest { 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 current_device_key: PublicKey, + pub current_browser_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 - /// `current_device_key`. A second signature by `next_device_key` proves the browser + pub next_browser_key: PublicKey, + /// Signature over `session_key` and `next_browser_key`, verified with + /// `current_browser_key`. A second signature by `next_browser_key` proves the browser /// holds the key it is announcing. - pub current_device_key_signature: ByteBuf, - pub next_device_key_signature: ByteBuf, + pub current_browser_key_signature: ByteBuf, + pub next_browser_key_signature: ByteBuf, /// The consented access level, fixed for the session's life. pub permissions: Option, /// Clamped to the session maximum. @@ -801,7 +801,7 @@ pub struct PrepareAccountSessionResponse { /// Which browser this sign-in was attributed to, so the settings list can mark the one /// the user is looking at, and so the browser knows which registration its key now /// belongs to. Not a credential: a caller never presents it. - pub device_id: SessionDeviceId, + pub browser_id: BrowserId, /// 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. From 26fb487c39a7c43b2aae28598ef1eeb223ad948c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:53 +0200 Subject: [PATCH 174/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 166 ++++++++++----------- 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 7965bde0dd..700befa219 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1440,8 +1440,8 @@ fn test_anchor_storage_migration_round_trip() { "empty anchor", storage.allocate_anchor(now).unwrap(), Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 0, devices: vec![], @@ -1475,8 +1475,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 1, devices: vec![Device { @@ -1521,8 +1521,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 2, devices: vec![Device { @@ -1567,8 +1567,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 3, devices: vec![Device { @@ -1613,8 +1613,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 4, devices: vec![Device { @@ -1659,8 +1659,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 5, devices: vec![Device { @@ -1705,8 +1705,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 6, devices: vec![Device { @@ -1751,8 +1751,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 7, devices: vec![Device { @@ -1811,8 +1811,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 8, devices: vec![ @@ -1858,8 +1858,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 9, devices: vec![], @@ -1880,8 +1880,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 10, devices: vec![], @@ -1915,8 +1915,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 11, devices: vec![Device { @@ -1968,8 +1968,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 12, devices: vec![Device { @@ -2008,8 +2008,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 13, devices: vec![], @@ -2043,8 +2043,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 14, devices: vec![Device { @@ -2092,8 +2092,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 15, devices: vec![Device { @@ -2140,8 +2140,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 16, devices: vec![Device { @@ -2186,8 +2186,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 17, devices: vec![Device { @@ -2232,8 +2232,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 18, devices: vec![Device { @@ -2278,8 +2278,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { - session_devices: vec![], - next_session_device_id: 0, + browsers: vec![], + next_browser_id: 0, session_count: 0, anchor_number: 19, devices: vec![Device { @@ -5075,7 +5075,7 @@ mod session_record_tests { valid_till_ns, max_idle_ns: NEVER_IDLE, last_refreshed_ns: None, - device_id: 1, + browser_id: 1, read_only: false, session_id, } @@ -5104,7 +5104,7 @@ mod session_record_tests { valid_till_ns: 22, max_idle_ns: 33, last_refreshed_ns: Some(44), - device_id: 55, + browser_id: 55, read_only: false, session_id: 66, }, @@ -5113,7 +5113,7 @@ mod session_record_tests { valid_till_ns: 88, max_idle_ns: 99, last_refreshed_ns: None, - device_id: 111, + browser_id: 111, read_only: true, session_id: 122, }, @@ -5309,7 +5309,7 @@ mod session_record_tests { // order would protect it. let flood: Vec = (0..500) .map(|index| SessionRecord { - device_id: index, + browser_id: index, ..session(index as u64 + 1, now - 1, now + DAY_NS) }) .collect(); @@ -5370,12 +5370,12 @@ mod session_creation_tests { (storage, anchor_number) } - fn params(anchor_number: AnchorNumber, device_id: u32, now: u64) -> CreateSessionParams { + fn params(anchor_number: AnchorNumber, browser_id: u32, now: u64) -> CreateSessionParams { CreateSessionParams { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id, + browser_id, valid_till_ns: now + 10_000, max_idle_ns: None, read_only: false, @@ -5547,7 +5547,7 @@ mod session_creation_tests { assert_eq!(session.created_at_ns, 1_000); assert_eq!(session.valid_till_ns, 11_000); assert_eq!(session.last_refreshed_ns, None); - assert_eq!(session.device_id, 1); + assert_eq!(session.browser_id, 1); assert_eq!(sessions_of(&storage, anchor_number), vec![session]); } @@ -5587,9 +5587,9 @@ mod session_creation_tests { #[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 { + for browser_id in 0..3 { storage - .create_session(params(anchor_number, device_id, 1_000)) + .create_session(params(anchor_number, browser_id, 1_000)) .unwrap(); } @@ -5599,7 +5599,7 @@ mod session_creation_tests { let sessions = sessions_of(&storage, anchor_number); assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].device_id, 9); + assert_eq!(sessions[0].browser_id, 9); } /// There is no per-reference cap: one browser holds one session per account, so the @@ -5607,15 +5607,15 @@ mod session_creation_tests { #[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); + for browser_id in 0..12u32 { + let mut p = params(anchor_number, browser_id, 1_000); p.valid_till_ns = 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)); + assert!(sessions.iter().any(|s| s.browser_id == 0)); } /// The per-identity cap reclaims to a watermark rather than blocking, taking expired @@ -5626,18 +5626,18 @@ mod session_creation_tests { let _application_number = application_number_for(&mut storage, &ORIGIN.to_string()); let sessions: Vec = (0..MAX_SESSIONS_PER_ANCHOR) - .map(|device_id| SessionRecord { + .map(|browser_id| SessionRecord { created_at_ns: 1_000, valid_till_ns: 1_000_000, // Device 0 is the stalest live one; device 1 has already expired. max_idle_ns: u64::MAX, - last_refreshed_ns: Some(500_000 + device_id as u64), - device_id, + last_refreshed_ns: Some(500_000 + browser_id as u64), + browser_id, read_only: false, - session_id: device_id as u64, + session_id: browser_id as u64, }) .map(|mut session| { - if session.device_id == 1 { + if session.browser_id == 1 { session.valid_till_ns = 2_000; } session @@ -5672,32 +5672,32 @@ mod session_creation_tests { "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.browser_id == 1)); + assert!(!remaining.iter().any(|s| s.browser_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)); + .any(|s| s.browser_id == MAX_SESSIONS_PER_ANCHOR - 1)); + assert!(remaining.iter().any(|s| s.browser_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); + for browser_id in 0..(MAX_SESSIONS_PER_ANCHOR + 120) { + let mut params = params(anchor_number, browser_id, 600_000 + browser_id as u64); params.valid_till_ns = 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" + "{stored} stored after {browser_id} sign-ins" ); assert_eq!( storage.read(anchor_number).unwrap().session_count as usize, stored, - "the counter parted ways with the lists after {device_id} sign-ins" + "the counter parted ways with the lists after {browser_id} sign-ins" ); } } @@ -5735,20 +5735,20 @@ mod session_creation_tests { const PER_LIST: u32 = MAX_SESSIONS_PER_ANCHOR / 2; let list = |id_base: u64, expired_device: u32| -> Vec { let sessions = (0..PER_LIST) - .map(|device_id| SessionRecord { + .map(|browser_id| SessionRecord { created_at_ns: 1, // The expired one, and the live ones ordered so the highest browser id // is the freshest and so the last to be given up. - valid_till_ns: if device_id == expired_device { + valid_till_ns: if browser_id == expired_device { 2 } else { 100_000_000 }, max_idle_ns: u64::MAX, - last_refreshed_ns: Some(500_000 + device_id as u64), - device_id, + last_refreshed_ns: Some(500_000 + browser_id as u64), + browser_id, read_only: false, - session_id: id_base + device_id as u64, + session_id: id_base + browser_id as u64, }) .collect(); vec![AccountReference { @@ -5781,7 +5781,7 @@ mod session_creation_tests { let mut ids: Vec = held_references(&storage, anchor_number, application_number) .into_iter() .flat_map(|reference| reference.sessions) - .map(|session| session.device_id) + .map(|session| session.browser_id) .collect(); ids.sort_unstable(); ids @@ -5818,19 +5818,19 @@ mod session_creation_tests { valid_till_ns: 100_000_000, max_idle_ns: u64::MAX, last_refreshed_ns: Some(400_000), - device_id: 1, + browser_id: 1, read_only: false, session_id: 1, }]; sessions.extend( - (2..=MAX_SESSIONS_PER_ANCHOR).map(|device_id| SessionRecord { + (2..=MAX_SESSIONS_PER_ANCHOR).map(|browser_id| SessionRecord { created_at_ns: 500_000, valid_till_ns: 100_000_000, max_idle_ns: u64::MAX, last_refreshed_ns: None, - device_id, + browser_id, read_only: false, - session_id: device_id as u64, + session_id: browser_id as u64, }), ); storage @@ -5857,7 +5857,7 @@ mod session_creation_tests { let remaining = sessions_of(&storage, anchor_number); assert!( - remaining.iter().any(|session| session.device_id == 1), + remaining.iter().any(|session| session.browser_id == 1), "the session that was kept alive was reclaimed" ); assert!( @@ -5908,11 +5908,11 @@ mod session_creation_tests { #[test] fn a_session_replaced_in_the_same_round_does_not_inherit_its_identity() { let (mut storage, anchor_number) = storage_with_anchor(); - let same_round = |device_id| CreateSessionParams { + let same_round = |browser_id| CreateSessionParams { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id, + browser_id, valid_till_ns: 10_000, max_idle_ns: None, read_only: false, @@ -5924,7 +5924,7 @@ mod session_creation_tests { let sibling = storage.create_session(same_round(2)).unwrap().1; assert_eq!(first.created_at_ns, replacement.created_at_ns); - assert_eq!(first.device_id, replacement.device_id); + assert_eq!(first.browser_id, replacement.browser_id); assert_ne!(first.session_id, replacement.session_id); assert_ne!(replacement.session_id, sibling.session_id); } @@ -5938,7 +5938,7 @@ mod session_creation_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 1, + browser_id: 1, valid_till_ns: u64::MAX, max_idle_ns: None, read_only, @@ -6048,7 +6048,7 @@ mod session_consent_change_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 1, + browser_id: 1, valid_till_ns: u64::MAX, max_idle_ns: None, read_only, @@ -6113,7 +6113,7 @@ mod session_consent_change_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 2, + browser_id: 2, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, From cabf76e5d2ba7508d730100305bac4546ec7fe44 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:54 +0200 Subject: [PATCH 175/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 4 +-- src/internet_identity/src/storage/anchor.rs | 6 ++-- src/internet_identity/src/storage/tests.rs | 28 +++++++++---------- .../tests/integration/sessions.rs | 6 ++-- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 184a257367..c972f163ea 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3188,7 +3188,7 @@ impl Storage { }; session.last_refreshed_ns = Some(now); - let device_id = session.device_id; + let browser_id = session.browser_id; write.account_reference.last_used = Some(now); // This list is being rewritten anyway, so its dead sessions go now. It costs one @@ -3204,7 +3204,7 @@ impl Storage { // Stamped before the write rather than after: the write may move the identity's // session count and store the anchor for it, and this way that store carries the // stamp too instead of needing a second one. - let stamped = anchor.stamp_session_device_use(device_id, now); + let stamped = anchor.stamp_browser_use(browser_id, now); self.write_account_state( &mut anchor, BTreeMap::from([(origin.clone(), Some((account_references, config)))]), diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 2bf98bc0a6..54bd60490b 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -743,11 +743,11 @@ impl Anchor { /// 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 { + pub fn stamp_browser_use(&mut self, browser_id: BrowserId, now: Timestamp) -> bool { match self - .session_devices + .browsers .iter_mut() - .find(|device| device.id == device_id) + .find(|device| device.id == browser_id) { Some(device) if device.last_used < now => { device.last_used = now; diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 8b4b25f898..843ad63811 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6177,7 +6177,7 @@ mod session_refresh_stamp_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 1, + browser_id: 1, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, @@ -6239,7 +6239,7 @@ mod session_refresh_stamp_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 9, + browser_id: 9, valid_till_ns: 1_500, max_idle_ns: None, read_only: false, @@ -6261,7 +6261,7 @@ mod session_refresh_stamp_tests { 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_eq!(sessions[0].browser_id, 1); assert!( storage .lookup_session_with_principal(dead_principal) @@ -6298,7 +6298,7 @@ mod session_refresh_stamp_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: 2, + browser_id: 2, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, @@ -6311,8 +6311,8 @@ mod session_refresh_stamp_tests { 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(); + let stamped = sessions.iter().find(|s| s.browser_id == 1).unwrap(); + let untouched = sessions.iter().find(|s| s.browser_id == 2).unwrap(); assert_eq!(stamped.last_refreshed_ns, Some(now)); assert_eq!(untouched.last_refreshed_ns, None); } @@ -6323,8 +6323,8 @@ mod session_refresh_stamp_tests { 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( + let (browser_id, _) = anchor + .resolve_browser( ByteBuf::from(vec![1; 91]), ByteBuf::from(vec![2; 91]), "Chrome".to_string(), @@ -6337,23 +6337,23 @@ mod session_refresh_stamp_tests { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id, + browser_id, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, now_ns: 1_000, }) .unwrap(); - (storage, anchor_number, key, device_id) + (storage, anchor_number, key, browser_id) } fn device_last_used(storage: &Storage, anchor_number: AnchorNumber) -> u64 { - storage.read(anchor_number).unwrap().session_devices()[0].last_used + storage.read(anchor_number).unwrap().browsers()[0].last_used } #[test] fn a_refresh_advances_the_device_registry() { - let (mut storage, anchor_number, key, _device_id) = storage_with_registered_device(); + let (mut storage, anchor_number, key, _browser_id) = storage_with_registered_device(); storage.record_session_use(&key, 9_000).unwrap(); @@ -6362,11 +6362,11 @@ mod session_refresh_stamp_tests { #[test] fn a_refresh_leaves_the_device_enrolment_timestamp_alone() { - let (mut storage, anchor_number, key, _device_id) = storage_with_registered_device(); + let (mut storage, anchor_number, key, _browser_id) = storage_with_registered_device(); storage.record_session_use(&key, 9_000).unwrap(); - let device = storage.read(anchor_number).unwrap().session_devices()[0].clone(); + let device = storage.read(anchor_number).unwrap().browsers()[0].clone(); assert_eq!(device.created_at, 1_000); assert_eq!(device.last_used, 9_000); } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 8d2c939c0b..510aaf7c52 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, SessionDeviceInfo, + PrepareAccountSessionResponse, BrowserInfo, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; @@ -589,11 +589,11 @@ fn should_advance_the_device_last_used_on_every_refresh() -> Result<(), RejectRe let identity_number = flows::register_anchor(&env, canister_id); let (_, session_principal) = create_session(&env, canister_id, identity_number); - let device = |env: &PocketIc| -> Result { + let device = |env: &PocketIc| -> Result { Ok( identity_info(env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .unwrap()[0] .clone(), ) From a235a671730e7bc5b41f981f6b045fe89522ae0e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:54 +0200 Subject: [PATCH 176/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 3ebdb7db6a..3403a643df 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6413,13 +6413,13 @@ mod session_removal_tests { storage.write(anchor).unwrap(); let keys = devices .iter() - .map(|device_id| { + .map(|browser_id| { storage .create_session(CreateSessionParams { anchor_number, origin: ORIGIN.to_string(), account_number: None, - device_id: *device_id, + browser_id: *browser_id, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, @@ -6445,7 +6445,7 @@ mod session_removal_tests { .unwrap() .sessions .into_iter() - .map(|session| session.device_id) + .map(|session| session.browser_id) .collect() } From 1f7e3502f46e7d9e2d89aa8dc50d7a3d42fe74e2 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:54 +0200 Subject: [PATCH 177/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 38 +++++++++---------- .../tests/integration/sessions.rs | 6 +-- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 3ed88f3996..77277f6a78 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -11,7 +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, AccountKey, SessionRecord, SessionRecordKey}; -use crate::storage::anchor::SessionDeviceError; +use crate::storage::anchor::BrowserError; use crate::storage::{CreateSessionParams, StorageError}; use crate::{update_root_hash, DAY_NS, MINUTE_NS}; use candid::Principal; @@ -73,10 +73,10 @@ pub async fn prepare_account_session( account_number, session_key, device_name, - current_device_key, - next_device_key, - current_device_key_signature, - next_device_key_signature, + current_browser_key, + next_browser_key, + current_browser_key_signature, + next_browser_key_signature, permissions, valid_for, max_idle, @@ -90,10 +90,10 @@ pub async fn prepare_account_session( )); } if !verify_device_keys( - ¤t_device_key, - ¤t_device_key_signature, - &next_device_key, - &next_device_key_signature, + ¤t_browser_key, + ¤t_browser_key_signature, + &next_browser_key, + &next_browser_key_signature, &session_key, ) { return Err(AccountSessionError::InvalidDeviceKey); @@ -126,16 +126,16 @@ pub async fn prepare_account_session( 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.current_device_key == current_device_key - || device.next_device_key == current_device_key + let known_device = anchor.browsers().iter().any(|device| { + device.current_browser_key == current_browser_key + || device.next_browser_key == current_browser_key }); - let (device_id, dropped_devices) = anchor - .resolve_session_device(current_device_key, next_device_key, device_name, now) + let (browser_id, dropped_devices) = anchor + .resolve_browser(current_browser_key, next_browser_key, device_name, now) .map_err(|error| match error { // Told apart from the rest because the browser can act on it: it is the only // party holding the successor that does resolve. - SessionDeviceError::StaleDeviceKey => AccountSessionError::StaleDeviceKey, + BrowserError::StaleDeviceKey => AccountSessionError::StaleDeviceKey, _ => AccountSessionError::InvalidDeviceKey, })?; storage_borrow_mut(|storage| storage.write(anchor)) @@ -144,14 +144,14 @@ pub async fn prepare_account_session( if !known_device { post_operation_bookkeeping( identity_number, - Operation::RegisterSessionDevice { + Operation::RegisterBrowser { name: Private::Redacted, }, ); } for dropped in dropped_devices { - storage_borrow_mut(|storage| storage.revoke_device_sessions(identity_number, dropped)) + storage_borrow_mut(|storage| storage.revoke_browser_sessions(identity_number, dropped)) .expect("failed to end the sessions of a browser the registry dropped"); } @@ -163,7 +163,7 @@ pub async fn prepare_account_session( anchor_number: identity_number, origin: origin.clone(), account_number, - device_id, + browser_id, valid_till_ns: valid_till, max_idle_ns: max_idle, read_only, @@ -192,7 +192,7 @@ pub async fn prepare_account_session( user_key: ByteBuf::from(der_encode_canister_sig_key(seed.to_vec())), expiration: session.valid_till_ns, session_id: session.session_id, - device_id, + browser_id, account_principal, }) } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 7d8906b22b..3a0f28c404 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -407,7 +407,7 @@ fn should_not_reuse_a_session_across_a_consent_change() -> Result<(), RejectResp /// 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; + const MAX_BROWSERS: u32 = 20; let env = env(); let canister_id = install_ii_with_archive(&env, None, None); @@ -415,7 +415,7 @@ fn should_end_the_sessions_of_a_browser_the_registry_dropped() -> Result<(), Rej let (_, first_principal) = create_session(&env, canister_id, identity_number); - for index in 0..MAX_SESSION_DEVICES { + for index in 0..MAX_BROWSERS { 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"); @@ -961,7 +961,7 @@ fn should_keep_the_browser_entry_across_a_rotation() -> Result<(), RejectRespons // 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_eq!(rotated.browser_id, first.browser_id); assert_ne!(rotated.session_id, first.session_id); assert!(app_prepare_delegation( From 9baa21d8d79ce3254868b3a81d71dbdd635d6e52 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:55 +0200 Subject: [PATCH 178/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/api/internet_identity/api_v2.rs | 4 +- .../lib/generated/internet_identity_idl.js | 4 +- .../generated/internet_identity_types.d.ts | 4 +- src/internet_identity/internet_identity.did | 4 +- src/internet_identity/src/main.rs | 4 +- src/internet_identity/src/sessions.rs | 4 +- src/internet_identity/src/storage/tests.rs | 28 +++--- .../tests/integration/sessions.rs | 96 +++++++++---------- .../src/internet_identity/types.rs | 2 +- 9 files changed, 75 insertions(+), 75 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 d66019f727..f908f36bd1 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -829,7 +829,7 @@ pub fn app_revoke_session( .map(|_| ()) } -pub fn revoke_device_sessions( +pub fn revoke_browser_sessions( env: &PocketIc, canister_id: CanisterId, sender: Principal, @@ -840,7 +840,7 @@ pub fn revoke_device_sessions( canister_id, RawEffectivePrincipal::None, sender, - "revoke_device_sessions", + "revoke_browser_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 b02a64005b..8bf9515666 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -811,7 +811,7 @@ export const idlFactory = ({ IDL }) => { 'registered' : IDL.Record({ 'user_number' : UserNumber }), }); const RevokeDeviceSessionsRequest = IDL.Record({ - 'device_id' : IDL.Nat32, + 'browser_id' : IDL.Nat32, 'identity_number' : UserNumber, }); const SessionRevokeError = IDL.Variant({ @@ -1451,7 +1451,7 @@ export const idlFactory = ({ IDL }) => { ), 'remove' : IDL.Func([UserNumber, DeviceKey], [], []), 'replace' : IDL.Func([UserNumber, DeviceKey, DeviceData], [], []), - 'revoke_device_sessions' : IDL.Func( + 'revoke_browser_sessions' : IDL.Func( [RevokeDeviceSessionsRequest], [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : SessionRevokeError })], [], 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 7cf8d8b58b..637870f9bd 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1645,7 +1645,7 @@ export type RegistrationFlowNextStep = { }; export type RegistrationId = string; export interface RevokeDeviceSessionsRequest { - 'device_id' : number, + 'browser_id' : number, 'identity_number' : UserNumber, } /** @@ -2586,7 +2586,7 @@ export interface _SERVICE { * Atomically replace device matching the device key with the new device data */ 'replace' : ActorMethod<[UserNumber, DeviceKey, DeviceData], undefined>, - 'revoke_device_sessions' : ActorMethod< + 'revoke_browser_sessions' : ActorMethod< [RevokeDeviceSessionsRequest], { 'Ok' : null } | { 'Err' : SessionRevokeError } diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index b3faade4b3..8083a5aee0 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1110,7 +1110,7 @@ type AppGetDelegationRequest = record { type RevokeDeviceSessionsRequest = record { identity_number : UserNumber; - device_id : nat32; + browser_id : nat32; }; type SessionRevokeError = variant { @@ -2000,7 +2000,7 @@ service : (opt InternetIdentityInit) -> { // session was already gone. An app can revoke only its own session. app_revoke_session : () -> (); - revoke_device_sessions : (RevokeDeviceSessionsRequest) -> (variant { Ok; Err : SessionRevokeError }); + revoke_browser_sessions : (RevokeDeviceSessionsRequest) -> (variant { Ok; Err : SessionRevokeError }); prepare_account_delegation : ( anchor_number : UserNumber, diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index b2812d9766..595b7e652f 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -509,8 +509,8 @@ fn app_prepare_delegation( } #[update] -fn revoke_device_sessions(request: RevokeDeviceSessionsRequest) -> Result<(), SessionRevokeError> { - sessions::revoke_device_sessions(request) +fn revoke_browser_sessions(request: RevokeDeviceSessionsRequest) -> Result<(), SessionRevokeError> { + sessions::revoke_browser_sessions(request) } #[update] diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 8eb6191d38..e264329907 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -450,14 +450,14 @@ fn account_seed(account: &Account) -> Result { Ok(account.calculate_seed_with_salt(&salt)) } -pub fn revoke_device_sessions( +pub fn revoke_browser_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) + storage.revoke_browser_sessions(request.identity_number, request.browser_id) }) .map(|_| ()) .map_err(|err| SessionRevokeError::InternalCanisterError(err.to_string())) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index cda0012cde..63308206dc 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3787,7 +3787,7 @@ mod tracked_default_eviction_tests { anchor_number, origin: doomed.clone(), account_number: None, - device_id: 1, + browser_id: 1, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, @@ -6571,7 +6571,7 @@ mod session_revocation_tests { storage: &mut Storage, anchor_number: AnchorNumber, origin: &str, - device_id: u32, + browser_id: u32, now: u64, ) { storage @@ -6579,7 +6579,7 @@ mod session_revocation_tests { anchor_number, origin: origin.to_string(), account_number: None, - device_id, + browser_id, valid_till_ns: u64::MAX, max_idle_ns: None, read_only: false, @@ -6588,7 +6588,7 @@ mod session_revocation_tests { .unwrap(); } - fn device_ids( + fn browser_ids( storage: &Storage, anchor_number: AnchorNumber, origin: &str, @@ -6602,7 +6602,7 @@ mod session_revocation_tests { .unwrap() .sessions .into_iter() - .map(|session| session.device_id) + .map(|session| session.browser_id) .collect() } @@ -6613,15 +6613,15 @@ mod session_revocation_tests { 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(); + let removed = storage.revoke_browser_sessions(anchor_number, 1).unwrap(); assert_eq!(removed, 2); assert_eq!( - device_ids(&storage, anchor_number, "https://a.com"), + browser_ids(&storage, anchor_number, "https://a.com"), vec![2] ); assert_eq!( - device_ids(&storage, anchor_number, "https://b.com"), + browser_ids(&storage, anchor_number, "https://b.com"), Vec::::new() ); } @@ -6635,10 +6635,10 @@ mod session_revocation_tests { 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(); + storage.revoke_browser_sessions(anchor_number, 1).unwrap(); assert_eq!( - device_ids(&storage, other_anchor_number, "https://a.com"), + browser_ids(&storage, other_anchor_number, "https://a.com"), vec![1] ); } @@ -6648,11 +6648,11 @@ mod session_revocation_tests { 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(); + let removed = storage.revoke_browser_sessions(anchor_number, 9).unwrap(); assert_eq!(removed, 0); assert_eq!( - device_ids(&storage, anchor_number, "https://a.com"), + browser_ids(&storage, anchor_number, "https://a.com"), vec![1] ); } @@ -6749,7 +6749,7 @@ mod write_path_property_tests { anchor_number, origin, account_number, - device_id: rng.below(4) as u32, + browser_id: rng.below(4) as u32, valid_till_ns: now + 1 + rng.below(20_000), max_idle_ns: None, read_only: false, @@ -6762,7 +6762,7 @@ mod write_path_property_tests { } } _ => { - let _ = storage.revoke_device_sessions(anchor_number, rng.below(4) as u32); + let _ = storage.revoke_browser_sessions(anchor_number, rng.below(4) as u32); } } } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index b0a5b7b5ea..5e14d047ff 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, revoke_device_sessions, + prepare_account_session, revoke_browser_sessions, }; use canister_tests::flows; use canister_tests::framework::{ @@ -12,7 +12,7 @@ use canister_tests::framework::{ use internet_identity_interface::internet_identity::types::{ AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, - PrepareAccountSessionResponse, RevokeDeviceSessionsRequest, SessionDeviceInfo, + PrepareAccountSessionResponse, RevokeDeviceSessionsRequest, BrowserInfo, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; @@ -33,18 +33,18 @@ fn session_request_from( browser: &BrowserKey, ) -> PrepareAccountSessionRequest { let session_key = ByteBuf::from(vec![1; 32]); - let next_device_key = browser.successor().public_key(); + let next_browser_key = browser.successor().public_key(); PrepareAccountSessionRequest { identity_number, origin: ORIGIN.to_string(), account_number: None, device_name: "Chrome on MacBook".to_string(), - current_device_key: browser.public_key(), - current_device_key_signature: browser.sign(&session_key, &next_device_key), - next_device_key_signature: browser + current_browser_key: browser.public_key(), + current_browser_key_signature: browser.sign(&session_key, &next_browser_key), + next_browser_key_signature: browser .successor() .sign_as_successor(&session_key, &browser.public_key()), - next_device_key, + next_browser_key, session_key, permissions: None, valid_for: None, @@ -408,7 +408,7 @@ fn should_not_reuse_a_session_across_a_consent_change() -> Result<(), RejectResp /// 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; + const MAX_BROWSERS: u32 = 20; let env = env(); let canister_id = install_ii_with_archive(&env, None, None); @@ -416,7 +416,7 @@ fn should_end_the_sessions_of_a_browser_the_registry_dropped() -> Result<(), Rej let (_, first_principal) = create_session(&env, canister_id, identity_number); - for index in 0..MAX_SESSION_DEVICES { + for index in 0..MAX_BROWSERS { 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"); @@ -523,7 +523,7 @@ fn should_archive_a_browser_registration_with_the_name_redacted() -> Result<(), .filter(|entry| { matches!( entry.operation, - Operation::RegisterSessionDevice { + Operation::RegisterBrowser { name: Private::Redacted } ) @@ -590,11 +590,11 @@ fn should_advance_the_device_last_used_on_every_refresh() -> Result<(), RejectRe let identity_number = flows::register_anchor(&env, canister_id); let (_, session_principal) = create_session(&env, canister_id, identity_number); - let device = |env: &PocketIc| -> Result { + let device = |env: &PocketIc| -> Result { Ok( identity_info(env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .unwrap()[0] .clone(), ) @@ -723,22 +723,22 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { 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)? + let browser_id = identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .unwrap() .into_iter() .find(|device| device.name == "Chrome on MacBook") .expect("the browser that signed in should be listed") .id; - revoke_device_sessions( + revoke_browser_sessions( &env, canister_id, principal_1(), RevokeDeviceSessionsRequest { identity_number, - device_id, + browser_id, }, )? .unwrap(); @@ -767,9 +767,9 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { let devices = identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .unwrap(); - assert!(devices.iter().any(|device| device.id == device_id)); + assert!(devices.iter().any(|device| device.id == browser_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 — and no time is allowed @@ -853,7 +853,7 @@ fn should_refuse_an_unknown_account_without_registering_a_browser() -> Result<() assert_eq!( identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices, + .browsers, None ); @@ -869,8 +869,8 @@ fn should_refuse_a_signature_from_another_key() -> Result<(), RejectResponse> { let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.current_device_key_signature = - BrowserKey::new(9).sign(&request.session_key, &request.next_device_key); + request.current_browser_key_signature = + BrowserKey::new(9).sign(&request.session_key, &request.next_browser_key); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); @@ -903,7 +903,7 @@ fn should_refuse_a_key_that_is_not_a_public_key() -> Result<(), RejectResponse> let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.current_device_key = ByteBuf::from(vec![0; 91]); + request.current_browser_key = ByteBuf::from(vec![0; 91]); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); @@ -922,13 +922,13 @@ fn should_register_no_browser_when_the_proof_fails() -> Result<(), RejectRespons let identity_number = flows::register_anchor(&env, canister_id); let mut request = session_request(identity_number); - request.current_device_key_signature = ByteBuf::from(vec![0; 64]); + request.current_browser_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, + .browsers, None ); @@ -959,7 +959,7 @@ fn should_register_a_second_browser_for_a_key_it_has_not_seen() -> Result<(), Re let devices = identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .expect("the identity should hold browsers"); assert_eq!(devices.len(), 2); @@ -997,7 +997,7 @@ fn should_register_a_fresh_browser_after_a_storage_wipe() -> Result<(), RejectRe let devices = identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .expect("the identity should hold browsers"); assert_eq!(devices.len(), 2); @@ -1032,11 +1032,11 @@ fn should_accept_the_successor_a_browser_announced() -> Result<(), RejectRespons )? .unwrap(); - assert_eq!(rotated.device_id, first.device_id); + assert_eq!(rotated.browser_id, first.browser_id); assert_eq!( identity_info(&env, canister_id, principal_1(), identity_number)? .unwrap() - .session_devices + .browsers .unwrap() .len(), 1 @@ -1074,14 +1074,14 @@ fn should_treat_a_retired_key_as_a_new_browser() -> Result<(), RejectResponse> { // 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.current_device_key_signature = - browser.sign(&request.session_key, &request.next_device_key); - request.next_device_key_signature = + request.next_browser_key = fresh.public_key(); + request.current_browser_key_signature = + browser.sign(&request.session_key, &request.next_browser_key); + request.next_browser_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); + assert_ne!(copy.browser_id, first.browser_id); Ok(()) } @@ -1159,7 +1159,7 @@ fn should_refuse_a_retired_key_and_accept_the_successor() -> Result<(), RejectRe )? .unwrap(); - assert_eq!(retried.device_id, first.device_id); + assert_eq!(retried.browser_id, first.browser_id); Ok(()) } @@ -1183,9 +1183,9 @@ fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectRespons let attacker = BrowserKey::new(2); let mut request = session_request_from(identity_number, &attacker); - request.next_device_key = victim.successor().public_key(); - request.current_device_key_signature = - attacker.sign(&request.session_key, &request.next_device_key); + request.next_browser_key = victim.successor().public_key(); + request.current_browser_key_signature = + attacker.sign(&request.session_key, &request.next_browser_key); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidDeviceKey)); @@ -1220,7 +1220,7 @@ fn should_keep_the_browser_entry_across_a_rotation() -> Result<(), RejectRespons // 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_eq!(rotated.browser_id, first.browser_id); assert_ne!(rotated.session_id, first.session_id); assert!(app_prepare_delegation( @@ -1247,7 +1247,7 @@ fn should_refuse_a_successor_the_caller_cannot_prove() -> Result<(), RejectRespo 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 = + request.next_browser_key_signature = browser.sign_as_successor(&request.session_key, &browser.public_key()); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; @@ -1267,10 +1267,10 @@ fn should_refuse_a_successor_equal_to_the_key_presented() -> Result<(), RejectRe let browser = BrowserKey::new(1); let mut request = session_request_from(identity_number, &browser); // Both signatures are real: the caller holds the key it is naming as its own successor. - request.next_device_key = browser.public_key(); - request.current_device_key_signature = - browser.sign(&request.session_key, &request.next_device_key); - request.next_device_key_signature = + request.next_browser_key = browser.public_key(); + request.current_browser_key_signature = + browser.sign(&request.session_key, &request.next_browser_key); + request.next_browser_key_signature = browser.sign_as_successor(&request.session_key, &browser.public_key()); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; @@ -1304,7 +1304,7 @@ fn should_store_the_requested_idle_bound() -> Result<(), RejectResponse> { identity_number, )? .unwrap() - .session_devices + .browsers .unwrap_or_default(); assert_eq!(devices.len(), 1); @@ -1332,10 +1332,10 @@ fn should_refuse_a_successor_another_browser_holds_even_when_proven() -> Result< // 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.current_device_key_signature = - attacker.sign(&request.session_key, &request.next_device_key); - request.next_device_key_signature = + request.next_browser_key = victim.public_key(); + request.current_browser_key_signature = + attacker.sign(&request.session_key, &request.next_browser_key); + request.next_browser_key_signature = victim.sign_as_successor(&request.session_key, &attacker.public_key()); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 728009f781..d2e0e338d5 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -871,7 +871,7 @@ pub enum AppSessionError { #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub struct RevokeDeviceSessionsRequest { pub identity_number: IdentityNumber, - pub device_id: SessionDeviceId, + pub browser_id: BrowserId, } #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] From 2825613c63da88d7b2388fc128a5659fa965f6a6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:55 +0200 Subject: [PATCH 179/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts index 20711c421d..d8884e2629 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts @@ -1,5 +1,5 @@ /** - * The label a browser gives itself when it registers a session device. + * The label a browser gives itself when it registers a browser. * * Self-reported, so it is something the user reads rather than evidence about where a * session came from. From 502fff0671f228cf280bc6065e6c7403911ba77c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:56 +0200 Subject: [PATCH 180/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/session-delegation.store.test.ts | 6 +++--- src/frontend/src/lib/stores/session-delegation.store.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/stores/session-delegation.store.test.ts b/src/frontend/src/lib/stores/session-delegation.store.test.ts index 7e5b25b916..c0855d8606 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.test.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.test.ts @@ -368,7 +368,7 @@ describe("forgetIdentity", () => { it("ends this browser's sessions for the identity it forgets", async () => { const revoke = vi.fn(() => Promise.resolve({ Ok: null })); const actor = { - revoke_device_sessions: revoke, + revoke_browser_sessions: revoke, } as unknown as ActorSubclass<_SERVICE>; const { authenticationStore } = await import("$lib/stores/authentication.store"); @@ -388,7 +388,7 @@ describe("forgetIdentity", () => { expect(revoke).toHaveBeenCalledWith({ identity_number: IDENTITY_NUMBER, - device_id: 7, + browser_id: 7, }); const { appSessionsForOrigin } = await import("$lib/stores/app-session.store"); @@ -405,7 +405,7 @@ describe("forgetIdentity", () => { /// is the thing they asked it to stop doing. it("forgets locally even when the canister call fails", async () => { const actor = { - revoke_device_sessions: vi.fn(() => Promise.reject(new Error("offline"))), + revoke_browser_sessions: vi.fn(() => Promise.reject(new Error("offline"))), } as unknown as ActorSubclass<_SERVICE>; const { authenticationStore } = await import("$lib/stores/authentication.store"); diff --git a/src/frontend/src/lib/stores/session-delegation.store.ts b/src/frontend/src/lib/stores/session-delegation.store.ts index 0bc38101a2..9ab76fb320 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.ts @@ -118,9 +118,9 @@ export const forgetIdentity = async (identityNumber: bigint): Promise => { deviceId === undefined ? undefined : await actorForIdentity(identityNumber); if (deviceId !== undefined && actor !== undefined) { try { - await actor.revoke_device_sessions({ + await actor.revoke_browser_sessions({ identity_number: identityNumber, - device_id: deviceId, + browser_id: deviceId, }); } catch { // The local records go either way. Keeping them because the canister could not be From c378843d4f785587659585e543595cfc5b5f17ec Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:56 +0200 Subject: [PATCH 181/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/stores/channelHandlers/sessionDelegation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index c04509c13c..ba5d09e840 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -253,10 +253,10 @@ const createSession = async ( account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, device_name: deviceName, - current_device_key: browser.publicKey, - next_device_key: browser.nextPublicKey, - current_device_key_signature: browser.signature, - next_device_key_signature: browser.nextSignature, + current_browser_key: browser.publicKey, + next_browser_key: browser.nextPublicKey, + current_browser_key_signature: browser.signature, + next_browser_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. @@ -272,7 +272,7 @@ const createSession = async ( .catch((error: unknown) => { throw asBrowserKeyError(error); }); - await browser.accept(prepared.device_id); + await browser.accept(prepared.browser_id); return prepared; }, ); From 2c0d0472ca2f6aca9dfea39730fd95594dc3d6db Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:57 +0200 Subject: [PATCH 182/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(authenticated)/settings/+page.svelte | 8 ++-- .../components/SessionDevicesSection.svelte | 8 ++-- .../settings/sessionDevices.test.ts | 46 +++++++++---------- .../settings/sessionDevices.ts | 16 +++---- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte index 50e290a71b..d8ef7dc635 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte @@ -5,8 +5,8 @@ import { fromCanisterMcpConfig } from "$lib/utils/mcpConfig"; import CliAccessSection from "./components/CliAccessSection.svelte"; import McpTrustedServersSection from "./components/McpTrustedServersSection.svelte"; - import SessionDevicesSection from "./components/SessionDevicesSection.svelte"; - import { fromCanisterSessionDevices } from "./sessionDevices"; + import BrowsersSection from "./components/BrowsersSection.svelte"; + import { fromCanisterBrowsers } from "./sessionDevices"; import { currentDeviceId } from "$lib/stores/browser-key.store"; import type { PageProps } from "./$types"; @@ -29,7 +29,7 @@ }); const sessionDevices = $derived( - fromCanisterSessionDevices(data.identityInfo.session_devices, thisBrowser), + fromCanisterBrowsers(data.identityInfo.browsers, thisBrowser), ); @@ -48,7 +48,7 @@ identityNumber={$authenticatedStore.identityNumber} {mcpConfig} /> - diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte index 66732a39c1..04de9a2145 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte @@ -6,11 +6,11 @@ import Badge from "$lib/components/ui/Badge.svelte"; import { authenticatedStore } from "$lib/stores/authentication.store"; import { toaster } from "$lib/components/utils/toaster"; - import { signOutSessionDevice, type SessionDevice } from "../sessionDevices"; + import { signOutBrowser, type Browser } from "../sessionDevices"; interface Props { identityNumber: bigint; - devices: SessionDevice[]; + devices: Browser[]; } const { identityNumber, devices }: Props = $props(); @@ -19,10 +19,10 @@ let signedOut = $state([]); let signingOut = $state(undefined); - const handleSignOut = async (device: SessionDevice) => { + const handleSignOut = async (device: Browser) => { signingOut = device.id; try { - await signOutSessionDevice( + await signOutBrowser( $authenticatedStore.actor, identityNumber, device.id, diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts index 439b99345a..0a3010a04f 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts @@ -3,8 +3,8 @@ import "fake-indexeddb/auto"; import type { ActorSubclass } from "@icp-sdk/core/agent"; import type { _SERVICE } from "$lib/generated/internet_identity_types"; import { - fromCanisterSessionDevices, - signOutSessionDevice, + fromCanisterBrowsers, + signOutBrowser, } from "./sessionDevices"; const device = ( @@ -19,14 +19,14 @@ const device = ( last_used: lastUsedNanos, }); -describe("fromCanisterSessionDevices", () => { +describe("fromCanisterBrowsers", () => { it("reports no devices for an identity that has never created a session", () => { - expect(fromCanisterSessionDevices([])).toEqual([]); + expect(fromCanisterBrowsers([])).toEqual([]); }); it("shows the most recently used browser first", () => { expect( - fromCanisterSessionDevices([ + fromCanisterBrowsers([ [ device(1, "Firefox on Linux", BigInt(1_000_000_000)), device(2, "Chrome on macOS", BigInt(3_000_000_000)), @@ -38,7 +38,7 @@ describe("fromCanisterSessionDevices", () => { it("orders on use rather than on registration", () => { expect( - fromCanisterSessionDevices([ + fromCanisterBrowsers([ [ device( 1, @@ -54,7 +54,7 @@ describe("fromCanisterSessionDevices", () => { it("converts both timestamps to milliseconds", () => { expect( - fromCanisterSessionDevices([ + fromCanisterBrowsers([ [device(1, "Chrome", BigInt(1_500_000_000), BigInt(4_200_000_000))], ]), ).toEqual([ @@ -69,7 +69,7 @@ describe("fromCanisterSessionDevices", () => { }); it("marks the browser being read from, so two of one name can be told apart", () => { - const marked = fromCanisterSessionDevices( + const marked = fromCanisterBrowsers( [ [ device(1, "Chrome on Mac", BigInt(1_000_000_000)), @@ -87,7 +87,7 @@ describe("fromCanisterSessionDevices", () => { it("marks nothing when this browser has never created a session", () => { expect( - fromCanisterSessionDevices([ + fromCanisterBrowsers([ [device(1, "Chrome on Mac", BigInt(1_000_000_000))], ]).some((entry) => entry.isCurrent), ).toBe(false); @@ -96,7 +96,7 @@ describe("fromCanisterSessionDevices", () => { /// An id from another browser's record must not mark an entry here. it("marks nothing when the id is one this identity does not hold", () => { expect( - fromCanisterSessionDevices( + fromCanisterBrowsers( [[device(1, "Chrome on Mac", BigInt(1_000_000_000))]], 99, ).some((entry) => entry.isCurrent), @@ -104,40 +104,40 @@ describe("fromCanisterSessionDevices", () => { }); }); -describe("signOutSessionDevice", () => { +describe("signOutBrowser", () => { it("names the browser by id and nothing else", async () => { - const revoke_device_sessions = vi.fn(() => Promise.resolve({ Ok: null })); + const revoke_browser_sessions = vi.fn(() => Promise.resolve({ Ok: null })); const actor = { - revoke_device_sessions, + revoke_browser_sessions, } as unknown as ActorSubclass<_SERVICE>; - await signOutSessionDevice(actor, BigInt(10_000), 3); + await signOutBrowser(actor, BigInt(10_000), 3); - expect(revoke_device_sessions).toHaveBeenCalledWith({ + expect(revoke_browser_sessions).toHaveBeenCalledWith({ identity_number: BigInt(10_000), - device_id: 3, + browser_id: 3, }); }); it("surfaces an internal failure", async () => { const actor = { - revoke_device_sessions: () => + revoke_browser_sessions: () => Promise.resolve({ Err: { InternalCanisterError: "boom" } }), } as unknown as ActorSubclass<_SERVICE>; await expect( - signOutSessionDevice(actor, BigInt(10_000), 3), + signOutBrowser(actor, BigInt(10_000), 3), ).rejects.toThrow("boom"); }); it("surfaces an unauthorized refusal", async () => { const actor = { - revoke_device_sessions: () => + revoke_browser_sessions: () => Promise.resolve({ Err: { Unauthorized: "2vxsx-fae" } }), } as unknown as ActorSubclass<_SERVICE>; await expect( - signOutSessionDevice(actor, BigInt(10_000), 3), + signOutBrowser(actor, BigInt(10_000), 3), ).rejects.toThrow(/Not authorized/); }); @@ -157,7 +157,7 @@ describe("signOutSessionDevice", () => { accountPrincipal: "2vxsx-fae", }; const actor = { - revoke_device_sessions: vi.fn(() => Promise.resolve({ Ok: null })), + revoke_browser_sessions: vi.fn(() => Promise.resolve({ Ok: null })), } as unknown as ActorSubclass<_SERVICE>; // This browser is device 3. await idbSet( @@ -171,12 +171,12 @@ describe("signOutSessionDevice", () => { record, ); // Signing another browser out must leave this one signed in locally. - await signOutSessionDevice(actor, BigInt(10_000), 9); + await signOutBrowser(actor, BigInt(10_000), 9); expect(await appSessionsForOrigin("https://app.example.com")).toHaveLength( 1, ); - await signOutSessionDevice(actor, BigInt(10_000), 3); + await signOutBrowser(actor, BigInt(10_000), 3); expect(await appSessionsForOrigin("https://app.example.com")).toEqual([]); }); }); diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts index 914b9a94ed..9995a00948 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts @@ -3,11 +3,11 @@ import { purgeAppSessions } from "$lib/stores/app-session.store"; import { currentDeviceId } from "$lib/stores/browser-key.store"; import type { _SERVICE, - SessionDeviceInfo, + BrowserInfo, } from "$lib/generated/internet_identity_types"; import { nanosToMillis } from "$lib/utils/time"; -export interface SessionDevice { +export interface Browser { id: number; name: string; createdAtMillis: number; @@ -16,10 +16,10 @@ export interface SessionDevice { isCurrent: boolean; } -export const fromCanisterSessionDevices = ( - devices: [] | [SessionDeviceInfo[]], +export const fromCanisterBrowsers = ( + devices: [] | [BrowserInfo[]], currentDeviceId?: number, -): SessionDevice[] => +): Browser[] => (devices[0] ?? []) .map((device) => ({ id: device.id, @@ -45,14 +45,14 @@ export const fromCanisterSessionDevices = ( * otherwise pass `false` for the user's own browser and leave exactly those chains * behind. */ -export const signOutSessionDevice = async ( +export const signOutBrowser = async ( actor: ActorSubclass<_SERVICE>, identityNumber: bigint, deviceId: number, ): Promise => { - const result = await actor.revoke_device_sessions({ + const result = await actor.revoke_browser_sessions({ identity_number: identityNumber, - device_id: deviceId, + browser_id: deviceId, }); if ("Err" in result) { throw new Error( From 099a6e5a7e41a6e3c0d3e1016a4a7c85d72ac89c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:09:57 +0200 Subject: [PATCH 183/298] refactor(be): a browser, not a session device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Device` is taken — it is the passkey credential that authenticates the anchor, and `DeviceData` is in the candid interface. The word this wants is the one its own doc comment already used, and the one the frontend uses throughout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.test.ts | 2 +- src/internet_identity/tests/integration/sessions.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 15c949932b..d6d3bc787e 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -117,7 +117,7 @@ const runCeremony = async ( expiration, session_id: BigInt(1_000), account_principal: Principal.fromText("2vxsx-fae"), - device_id: BigInt(1), + browser_id: BigInt(1), }, }) ), diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 4a291ed5ff..dcdc02d251 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -817,13 +817,13 @@ fn should_report_a_revoked_session_as_gone() -> Result<(), RejectResponse> { let identity_number = flows::register_anchor(&env, canister_id); let (prepared, session_principal) = create_session(&env, canister_id, identity_number); - revoke_device_sessions( + revoke_browser_sessions( &env, canister_id, principal_1(), RevokeDeviceSessionsRequest { identity_number, - device_id: prepared.device_id, + browser_id: prepared.browser_id, }, )? .unwrap(); From c6346b68c2be8698ad2350304865a4b392ed8d40 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:15:55 +0200 Subject: [PATCH 184/298] refactor(be): a dropped browser's sessions go in the write that dropped it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_session` takes the anchor and the browsers the registry gave up making room for this one, and ends their sessions in the same write. It reads what the identity holds everywhere rather than just at this origin, because a browser that was given up may hold sessions anywhere. Taking the anchor also means the browser registration and the prune that produced those ids ride on this write rather than being committed before it — so a sign-in that is refused no longer leaves a browser registered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 62 ++++- src/internet_identity/src/storage/account.rs | 4 +- src/internet_identity/src/storage/anchor.rs | 9 +- .../src/storage/anchor/tests.rs | 75 ++---- src/internet_identity/src/storage/storable.rs | 4 +- .../src/storage/storable/anchor.rs | 4 +- .../src/storage/storable/session_record.rs | 2 +- src/internet_identity/src/storage/tests.rs | 231 ++++++++++++------ 8 files changed, 240 insertions(+), 151 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 68e4adc6ef..bc7ff983fc 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2701,10 +2701,11 @@ impl Storage { /// whatever this browser already held at this account. pub fn create_session( &mut self, + anchor: &mut Anchor, params: CreateSessionParams, ) -> Result<(SessionRecordKey, SessionRecord), StorageError> { + let anchor_number = anchor.anchor_number(); let CreateSessionParams { - anchor_number, origin, account_number, browser_id, @@ -2712,6 +2713,7 @@ impl Storage { max_idle_ns, read_only, now_ns, + dropped_browsers, } = params; // Defaulted and clamped here rather than at the caller, so every path that @@ -2732,7 +2734,6 @@ impl Storage { // account reference list, created by this write where the origin is new, the // session itself, and the dead sessions pruned off every reference beside it. // Everything that can refuse does so before any of it is stored. - let mut anchor = self.read(anchor_number)?; let stored = self .lookup_application_number_with_origin(&origin) .and_then(|application_number| { @@ -2746,8 +2747,33 @@ impl Storage { name: origin, }); } - let (mut account_references, config) = - self.account_state_for_origin(anchor_number, &origin); + + // The whole of what the identity holds, not just this origin: a browser the + // registry gave up to make room for this one may hold sessions anywhere, and those + // have to go in the same write as the browser that held them. + let mut state = self.account_state(anchor_number); + if !state.contains_key(&origin) { + let held = self.account_state_for_origin(anchor_number, &origin); + state.insert(origin.clone(), Some(held)); + } + if !dropped_browsers.is_empty() { + for held in state.values_mut() { + let Some((account_references, _)) = held else { + continue; + }; + for write in account_references.iter_mut() { + write + .account_reference + .sessions + .retain(|session| !dropped_browsers.contains(&session.browser_id)); + } + } + } + + let (account_references, _) = state + .get_mut(&origin) + .and_then(Option::as_mut) + .expect("the origin was just put there"); let position = account_references .iter() @@ -2802,10 +2828,10 @@ impl Storage { // The list is the whole of it: the index entries for the session created here and // for the ones pruned above, and the identity's session count, all follow from it. - self.write_account_state( - &mut anchor, - BTreeMap::from([(origin.clone(), Some((account_references, config)))]), - )?; + // One write for all of it: the session created here, the dead ones pruned above, + // the sessions of every browser the registry gave up, the account reference list + // this origin gets if it did not have one, and the identity's session count. + self.write_account_state(anchor, state)?; let key = SessionRecordKey { anchor_number, @@ -2818,10 +2844,24 @@ impl Storage { // Called by the sign-in ceremony, which lands two PRs up. #[allow(dead_code)] + /// [`Self::create_session`] for a test that has an anchor number rather than the + /// anchor. Production hands the anchor in, because the caller has just registered a + /// browser on it and that registration rides on the same write. + #[cfg(test)] + fn create_session_for_testing( + &mut self, + anchor_number: AnchorNumber, + params: CreateSessionParams, + ) -> Result<(SessionRecordKey, SessionRecord), StorageError> { + let mut anchor = self.read(anchor_number)?; + self.create_session(&mut anchor, params) + } + /// The session `key` names, or `None` where the identity holds no such session. /// /// A key whose session was replaced reads as `None` rather than as its successor: /// the successor was allocated an id of its own. + #[allow(dead_code)] // Used by the sign-in ceremony, which lands two PRs up. pub fn read_session(&self, key: &SessionRecordKey) -> Option { let application_number = self.lookup_application_number_with_origin(&key.origin)?; @@ -3554,7 +3594,6 @@ impl Storage { // Constructed by the sign-in ceremony, which lands two PRs up. #[allow(dead_code)] pub struct CreateSessionParams { - pub anchor_number: AnchorNumber, pub origin: FrontendHostname, pub account_number: Option, pub browser_id: BrowserId, @@ -3562,6 +3601,11 @@ pub struct CreateSessionParams { pub max_idle_ns: Option, pub read_only: bool, pub now_ns: Timestamp, + /// Browsers the registry gave up to make room for this one, whose sessions go with + /// them. Handed in rather than swept afterwards: dropping a browser and ending its + /// sessions is one change, and doing it in two writes means an `Err` from the second + /// leaves a browser gone with its sessions still live. + pub dropped_browsers: Vec, } /// How far the sweep has got: which list, and how many of that list's references are diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 0833b81683..13f30e8536 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -7,8 +7,8 @@ use crate::{ use ic_cdk::trap; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ - AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, FrontendHostname, - BrowserId, SessionId, Timestamp, UserKey, + AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, BrowserId, + FrontendHostname, SessionId, Timestamp, UserKey, }; use serde::{Deserialize, Serialize}; diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index f7a4427a66..933f2cf551 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -2,11 +2,11 @@ use crate::email_inbound::MAX_VERIFIED_EMAILS_PER_ANCHOR; use crate::ii_domain::IIDomain; use crate::openid::{OpenIdCredential, OpenIdCredentialKey}; use crate::storage::storable::anchor::StorableAnchor; +use crate::storage::storable::browser::StorableBrowser; use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCredential; use crate::storage::storable::fixed_anchor::StorableFixedAnchor; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; -use crate::storage::storable::browser::StorableBrowser; use crate::storage::storable::special_device_migration::SpecialDeviceMigration; use crate::storage::storable::verified_email::StorableVerifiedEmail; use crate::{IC0_APP_ORIGIN, ID_AI_ORIGIN, INTERNETCOMPUTER_ORG_ORIGIN}; @@ -271,12 +271,7 @@ impl From for (StorableFixedAnchor, StorableAnchor) { .collect(), ); let next_browser_id = Some(next_browser_id); - let browsers = Some( - browsers - .into_iter() - .map(StorableBrowser::from) - .collect(), - ); + let browsers = Some(browsers.into_iter().map(StorableBrowser::from).collect()); let (mut passkey_credentials, mut recovery_keys, mut recovery_devices) = (vec![], vec![], vec![]); diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index af1e959de8..713b40358c 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -1312,10 +1312,7 @@ mod browser_tests { assert_eq!(id, 0); assert_eq!(anchor.browsers().len(), 1); assert_eq!(anchor.browsers()[0].name, "Chrome on MacBook"); - assert_eq!( - anchor.browsers()[0].current_browser_key, - browser_key(1) - ); + assert_eq!(anchor.browsers()[0].current_browser_key, browser_key(1)); assert_eq!(anchor.browsers()[0].created_at, 1_000); } @@ -1463,14 +1460,8 @@ mod browser_tests { assert_eq!(anchor.browsers().len(), MAX_BROWSERS); assert!(anchor.browsers().iter().any(|d| d.id == newest)); - assert!(!anchor - .browsers() - .iter() - .any(|d| d.name == "device-0")); - assert!(anchor - .browsers() - .iter() - .any(|d| d.name == "device-1")); + assert!(!anchor.browsers().iter().any(|d| d.name == "device-0")); + assert!(anchor.browsers().iter().any(|d| d.name == "device-1")); assert_eq!(dropped, vec![0]); } @@ -1509,10 +1500,7 @@ mod browser_tests { .unwrap(); assert_eq!(dropped, vec![1]); - assert!(anchor - .browsers() - .iter() - .any(|device| device.id == first)); + assert!(anchor.browsers().iter().any(|device| device.id == first)); } #[test] @@ -1543,10 +1531,7 @@ mod browser_tests { .unwrap(); } - assert!(anchor - .browsers() - .iter() - .any(|device| device.id == kept)); + assert!(anchor.browsers().iter().any(|device| device.id == kept)); } #[test] @@ -1597,10 +1582,7 @@ mod browser_tests { assert_eq!(again, id); assert_eq!(anchor.browsers().len(), 1); - assert_eq!( - anchor.browsers()[0].current_browser_key, - successor_key(1) - ); + assert_eq!(anchor.browsers()[0].current_browser_key, successor_key(1)); assert_eq!(anchor.browsers()[0].next_browser_key, browser_key(2)); } @@ -1664,10 +1646,7 @@ mod browser_tests { assert_eq!(retried, Err(BrowserError::StaleDeviceKey)); assert_eq!(anchor.browsers().len(), 1); - assert_eq!( - anchor.browsers()[0].next_browser_key, - successor_key(1) - ); + assert_eq!(anchor.browsers()[0].next_browser_key, successor_key(1)); } /// The other half of the same rule, from the browser's side: promoting the successor @@ -1695,14 +1674,8 @@ mod browser_tests { assert_eq!(again, id); assert_eq!(anchor.browsers().len(), 1); - assert_eq!( - anchor.browsers()[0].current_browser_key, - successor_key(1) - ); - assert_eq!( - anchor.browsers()[0].next_browser_key, - successor_key(2) - ); + assert_eq!(anchor.browsers()[0].current_browser_key, successor_key(1)); + assert_eq!(anchor.browsers()[0].next_browser_key, successor_key(2)); } #[test] @@ -1741,12 +1714,8 @@ mod browser_tests { ) .unwrap(); - let stealing_the_key = anchor.resolve_browser( - browser_key(2), - browser_key(1), - "Firefox".to_string(), - 2_000, - ); + let stealing_the_key = + anchor.resolve_browser(browser_key(2), browser_key(1), "Firefox".to_string(), 2_000); let stealing_the_successor = anchor.resolve_browser( browser_key(2), successor_key(1), @@ -1754,10 +1723,7 @@ mod browser_tests { 2_000, ); - assert_eq!( - stealing_the_key, - Err(BrowserError::SuccessorAlreadyInUse) - ); + assert_eq!(stealing_the_key, Err(BrowserError::SuccessorAlreadyInUse)); assert_eq!( stealing_the_successor, Err(BrowserError::SuccessorAlreadyInUse) @@ -1800,12 +1766,7 @@ mod browser_tests { // announced the key it is presenting would keep it alive for as long as it kept // asking, and so would whoever leaked it. assert_eq!( - anchor.resolve_browser( - browser_key(1), - browser_key(1), - "Chrome".to_string(), - 1_000 - ), + anchor.resolve_browser(browser_key(1), browser_key(1), "Chrome".to_string(), 1_000), Err(BrowserError::SuccessorMatchesCurrent) ); assert!(anchor.browsers().is_empty()); @@ -1833,13 +1794,7 @@ mod browser_tests { Err(BrowserError::SuccessorMatchesCurrent) ); // The entry is left as it was, still awaiting a successor it has not seen. - assert_eq!( - anchor.browsers()[0].current_browser_key, - browser_key(1) - ); - assert_eq!( - anchor.browsers()[0].next_browser_key, - successor_key(1) - ); + assert_eq!(anchor.browsers()[0].current_browser_key, browser_key(1)); + assert_eq!(anchor.browsers()[0].next_browser_key, successor_key(1)); } } diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index ba0c6f8f9c..042db84513 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -10,6 +10,8 @@ pub mod anchor_number; pub mod anchor_number_list; pub mod application; pub mod application_number; +pub mod browser; +pub mod browser_id; pub mod credential_id; pub mod discrepancy_counter; pub mod duration; @@ -25,8 +27,6 @@ pub mod openid_credential_key; pub mod openid_jwks; pub mod passkey_credential; pub mod recovery_key; -pub mod browser; -pub mod browser_id; pub mod session_handle; pub mod session_id; pub mod session_record; diff --git a/src/internet_identity/src/storage/storable/anchor.rs b/src/internet_identity/src/storage/storable/anchor.rs index 6e58582594..a0ba78f275 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -1,9 +1,9 @@ +use crate::storage::storable::browser::StorableBrowser; +use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCredential; use crate::storage::storable::openid_credential::StorableOpenIdCredential; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; use crate::storage::storable::recovery_key::StorableRecoveryKey; -use crate::storage::storable::browser::StorableBrowser; -use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::verified_email::StorableVerifiedEmail; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index a964e2a44c..9039847a94 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -1,6 +1,6 @@ use crate::storage::account::SessionRecord; -use crate::storage::storable::duration::StorableDuration; use crate::storage::storable::browser_id::StorableBrowserId; +use crate::storage::storable::duration::StorableDuration; use crate::storage::storable::session_id::StorableSessionId; use crate::storage::storable::timestamp::StorableTimestamp; use ic_stable_structures::storable::Bound; diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 9da5998858..778f2a4ba5 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5364,9 +5364,61 @@ mod session_creation_tests { (storage, anchor_number) } - fn params(anchor_number: AnchorNumber, browser_id: u32, now: u64) -> CreateSessionParams { + /// A browser the registry gave up takes its sessions with it, wherever they were, in + /// the write that made room for the browser replacing it. + /// + /// It used to be a loop at the caller: register the browser, write the anchor, then one + /// `revoke_browser_sessions` per browser dropped. That is a browser gone in one write + /// and its sessions ended in others — and on the IC an `Err` from a later one commits + /// the earlier ones, so a browser could end up gone with its sessions still live. + #[test] + fn a_dropped_browser_takes_its_sessions_with_it() { + let (mut storage, anchor_number) = storage_with_anchor(); + let elsewhere = "https://elsewhere.example".to_string(); + + // The browser that is about to be given up, holding a session at each of two + // origins, so this also shows the sweep is not limited to the one being written. + for origin in [ORIGIN.to_string(), elsewhere.clone()] { + storage + .create_session_for_testing( + anchor_number, + CreateSessionParams { + origin, + ..params(7, 1_000) + }, + ) + .unwrap(); + } + assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); + + storage + .create_session_for_testing( + anchor_number, + CreateSessionParams { + dropped_browsers: vec![7], + ..params(8, 2_000) + }, + ) + .unwrap(); + + let held: Vec = storage + .account_state(anchor_number) + .into_values() + .flatten() + .flat_map(|(account_references, _)| account_references) + .flat_map(|write| write.account_reference.sessions) + .map(|session| session.browser_id) + .collect(); + assert_eq!(held, vec![8], "only the browser that replaced it is left"); + assert_eq!( + storage.read(anchor_number).unwrap().session_count, + 1, + "and the count followed in the same write" + ); + } + + fn params(browser_id: u32, now: u64) -> CreateSessionParams { CreateSessionParams { - anchor_number, origin: ORIGIN.to_string(), account_number: None, browser_id, @@ -5374,6 +5426,7 @@ mod session_creation_tests { max_idle_ns: None, read_only: false, now_ns: now, + dropped_browsers: vec![], } } @@ -5401,12 +5454,12 @@ mod session_creation_tests { fn creating_a_session_indexes_the_account_it_belongs_to() { let (mut storage, anchor_number) = storage_with_anchor(); storage - .create_session(params(anchor_number, 1, 1_000)) + .create_session_for_testing(anchor_number, params(1, 1_000)) .unwrap(); forget_account_principals(&mut storage); storage - .create_session(params(anchor_number, 2, 2_000)) + .create_session_for_testing(anchor_number, params(2, 2_000)) .unwrap(); let application_number = storage @@ -5446,11 +5499,14 @@ mod session_creation_tests { let asked = 20 * MINUTE_NS; let session = storage - .create_session(CreateSessionParams { - max_idle_ns: Some(asked), - valid_till_ns: DAY_NS, - ..params(anchor_number, 1, 0) - }) + .create_session_for_testing( + anchor_number, + CreateSessionParams { + max_idle_ns: Some(asked), + valid_till_ns: DAY_NS, + ..params(1, 0) + }, + ) .unwrap() .1; @@ -5462,11 +5518,14 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session(CreateSessionParams { - max_idle_ns: Some(MINUTE_NS), - valid_till_ns: DAY_NS, - ..params(anchor_number, 1, 0) - }) + .create_session_for_testing( + anchor_number, + CreateSessionParams { + max_idle_ns: Some(MINUTE_NS), + valid_till_ns: DAY_NS, + ..params(1, 0) + }, + ) .unwrap() .1; @@ -5480,11 +5539,14 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session(CreateSessionParams { - max_idle_ns: Some(400 * DAY_NS), - valid_till_ns: DAY_NS, - ..params(anchor_number, 1, 0) - }) + .create_session_for_testing( + anchor_number, + CreateSessionParams { + max_idle_ns: Some(400 * DAY_NS), + valid_till_ns: DAY_NS, + ..params(1, 0) + }, + ) .unwrap() .1; @@ -5498,10 +5560,13 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session(CreateSessionParams { - valid_till_ns: 30 * DAY_NS, - ..params(anchor_number, 1, 0) - }) + .create_session_for_testing( + anchor_number, + CreateSessionParams { + valid_till_ns: 30 * DAY_NS, + ..params(1, 0) + }, + ) .unwrap() .1; @@ -5518,11 +5583,14 @@ mod session_creation_tests { // Under the floor the range inverts, and clamping in one call would trap. let session = storage - .create_session(CreateSessionParams { - valid_till_ns: MINUTE_NS, - max_idle_ns: Some(30 * MINUTE_NS), - ..params(anchor_number, 1, 0) - }) + .create_session_for_testing( + anchor_number, + CreateSessionParams { + valid_till_ns: MINUTE_NS, + max_idle_ns: Some(30 * MINUTE_NS), + ..params(1, 0) + }, + ) .unwrap() .1; @@ -5534,7 +5602,7 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session(params(anchor_number, 1, 1_000)) + .create_session_for_testing(anchor_number, params(1, 1_000)) .unwrap() .1; @@ -5551,12 +5619,12 @@ mod session_creation_tests { 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)) + .create_session_for_testing(anchor_number, params(1, 1_000)) .unwrap() .1; let again = storage - .create_session(params(anchor_number, 1, 5_000)) + .create_session_for_testing(anchor_number, params(1, 5_000)) .unwrap() .1; @@ -5568,11 +5636,11 @@ mod session_creation_tests { 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)) + .create_session_for_testing(anchor_number, params(1, 1_000)) .unwrap(); storage - .create_session(params(anchor_number, 2, 1_000)) + .create_session_for_testing(anchor_number, params(2, 1_000)) .unwrap(); assert_eq!(sessions_of(&storage, anchor_number).len(), 2); @@ -5583,12 +5651,12 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); for browser_id in 0..3 { storage - .create_session(params(anchor_number, browser_id, 1_000)) + .create_session_for_testing(anchor_number, params(browser_id, 1_000)) .unwrap(); } storage - .create_session(params(anchor_number, 9, 20_000)) + .create_session_for_testing(anchor_number, params(9, 20_000)) .unwrap(); let sessions = sessions_of(&storage, anchor_number); @@ -5602,9 +5670,11 @@ mod session_creation_tests { fn one_reference_holds_one_session_per_browser() { let (mut storage, anchor_number) = storage_with_anchor(); for browser_id in 0..12u32 { - let mut p = params(anchor_number, browser_id, 1_000); + let mut p = params(browser_id, 1_000); p.valid_till_ns = 1_000_000; - storage.create_session(p).unwrap(); + storage + .create_session_for_testing(anchor_number, p) + .unwrap(); } let sessions = sessions_of(&storage, anchor_number); @@ -5619,7 +5689,7 @@ mod session_creation_tests { fn a_session_handle_resolves_through_the_account_principal_index() { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session(params(anchor_number, 7, 1_000)) + .create_session_for_testing(anchor_number, params(7, 1_000)) .unwrap() .1; let application_number = storage @@ -5645,10 +5715,12 @@ mod session_creation_tests { let named = storage .create_account(anchor_number, ORIGIN.to_string(), "named".to_string()) .unwrap(); - let mut p = params(anchor_number, 1, 1_000); + let mut p = params(1, 1_000); p.account_number = named.account_number; - storage.create_session(p).unwrap(); + storage + .create_session_for_testing(anchor_number, p) + .unwrap(); assert_eq!(sessions_of(&storage, anchor_number).len(), 0); let application_number = storage @@ -5665,10 +5737,10 @@ mod session_creation_tests { #[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); + let mut p = params(1, 1_000); p.account_number = Some(4_242); - let result = storage.create_session(p); + let result = storage.create_session_for_testing(anchor_number, p); assert!(result.is_err()); } @@ -5682,7 +5754,6 @@ mod session_creation_tests { fn a_session_replaced_in_the_same_round_does_not_inherit_its_identity() { let (mut storage, anchor_number) = storage_with_anchor(); let same_round = |browser_id| CreateSessionParams { - anchor_number, origin: ORIGIN.to_string(), account_number: None, browser_id, @@ -5690,11 +5761,21 @@ mod session_creation_tests { max_idle_ns: None, read_only: false, now_ns: 1_000, + dropped_browsers: vec![], }; - let first = storage.create_session(same_round(1)).unwrap().1; - let replacement = storage.create_session(same_round(1)).unwrap().1; - let sibling = storage.create_session(same_round(2)).unwrap().1; + let first = storage + .create_session_for_testing(anchor_number, same_round(1)) + .unwrap() + .1; + let replacement = storage + .create_session_for_testing(anchor_number, same_round(1)) + .unwrap() + .1; + let sibling = storage + .create_session_for_testing(anchor_number, same_round(2)) + .unwrap() + .1; assert_eq!(first.created_at_ns, replacement.created_at_ns); assert_eq!(first.browser_id, replacement.browser_id); @@ -5708,7 +5789,6 @@ mod session_creation_tests { 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, browser_id: 1, @@ -5716,13 +5796,22 @@ mod session_creation_tests { max_idle_ns: None, read_only, now_ns: 1_000, + dropped_browsers: vec![], }; - let first = storage.create_session(params(false)).unwrap().1; - storage.create_session(params(false)).unwrap(); + let first = storage + .create_session_for_testing(anchor_number, params(false)) + .unwrap() + .1; + storage + .create_session_for_testing(anchor_number, params(false)) + .unwrap(); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); - let replaced = storage.create_session(params(true)).unwrap().1; + let replaced = storage + .create_session_for_testing(anchor_number, params(true)) + .unwrap() + .1; assert_ne!(replaced.read_only, first.read_only); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); } @@ -5817,16 +5906,19 @@ mod session_consent_change_tests { now: u64, ) -> u64 { storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 1, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only, - now_ns: now, - }) + CreateSessionParams { + origin: ORIGIN.to_string(), + account_number: None, + browser_id: 1, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only, + now_ns: now, + dropped_browsers: vec![], + }, + ) .unwrap() .1 .created_at_ns @@ -5882,16 +5974,19 @@ mod session_consent_change_tests { fn a_consent_change_leaves_another_browser_alone() { let (mut storage, anchor_number) = storage_with_anchor(); storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 2, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - }) + CreateSessionParams { + origin: ORIGIN.to_string(), + account_number: None, + browser_id: 2, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + dropped_browsers: vec![], + }, + ) .unwrap(); create(&mut storage, anchor_number, false, 1_000); From b3991dbbe012ec391a7c41e8c6b42f3685ab651d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:32:06 +0200 Subject: [PATCH 185/298] fix(be): bring the tests onto the current create_session shape The anchor goes in rather than its number, and the browsers the registry gave up go with it, so a test that built the old params no longer compiles. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 84 +++++++++++-------- .../tests/integration/sessions.rs | 4 +- 2 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 6277049904..3570cfe796 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6194,16 +6194,19 @@ mod session_refresh_stamp_tests { let anchor_number = anchor.anchor_number(); storage.write(anchor).unwrap(); let (key, _) = storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 1, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - }) + CreateSessionParams { + origin: ORIGIN.to_string(), + account_number: None, + browser_id: 1, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + dropped_browsers: vec![], + }, + ) .unwrap(); (storage, anchor_number, key) } @@ -6256,16 +6259,19 @@ mod session_refresh_stamp_tests { let (mut storage, anchor_number, key) = storage_with_session(); let (_, dead) = storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 9, - valid_till_ns: 1_500, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - }) + CreateSessionParams { + origin: ORIGIN.to_string(), + account_number: None, + browser_id: 9, + valid_till_ns: 1_500, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + dropped_browsers: vec![], + }, + ) .unwrap(); let dead_principal = storage .lookup_session_with_principal_memory @@ -6315,16 +6321,19 @@ mod session_refresh_stamp_tests { fn stamping_leaves_a_second_device_alone() { let (mut storage, anchor_number, key) = storage_with_session(); storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 2, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - }) + CreateSessionParams { + origin: ORIGIN.to_string(), + account_number: None, + browser_id: 2, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + dropped_browsers: vec![], + }, + ) .unwrap(); let now = 2_000; @@ -6354,16 +6363,19 @@ mod session_refresh_stamp_tests { .unwrap(); storage.write(anchor).unwrap(); let (key, _) = storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - browser_id, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - }) + CreateSessionParams { + origin: ORIGIN.to_string(), + account_number: None, + browser_id, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + dropped_browsers: vec![], + }, + ) .unwrap(); (storage, anchor_number, key, browser_id) } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 510aaf7c52..2df65882d9 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -10,8 +10,8 @@ use canister_tests::framework::{ }; use internet_identity_interface::internet_identity::types::{ AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, - GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, - PrepareAccountSessionResponse, BrowserInfo, + BrowserInfo, GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, + PrepareAccountSessionResponse, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; From f4e7c621318f0bca974455da08f08a43651490c9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:32:58 +0200 Subject: [PATCH 186/298] fix(be): bring the tests onto the current create_session shape The anchor goes in rather than its number, and the browsers the registry gave up go with it, so a test that built the old params no longer compiles. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index fbec2c938b..4328ad5a07 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6448,16 +6448,19 @@ mod session_removal_tests { .iter() .map(|browser_id| { storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - browser_id: *browser_id, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - }) + CreateSessionParams { + origin: ORIGIN.to_string(), + account_number: None, + browser_id: *browser_id, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1_000, + dropped_browsers: vec![], + }, + ) .unwrap() .0 }) From 7b89283c7f2707bd0f99dc443d6ad0aaab5ac0fd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:33:51 +0200 Subject: [PATCH 187/298] fix(be): bring the tests onto the current create_session shape The anchor goes in rather than its number, and the browsers the registry gave up go with it, so a test that built the old params no longer compiles. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 63 +++++++++++-------- .../tests/integration/sessions.rs | 4 +- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 0f571af7c9..08f62c1fcc 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3783,16 +3783,19 @@ mod tracked_default_eviction_tests { // list and therefore the first thing eviction gives up. let doomed = origin_of(0); let (key, _) = storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: doomed.clone(), - account_number: None, - browser_id: 1, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1, - }) + CreateSessionParams { + origin: doomed.clone(), + account_number: None, + browser_id: 1, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: 1, + dropped_browsers: vec![], + }, + ) .expect("signing in at a fresh origin"); let session_principals: Vec<_> = storage .lookup_session_with_principal_memory @@ -6611,16 +6614,19 @@ mod session_revocation_tests { now: u64, ) { storage - .create_session(CreateSessionParams { + .create_session_for_testing( anchor_number, - origin: origin.to_string(), - account_number: None, - browser_id, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: now, - }) + CreateSessionParams { + origin: origin.to_string(), + account_number: None, + browser_id, + valid_till_ns: u64::MAX, + max_idle_ns: None, + read_only: false, + now_ns: now, + dropped_browsers: vec![], + }, + ) .unwrap(); } @@ -6781,16 +6787,19 @@ mod write_path_property_tests { } 4 => { let account_number = pick_account(storage, anchor_number, &origin, rng); - let _ = storage.create_session(CreateSessionParams { + let _ = storage.create_session_for_testing( anchor_number, - origin, - account_number, - browser_id: rng.below(4) as u32, - valid_till_ns: now + 1 + rng.below(20_000), - max_idle_ns: None, - read_only: false, - now_ns: now, - }); + CreateSessionParams { + origin, + account_number, + browser_id: rng.below(4) as u32, + valid_till_ns: now + 1 + rng.below(20_000), + max_idle_ns: None, + read_only: false, + now_ns: now, + dropped_browsers: vec![], + }, + ); } 5 => { if let Some(key) = pick_session(storage, anchor_number, &origin, rng) { diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 5e14d047ff..012f4fdb2d 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -11,8 +11,8 @@ use canister_tests::framework::{ }; use internet_identity_interface::internet_identity::types::{ AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, - GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, - PrepareAccountSessionResponse, RevokeDeviceSessionsRequest, BrowserInfo, + BrowserInfo, GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, + PrepareAccountSessionResponse, RevokeDeviceSessionsRequest, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; From 84bba71cca3302b2a1612b060c3d5e1b6f7f4e11 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 13:53:14 +0200 Subject: [PATCH 188/298] test(be): restore two tests a merge silently deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conflict resolution on this branch swallowed the `reclaim_sessions` call and these two tests with it. The call broke the cap tests and announced itself; the tests just stopped existing, which nothing reports. `an_over_counting_anchor_is_corrected_rather_than_denied` is the one that caught the session count being accumulated rather than counted — the design said the distinction had collapsed, and it had not. Both falsify against the behaviour they describe. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 8c7667d46a..17e367d895 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5690,6 +5690,51 @@ mod session_creation_tests { /// The per-identity cap reclaims to a watermark rather than blocking, taking expired /// records first and then the least recently used. + #[test] + fn an_over_counting_anchor_is_corrected_rather_than_denied() { + let (mut storage, anchor_number) = storage_with_anchor(); + storage + .create_session_for_testing(anchor_number, params(1, 1_000)) + .unwrap(); + + // Nothing observes a session expiring, so the count drifts up. The cap must be + // enforced against what the lists 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_for_testing(anchor_number, params(2, 2_000)) + .unwrap(); + + assert_eq!(sessions_of(&storage, anchor_number).len(), 2); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); + } + + #[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(device_id, 600_000 + device_id as u64); + params.valid_till_ns = 100_000_000; + storage + .create_session_for_testing(anchor_number, 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 lists after {device_id} sign-ins" + ); + } + } + #[test] fn the_session_cap_reclaims_to_the_watermark() { let (mut storage, anchor_number) = storage_with_anchor(); From 7445de5f24da7beb149be327be43191f02bf786f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 14:47:22 +0200 Subject: [PATCH 189/298] refactor(fe): the settings files follow the type they are about The rename went through file contents but not filenames, so the settings module was still called `sessionDevices` for a type called `Browser`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../manage/(authenticated)/settings/+page.svelte | 6 +++--- .../settings/{sessionDevices.test.ts => browsers.test.ts} | 2 +- .../settings/{sessionDevices.ts => browsers.ts} | 0 ...{SessionDevicesSection.svelte => BrowsersSection.svelte} | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/{sessionDevices.test.ts => browsers.test.ts} (99%) rename src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/{sessionDevices.ts => browsers.ts} (100%) rename src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/{SessionDevicesSection.svelte => BrowsersSection.svelte} (98%) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte index d8ef7dc635..504d52ebe1 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte @@ -6,7 +6,7 @@ import CliAccessSection from "./components/CliAccessSection.svelte"; import McpTrustedServersSection from "./components/McpTrustedServersSection.svelte"; import BrowsersSection from "./components/BrowsersSection.svelte"; - import { fromCanisterBrowsers } from "./sessionDevices"; + import { fromCanisterBrowsers } from "./browsers"; import { currentDeviceId } from "$lib/stores/browser-key.store"; import type { PageProps } from "./$types"; @@ -28,7 +28,7 @@ ); }); - const sessionDevices = $derived( + const browsers = $derived( fromCanisterBrowsers(data.identityInfo.browsers, thisBrowser), ); @@ -50,6 +50,6 @@ /> diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts similarity index 99% rename from src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts rename to src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts index 0a3010a04f..c8aa64fd6f 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts @@ -5,7 +5,7 @@ import type { _SERVICE } from "$lib/generated/internet_identity_types"; import { fromCanisterBrowsers, signOutBrowser, -} from "./sessionDevices"; +} from "./browsers"; const device = ( id: number, diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts similarity index 100% rename from src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/sessionDevices.ts rename to src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte similarity index 98% rename from src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte rename to src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte index 04de9a2145..3f839112e6 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/SessionDevicesSection.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte @@ -6,7 +6,7 @@ import Badge from "$lib/components/ui/Badge.svelte"; import { authenticatedStore } from "$lib/stores/authentication.store"; import { toaster } from "$lib/components/utils/toaster"; - import { signOutBrowser, type Browser } from "../sessionDevices"; + import { signOutBrowser, type Browser } from "../browsers"; interface Props { identityNumber: bigint; From d60ae4b4fe1dddd6dd12be0bfef8b2e1b33dc46f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:14:38 +0200 Subject: [PATCH 190/298] refactor(be): a browser's stale key says browser `BrowserError::StaleDeviceKey` and the locals around it were the last of the device vocabulary in the browser registry. `Device` stays what it is, the passkey credential. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/anchor.rs | 24 +++++++++---------- .../src/storage/anchor/tests.rs | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 9059fb1148..1e4579a9fe 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -73,7 +73,7 @@ pub enum BrowserError { /// holding both keys. Registering it as a new browser instead would turn every dropped /// response into a second list for one browser, and accepting it would leave a leaked /// key useful for longer than the one sign-in rotation allows it. - StaleDeviceKey, + StaleBrowserKey, } /// A browser this anchor has signed in from. The name is self-reported by the client. @@ -734,7 +734,7 @@ impl Anchor { /// An entry is reached only by the successor it announced. Presenting it promotes that /// successor, retires the key it replaces, and leaves the entry awaiting /// `next_browser_key` — what the browser presents at its next sign-in. A key some entry - /// has already retired is refused with [`BrowserError::StaleDeviceKey`] rather + /// has already retired is refused with [`BrowserError::StaleBrowserKey`] rather /// than accepted or registered afresh, so a key is good for exactly one sign-in and a /// browser that lost a response is told to promote its own successor instead of /// becoming a second list. A key no entry holds at all registers a new browser. @@ -758,11 +758,11 @@ impl Anchor { let entry_awaiting = |candidate: &PublicKey| { self.browsers .iter() - .position(|device| device.next_browser_key == *candidate) + .position(|browser| browser.next_browser_key == *candidate) }; let entry_holding = |candidate: &PublicKey| { - self.browsers.iter().position(|device| { - device.current_browser_key == *candidate || device.next_browser_key == *candidate + self.browsers.iter().position(|browser| { + browser.current_browser_key == *candidate || browser.next_browser_key == *candidate }) }; @@ -777,15 +777,15 @@ impl Anchor { } if let Some(index) = advances { - let device = &mut self.browsers[index]; - device.current_browser_key = current_browser_key; - device.next_browser_key = next_browser_key; - device.last_used = now; - return Ok((device.id, vec![])); + let browser = &mut self.browsers[index]; + browser.current_browser_key = current_browser_key; + browser.next_browser_key = next_browser_key; + browser.last_used = now; + return Ok((browser.id, vec![])); } if owner.is_some() { - return Err(BrowserError::StaleDeviceKey); + return Err(BrowserError::StaleBrowserKey); } let id = self.next_browser_id; @@ -805,7 +805,7 @@ impl Anchor { .browsers .iter() .enumerate() - .min_by_key(|(_, device)| (device.last_used, device.id)) + .min_by_key(|(_, browser)| (browser.last_used, browser.id)) .map(|(index, _)| index); match least_recently_used { Some(index) => { diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index 9c25208846..21bb6ac225 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -1641,7 +1641,7 @@ mod browser_tests { 2_000, ); - assert_eq!(retried, Err(BrowserError::StaleDeviceKey)); + assert_eq!(retried, Err(BrowserError::StaleBrowserKey)); assert_eq!(anchor.browsers().len(), 1); assert_eq!(anchor.browsers()[0].next_browser_key, successor_key(1)); } From a7b0d9ef42ac449bdb1924e3ca090ccea4ffb400 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:17:16 +0200 Subject: [PATCH 191/298] refactor(be): the key a browser proves with says browser The module, its function, its parameters and both signature domains said device. The domain strings are what the browser signs over, so they change with the frontend that produces them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 2 +- .../{device_key.rs => browser_key.rs} | 50 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) rename src/internet_identity/src/sessions/{device_key.rs => browser_key.rs} (87%) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 11fef341fa..1eac31f2ff 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -2,4 +2,4 @@ // holds only the verifier its request will be checked against. #![allow(dead_code)] -pub mod device_key; +pub mod browser_key; diff --git a/src/internet_identity/src/sessions/device_key.rs b/src/internet_identity/src/sessions/browser_key.rs similarity index 87% rename from src/internet_identity/src/sessions/device_key.rs rename to src/internet_identity/src/sessions/browser_key.rs index ebacc9b1ae..afab5f675a 100644 --- a/src/internet_identity/src/sessions/device_key.rs +++ b/src/internet_identity/src/sessions/browser_key.rs @@ -7,14 +7,14 @@ use p256::pkcs8::DecodePublicKey; /// Prefixed to the signed message so the browser key cannot be made to sign for another /// purpose by presenting a message from one. -const DEVICE_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-device-key"; +const BROWSER_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-browser-key"; /// A different prefix for the successor's own signature, so neither signature can be /// replayed in the other's role. -const SUCCESSOR_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-device-successor"; +const SUCCESSOR_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-browser-successor"; /// A browser key is P-256, and the signature the raw `r || s` pair WebCrypto produces. -const DEVICE_KEY_SIGNATURE_BYTES: usize = 64; +const BROWSER_KEY_SIGNATURE_BYTES: usize = 64; /// Both keys sign: the current one over the session key and its successor, and the successor /// over the session key and the key it replaces. @@ -22,26 +22,30 @@ const DEVICE_KEY_SIGNATURE_BYTES: usize = 64; /// The successor's own signature is what stops a key being announced by someone who does not /// hold it — without it, keys read off the wire could be planted as another browser's /// successor and claimed when that browser next presented one. -pub fn verify_device_keys( - device_key: &PublicKey, - device_key_signature: &[u8], +pub fn verify_browser_keys( + current_browser_key: &PublicKey, + current_browser_key_signature: &[u8], next_browser_key: &PublicKey, next_browser_key_signature: &[u8], session_key: &SessionKey, ) -> bool { verify( - device_key, - device_key_signature, - &signed_message(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key), + current_browser_key, + current_browser_key_signature, + &signed_message(BROWSER_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key), ) && verify( next_browser_key, next_browser_key_signature, - &signed_message(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, device_key), + &signed_message( + SUCCESSOR_KEY_SIGNATURE_DOMAIN, + session_key, + current_browser_key, + ), ) } fn verify(key: &PublicKey, signature: &[u8], message: &[u8]) -> bool { - if signature.len() != DEVICE_KEY_SIGNATURE_BYTES { + if signature.len() != BROWSER_KEY_SIGNATURE_BYTES { return false; } let Ok(key) = VerifyingKey::from_public_key_der(key) else { @@ -102,7 +106,7 @@ mod tests { } fn current(&self, session_key: &SessionKey, next: &PublicKey) -> Vec { - self.sign(DEVICE_KEY_SIGNATURE_DOMAIN, session_key, next) + self.sign(BROWSER_KEY_SIGNATURE_DOMAIN, session_key, next) } fn successor(&self, session_key: &SessionKey, current: &PublicKey) -> Vec { @@ -116,7 +120,7 @@ mod tests { /// A rotation as an honest browser performs it: it holds both keys and signs with both. fn rotation(current: &Key, next: &Key, session: &SessionKey) -> bool { - verify_device_keys( + verify_browser_keys( ¤t.public, ¤t.current(session, &next.public), &next.public, @@ -137,7 +141,7 @@ mod tests { let session = session_key(7); // Everything the wire carries, but signed only by the key the caller holds. - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, ¤t.current(&session, &announced.public), &announced.public, @@ -152,7 +156,7 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, ¤t.successor(&session, &next.public), &next.public, @@ -166,7 +170,7 @@ mod tests { let current = key(1); let next = key(2); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, ¤t.current(&session_key(7), &next.public), &next.public, @@ -182,7 +186,7 @@ mod tests { let substituted = key(3); let session = session_key(7); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, ¤t.current(&session, &announced.public), &substituted.public, @@ -198,7 +202,7 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, &other.current(&session, &next.public), &next.public, @@ -214,7 +218,7 @@ mod tests { let session = session_key(7); let bare: Signature = current.signing.sign(&session); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, &bare.to_bytes(), &next.public, @@ -229,7 +233,7 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( &ByteBuf::from(vec![0u8; 91]), ¤t.current(&session, &next.public), &next.public, @@ -246,7 +250,7 @@ mod tests { let mut signature = current.current(&session, &next.public); signature.push(0); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, &signature, &next.public, @@ -261,14 +265,14 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, &[], &next.public, &next.successor(&session, ¤t.public), &session )); - assert!(!verify_device_keys( + assert!(!verify_browser_keys( ¤t.public, ¤t.current(&session, &next.public), &next.public, From d8b3bd5be9da337a1e0ca6206f90f4f8794dade8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:26:09 +0200 Subject: [PATCH 192/298] test(be): the request field is browser_name Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/tests/integration/sessions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 1728a0a1b4..c0ed33c080 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -417,7 +417,7 @@ fn should_end_the_sessions_of_a_browser_the_registry_dropped() -> Result<(), Rej for index in 0..MAX_BROWSERS { let mut request = session_request_from(identity_number, &BrowserKey::new(index as u8 + 2)); - request.device_name = format!("browser-{index}"); + request.browser_name = format!("browser-{index}"); request.origin = format!("https://dapp-{index}.com"); prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); } From c4823585ace176a0bba9de6a3037279ad2be6cc9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:26:21 +0200 Subject: [PATCH 193/298] test(be): the request field is browser_name Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/tests/integration/sessions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 96db597f6d..fcdc24f595 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -671,7 +671,7 @@ fn should_leave_another_browsers_session_alone() -> Result<(), RejectResponse> { 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(); + second_request.browser_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); From ec17dd01ced97777fd8b34979f50b903cb912479 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:27:38 +0200 Subject: [PATCH 194/298] refactor(be): the request that signs a browser out says browser `RevokeDeviceSessionsRequest` is the argument of `revoke_browser_sessions`, and now says so, in the candid, the interface types, the generated bindings and the test API. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/canister_tests/src/api/internet_identity/api_v2.rs | 2 +- src/frontend/src/lib/generated/internet_identity_idl.js | 4 ++-- src/frontend/src/lib/generated/internet_identity_types.d.ts | 4 ++-- src/internet_identity/internet_identity.did | 4 ++-- src/internet_identity/src/main.rs | 4 +++- src/internet_identity/src/sessions.rs | 4 ++-- src/internet_identity/tests/integration/sessions.rs | 6 +++--- .../src/internet_identity/types.rs | 2 +- 8 files changed, 16 insertions(+), 14 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 f908f36bd1..0917089419 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -833,7 +833,7 @@ pub fn revoke_browser_sessions( env: &PocketIc, canister_id: CanisterId, sender: Principal, - request: RevokeDeviceSessionsRequest, + request: RevokeBrowserSessionsRequest, ) -> Result, RejectResponse> { call_candid_as( env, diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 11a293edb3..0fa4ebc700 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -810,7 +810,7 @@ export const idlFactory = ({ IDL }) => { 'canister_full' : IDL.Null, 'registered' : IDL.Record({ 'user_number' : UserNumber }), }); - const RevokeDeviceSessionsRequest = IDL.Record({ + const RevokeBrowserSessionsRequest = IDL.Record({ 'browser_id' : IDL.Nat32, 'identity_number' : UserNumber, }); @@ -1452,7 +1452,7 @@ export const idlFactory = ({ IDL }) => { 'remove' : IDL.Func([UserNumber, DeviceKey], [], []), 'replace' : IDL.Func([UserNumber, DeviceKey, DeviceData], [], []), 'revoke_browser_sessions' : IDL.Func( - [RevokeDeviceSessionsRequest], + [RevokeBrowserSessionsRequest], [IDL.Variant({ 'Ok' : IDL.Null, 'Err' : SessionRevokeError })], [], ), 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 b4e8d0b5d5..2686e12eef 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1644,7 +1644,7 @@ export type RegistrationFlowNextStep = { 'Finish' : null }; export type RegistrationId = string; -export interface RevokeDeviceSessionsRequest { +export interface RevokeBrowserSessionsRequest { 'browser_id' : number, 'identity_number' : UserNumber, } @@ -2587,7 +2587,7 @@ export interface _SERVICE { */ 'replace' : ActorMethod<[UserNumber, DeviceKey, DeviceData], undefined>, 'revoke_browser_sessions' : ActorMethod< - [RevokeDeviceSessionsRequest], + [RevokeBrowserSessionsRequest], { 'Ok' : null } | { 'Err' : SessionRevokeError } >, diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 3ff4f8e275..d3b60f25f3 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1108,7 +1108,7 @@ type AppGetDelegationRequest = record { }; -type RevokeDeviceSessionsRequest = record { +type RevokeBrowserSessionsRequest = record { identity_number : UserNumber; browser_id : nat32; }; @@ -2000,7 +2000,7 @@ service : (opt InternetIdentityInit) -> { // session was already gone. An app can revoke only its own session. app_revoke_session : () -> (); - revoke_browser_sessions : (RevokeDeviceSessionsRequest) -> (variant { Ok; Err : SessionRevokeError }); + revoke_browser_sessions : (RevokeBrowserSessionsRequest) -> (variant { Ok; Err : SessionRevokeError }); prepare_account_delegation : ( anchor_number : UserNumber, diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 595b7e652f..52db700afd 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -509,7 +509,9 @@ fn app_prepare_delegation( } #[update] -fn revoke_browser_sessions(request: RevokeDeviceSessionsRequest) -> Result<(), SessionRevokeError> { +fn revoke_browser_sessions( + request: RevokeBrowserSessionsRequest, +) -> Result<(), SessionRevokeError> { sessions::revoke_browser_sessions(request) } diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index ce9a0b0d01..1dd7129032 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -25,7 +25,7 @@ use internet_identity_interface::internet_identity::types::{ AccountNumber, AccountSessionError, AnchorNumber, AppGetDelegationRequest, AppPrepareDelegationRequest, AppPrepareDelegationResponse, AppSessionError, Delegation, FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, - PrepareAccountSessionRequest, PrepareAccountSessionResponse, RevokeDeviceSessionsRequest, + PrepareAccountSessionRequest, PrepareAccountSessionResponse, RevokeBrowserSessionsRequest, SessionRevokeError, SignedDelegation, Timestamp, }; use serde_bytes::ByteBuf; @@ -451,7 +451,7 @@ fn account_seed(account: &Account) -> Result { } pub fn revoke_browser_sessions( - request: RevokeDeviceSessionsRequest, + request: RevokeBrowserSessionsRequest, ) -> Result<(), SessionRevokeError> { check_authorization(request.identity_number) .map_err(|err| SessionRevokeError::Unauthorized(err.principal))?; diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 47db3d226a..41fa21b6a0 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -12,7 +12,7 @@ use canister_tests::framework::{ use internet_identity_interface::internet_identity::types::{ AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, BrowserInfo, GetAccountSessionRequest, Permissions, PrepareAccountSessionRequest, - PrepareAccountSessionResponse, RevokeDeviceSessionsRequest, + PrepareAccountSessionResponse, RevokeBrowserSessionsRequest, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; @@ -717,7 +717,7 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { 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(); + other_browser.browser_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); @@ -736,7 +736,7 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { &env, canister_id, principal_1(), - RevokeDeviceSessionsRequest { + RevokeBrowserSessionsRequest { identity_number, browser_id, }, diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index ff9b802edb..89cfad4810 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -870,7 +870,7 @@ pub enum AppSessionError { /// Signs one browser out of every app it is signed into. #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] -pub struct RevokeDeviceSessionsRequest { +pub struct RevokeBrowserSessionsRequest { pub identity_number: IdentityNumber, pub browser_id: BrowserId, } From 17e4cf57d01170a2b01c4b05e83f85f793c9dbd0 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:28:22 +0200 Subject: [PATCH 195/298] refactor(fe): the name-length bound says browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors MAX_BROWSER_NAME_BYTES on the canister side. "Device" stays where it means the machine — a Chromebook, an iPhone — which is what the label names. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/describeBrowser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts index d8884e2629..be73f585e1 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts @@ -21,7 +21,7 @@ const BROWSERS: [RegExp, string][] = [ [/Safari\//, "Safari"], ]; -const MAX_DEVICE_NAME_BYTES = 128; +const MAX_BROWSER_NAME_BYTES = 128; const browserOf = (agent: string): string => BROWSERS.find(([token]) => token.test(agent))?.[1] ?? "Browser"; @@ -40,7 +40,7 @@ const platformOf = (agent: string, touchPoints: number): string => { }; const withinLimit = (label: string): boolean => - new TextEncoder().encode(label).length <= MAX_DEVICE_NAME_BYTES; + new TextEncoder().encode(label).length <= MAX_BROWSER_NAME_BYTES; export const browserLabel = ({ agent, From 01487eb9673623c573d7538007845b6c01231bcd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:29:00 +0200 Subject: [PATCH 196/298] refactor(fe): the browser key store says browser Both signature domains and the stored id. The domains are what the canister verifies against, and change with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/browser-key.store.test.ts | 8 ++++---- src/frontend/src/lib/stores/browser-key.store.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts index 62705c699f..43718ff479 100644 --- a/src/frontend/src/lib/stores/browser-key.store.test.ts +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -25,9 +25,9 @@ import { /// 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 SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-browser-key"); const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( - "ii-session-device-successor", + "ii-session-browser-successor", ); const signedMessage = ( @@ -69,9 +69,9 @@ 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) => +const signIn = (identityNumber: bigint, seed: number, browserId = 1) => withBrowserProof(identityNumber, sessionKey(seed), async (proof) => { - await proof.accept(deviceId); + await proof.accept(browserId); return proof; }); diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts index 3ca4c8bd82..83d7236acd 100644 --- a/src/frontend/src/lib/stores/browser-key.store.ts +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -17,7 +17,7 @@ interface BrowserKeyRecord { * it would leave the browser unable to prove it is itself ever again. */ announced?: CryptoKeyPair; /** Absent until a sign-in has told us which browser we are. */ - deviceId?: number; + browserId?: number; } /** @@ -37,9 +37,9 @@ export class StaleBrowserKeyError extends Error { 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 SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-browser-key"); const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( - "ii-session-device-successor", + "ii-session-browser-successor", ); /** @@ -107,7 +107,7 @@ export interface BrowserProof { /** 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; + accept: (browserId: number) => Promise; } /** Serialises sign-ins for one identity: two at once would leave us holding a key the @@ -175,8 +175,8 @@ const attempt = async ( nextPublicKey, signature, nextSignature, - accept: (deviceId) => - write(identityNumber, { keyPair: successor, deviceId }), + accept: (browserId) => + write(identityNumber, { keyPair: successor, browserId }), }); }; @@ -222,4 +222,4 @@ export const withBrowserProof = ( /** 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; +): Promise => (await read(identityNumber))?.browserId; From d707470ad8f6e40d2b96851ec9b85e8404fa4db4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:29:00 +0200 Subject: [PATCH 197/298] refactor(fe): the browser key store says browser Both signature domains and the stored id. The domains are what the canister verifies against, and change with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/browser-key.store.test.ts | 14 +++++++------- src/frontend/src/lib/stores/browser-key.store.ts | 16 ++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts index 62705c699f..e8237368f0 100644 --- a/src/frontend/src/lib/stores/browser-key.store.test.ts +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -17,7 +17,7 @@ vi.mock("idb-keyval", async (importOriginal) => { }; }); import { - currentDeviceId, + currentBrowserId, StaleBrowserKeyError, withBrowserProof, } from "./browser-key.store"; @@ -25,9 +25,9 @@ import { /// 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 SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-browser-key"); const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( - "ii-session-device-successor", + "ii-session-browser-successor", ); const signedMessage = ( @@ -69,9 +69,9 @@ 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) => +const signIn = (identityNumber: bigint, seed: number, browserId = 1) => withBrowserProof(identityNumber, sessionKey(seed), async (proof) => { - await proof.accept(deviceId); + await proof.accept(browserId); return proof; }); @@ -277,13 +277,13 @@ describe("browser key", () => { it("remembers which browser the canister said this is", async () => { await signIn(IDENTITY, 1, 7); - await expect(currentDeviceId(IDENTITY)).resolves.toBe(7); + await expect(currentBrowserId(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(); + await expect(currentBrowserId(IDENTITY)).resolves.toBeUndefined(); }); it("has the successor sign for itself, so an unheld key cannot be announced", async () => { diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts index 3ca4c8bd82..1fe0acf623 100644 --- a/src/frontend/src/lib/stores/browser-key.store.ts +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -17,7 +17,7 @@ interface BrowserKeyRecord { * it would leave the browser unable to prove it is itself ever again. */ announced?: CryptoKeyPair; /** Absent until a sign-in has told us which browser we are. */ - deviceId?: number; + browserId?: number; } /** @@ -37,9 +37,9 @@ export class StaleBrowserKeyError extends Error { 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 SIGNATURE_DOMAIN = new TextEncoder().encode("ii-session-browser-key"); const SUCCESSOR_SIGNATURE_DOMAIN = new TextEncoder().encode( - "ii-session-device-successor", + "ii-session-browser-successor", ); /** @@ -107,7 +107,7 @@ export interface BrowserProof { /** 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; + accept: (browserId: number) => Promise; } /** Serialises sign-ins for one identity: two at once would leave us holding a key the @@ -175,8 +175,8 @@ const attempt = async ( nextPublicKey, signature, nextSignature, - accept: (deviceId) => - write(identityNumber, { keyPair: successor, deviceId }), + accept: (browserId) => + write(identityNumber, { keyPair: successor, browserId }), }); }; @@ -220,6 +220,6 @@ export const withBrowserProof = ( }); /** Which browser the canister knows this one as, for the settings list to mark it. */ -export const currentDeviceId = async ( +export const currentBrowserId = async ( identityNumber: bigint, -): Promise => (await read(identityNumber))?.deviceId; +): Promise => (await read(identityNumber))?.browserId; From 966e52a464278836a76717bdfe9d36ebc12034eb Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:30:10 +0200 Subject: [PATCH 198/298] refactor(fe): the session store names the browser it reads Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/session-delegation.store.test.ts | 8 +++++--- .../src/lib/stores/session-delegation.store.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/lib/stores/session-delegation.store.test.ts b/src/frontend/src/lib/stores/session-delegation.store.test.ts index c0855d8606..209fc8e376 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.test.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.test.ts @@ -349,10 +349,10 @@ describe("forgetIdentity", () => { ); }; - const knownBrowser = (deviceId: number) => + const knownBrowser = (browserId: number) => idbSet( IDENTITY_NUMBER.toString(), - { keyPair: undefined, deviceId }, + { keyPair: undefined, browserId }, BROWSER_KEY_STORE, ); @@ -405,7 +405,9 @@ describe("forgetIdentity", () => { /// is the thing they asked it to stop doing. it("forgets locally even when the canister call fails", async () => { const actor = { - revoke_browser_sessions: vi.fn(() => Promise.reject(new Error("offline"))), + revoke_browser_sessions: vi.fn(() => + Promise.reject(new Error("offline")), + ), } as unknown as ActorSubclass<_SERVICE>; const { authenticationStore } = await import("$lib/stores/authentication.store"); diff --git a/src/frontend/src/lib/stores/session-delegation.store.ts b/src/frontend/src/lib/stores/session-delegation.store.ts index 9ab76fb320..60174ec85f 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.ts @@ -9,7 +9,7 @@ import { Actor, ActorSubclass, HttpAgent } from "@icp-sdk/core/agent"; import type { _SERVICE } from "$lib/generated/internet_identity_types"; import { idlFactory as internet_identity_idl } from "$lib/generated/internet_identity_idl"; import { authenticationStore } from "$lib/stores/authentication.store"; -import { currentDeviceId } from "$lib/stores/browser-key.store"; +import { currentBrowserId } from "$lib/stores/browser-key.store"; import { purgeAppSessions } from "$lib/stores/app-session.store"; import { canisterId, agentOptions } from "$lib/globals"; import { @@ -113,14 +113,16 @@ export const actorForIdentity = async ( * browser, and this identity on the user's other browsers, alone. */ export const forgetIdentity = async (identityNumber: bigint): Promise => { - const deviceId = await currentDeviceId(identityNumber); + const browserId = await currentBrowserId(identityNumber); const actor = - deviceId === undefined ? undefined : await actorForIdentity(identityNumber); - if (deviceId !== undefined && actor !== undefined) { + browserId === undefined + ? undefined + : await actorForIdentity(identityNumber); + if (browserId !== undefined && actor !== undefined) { try { await actor.revoke_browser_sessions({ identity_number: identityNumber, - browser_id: deviceId, + browser_id: browserId, }); } catch { // The local records go either way. Keeping them because the canister could not be From 9f3be291424034d617e4f04d125580ecd33d0720 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:30:33 +0200 Subject: [PATCH 199/298] refactor(fe): the sign-in handler names a browser Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/stores/channelHandlers/sessionDelegation.test.ts | 2 +- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index f71a359a72..12831c9bce 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -99,7 +99,7 @@ describe("ii_session_delegation", () => { describe("asBrowserKeyError", () => { it("names a retired browser key so the key store can promote its successor", () => { const stale = asBrowserKeyError( - new CanisterError({ StaleDeviceKey: null }), + new CanisterError({ StaleBrowserKey: null }), ); expect(stale).toBeInstanceOf(StaleBrowserKeyError); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index ba5d09e840..b2efbb8a1b 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -208,7 +208,8 @@ export const handleSessionDelegationRequest = * form the key store acts on, which is where the successor that does resolve is kept. */ export const asBrowserKeyError = (error: unknown): unknown => - isCanisterError(error) && error.type === "StaleDeviceKey" + isCanisterError(error) && + error.type === "StaleBrowserKey" ? new StaleBrowserKeyError() : error; @@ -240,7 +241,7 @@ const createSession = async ( 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 browserName = await describeBrowser(); const prepared = await withBrowserProof( identityNumber, @@ -252,7 +253,7 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, - device_name: deviceName, + browser_name: browserName, current_browser_key: browser.publicKey, next_browser_key: browser.nextPublicKey, current_browser_key_signature: browser.signature, From 012bed9befb356689a4166fe67d46aa95d9a12bc Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:31:54 +0200 Subject: [PATCH 200/298] refactor(fe): the settings list says browsers Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(authenticated)/settings/+page.svelte | 6 ++-- .../(authenticated)/settings/browsers.test.ts | 28 ++++++++-------- .../(authenticated)/settings/browsers.ts | 28 ++++++++-------- .../components/BrowsersSection.svelte | 32 +++++++++---------- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte index 504d52ebe1..09b6506c5e 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte @@ -7,7 +7,7 @@ import McpTrustedServersSection from "./components/McpTrustedServersSection.svelte"; import BrowsersSection from "./components/BrowsersSection.svelte"; import { fromCanisterBrowsers } from "./browsers"; - import { currentDeviceId } from "$lib/stores/browser-key.store"; + import { currentBrowserId } from "$lib/stores/browser-key.store"; import type { PageProps } from "./$types"; const { data }: PageProps = $props(); @@ -23,7 +23,7 @@ // way to tell which browser is asking: `identity_info` is signed by an access method. let thisBrowser = $state(undefined); $effect(() => { - void currentDeviceId($authenticatedStore.identityNumber).then( + void currentBrowserId($authenticatedStore.identityNumber).then( (id) => (thisBrowser = id), ); }); @@ -50,6 +50,6 @@ /> diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts index c8aa64fd6f..9559e3f28a 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts @@ -7,7 +7,7 @@ import { signOutBrowser, } from "./browsers"; -const device = ( +const browser = ( id: number, name: string, createdAtNanos: bigint, @@ -20,7 +20,7 @@ const device = ( }); describe("fromCanisterBrowsers", () => { - it("reports no devices for an identity that has never created a session", () => { + it("reports no browsers for an identity that has never created a session", () => { expect(fromCanisterBrowsers([])).toEqual([]); }); @@ -28,9 +28,9 @@ describe("fromCanisterBrowsers", () => { expect( fromCanisterBrowsers([ [ - device(1, "Firefox on Linux", BigInt(1_000_000_000)), - device(2, "Chrome on macOS", BigInt(3_000_000_000)), - device(3, "Safari on iOS", BigInt(2_000_000_000)), + browser(1, "Firefox on Linux", BigInt(1_000_000_000)), + browser(2, "Chrome on macOS", BigInt(3_000_000_000)), + browser(3, "Safari on iOS", BigInt(2_000_000_000)), ], ]).map((entry) => entry.name), ).toEqual(["Chrome on macOS", "Safari on iOS", "Firefox on Linux"]); @@ -40,13 +40,13 @@ describe("fromCanisterBrowsers", () => { expect( fromCanisterBrowsers([ [ - device( + browser( 1, "enrolled first, still in use", BigInt(1), BigInt(9_000_000_000), ), - device(2, "enrolled later, gone quiet", BigInt(5_000_000_000)), + browser(2, "enrolled later, gone quiet", BigInt(5_000_000_000)), ], ]).map((entry) => entry.name), ).toEqual(["enrolled first, still in use", "enrolled later, gone quiet"]); @@ -55,7 +55,7 @@ describe("fromCanisterBrowsers", () => { it("converts both timestamps to milliseconds", () => { expect( fromCanisterBrowsers([ - [device(1, "Chrome", BigInt(1_500_000_000), BigInt(4_200_000_000))], + [browser(1, "Chrome", BigInt(1_500_000_000), BigInt(4_200_000_000))], ]), ).toEqual([ { @@ -72,8 +72,8 @@ describe("fromCanisterBrowsers", () => { const marked = fromCanisterBrowsers( [ [ - device(1, "Chrome on Mac", BigInt(1_000_000_000)), - device(2, "Chrome on Mac", BigInt(2_000_000_000)), + browser(1, "Chrome on Mac", BigInt(1_000_000_000)), + browser(2, "Chrome on Mac", BigInt(2_000_000_000)), ], ], 2, @@ -88,7 +88,7 @@ describe("fromCanisterBrowsers", () => { it("marks nothing when this browser has never created a session", () => { expect( fromCanisterBrowsers([ - [device(1, "Chrome on Mac", BigInt(1_000_000_000))], + [browser(1, "Chrome on Mac", BigInt(1_000_000_000))], ]).some((entry) => entry.isCurrent), ).toBe(false); }); @@ -97,7 +97,7 @@ describe("fromCanisterBrowsers", () => { it("marks nothing when the id is one this identity does not hold", () => { expect( fromCanisterBrowsers( - [[device(1, "Chrome on Mac", BigInt(1_000_000_000))]], + [[browser(1, "Chrome on Mac", BigInt(1_000_000_000))]], 99, ).some((entry) => entry.isCurrent), ).toBe(false); @@ -159,10 +159,10 @@ describe("signOutBrowser", () => { const actor = { revoke_browser_sessions: vi.fn(() => Promise.resolve({ Ok: null })), } as unknown as ActorSubclass<_SERVICE>; - // This browser is device 3. + // This browser is browser 3. await idbSet( BigInt(10_000).toString(), - { keyPair: undefined, deviceId: 3 }, + { keyPair: undefined, browserId: 3 }, createStore("ii-browser-keys", "keys"), ); diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts index 9995a00948..08af1f317f 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts @@ -1,6 +1,6 @@ import type { ActorSubclass } from "@icp-sdk/core/agent"; import { purgeAppSessions } from "$lib/stores/app-session.store"; -import { currentDeviceId } from "$lib/stores/browser-key.store"; +import { currentBrowserId } from "$lib/stores/browser-key.store"; import type { _SERVICE, BrowserInfo, @@ -17,23 +17,23 @@ export interface Browser { } export const fromCanisterBrowsers = ( - devices: [] | [BrowserInfo[]], - currentDeviceId?: number, + browsers: [] | [BrowserInfo[]], + currentBrowserId?: number, ): Browser[] => - (devices[0] ?? []) - .map((device) => ({ - id: device.id, - name: device.name, - createdAtMillis: nanosToMillis(device.created_at), - lastUsedMillis: nanosToMillis(device.last_used), - isCurrent: device.id === currentDeviceId, + (browsers[0] ?? []) + .map((browser) => ({ + id: browser.id, + name: browser.name, + createdAtMillis: nanosToMillis(browser.created_at), + lastUsedMillis: nanosToMillis(browser.last_used), + isCurrent: browser.id === currentBrowserId, })) .sort((a, b) => b.lastUsedMillis - a.lastUsedMillis); /** * Ends every session this browser holds, across every app it is signed into. * - * The device record itself survives, so a browser that has been signed out is still one + * The browser record itself survives, so a browser that has been signed out is still one * the user recognises and signing back in from it reuses the same entry. * * Signing *this* browser out also discards the session chains it holds locally. The @@ -48,11 +48,11 @@ export const fromCanisterBrowsers = ( export const signOutBrowser = async ( actor: ActorSubclass<_SERVICE>, identityNumber: bigint, - deviceId: number, + browserId: number, ): Promise => { const result = await actor.revoke_browser_sessions({ identity_number: identityNumber, - browser_id: deviceId, + browser_id: browserId, }); if ("Err" in result) { throw new Error( @@ -61,7 +61,7 @@ export const signOutBrowser = async ( : result.Err.InternalCanisterError, ); } - if ((await currentDeviceId(identityNumber)) === deviceId) { + if ((await currentBrowserId(identityNumber)) === browserId) { await purgeAppSessions(identityNumber); } }; diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte index 3f839112e6..9259096edd 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte @@ -10,24 +10,24 @@ interface Props { identityNumber: bigint; - devices: Browser[]; + browsers: Browser[]; } - const { identityNumber, devices }: Props = $props(); + const { identityNumber, browsers }: Props = $props(); const titleId = $props.id(); let signedOut = $state([]); let signingOut = $state(undefined); - const handleSignOut = async (device: Browser) => { - signingOut = device.id; + const handleSignOut = async (browser: Browser) => { + signingOut = browser.id; try { await signOutBrowser( $authenticatedStore.actor, identityNumber, - device.id, + browser.id, ); - signedOut = [...signedOut, device.id]; + signedOut = [...signedOut, browser.id]; } catch (error) { toaster.error({ title: $t`Couldn't sign this browser out`, @@ -55,7 +55,7 @@ {$t`Signed-in browsers`}

- {#if devices.length === 0} + {#if browsers.length === 0} Apps you sign in to from a browser will show up here, so you can end their access at any time. @@ -69,19 +69,19 @@

- {#if devices.length > 0} + {#if browsers.length > 0}
    - {#each devices as device (device.id)} - {@const lastUsed = new Date(device.lastUsedMillis)} + {#each browsers as browser (browser.id)} + {@const lastUsed = new Date(browser.lastUsedMillis)}
  • - {device.name} + {browser.name} - {#if device.isCurrent} + {#if browser.isCurrent} - #{device.id} + #{browser.id}
    - {#if signedOut.includes(device.id)} + {#if signedOut.includes(browser.id)} {$t`Signed out`} @@ -121,9 +121,9 @@ {/if}
  • From e7f59ad43593cbfee584dc0e379f7ec0fcd54b09 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 19:32:10 +0200 Subject: [PATCH 201/298] test(be): the revoke request type says browser Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/tests/integration/sessions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index e6e6117427..e59a614841 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -821,7 +821,7 @@ fn should_report_a_revoked_session_as_gone() -> Result<(), RejectResponse> { &env, canister_id, principal_1(), - RevokeDeviceSessionsRequest { + RevokeBrowserSessionsRequest { identity_number, browser_id: prepared.browser_id, }, From 465d98ed482dc28f69fab27a309b22c2651ae525 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 20:45:39 +0200 Subject: [PATCH 202/298] fix(be): store the anchor the write path was handed, always MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path takes the anchor rather than its number so that it owns storing it, but it stored it only where its own change — the session count — had landed. A caller's change to the same anchor was discarded whenever the count did not move. Registering a browser and rotating its key is exactly such a change, and a sign-in from a browser that already holds a session at that account replaces that session rather than adding one, so the count does not move. Every ordinary repeat sign-in therefore threw the rotation away, leaving a browser key that is meant to last one sign-in usable indefinitely — and leaving the browser's last-used stamp, which the registry cap orders on, unmoved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 18 +++--- src/internet_identity/src/storage/tests.rs | 69 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 935a77be47..cc0ee31c05 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2450,16 +2450,20 @@ impl Storage { written.insert(origin, result); } - // Written once for the whole call, and only where the count moved. Trapping - // rather than reporting: an `Err` on the IC commits everything above this line, - // so a count that could not be stored has to take the whole message with it. The - // anchor was read by the caller and comes back with one `u32` changed, so a - // failure here is a broken invariant rather than a case to handle. + // Written once for the whole call, and unconditionally. This is handed the anchor + // rather than its number so that it owns storing it, and a caller is free to have + // changed it before handing it over — registering a browser and rotating its key is + // exactly that. Writing only where this function's own change to it landed would + // discard the caller's, silently, on any write whose session count did not move. + // + // Trapping rather than reporting: an `Err` on the IC commits everything above this + // line, so an anchor that could not be stored has to take the whole message with + // it. if let Some(session_count) = session_count { anchor.session_count = session_count; - self.write(anchor.clone()) - .expect("the anchor this write was handed cannot be written back"); } + self.write(anchor.clone()) + .expect("the anchor this write was handed cannot be written back"); written } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 17e367d895..0253bd71f8 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3582,6 +3582,7 @@ mod default_account_tracking_tests { use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; use pretty_assertions::assert_eq; + use serde_bytes::ByteBuf; fn storage_with_anchor() -> (Storage, AnchorNumber) { let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); @@ -5357,6 +5358,7 @@ mod session_creation_tests { use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; use pretty_assertions::assert_eq; + use serde_bytes::ByteBuf; const SALT: [u8; 32] = [17u8; 32]; const ORIGIN: &str = "https://example.com"; @@ -5370,6 +5372,73 @@ mod session_creation_tests { (storage, anchor_number) } + /// The write path is handed the anchor, not its number, so that it owns storing it — + /// which means storing what the *caller* changed too, not only the session count it + /// sets itself. + /// + /// Registering a browser and rotating its key is exactly such a change, and a sign-in + /// from a browser that already holds a session at this account replaces that session + /// rather than adding one, so the count does not move. A write that stored the anchor + /// only when the count moved discarded the rotation on every such sign-in, leaving a + /// key that is meant to last one sign-in usable for good. + #[test] + fn an_anchor_the_caller_changed_is_stored_even_when_the_session_count_does_not_move() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + + let mut anchor = storage.read(anchor_number).unwrap(); + let (browser_id, _) = anchor + .resolve_browser( + ByteBuf::from(vec![1u8; 32]), + ByteBuf::from(vec![2u8; 32]), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + // No session anywhere, so this write moves no session count. + storage + .write_account_state( + &mut anchor, + write_at(&origin, vec![AccountReference::new(None, Some(1_000))], None), + ) + .unwrap(); + + let stored = storage.read(anchor_number).unwrap(); + let browser = stored + .browsers() + .iter() + .find(|browser| browser.id == browser_id) + .expect("the browser the caller registered is not stored"); + assert_eq!(browser.current_browser_key, ByteBuf::from(vec![1u8; 32])); + assert_eq!(browser.next_browser_key, ByteBuf::from(vec![2u8; 32])); + + // And the rotation that follows it, which is the case the count never moves for. + let mut anchor = storage.read(anchor_number).unwrap(); + anchor + .resolve_browser( + ByteBuf::from(vec![2u8; 32]), + ByteBuf::from(vec![3u8; 32]), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + storage + .write_account_state( + &mut anchor, + write_at(&origin, vec![AccountReference::new(None, Some(2_000))], None), + ) + .unwrap(); + + let stored = storage.read(anchor_number).unwrap(); + let browser = &stored.browsers()[0]; + assert_eq!( + browser.current_browser_key, + ByteBuf::from(vec![2u8; 32]), + "the rotated key was not stored" + ); + assert_eq!(browser.next_browser_key, ByteBuf::from(vec![3u8; 32])); + } + /// A browser the registry gave up takes its sessions with it, wherever they were, in /// the write that made room for the browser replacing it. /// From b9d663cdb587d1cd211effeea3fc027927c5d83f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 20:45:39 +0200 Subject: [PATCH 203/298] fix(be): store the anchor the write path was handed, always MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write path takes the anchor rather than its number so that it owns storing it, but it stored it only where its own change — the session count — had landed. A caller's change to the same anchor was discarded whenever the count did not move. Registering a browser and rotating its key is exactly such a change, and a sign-in from a browser that already holds a session at that account replaces that session rather than adding one, so the count does not move. Every ordinary repeat sign-in therefore threw the rotation away, leaving a browser key that is meant to last one sign-in usable indefinitely — and leaving the browser's last-used stamp, which the registry cap orders on, unmoved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 18 +++-- src/internet_identity/src/storage/tests.rs | 76 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 935a77be47..cc0ee31c05 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2450,16 +2450,20 @@ impl Storage { written.insert(origin, result); } - // Written once for the whole call, and only where the count moved. Trapping - // rather than reporting: an `Err` on the IC commits everything above this line, - // so a count that could not be stored has to take the whole message with it. The - // anchor was read by the caller and comes back with one `u32` changed, so a - // failure here is a broken invariant rather than a case to handle. + // Written once for the whole call, and unconditionally. This is handed the anchor + // rather than its number so that it owns storing it, and a caller is free to have + // changed it before handing it over — registering a browser and rotating its key is + // exactly that. Writing only where this function's own change to it landed would + // discard the caller's, silently, on any write whose session count did not move. + // + // Trapping rather than reporting: an `Err` on the IC commits everything above this + // line, so an anchor that could not be stored has to take the whole message with + // it. if let Some(session_count) = session_count { anchor.session_count = session_count; - self.write(anchor.clone()) - .expect("the anchor this write was handed cannot be written back"); } + self.write(anchor.clone()) + .expect("the anchor this write was handed cannot be written back"); written } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 17e367d895..4419680acf 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5357,6 +5357,7 @@ mod session_creation_tests { use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; use pretty_assertions::assert_eq; + use serde_bytes::ByteBuf; const SALT: [u8; 32] = [17u8; 32]; const ORIGIN: &str = "https://example.com"; @@ -5370,6 +5371,81 @@ mod session_creation_tests { (storage, anchor_number) } + /// The write path is handed the anchor, not its number, so that it owns storing it — + /// which means storing what the *caller* changed too, not only the session count it + /// sets itself. + /// + /// Registering a browser and rotating its key is exactly such a change, and a sign-in + /// from a browser that already holds a session at this account replaces that session + /// rather than adding one, so the count does not move. A write that stored the anchor + /// only when the count moved discarded the rotation on every such sign-in, leaving a + /// key that is meant to last one sign-in usable for good. + #[test] + fn an_anchor_the_caller_changed_is_stored_even_when_the_session_count_does_not_move() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + + let mut anchor = storage.read(anchor_number).unwrap(); + let (browser_id, _) = anchor + .resolve_browser( + ByteBuf::from(vec![1u8; 32]), + ByteBuf::from(vec![2u8; 32]), + "Chrome".to_string(), + 1_000, + ) + .unwrap(); + // No session anywhere, so this write moves no session count. + storage + .write_account_state( + &mut anchor, + write_at( + &origin, + vec![AccountReference::new(None, Some(1_000))], + None, + ), + ) + .unwrap(); + + let stored = storage.read(anchor_number).unwrap(); + let browser = stored + .browsers() + .iter() + .find(|browser| browser.id == browser_id) + .expect("the browser the caller registered is not stored"); + assert_eq!(browser.current_browser_key, ByteBuf::from(vec![1u8; 32])); + assert_eq!(browser.next_browser_key, ByteBuf::from(vec![2u8; 32])); + + // And the rotation that follows it, which is the case the count never moves for. + let mut anchor = storage.read(anchor_number).unwrap(); + anchor + .resolve_browser( + ByteBuf::from(vec![2u8; 32]), + ByteBuf::from(vec![3u8; 32]), + "Chrome".to_string(), + 2_000, + ) + .unwrap(); + storage + .write_account_state( + &mut anchor, + write_at( + &origin, + vec![AccountReference::new(None, Some(2_000))], + None, + ), + ) + .unwrap(); + + let stored = storage.read(anchor_number).unwrap(); + let browser = &stored.browsers()[0]; + assert_eq!( + browser.current_browser_key, + ByteBuf::from(vec![2u8; 32]), + "the rotated key was not stored" + ); + assert_eq!(browser.next_browser_key, ByteBuf::from(vec![3u8; 32])); + } + /// A browser the registry gave up takes its sessions with it, wherever they were, in /// the write that made room for the browser replacing it. /// From ff06ce617cbe9d0ac7b5a0e60aacd7d52553ae24 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 23:08:08 +0200 Subject: [PATCH 204/298] refactor(be): the write path takes the identity record, and stores it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that are one change. The write path took `&mut Anchor` and stored it only where its own change to it — the session count — had landed. It now takes the record by value, which is what `write` wanted anyway, and stores it unconditionally: taking it is taking the storing of it. The clone goes with the borrow, and the five callers that never touched the record lose a `mut` that was never true. `create_session` resolves the browser itself instead of being handed the results. The registry cap can give a browser up, and every session that browser held has to go in the same write; passing that consequence in as `dropped_browsers` made it something a caller could forget. Now nothing is passed: the params carry the keys the browser presents, and the id, the entry and whatever the cap gives up are all worked out inside. The unit tests go through real resolution rather than naming browser ids the registry never minted, which is why a browser signing in twice now rotates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 93 +++--- src/internet_identity/src/storage/tests.rs | 366 +++++++++++---------- 2 files changed, 238 insertions(+), 221 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index bc7ff983fc..5e4f9e25df 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -111,7 +111,7 @@ use crate::stats::event_stats::{EventData, EventKey}; use crate::storage::account::{ AccountReference, SessionRecord, DEFAULT_SESSION_IDLE_NS, MIN_SESSION_IDLE_NS, }; -use crate::storage::anchor::Anchor; +use crate::storage::anchor::{Anchor, BrowserError}; use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; use crate::storage::storable::account::StorableAccount; @@ -1674,7 +1674,7 @@ impl Storage { /// filled in, each list in the order it was given. fn write_account_state( &mut self, - anchor: &mut Anchor, + anchor: Anchor, writes: BTreeMap, ) -> Result, StorageError> { let validated = self.validate_account_state(anchor.anchor_number(), writes)?; @@ -1690,8 +1690,8 @@ impl Storage { anchor_number: AnchorNumber, writes: BTreeMap, ) -> Result, StorageError> { - let mut anchor = self.read(anchor_number)?; - self.write_account_state(&mut anchor, writes) + let anchor = self.read(anchor_number)?; + self.write_account_state(anchor, writes) } /// Everything that can refuse. Reads what is stored, works out what would be minted @@ -2204,7 +2204,7 @@ impl Storage { /// is a broken invariant rather than a case to report. fn apply_account_state( &mut self, - anchor: &mut Anchor, + mut anchor: Anchor, validated: ValidatedAccountStateWrite, ) -> BTreeMap { let anchor_number = anchor.anchor_number(); @@ -2330,14 +2330,18 @@ impl Storage { } else { anchor.session_count.saturating_add(session_delta as u32) }; - // Trapping rather than reporting: an `Err` on the IC commits everything above - // this line, so a count that could not be stored has to take the whole message - // with it. The anchor was read by the caller and comes back with one `u32` - // changed, so this is a broken invariant rather than a case to handle. - self.write(anchor.clone()) - .expect("the anchor this write was handed cannot be written back"); } + // Taking the identity record is taking the storing of it, so it is stored whatever + // was changed on it — the count above, or anything a caller changed before giving + // it up. Storing it only where this function's own change landed would discard the + // caller's, silently. + // + // Trapping rather than reporting: an `Err` on the IC commits everything above this + // line, so a record that could not be stored has to take the whole message with it. + self.write(anchor) + .expect("the identity record this write was handed cannot be written back"); + written } @@ -2701,20 +2705,21 @@ impl Storage { /// whatever this browser already held at this account. pub fn create_session( &mut self, - anchor: &mut Anchor, params: CreateSessionParams, ) -> Result<(SessionRecordKey, SessionRecord), StorageError> { - let anchor_number = anchor.anchor_number(); let CreateSessionParams { + anchor_number, origin, account_number, - browser_id, + current_browser_key, + next_browser_key, + browser_name, valid_till_ns, max_idle_ns, read_only, now_ns, - dropped_browsers, } = params; + let mut anchor = self.read(anchor_number)?; // Defaulted and clamped here rather than at the caller, so every path that // creates a session gets the same answer whatever it asked for. The ceiling is @@ -2748,6 +2753,17 @@ impl Storage { }); } + // Resolved here rather than by a caller, because what follows from it is this + // function's to work out: the registry may be at its cap, in which case a browser + // is given up and every session it held has to go in the same write. A caller + // handed that consequence is a caller that can forget it. + // + // After the refusals above, so a ceremony that cannot happen registers nothing — + // the record reaches storage only through the write at the end. + let (browser_id, dropped_browsers) = anchor + .resolve_browser(current_browser_key, next_browser_key, browser_name, now_ns) + .map_err(StorageError::Browser)?; + // The whole of what the identity holds, not just this origin: a browser the // registry gave up to make room for this one may hold sessions anywhere, and those // have to go in the same write as the browser that held them. @@ -2842,21 +2858,6 @@ impl Storage { Ok((key, session)) } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] - /// [`Self::create_session`] for a test that has an anchor number rather than the - /// anchor. Production hands the anchor in, because the caller has just registered a - /// browser on it and that registration rides on the same write. - #[cfg(test)] - fn create_session_for_testing( - &mut self, - anchor_number: AnchorNumber, - params: CreateSessionParams, - ) -> Result<(SessionRecordKey, SessionRecord), StorageError> { - let mut anchor = self.read(anchor_number)?; - self.create_session(&mut anchor, params) - } - /// The session `key` names, or `None` where the identity holds no such session. /// /// A key whose session was replaced reads as `None` rather than as its successor: @@ -2885,7 +2886,7 @@ impl Storage { // back. Nothing here ranges over storage itself and no application number reaches // this function: the sweep is one write, so an `Err` cannot sign the browser out // of some applications and report failure. - let mut anchor = self.read(anchor_number)?; + let anchor = self.read(anchor_number)?; let mut state = self.account_state(anchor_number); let mut revoked = 0u64; @@ -2904,7 +2905,7 @@ impl Storage { } } - self.write_account_state(&mut anchor, state)?; + self.write_account_state(anchor, state)?; Ok(revoked) } @@ -3170,7 +3171,7 @@ impl Storage { // stale, and an identity that does not exist has nothing to hold what is about to // be written — the counters, the account reference lists and the session count all // key on a record that would not be there. - let mut anchor = self.read(anchor_number)?; + let anchor = self.read(anchor_number)?; let (mut account_references, config) = self.account_state_for_origin(anchor_number, &origin); // Where the write leaves it, and so where its minted number comes back. @@ -3186,7 +3187,7 @@ impl Storage { }); let written = self.write_account_state( - &mut anchor, + anchor, BTreeMap::from([(origin.clone(), Some((account_references, config)))]), )?; @@ -3237,7 +3238,7 @@ impl Storage { } } - let mut anchor = self.read(anchor_number)?; + let anchor = self.read(anchor_number)?; let (mut account_references, config) = self.account_state_for_origin(anchor_number, &origin); let Some(position) = account_references @@ -3287,7 +3288,7 @@ impl Storage { } let written = self.write_account_state( - &mut anchor, + anchor, BTreeMap::from([(origin.clone(), Some((account_references, config)))]), )?; let write = &written[&origin] @@ -3328,10 +3329,10 @@ impl Storage { ) -> Result<(), StorageError> { check_frontend_length(&origin); - let mut anchor = self.read(anchor_number)?; + let anchor = self.read(anchor_number)?; let (account_references, _) = self.account_state_for_origin(anchor_number, &origin); self.write_account_state( - &mut anchor, + anchor, BTreeMap::from([( origin, Some(( @@ -3594,18 +3595,19 @@ impl Storage { // Constructed by the sign-in ceremony, which lands two PRs up. #[allow(dead_code)] pub struct CreateSessionParams { + pub anchor_number: AnchorNumber, pub origin: FrontendHostname, pub account_number: Option, - pub browser_id: BrowserId, + /// What the browser proves it holds, and the successor it announces. Its registry + /// entry, its id, and whatever the cap gives up to make room for it are all worked out + /// inside the write, so no caller states any of them. + pub current_browser_key: PublicKey, + pub next_browser_key: PublicKey, + pub browser_name: String, pub valid_till_ns: Timestamp, pub max_idle_ns: Option, pub read_only: bool, pub now_ns: Timestamp, - /// Browsers the registry gave up to make room for this one, whose sessions go with - /// them. Handed in rather than swept afterwards: dropping a browser and ending its - /// sessions is one change, and doing it in two writes means an `Err` from the second - /// leaves a browser gone with its sessions still live. - pub dropped_browsers: Vec, } /// How far the sweep has got: which list, and how many of that list's references are @@ -3952,6 +3954,8 @@ pub enum StorageError { AccountLimitReached { anchor_number: AnchorNumber, }, + /// The browser presenting itself could not be resolved to a registry entry. + Browser(BrowserError), AnchorNumberOutOfRange { anchor_number: AnchorNumber, range: (AnchorNumber, AnchorNumber), @@ -4034,6 +4038,7 @@ impl fmt::Display for StorageError { range.0, range.1 ), Self::BadAnchorNumber(n) => write!(f, "bad Identity Anchor {n}"), + Self::Browser(err) => write!(f, "the browser could not be resolved: {err:?}"), Self::DeserializationError(err) => { write!(f, "failed to deserialize a Candid value: {err}") } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 778f2a4ba5..3b3cefa1c8 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -11,12 +11,12 @@ use crate::storage::storable::anchor_application_config::AnchorApplicationConfig use crate::storage::storable::application::StorableApplication; use crate::storage::StorableOriginSha256; use crate::storage::{AccountReferenceListWrite, AccountReferenceWrite}; -use crate::storage::{Header, StorageError, MAX_ENTRIES}; +use crate::storage::{CreateSessionParams, Header, StorageError, MAX_ENTRIES}; use crate::Storage; use candid::Principal; use ic_stable_structures::{Memory, VectorMemory}; use internet_identity_interface::internet_identity::types::{ - AccountNumber, AnchorNumber, ApplicationNumber, FrontendHostname, Timestamp, + AccountNumber, AnchorNumber, ApplicationNumber, FrontendHostname, PublicKey, Timestamp, }; use internet_identity_interface::internet_identity::types::{ ArchiveConfig, DeviceProtection, KeyType, Purpose, @@ -91,6 +91,47 @@ fn held_references( } /// One origin's worth of a write, in the shape the gate takes it. +/// The key a browser proves with at `generation`, and the successor it announces. +/// +/// A browser is registered by the key it presents and reached only by the successor it +/// announced, so signing in twice from one browser means presenting `generation` and +/// then `generation + 1` — which is what a real browser does when it rotates. +const SESSION_TEST_ORIGIN: &str = "https://example.com"; + +pub(crate) fn browser_key(seed: u8, generation: u8) -> PublicKey { + let mut key = vec![0u8; 32]; + key[0] = seed; + key[1] = generation; + ByteBuf::from(key) +} + +/// A first sign-in from the browser `seed` names. +pub(crate) fn params(anchor_number: AnchorNumber, seed: u8, now: u64) -> CreateSessionParams { + params_at(anchor_number, seed, 0, now) +} + +/// A sign-in from the browser `seed` names, presenting the key it holds after +/// `generation` rotations. +pub(crate) fn params_at( + anchor_number: AnchorNumber, + seed: u8, + generation: u8, + now: u64, +) -> CreateSessionParams { + CreateSessionParams { + anchor_number, + origin: SESSION_TEST_ORIGIN.to_string(), + account_number: None, + current_browser_key: browser_key(seed, generation), + next_browser_key: browser_key(seed, generation + 1), + browser_name: format!("browser {seed}"), + valid_till_ns: now + 10_000, + max_idle_ns: None, + read_only: false, + now_ns: now, + } +} + pub(crate) fn write_at( origin: &FrontendHostname, account_references: Vec, @@ -5344,8 +5385,10 @@ mod session_record_tests { mod session_creation_tests { use super::held_references; + use super::{params, params_at}; use crate::delegation::calculate_session_seed_with_salt; use crate::storage::account::{SessionRecord, DEFAULT_SESSION_IDLE_NS, MIN_SESSION_IDLE_NS}; + use crate::storage::anchor::MAX_BROWSERS; use crate::storage::CreateSessionParams; use crate::{Storage, DAY_NS, MINUTE_NS}; use ic_stable_structures::VectorMemory; @@ -5376,30 +5419,28 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let elsewhere = "https://elsewhere.example".to_string(); - // The browser that is about to be given up, holding a session at each of two - // origins, so this also shows the sweep is not limited to the one being written. - for origin in [ORIGIN.to_string(), elsewhere.clone()] { + // The browser that will be given up, holding a session at each of two origins, so + // this also shows the sweep is not limited to the origin being written. Its second + // sign-in presents the successor it announced at its first. + storage + .create_session(params(anchor_number, 7, 1_000)) + .unwrap(); + storage + .create_session(CreateSessionParams { + origin: elsewhere.clone(), + ..params_at(anchor_number, 7, 1, 1_000) + }) + .unwrap(); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); + + // Fill the registry, so the browser above is the least recently used when the one + // that does not fit arrives. Nothing here says which browser is given up, or that + // its sessions go with it: the write works both out. + for index in 0..MAX_BROWSERS { storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - origin, - ..params(7, 1_000) - }, - ) + .create_session(params(anchor_number, 100 + index as u8, 2_000)) .unwrap(); } - assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); - - storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - dropped_browsers: vec![7], - ..params(8, 2_000) - }, - ) - .unwrap(); let held: Vec = storage .account_state(anchor_number) @@ -5409,27 +5450,22 @@ mod session_creation_tests { .flat_map(|write| write.account_reference.sessions) .map(|session| session.browser_id) .collect(); - assert_eq!(held, vec![8], "only the browser that replaced it is left"); + assert!( + !held.contains(&0), + "the dropped browser's sessions outlived it: {held:?}" + ); assert_eq!( - storage.read(anchor_number).unwrap().session_count, - 1, + held.len(), + MAX_BROWSERS, + "one session each for the browsers still registered" + ); + assert_eq!( + storage.read(anchor_number).unwrap().session_count as usize, + MAX_BROWSERS, "and the count followed in the same write" ); } - fn params(browser_id: u32, now: u64) -> CreateSessionParams { - CreateSessionParams { - origin: ORIGIN.to_string(), - account_number: None, - browser_id, - valid_till_ns: now + 10_000, - max_idle_ns: None, - read_only: false, - now_ns: now, - dropped_browsers: vec![], - } - } - /// A list that predates the principal index, which is every list an existing user has: /// the index is written only where a list's set of account numbers changes, and by the /// backfill sweep. Emptied here to stand in for a list the sweep has not reached. @@ -5454,12 +5490,12 @@ mod session_creation_tests { fn creating_a_session_indexes_the_account_it_belongs_to() { let (mut storage, anchor_number) = storage_with_anchor(); storage - .create_session_for_testing(anchor_number, params(1, 1_000)) + .create_session(params(anchor_number, 1, 1_000)) .unwrap(); forget_account_principals(&mut storage); storage - .create_session_for_testing(anchor_number, params(2, 2_000)) + .create_session(params(anchor_number, 2, 2_000)) .unwrap(); let application_number = storage @@ -5499,14 +5535,11 @@ mod session_creation_tests { let asked = 20 * MINUTE_NS; let session = storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - max_idle_ns: Some(asked), - valid_till_ns: DAY_NS, - ..params(1, 0) - }, - ) + .create_session(CreateSessionParams { + max_idle_ns: Some(asked), + valid_till_ns: DAY_NS, + ..params(anchor_number, 1, 0) + }) .unwrap() .1; @@ -5518,14 +5551,11 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - max_idle_ns: Some(MINUTE_NS), - valid_till_ns: DAY_NS, - ..params(1, 0) - }, - ) + .create_session(CreateSessionParams { + max_idle_ns: Some(MINUTE_NS), + valid_till_ns: DAY_NS, + ..params(anchor_number, 1, 0) + }) .unwrap() .1; @@ -5539,14 +5569,11 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - max_idle_ns: Some(400 * DAY_NS), - valid_till_ns: DAY_NS, - ..params(1, 0) - }, - ) + .create_session(CreateSessionParams { + max_idle_ns: Some(400 * DAY_NS), + valid_till_ns: DAY_NS, + ..params(anchor_number, 1, 0) + }) .unwrap() .1; @@ -5560,13 +5587,10 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - valid_till_ns: 30 * DAY_NS, - ..params(1, 0) - }, - ) + .create_session(CreateSessionParams { + valid_till_ns: 30 * DAY_NS, + ..params(anchor_number, 1, 0) + }) .unwrap() .1; @@ -5583,14 +5607,11 @@ mod session_creation_tests { // Under the floor the range inverts, and clamping in one call would trap. let session = storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - valid_till_ns: MINUTE_NS, - max_idle_ns: Some(30 * MINUTE_NS), - ..params(1, 0) - }, - ) + .create_session(CreateSessionParams { + valid_till_ns: MINUTE_NS, + max_idle_ns: Some(30 * MINUTE_NS), + ..params(anchor_number, 1, 0) + }) .unwrap() .1; @@ -5602,32 +5623,66 @@ mod session_creation_tests { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session_for_testing(anchor_number, params(1, 1_000)) + .create_session(params(anchor_number, 1, 1_000)) .unwrap() .1; assert_eq!(session.created_at_ns, 1_000); assert_eq!(session.valid_till_ns, 11_000); assert_eq!(session.last_refreshed_ns, None); - assert_eq!(session.browser_id, 1); + // The registry minted it; the caller presented a key, not an id. + assert_eq!(session.browser_id, 0); assert_eq!(sessions_of(&storage, anchor_number), vec![session]); } + /// The write path is handed the identity record, so it stores it — including what the + /// resolution above changed on it, not only the session count it moves itself. + /// + /// A browser signing in again at an account it already holds a session at *replaces* + /// that session, so the count does not move. A write that stored the record only where + /// the count moved would throw the key rotation away on exactly those sign-ins, leaving + /// a key that is good for one sign-in usable for good — and the next rotation, which + /// presents the successor of a key that was never stored, would be refused. + #[test] + fn a_rotation_survives_a_sign_in_that_moves_no_session_count() { + let (mut storage, anchor_number) = storage_with_anchor(); + + // Registers the browser. One session added, so the count moves. + storage + .create_session(params(anchor_number, 1, 1_000)) + .unwrap(); + // Replaces it: one session out, one in, and the count stays where it was. + storage + .create_session(params_at(anchor_number, 1, 1, 2_000)) + .unwrap(); + + // Only reachable if the rotation the write above performed was stored. + let third = storage + .create_session(params_at(anchor_number, 1, 2, 3_000)) + .expect("the rotation from a count-neutral sign-in was not stored"); + assert_eq!(third.1.browser_id, 0, "still the one registry entry"); + assert_eq!(storage.read(anchor_number).unwrap().browsers().len(), 1); + } + /// 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_for_testing(anchor_number, params(1, 1_000)) + .create_session(params(anchor_number, 1, 1_000)) .unwrap() .1; let again = storage - .create_session_for_testing(anchor_number, params(1, 5_000)) + .create_session(params_at(anchor_number, 1, 1, 5_000)) .unwrap() .1; + assert_eq!( + again.browser_id, first.browser_id, + "the same registry entry" + ); assert_ne!(again.created_at_ns, first.created_at_ns); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); } @@ -5636,11 +5691,11 @@ mod session_creation_tests { fn a_different_device_gets_its_own_session() { let (mut storage, anchor_number) = storage_with_anchor(); storage - .create_session_for_testing(anchor_number, params(1, 1_000)) + .create_session(params(anchor_number, 1, 1_000)) .unwrap(); storage - .create_session_for_testing(anchor_number, params(2, 1_000)) + .create_session(params(anchor_number, 2, 1_000)) .unwrap(); assert_eq!(sessions_of(&storage, anchor_number).len(), 2); @@ -5649,19 +5704,20 @@ mod session_creation_tests { #[test] fn expired_sessions_are_pruned_when_the_list_is_written() { let (mut storage, anchor_number) = storage_with_anchor(); - for browser_id in 0..3 { + for seed in 0..3 { storage - .create_session_for_testing(anchor_number, params(browser_id, 1_000)) + .create_session(params(anchor_number, seed, 1_000)) .unwrap(); } - storage - .create_session_for_testing(anchor_number, params(9, 20_000)) - .unwrap(); + let latest = storage + .create_session(params(anchor_number, 9, 20_000)) + .unwrap() + .1; let sessions = sessions_of(&storage, anchor_number); assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].browser_id, 9); + assert_eq!(sessions[0].browser_id, latest.browser_id); } /// There is no per-reference cap: one browser holds one session per account, so the @@ -5669,12 +5725,10 @@ mod session_creation_tests { #[test] fn one_reference_holds_one_session_per_browser() { let (mut storage, anchor_number) = storage_with_anchor(); - for browser_id in 0..12u32 { - let mut p = params(browser_id, 1_000); + for seed in 0..12u8 { + let mut p = params(anchor_number, seed, 1_000); p.valid_till_ns = 1_000_000; - storage - .create_session_for_testing(anchor_number, p) - .unwrap(); + storage.create_session(p).unwrap(); } let sessions = sessions_of(&storage, anchor_number); @@ -5689,7 +5743,7 @@ mod session_creation_tests { fn a_session_handle_resolves_through_the_account_principal_index() { let (mut storage, anchor_number) = storage_with_anchor(); let session = storage - .create_session_for_testing(anchor_number, params(7, 1_000)) + .create_session(params(anchor_number, 7, 1_000)) .unwrap() .1; let application_number = storage @@ -5706,7 +5760,7 @@ mod session_creation_tests { assert_eq!(locator.anchor_number, anchor_number); assert_eq!(locator.application_number, application_number); - assert_eq!(session.browser_id, 7); + assert_eq!(session.browser_id, 0); } #[test] @@ -5715,12 +5769,10 @@ mod session_creation_tests { let named = storage .create_account(anchor_number, ORIGIN.to_string(), "named".to_string()) .unwrap(); - let mut p = params(1, 1_000); + let mut p = params(anchor_number, 1, 1_000); p.account_number = named.account_number; - storage - .create_session_for_testing(anchor_number, p) - .unwrap(); + storage.create_session(p).unwrap(); assert_eq!(sessions_of(&storage, anchor_number).len(), 0); let application_number = storage @@ -5737,10 +5789,10 @@ mod session_creation_tests { #[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(1, 1_000); + let mut p = params(anchor_number, 1, 1_000); p.account_number = Some(4_242); - let result = storage.create_session_for_testing(anchor_number, p); + let result = storage.create_session(p); assert!(result.is_err()); } @@ -5753,29 +5805,15 @@ mod session_creation_tests { #[test] fn a_session_replaced_in_the_same_round_does_not_inherit_its_identity() { let (mut storage, anchor_number) = storage_with_anchor(); - let same_round = |browser_id| CreateSessionParams { - origin: ORIGIN.to_string(), - account_number: None, - browser_id, + let same_round = |seed, generation| CreateSessionParams { valid_till_ns: 10_000, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - dropped_browsers: vec![], + ..params_at(anchor_number, seed, generation, 1_000) }; - let first = storage - .create_session_for_testing(anchor_number, same_round(1)) - .unwrap() - .1; - let replacement = storage - .create_session_for_testing(anchor_number, same_round(1)) - .unwrap() - .1; - let sibling = storage - .create_session_for_testing(anchor_number, same_round(2)) - .unwrap() - .1; + let first = storage.create_session(same_round(1, 0)).unwrap().1; + // The same browser again, presenting the successor it announced a moment ago. + let replacement = storage.create_session(same_round(1, 1)).unwrap().1; + let sibling = storage.create_session(same_round(2, 0)).unwrap().1; assert_eq!(first.created_at_ns, replacement.created_at_ns); assert_eq!(first.browser_id, replacement.browser_id); @@ -5788,30 +5826,18 @@ mod session_creation_tests { #[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 { - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 1, + // One browser, rotating as it must, signing in three times in the same round. + let attempt = |generation, read_only| CreateSessionParams { valid_till_ns: u64::MAX, - max_idle_ns: None, read_only, - now_ns: 1_000, - dropped_browsers: vec![], + ..params_at(anchor_number, 1, generation, 1_000) }; - let first = storage - .create_session_for_testing(anchor_number, params(false)) - .unwrap() - .1; - storage - .create_session_for_testing(anchor_number, params(false)) - .unwrap(); + let first = storage.create_session(attempt(0, false)).unwrap().1; + storage.create_session(attempt(1, false)).unwrap(); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); - let replaced = storage - .create_session_for_testing(anchor_number, params(true)) - .unwrap() - .1; + let replaced = storage.create_session(attempt(2, true)).unwrap().1; assert_ne!(replaced.read_only, first.read_only); assert_eq!(sessions_of(&storage, anchor_number).len(), 1); } @@ -5882,6 +5908,7 @@ mod session_creation_tests { mod session_consent_change_tests { use super::held_references; + use super::params_at; use crate::storage::CreateSessionParams; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -5899,26 +5926,20 @@ mod session_consent_change_tests { (storage, anchor_number) } + /// One browser signing in again, presenting the successor it announced last time. fn create( storage: &mut Storage, anchor_number: AnchorNumber, + generation: u8, read_only: bool, now: u64, ) -> u64 { storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 1, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only, - now_ns: now, - dropped_browsers: vec![], - }, - ) + .create_session(CreateSessionParams { + valid_till_ns: u64::MAX, + read_only, + ..params_at(anchor_number, 1, generation, now) + }) .unwrap() .1 .created_at_ns @@ -5941,9 +5962,9 @@ mod session_consent_change_tests { #[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 first = create(&mut storage, anchor_number, 0, false, 1_000); - let again = create(&mut storage, anchor_number, false, 2_000); + let again = create(&mut storage, anchor_number, 1, false, 2_000); assert_ne!(again, first); assert_eq!(sessions(&storage, anchor_number), vec![false]); @@ -5952,9 +5973,9 @@ mod session_consent_change_tests { #[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 full_access = create(&mut storage, anchor_number, 0, false, 1_000); - let read_only = create(&mut storage, anchor_number, true, 2_000); + let read_only = create(&mut storage, anchor_number, 1, true, 2_000); assert_ne!(read_only, full_access); assert_eq!(sessions(&storage, anchor_number), vec![true]); @@ -5963,9 +5984,9 @@ mod session_consent_change_tests { #[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, 0, true, 1_000); - create(&mut storage, anchor_number, false, 2_000); + create(&mut storage, anchor_number, 1, false, 2_000); assert_eq!(sessions(&storage, anchor_number), vec![false]); } @@ -5974,23 +5995,14 @@ mod session_consent_change_tests { fn a_consent_change_leaves_another_browser_alone() { let (mut storage, anchor_number) = storage_with_anchor(); storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - origin: ORIGIN.to_string(), - account_number: None, - browser_id: 2, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1_000, - dropped_browsers: vec![], - }, - ) + .create_session(CreateSessionParams { + valid_till_ns: u64::MAX, + ..params_at(anchor_number, 2, 0, 1_000) + }) .unwrap(); - create(&mut storage, anchor_number, false, 1_000); + create(&mut storage, anchor_number, 0, false, 1_000); - create(&mut storage, anchor_number, true, 2_000); + create(&mut storage, anchor_number, 1, true, 2_000); let mut held = sessions(&storage, anchor_number); held.sort_unstable(); From 74fa1e4ab78e92c35fcc0c23932b945098365d00 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 23:18:40 +0200 Subject: [PATCH 205/298] refactor(be): the ceremony hands over keys, not conclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare_account_session` resolved the browser itself and passed the results — the id, and the browsers the cap gave up — into the write that had to act on them. It now passes the keys the browser presented and lets the write work the rest out, so the registration, the browser the cap gives up, that browser's sessions and the new session are one change nothing has to remember to carry. The ceremony no longer reads or holds the identity record at all. Registering a browser stops being archived, and `Operation::RegisterBrowser` goes with it: knowing whether a browser was new was the last thing the ceremony needed the record for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/archive/archive.did | 5 -- src/canister_tests/src/api/archive.rs | 3 +- src/internet_identity/src/sessions.rs | 76 +++++++------------ .../tests/integration/sessions.rs | 65 ---------------- .../src/archive/types.rs | 6 -- 5 files changed, 29 insertions(+), 126 deletions(-) diff --git a/src/archive/archive.did b/src/archive/archive.did index 3ecc2ba589..01b3d98f42 100644 --- a/src/archive/archive.did +++ b/src/archive/archive.did @@ -71,11 +71,6 @@ 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_browser : 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 205933f105..5d53bf64f4 100644 --- a/src/canister_tests/src/api/archive.rs +++ b/src/canister_tests/src/api/archive.rs @@ -134,8 +134,7 @@ pub mod compat { | Operation::AddEmailRecovery | Operation::RemoveEmailRecovery | Operation::AddVerifiedEmail - | Operation::RemoveVerifiedEmail - | Operation::RegisterBrowser { .. } => { + | Operation::RemoveVerifiedEmail => { panic!("not available in compat type") } Operation::CreateAccount { name } => CompatOperation::CreateAccount { name }, diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 1e7fd4dcb7..c9207d0fa4 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -1,6 +1,5 @@ pub mod browser_key; -use crate::anchor_management::post_operation_bookkeeping; use crate::authz_utils::{ check_authorization, check_authz_and_record_activity, AuthorizationError, IdentityUpdateError, }; @@ -19,7 +18,6 @@ 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, Delegation, FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, PrepareAccountSessionRequest, @@ -122,53 +120,35 @@ pub async fn prepare_account_session( 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_browser = anchor.browsers().iter().any(|browser| { - browser.current_browser_key == current_browser_key - || browser.next_browser_key == current_browser_key - }); - let (browser_id, dropped_browsers) = anchor - .resolve_browser(current_browser_key, next_browser_key, browser_name, now) - .map_err(|error| match error { - // Told apart from the rest because the browser can act on it: it is the only - // party holding the successor that does resolve. - BrowserError::StaleBrowserKey => AccountSessionError::StaleBrowserKey, - _ => AccountSessionError::InvalidBrowserKey, - })?; - - if !known_browser { - post_operation_bookkeeping( - identity_number, - Operation::RegisterBrowser { - name: Private::Redacted, - }, - ); - } - - // 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. - // The anchor goes in rather than being written first: the browser registered above, - // the browsers the registry gave up to make room for it, their sessions, and the - // session created here are one change, so they are one write. A sign-in that is - // refused leaves none of it behind. + // The browser is resolved by the write, not here: registering it can put the registry + // over its cap, and the browser that gives way takes its sessions with it. That is one + // change with the session created below, so it is one write, and working any of it out + // here would be working out something the write has to be told. let (_, session) = storage_borrow_mut(|storage| { - storage.create_session( - &mut anchor, - CreateSessionParams { - origin: origin.clone(), - account_number, - browser_id, - valid_till_ns: valid_till, - max_idle_ns: max_idle, - read_only, - now_ns: now, - dropped_browsers, - }, - ) + storage.create_session(CreateSessionParams { + anchor_number: identity_number, + origin: origin.clone(), + account_number, + current_browser_key, + next_browser_key, + browser_name, + valid_till_ns: valid_till, + max_idle_ns: max_idle, + read_only, + now_ns: now, + }) }) - .expect("failed to create a session for an account that was just read"); + .map_err(|err| match err { + // Told apart from the rest because the browser can act on it: it is the only + // party holding the successor that does resolve. + StorageError::Browser(BrowserError::StaleBrowserKey) => { + AccountSessionError::StaleBrowserKey + } + StorageError::Browser(_) => AccountSessionError::InvalidBrowserKey, + // The account was checked above, so anything left is a broken storage invariant + // rather than a request this caller could have got wrong. + err => AccountSessionError::InternalCanisterError(err.to_string()), + })?; let seed = session_identity(identity_number, &origin, account_number, &session) .expect("failed to derive the identity of a session that was just created"); @@ -190,7 +170,7 @@ pub async fn prepare_account_session( user_key: ByteBuf::from(der_encode_canister_sig_key(seed.to_vec())), expiration: session.valid_till_ns, session_id: session.session_id, - browser_id, + browser_id: session.browser_id, account_principal, }) } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 880f1d7433..85abb0a677 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -15,7 +15,6 @@ use internet_identity_interface::internet_identity::types::{ 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"; @@ -118,70 +117,6 @@ fn should_refuse_a_session_for_another_anchor() -> Result<(), RejectResponse> { 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); - - let browser = BrowserKey::new(1); - prepare_account_session( - &env, - ii_canister, - principal_1(), - session_request_from(identity_number, &browser), - )? - .unwrap(); - - // The same browser signing in again is not a registration. It presents the successor - // it announced, which is the only key that reaches its entry. - let mut again = session_request_from(identity_number, &browser.successor()); - 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::RegisterBrowser { - 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. diff --git a/src/internet_identity_interface/src/archive/types.rs b/src/internet_identity_interface/src/archive/types.rs index ee50db0e65..a60ae1a240 100644 --- a/src/internet_identity_interface/src/archive/types.rs +++ b/src/internet_identity_interface/src/archive/types.rs @@ -77,12 +77,6 @@ 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_browser")] - RegisterBrowser { name: Private }, } #[derive(Eq, PartialEq, Clone, Debug, CandidType, Deserialize)] From 786aa5bf0d3613f54645c954a514ef8f213b43d8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Sun, 6 Sep 2026 23:38:01 +0200 Subject: [PATCH 206/298] test(be): the revocation and eviction tests sign in for real They named browser ids the registry never minted and reached `create_session` through a shim that handed it an identity record. Both are gone: a browser presents keys, the write mints its id, and a browser signing in twice rotates as one does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 97 ++++++++++------------ 1 file changed, 46 insertions(+), 51 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f295218d88..89ac28e963 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3776,6 +3776,7 @@ mod default_account_tracking_tests { mod tracked_default_eviction_tests { use super::application_number_for; use super::held_references; + use super::params; use super::record_use; use super::remove_at; use super::write_at; @@ -3824,19 +3825,11 @@ mod tracked_default_eviction_tests { // list and therefore the first thing eviction gives up. let doomed = origin_of(0); let (key, _) = storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - origin: doomed.clone(), - account_number: None, - browser_id: 1, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: 1, - dropped_browsers: vec![], - }, - ) + .create_session(CreateSessionParams { + origin: doomed.clone(), + valid_till_ns: u64::MAX, + ..params(anchor_number, 1, 1) + }) .expect("signing in at a fresh origin"); let session_principals: Vec<_> = storage .lookup_session_with_principal_memory @@ -6618,6 +6611,7 @@ mod session_removal_tests { mod session_revocation_tests { use super::held_references; + use super::params_at; use crate::storage::CreateSessionParams; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -6633,27 +6627,22 @@ mod session_revocation_tests { (storage, anchor_number) } + /// A sign-in from the browser `seed` names, at `origin`. A browser that has signed in + /// before presents the successor it announced, so `generation` says how many times. fn create( storage: &mut Storage, anchor_number: AnchorNumber, origin: &str, - browser_id: u32, + seed: u8, + generation: u16, now: u64, ) { storage - .create_session_for_testing( - anchor_number, - CreateSessionParams { - origin: origin.to_string(), - account_number: None, - browser_id, - valid_till_ns: u64::MAX, - max_idle_ns: None, - read_only: false, - now_ns: now, - dropped_browsers: vec![], - }, - ) + .create_session(CreateSessionParams { + origin: origin.to_string(), + valid_till_ns: u64::MAX, + ..params_at(anchor_number, seed, generation, now) + }) .unwrap(); } @@ -6678,16 +6667,17 @@ mod session_revocation_tests { #[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); + create(&mut storage, anchor_number, "https://a.com", 1, 0, 1_000); + create(&mut storage, anchor_number, "https://b.com", 1, 1, 1_000); + create(&mut storage, anchor_number, "https://a.com", 2, 0, 1_000); - let removed = storage.revoke_browser_sessions(anchor_number, 1).unwrap(); + // The registry minted 0 for the first browser to sign in and 1 for the second. + let removed = storage.revoke_browser_sessions(anchor_number, 0).unwrap(); assert_eq!(removed, 2); assert_eq!( browser_ids(&storage, anchor_number, "https://a.com"), - vec![2] + vec![1] ); assert_eq!( browser_ids(&storage, anchor_number, "https://b.com"), @@ -6701,28 +6691,36 @@ mod session_revocation_tests { 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); + create(&mut storage, anchor_number, "https://a.com", 1, 0, 1_000); + create( + &mut storage, + other_anchor_number, + "https://a.com", + 1, + 0, + 1_000, + ); - storage.revoke_browser_sessions(anchor_number, 1).unwrap(); + storage.revoke_browser_sessions(anchor_number, 0).unwrap(); assert_eq!( browser_ids(&storage, other_anchor_number, "https://a.com"), - vec![1] + vec![0], + "each identity has a registry of its own" ); } #[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); + create(&mut storage, anchor_number, "https://a.com", 1, 0, 1_000); let removed = storage.revoke_browser_sessions(anchor_number, 9).unwrap(); assert_eq!(removed, 0); assert_eq!( browser_ids(&storage, anchor_number, "https://a.com"), - vec![1] + vec![0] ); } } @@ -6736,6 +6734,7 @@ mod session_revocation_tests { /// one of those maintained by hand and forgotten at one write site. A test per operation /// catches the site it names; this catches the ones nobody thought to name. mod write_path_property_tests { + use super::params_at; use super::record_use; use crate::storage::account::{AccountKey, AccountReference}; use crate::storage::{CreateSessionParams, Storage}; @@ -6814,19 +6813,15 @@ mod write_path_property_tests { } 4 => { let account_number = pick_account(storage, anchor_number, &origin, rng); - let _ = storage.create_session_for_testing( - anchor_number, - CreateSessionParams { - origin, - account_number, - browser_id: rng.below(4) as u32, - valid_till_ns: now + 1 + rng.below(20_000), - max_idle_ns: None, - read_only: false, - now_ns: now, - dropped_browsers: vec![], - }, - ); + // A browser presents keys, and a stale one is refused like any other + // arbitrary write here: what matters is that whatever is stored stays + // consistent, not that every attempt succeeds. + let _ = storage.create_session(CreateSessionParams { + origin, + account_number, + valid_till_ns: now + 1 + rng.below(20_000), + ..params_at(anchor_number, rng.below(4) as u8, rng.below(3) as u16, now) + }); } 5 => { if let Some(key) = pick_session(storage, anchor_number, &origin, rng) { From ad956bef8c3c10e26a9aaa3a4f52757c94b72c9c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 00:55:50 +0200 Subject: [PATCH 207/298] fix(be): refuse a session that is already over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_session` clamped the idle bound to the life the session was granted, so a `valid_till_ns` at or before now made it zero — and the sweep that prunes dead sessions, which runs in this same call, took the new record straight back out. The write then stored a list without it and this returned `Ok` naming a session no list holds. Refused before anything is read or stored. Not reachable from the ceremony, which clamps the life to at least ten minutes ahead, but this is public and should not answer `Ok` for a session it did not create. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 16 ++++++++++++++++ src/internet_identity/src/storage/tests.rs | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5e4f9e25df..101783ad00 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2719,6 +2719,14 @@ impl Storage { read_only, now_ns, } = params; + + // A session that is over before it starts would be pruned by the sweep below, in + // the same call that created it, and this would return `Ok` naming a session no + // list holds. Refused here instead, where nothing has been read or stored yet. + if valid_till_ns <= now_ns { + return Err(StorageError::SessionAlreadyOver { anchor_number }); + } + let mut anchor = self.read(anchor_number)?; // Defaulted and clamped here rather than at the caller, so every path that @@ -3956,6 +3964,10 @@ pub enum StorageError { }, /// The browser presenting itself could not be resolved to a registry entry. Browser(BrowserError), + /// A session was asked for that is already over, which no list would hold. + SessionAlreadyOver { + anchor_number: AnchorNumber, + }, AnchorNumberOutOfRange { anchor_number: AnchorNumber, range: (AnchorNumber, AnchorNumber), @@ -4039,6 +4051,10 @@ impl fmt::Display for StorageError { ), Self::BadAnchorNumber(n) => write!(f, "bad Identity Anchor {n}"), Self::Browser(err) => write!(f, "the browser could not be resolved: {err:?}"), + Self::SessionAlreadyOver { anchor_number } => write!( + f, + "a session for Identity Anchor {anchor_number} would be over before it started" + ), Self::DeserializationError(err) => { write!(f, "failed to deserialize a Candid value: {err}") } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 3b3cefa1c8..79bdf43f9a 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5390,6 +5390,7 @@ mod session_creation_tests { use crate::storage::account::{SessionRecord, DEFAULT_SESSION_IDLE_NS, MIN_SESSION_IDLE_NS}; use crate::storage::anchor::MAX_BROWSERS; use crate::storage::CreateSessionParams; + use crate::storage::StorageError; use crate::{Storage, DAY_NS, MINUTE_NS}; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -5664,6 +5665,23 @@ mod session_creation_tests { assert_eq!(storage.read(anchor_number).unwrap().browsers().len(), 1); } + /// A session whose life has already run out is refused rather than created, because the + /// sweep that prunes dead sessions runs in this same call and would take it straight + /// back out — leaving this returning `Ok` for a session no list holds. + #[test] + fn a_session_that_is_already_over_is_refused() { + let (mut storage, anchor_number) = storage_with_anchor(); + + let mut expired = params(anchor_number, 1, 5_000); + expired.valid_till_ns = 5_000; + + assert!(matches!( + storage.create_session(expired), + Err(StorageError::SessionAlreadyOver { .. }) + )); + assert_eq!(storage.read(anchor_number).unwrap().browsers().len(), 0); + } + /// 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] From 2cb867c80f6f5791e55f1310d252d9ab0e426c8a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 00:58:14 +0200 Subject: [PATCH 208/298] fix(fe): a failed route change after recovery does not sign the browser out The two recovery handlers do the same job and did different things on error: the email one drops this browser's session delegation, the phrase one called `forgetIdentity`, which revokes every app session this browser holds for the identity and purges the app records with them. The recovery itself had succeeded in both; what failed was the navigation after it. They match now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/routes/(new-styling)/recovery/+page.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/recovery/+page.svelte b/src/frontend/src/routes/(new-styling)/recovery/+page.svelte index 7b347d93c4..ace91c6659 100644 --- a/src/frontend/src/routes/(new-styling)/recovery/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/recovery/+page.svelte @@ -38,7 +38,7 @@ import { throwCanisterError } from "$lib/utils/utils"; import { handleError } from "$lib/components/utils/error"; import { authenticationStore } from "$lib/stores/authentication.store"; - import { forgetIdentity, purgeSession } from "$lib/stores/session-delegation.store"; + import { purgeSession } from "$lib/stores/session-delegation.store"; import { authenticateWithSession } from "$lib/utils/authentication"; import { goto, preloadData } from "$app/navigation"; import { page } from "$app/state"; @@ -200,7 +200,11 @@ } catch (error) { showRecoveryDialog = false; authenticationStore.reset(); - void forgetIdentity(identityNumber); + // The recovery itself succeeded; what failed is the navigation after it. Dropping + // this browser's session delegation is enough — signing the identity out of every + // app it is signed into from here, as `forgetIdentity` does, is not something a + // failed route change should do. Matches the email-recovery handler above. + void purgeSession(identityNumber); handleError(error); } }; From a1ab2f8046e8a38714fdb02d526bda5bcae3dd64 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 12:53:18 +0200 Subject: [PATCH 209/298] refactor(be): the delegation paths ask for the salt rather than awaiting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A canister sets its salt once, at deployment. Awaiting `ensure_salt_set` on a delegation path was there to set it on the very first call, and the cost of that is an inter-canister boundary in the middle of every delegation ever issued: `time()` is constant only within one execution, so the clock had to be read after the await, and anything computed before it could arrive stale. `prepare_account_delegation`, `session_delegation::prepare_session_delegation` and `mcp::prepare_delegation` drop the await and take `now` from the endpoint that is the instant. All three, and the four endpoints above them, stop being async — there is no interleaving point left on the path. Same reasoning as the account endpoints in this stack, which stopped setting the salt for the same reason. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 26 ++++++------------- src/internet_identity/src/main.rs | 26 +++++++++++-------- src/internet_identity/src/mcp.rs | 10 +++---- .../src/session_delegation.rs | 8 +++--- 4 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index 94d9eee575..aed72e88da 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -18,7 +18,7 @@ use crate::{ update_root_hash, }; use ic_canister_sig_creation::{signature_map::CanisterSigInputs, DELEGATION_SIG_DOMAIN}; -use ic_cdk::{api::time, caller}; +use ic_cdk::caller; use ic_stable_structures::DefaultMemoryImpl; use internet_identity_interface::{ archive::types::{Operation, Private}, @@ -293,7 +293,7 @@ pub fn update_account_for_origin( // and the delegation access level through to the signature; the parameter list // is wide but each argument is distinct and load-bearing. #[allow(clippy::too_many_arguments)] -pub async fn prepare_account_delegation( +pub fn prepare_account_delegation( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, @@ -302,8 +302,8 @@ pub async fn prepare_account_delegation( max_expiration: Option, access: DelegationAccess, ii_domain: &Option, + now: Timestamp, ) -> Result { - state::ensure_salt_set().await; check_frontend_length(&origin); let account = storage_borrow(|storage| { @@ -317,26 +317,16 @@ pub async fn prepare_account_delegation( .ok_or(AccountDelegationError::Unauthorized(caller())) })?; - // One read, passed to everything below. `time()` is constant only within a single - // execution, and an `await` on an inter-canister call ends one — the continuation - // resumes with a later time. Reading it at each use would make "the same instant" - // rest on no `await` ever appearing between them, which is not a property to leave - // to where the calls happen to sit. - let now = time(); - let session_duration_ns = u64::min( max_ttl.unwrap_or(crate::delegation::DEFAULT_EXPIRATION_PERIOD_NS), crate::delegation::MAX_EXPIRATION_PERIOD_NS, ); // `max_expiration` is an *absolute* cap (e.g. the MCP session grant's - // expiry): a relative TTL computed by the caller before the await above - // could drift past it by however much time the await spans. By the same - // token the cap itself can already have passed once the await resolves - // (the caller checked it *before* awaiting) — refuse rather than sign a - // delegation that is already expired on arrival, which would read as - // success while wasting a signature-map entry on an unusable delegation. - // For the MCP path this is exactly the session-over signal: the grant - // expired mid-call. + // expiry), and the caller checked it before calling. Checked again here + // because a cap that has passed must not be signed over: a delegation that + // is already expired on arrival reads as success while spending a + // signature-map entry on something unusable. For the MCP path this is + // exactly the session-over signal: the grant expired mid-call. if max_expiration.is_some_and(|cap| cap <= now) { return Err(AccountDelegationError::Unauthorized(caller())); } diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 17cbbd3371..511246ede0 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -12,7 +12,7 @@ use authz_utils::{ }; use candid::Principal; use ic_canister_sig_creation::signature_map::LABEL_SIG; -use ic_cdk::api::{caller, set_certified_data, trap}; +use ic_cdk::api::{caller, set_certified_data, time, trap}; use ic_cdk::call; use ic_cdk_macros::{init, post_upgrade, pre_upgrade, query, update}; use internet_identity_interface::archive::types::{BufferedEntry, Operation}; @@ -337,7 +337,7 @@ fn get_principal(anchor_number: AnchorNumber, frontend: FrontendHostname) -> Pri } #[update] -async fn prepare_delegation( +fn prepare_delegation( anchor_number: AnchorNumber, frontend: FrontendHostname, session_key: SessionKey, @@ -356,8 +356,8 @@ async fn prepare_delegation( // The legacy endpoint has no read-only option. DelegationAccess::Unrestricted, &ii_domain, + time(), ) - .await .map( |PrepareAccountDelegation { user_key, @@ -484,7 +484,7 @@ fn set_default_account( } #[update] -async fn prepare_account_delegation( +fn prepare_account_delegation( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, @@ -508,8 +508,8 @@ async fn prepare_account_delegation( // value (queries-only by default in the CLI and MCP flows). DelegationAccess::from(permissions), &ii_domain, + time(), ) - .await } Err(err) => Err(err.into()), } @@ -588,15 +588,19 @@ fn mcp_set_config(anchor_number: AnchorNumber, config: McpConfig) -> Result<(), /// key) and hands back the `McpSession` the operation runs on, so there is no /// way to reach `prepare_delegation` without it. #[update] -async fn mcp_prepare_delegation( +fn mcp_prepare_delegation( target_origin: FrontendHostname, account_number: Option, session_key: SessionKey, max_ttl: Option, ) -> Result { - mcp::authorize_mcp_session_for_update()? - .prepare_delegation(target_origin, account_number, session_key, max_ttl) - .await + mcp::authorize_mcp_session_for_update()?.prepare_delegation( + target_origin, + account_number, + session_key, + max_ttl, + time(), + ) } /// Fetch the delegation prepared by `mcp_prepare_delegation`. The anchor is @@ -631,7 +635,7 @@ fn mcp_get_accounts( } #[update] -async fn prepare_session_delegation( +fn prepare_session_delegation( anchor_number: AnchorNumber, session_key: SessionKey, max_ttl: Option, @@ -639,7 +643,7 @@ async fn prepare_session_delegation( internet_identity_interface::internet_identity::types::PrepareSessionDelegation, internet_identity_interface::internet_identity::types::SessionDelegationError, > { - session_delegation::prepare_session_delegation(anchor_number, session_key, max_ttl).await + session_delegation::prepare_session_delegation(anchor_number, session_key, max_ttl, time()) } #[query] diff --git a/src/internet_identity/src/mcp.rs b/src/internet_identity/src/mcp.rs index 3b240c4dba..02da1bbf5c 100644 --- a/src/internet_identity/src/mcp.rs +++ b/src/internet_identity/src/mcp.rs @@ -486,17 +486,17 @@ impl McpSession { /// default account at an origin is mutable, so if `get` re-resolved it /// independently and it had changed in between, it would look under a /// different account's seed and `NoSuchDelegation`. - pub async fn prepare_delegation( + pub fn prepare_delegation( self, target_origin: FrontendHostname, account_number: Option, session_key: SessionKey, max_ttl: Option, + now: Timestamp, ) -> Result { let anchor_number = self.grant.anchor_number; // Cap at 5 minutes; the grant expiry is passed as an *absolute* cap so - // the delegation can't outlive the session even by the time an await - // spans. + // the delegation cannot outlive the session. let capped_ttl = Some(u64::min( max_ttl.unwrap_or(MCP_MAX_EXPIRATION_PERIOD_NS), MCP_MAX_EXPIRATION_PERIOD_NS, @@ -513,8 +513,8 @@ impl McpSession { Some(self.grant.expires_at_ns), DelegationAccess::from_read_only(self.grant.read_only), &None, - ) - .await?; + now, + )?; Ok(McpPrepareDelegation { user_key: prepared.user_key, expiration: prepared.expiration, diff --git a/src/internet_identity/src/session_delegation.rs b/src/internet_identity/src/session_delegation.rs index 444bd5add3..2c174bff07 100644 --- a/src/internet_identity/src/session_delegation.rs +++ b/src/internet_identity/src/session_delegation.rs @@ -5,7 +5,6 @@ use candid::Principal; use ic_canister_sig_creation::{ delegation_signature_msg, signature_map::CanisterSigInputs, DELEGATION_SIG_DOMAIN, }; -use ic_cdk::api::time; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ AnchorNumber, Delegation, PrepareSessionDelegation, SessionDelegationError, SessionKey, @@ -43,21 +42,20 @@ pub(crate) fn expected_session_principal(anchor_number: AnchorNumber) -> Princip Principal::self_authenticating(der_encode_canister_sig_key(seed.to_vec())) } -pub async fn prepare_session_delegation( +pub fn prepare_session_delegation( anchor_number: AnchorNumber, session_key: SessionKey, max_ttl: Option, + now: Timestamp, ) -> Result { check_authorization(anchor_number) .map_err(|err| SessionDelegationError::Unauthorized(err.principal))?; - state::ensure_salt_set().await; - let session_duration_ns = u64::min( max_ttl.unwrap_or(DEFAULT_SESSION_DELEGATION_TTL_NS), MAX_SESSION_DELEGATION_TTL_NS, ); - let expiration = time().saturating_add(session_duration_ns); + let expiration = now.saturating_add(session_duration_ns); let seed = session_delegation_seed(anchor_number); From 71737e9adf07653ec62b236cc8d8e19f04b310ae Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 12:57:54 +0200 Subject: [PATCH 210/298] refactor(be): nothing awaits the salt any more `ensure_salt_set` set the salt on first use, which meant every delegation path carried an inter-canister boundary for a case that happens once in a canister's life. `openid::prepare_jwt_delegation`, `email_inbound::stamp_recovery_delegation` and both attribute-sharing paths drop it and go sync; the legacy VC path keeps `async` for `random_salt`. With no caller left, `ensure_salt_set` itself goes. A canister sets its salt through `init_salt` at deployment, and a path that needs it reads it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/authz_utils.rs | 4 ++-- src/internet_identity/src/email_inbound/smtp.rs | 8 +------- .../src/email_inbound/submit_leaf.rs | 5 ++--- src/internet_identity/src/main.rs | 13 +++---------- src/internet_identity/src/openid.rs | 4 +--- src/internet_identity/src/session_delegation.rs | 8 ++++---- src/internet_identity/src/state.rs | 14 -------------- src/internet_identity/src/vc_mvp.rs | 1 - 8 files changed, 13 insertions(+), 44 deletions(-) diff --git a/src/internet_identity/src/authz_utils.rs b/src/internet_identity/src/authz_utils.rs index 78ace546c4..a8bffeff08 100644 --- a/src/internet_identity/src/authz_utils.rs +++ b/src/internet_identity/src/authz_utils.rs @@ -143,8 +143,8 @@ pub fn check_authorization( // and compare. See `crate::email_inbound::smtp::calculate_email_recovery_seed`. // // If the canister salt hasn't been initialised yet, no recovery - // delegation could have been stamped (the stamper awaits - // `ensure_salt_set()` before issuing). Skip the branch rather than + // delegation could have been stamped, because stamping reads it. + // Skip the branch rather than // calling `state::salt()`, which would trap. An unauthorized // caller in that state then falls through to a clean // `AuthorizationError` instead of a canister trap. diff --git a/src/internet_identity/src/email_inbound/smtp.rs b/src/internet_identity/src/email_inbound/smtp.rs index 10310c7be1..e8a4250593 100644 --- a/src/internet_identity/src/email_inbound/smtp.rs +++ b/src/internet_identity/src/email_inbound/smtp.rs @@ -887,7 +887,7 @@ pub(super) fn recovery_snapshot( /// `RecoveryReady { user_key, expiration, anchor_number }`, and /// `email_recovery_get_delegation` reads the cached `seed` to look /// up the signature without re-deriving from the anchor. -pub(super) async fn stamp_recovery_delegation( +pub(super) fn stamp_recovery_delegation( snapshot: &PendingSnapshot, session_pk: &SessionKey, ) -> Result { @@ -903,12 +903,6 @@ pub(super) async fn stamp_recovery_delegation( }) .ok_or(EmailChallengeError::AddressNotRegistered)?; - // The signature-map operations need the canister salt. In - // production it's already initialised (every prior delegation - // call paid that cost); we await defensively in case this is - // the very first delegation since deploy. - state::ensure_salt_set().await; - let expiration = ic_cdk::api::time().saturating_add(crate::delegation::DEFAULT_EXPIRATION_PERIOD_NS); let seed: Hash = calculate_email_recovery_seed(&snapshot.claimed_address, anchor_number); diff --git a/src/internet_identity/src/email_inbound/submit_leaf.rs b/src/internet_identity/src/email_inbound/submit_leaf.rs index 11f4ced414..5cfc9de44a 100644 --- a/src/internet_identity/src/email_inbound/submit_leaf.rs +++ b/src/internet_identity/src/email_inbound/submit_leaf.rs @@ -262,7 +262,7 @@ async fn finalize( mat.registered_domain.clone(), session_pk.clone(), ); - match super::smtp::stamp_recovery_delegation(&smtp_snapshot, session_pk).await { + match super::smtp::stamp_recovery_delegation(&smtp_snapshot, session_pk) { Ok(outcome) => pending::with_mut(nonce, now_secs, |c| { c.recovery_outcome = Some(outcome); c.status = PendingStatus::Succeeded; @@ -609,8 +609,7 @@ fn finalize_via_doh( ); let session_pk = session_pk.clone(); ic_cdk::spawn(async move { - match super::smtp::stamp_recovery_delegation(&smtp_snapshot, &session_pk).await - { + match super::smtp::stamp_recovery_delegation(&smtp_snapshot, &session_pk) { Ok(outcome) => pending::with_mut(&nonce, now_secs, |c| { c.recovery_outcome = Some(outcome); c.status = PendingStatus::Succeeded; diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 511246ede0..c1701e2411 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -1537,9 +1537,8 @@ mod openid_api { state::storage_borrow_mut(|storage| storage.write(anchor)) .map_err(|_| OpenIdDelegationError::NoSuchAnchor)?; - let (user_key, expiration) = openid_credential - .prepare_jwt_delegation(session_key, anchor_number) - .await; + let (user_key, expiration) = + openid_credential.prepare_jwt_delegation(session_key, anchor_number); // Checking again because the association could've changed during the .await let still_anchor_number = state::storage_borrow(|storage| { @@ -1672,8 +1671,7 @@ mod openid_api { let (user_key, expiration) = identity .credential - .prepare_jwt_delegation(session_key, anchor_number) - .await; + .prepare_jwt_delegation(session_key, anchor_number); // The session deadline is fixed here, at the ceremony, from the // policy captured while the discovery cache was warm. Everything @@ -2238,9 +2236,6 @@ mod attribute_sharing { let account = get_account_for_origin(anchor.anchor_number(), origin, account_number) .map_err(PrepareAttributeError::GetAccountError)?; - // This is the only async operation, so we do it first, call operations that depend on - // the time. TODO: refactor to avoid asynchronicity here. - state::ensure_salt_set().await; let issued_at_timestamp_ns = ic_cdk::api::time(); let attributes = anchor.prepare_attributes(attribute_keys, account, issued_at_timestamp_ns); @@ -2316,8 +2311,6 @@ mod attribute_sharing { get_account_for_origin(anchor.anchor_number(), origin.clone(), account_number) .map_err(PrepareIcrc3AttributeError::GetAccountError)?; - state::ensure_salt_set().await; - let issued_at_timestamp_ns = ic_cdk::api::time(); let message = anchor.prepare_icrc3_attributes( attributes, diff --git a/src/internet_identity/src/openid.rs b/src/internet_identity/src/openid.rs index e98a346fed..b395617fa7 100644 --- a/src/internet_identity/src/openid.rs +++ b/src/internet_identity/src/openid.rs @@ -128,13 +128,11 @@ impl OpenIdCredential { Principal::self_authenticating(public_key) } - pub async fn prepare_jwt_delegation( + pub fn prepare_jwt_delegation( &self, session_key: SessionKey, anchor_number: AnchorNumber, ) -> (UserKey, Timestamp) { - state::ensure_salt_set().await; - let expiration = time().saturating_add(OPENID_SESSION_DURATION_NS); let seed = calculate_delegation_seed(&self.key(), anchor_number); diff --git a/src/internet_identity/src/session_delegation.rs b/src/internet_identity/src/session_delegation.rs index 2c174bff07..5a4a157cbb 100644 --- a/src/internet_identity/src/session_delegation.rs +++ b/src/internet_identity/src/session_delegation.rs @@ -79,10 +79,10 @@ pub fn get_session_delegation( check_authorization(anchor_number) .map_err(|err| SessionDelegationError::Unauthorized(err.principal))?; - // No session could have been prepared before the canister salt was - // initialised (`prepare_session_delegation` awaits `ensure_salt_set` - // before stamping). Skip the seed derivation, which would otherwise - // trap on `state::salt()`, and report NoSuchDelegation instead. + // No session could have been prepared before the canister salt was set: a + // canister sets it once at deployment, and `prepare_session_delegation` reads + // it to stamp. Skip the seed derivation, which would otherwise trap on + // `state::salt()`, and report NoSuchDelegation instead. let salt_initialised = state::storage_borrow(|storage| storage.salt().is_some()); if !salt_initialised { return Err(SessionDelegationError::NoSuchDelegation); diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index 86cbd61f21..a7b7db8fd3 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -245,20 +245,6 @@ struct State { event_data_uniqueness_counter: Cell, } -// Checks if salt is empty and calls `init_salt` to set it. -pub async fn ensure_salt_set() { - let salt = storage_borrow(|storage| storage.salt().cloned()); - if salt.is_none() { - init_salt().await; - } - - storage_borrow(|storage| { - if storage.salt().is_none() { - trap("Salt is not set. Try calling init_salt() to set it"); - } - }); -} - pub async fn init_salt() { storage_borrow(|storage| { if storage.salt().is_some() { diff --git a/src/internet_identity/src/vc_mvp.rs b/src/internet_identity/src/vc_mvp.rs index 5c0f4df50d..cc6e4aacc5 100644 --- a/src/internet_identity/src/vc_mvp.rs +++ b/src/internet_identity/src/vc_mvp.rs @@ -40,7 +40,6 @@ pub async fn prepare_id_alias( identity_number: IdentityNumber, dapps: InvolvedDapps, ) -> PreparedIdAlias { - state::ensure_salt_set().await; check_frontend_length(&dapps.relying_party); check_frontend_length(&dapps.issuer); From f1286758985d08d6e51836f61f194c128bf2f38a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 14:39:09 +0200 Subject: [PATCH 211/298] fix(internet_identity): derive the salt from a timer at install The lazy initialisation that used to give a fresh canister its salt was removed along with the awaits it forced on every delegation path, which left a new deployment with no salt until someone called `init_salt` by hand. Deriving one needs raw randomness, so it needs an await that neither lifecycle hook can make; a zero-delay timer installed from `initialize` makes it the moment the install returns. The derivation does nothing where a salt is already set, which is what lets the timer be installed from `post_upgrade` as well as `init` without asking which case the canister is in. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 9 +++++++++ src/internet_identity/src/state.rs | 23 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index c1701e2411..9019ce36d5 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -14,7 +14,9 @@ use candid::Principal; use ic_canister_sig_creation::signature_map::LABEL_SIG; use ic_cdk::api::{caller, set_certified_data, time, trap}; use ic_cdk::call; +use ic_cdk::spawn; use ic_cdk_macros::{init, post_upgrade, pre_upgrade, query, update}; +use ic_cdk_timers::set_timer; use internet_identity_interface::archive::types::{BufferedEntry, Operation}; use internet_identity_interface::http_gateway::{HttpRequest, HttpResponse}; use internet_identity_interface::internet_identity::types::attributes::{ @@ -38,6 +40,7 @@ use internet_identity_interface::internet_identity::types::vc_mvp::{ use internet_identity_interface::internet_identity::types::*; use serde_bytes::ByteBuf; use std::collections::HashMap; +use std::time::Duration; use storage::account::{AccountDelegationError, PrepareAccountDelegation}; use storage::{Salt, Storage}; @@ -832,6 +835,12 @@ fn initialize(maybe_arg: Option) { if let Some(openid_configs) = config.openid_configs { openid::setup(openid_configs); } + + // A salt is derived from raw randomness, which takes an await this hook cannot make, so + // the derivation runs the moment the install returns instead. Doing nothing on a + // canister that already has one is what makes this safe from `post_upgrade` too, where + // the salt is already in stable memory. + set_timer(Duration::ZERO, || spawn(state::set_salt_if_unset())); } fn apply_install_arg(maybe_arg: Option) { diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index a7b7db8fd3..2e24e65dd5 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -252,8 +252,29 @@ pub async fn init_salt() { } }); + set_salt_if_unset().await; +} + +/// Gives the canister a salt where it has none, and does nothing where it already has one. +/// +/// Deriving one needs raw randomness, so it needs an await that neither `init` nor +/// `post_upgrade` can make; a zero-delay timer installed from there calls this instead. +/// Being a no-op on a canister that already carries a salt is what lets that timer be +/// installed from both without asking which case it is in. +pub async fn set_salt_if_unset() { + if storage_borrow(|storage| storage.salt().is_some()) { + return; + } + let salt = random_salt().await; - storage_borrow_mut(|storage| storage.update_salt(salt)); // update_salt() traps if salt has already been set + storage_borrow_mut(|storage| { + // Re-checked after the await, which is where a second message can have gone all + // the way through. Both salts are random, so the loser discards its own rather + // than trapping a caller whose request was perfectly good. + if storage.salt().is_none() { + storage.update_salt(salt); + } + }); } pub fn salt() -> [u8; 32] { From 77d457416178859574fc3ddceeab629c1e8c8a48 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 16:04:26 +0200 Subject: [PATCH 212/298] fix(internet_identity): derive the salt from init, not from every upgrade `initialize` is called from `post_upgrade` as well as `init`, so installing the timer there had every upgrade schedule a derivation for a canister that has had a salt for years. The derivation returns early where one is set, so it was harmless, but the guard exists to make the call safe rather than to excuse installing it where it has nothing to do. A salt is needed exactly once, on first install. `post_upgrade` finds it already in stable memory, because that is where it lives and what surviving an upgrade means. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 9019ce36d5..0289ebd654 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -811,6 +811,11 @@ fn acknowledge_entries(sequence_number: u64) { fn init(maybe_arg: Option) { state::init_new(); initialize(maybe_arg); + // A salt is derived from raw randomness, which takes an await this hook cannot make, so + // the derivation runs the moment the install returns instead. Only a first install needs + // one: the salt lives in stable memory, which is what an upgrade preserves, so + // `post_upgrade` has nothing to do here. + set_timer(Duration::ZERO, || spawn(state::set_salt_if_unset())); } #[post_upgrade] @@ -835,12 +840,6 @@ fn initialize(maybe_arg: Option) { if let Some(openid_configs) = config.openid_configs { openid::setup(openid_configs); } - - // A salt is derived from raw randomness, which takes an await this hook cannot make, so - // the derivation runs the moment the install returns instead. Doing nothing on a - // canister that already has one is what makes this safe from `post_upgrade` too, where - // the salt is already in stable memory. - set_timer(Duration::ZERO, || spawn(state::set_salt_if_unset())); } fn apply_install_arg(maybe_arg: Option) { From 69cfc968ccd0c2fee27da36f0b6a432d1e60523c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 16:04:26 +0200 Subject: [PATCH 213/298] fix(internet_identity): refuse a repeated account reference A list holding one account twice, or holding a second numberless reference, was storable. Neither is a state any write intends, and both break things outside the list: two references naming one account derive a single principal for two slots while the counters claim both, and a second tracked default is counted as another default, inflating an identity's evictable count against its cap while leaving a list eviction can never pick, since eviction only picks a list whose single reference is a tracked default. Refused here, alongside the empty list, because this is the only way to build a list to be stored. A reference is identified by the account it names and the numberless one names the tracked default, so one pass over `Option` catches both shapes. The rule holds for every list already stored, which is the bar this file sets for a new refusal: an absent list was created holding one numberless reference and one number, an existing list only ever had a freshly allocated number pushed onto it, and a `last_used` stamp rewrote a list in place without changing its length. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../storable/account_reference_list.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/internet_identity/src/storage/storable/account_reference_list.rs b/src/internet_identity/src/storage/storable/account_reference_list.rs index 997501dc1f..dfd21b3aec 100644 --- a/src/internet_identity/src/storage/storable/account_reference_list.rs +++ b/src/internet_identity/src/storage/storable/account_reference_list.rs @@ -2,8 +2,10 @@ use crate::storage::account::AccountReference; use crate::storage::storable::account_reference::StorableAccountReference; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; +use internet_identity_interface::internet_identity::types::AccountNumber; use minicbor::{Decode, Encode}; use std::borrow::Cow; +use std::collections::BTreeSet; use std::fmt; /// Vectors are not supported yet in ic-stable-structures, this file @@ -42,6 +44,17 @@ pub enum StorableAccountReferenceListError { /// rather than an intent — storing it would deny an identity its default account /// for good. Empty, + /// One account cannot be held twice at one origin. Both references would derive the + /// same principal, so the index would hold one entry for two slots while the stored + /// account count claimed both — and nothing downstream could tell which reference a + /// read had answered from. + RepeatedAccountNumber(AccountNumber), + /// An identity has one tracked default at an origin. A second numberless reference + /// derives the same principal as the first and is counted as another default, which + /// inflates the identity's evictable-default count against its cap and leaves a list + /// no eviction can ever pick, because eviction only picks a list whose *single* + /// reference is a tracked default. + RepeatedTrackedDefault, } impl fmt::Display for StorableAccountReferenceListError { @@ -51,6 +64,14 @@ impl fmt::Display for StorableAccountReferenceListError { f, "refusing to store an empty account reference list, which would be a tombstone" ), + Self::RepeatedAccountNumber(account_number) => write!( + f, + "refusing to store an account reference list holding account {account_number} twice" + ), + Self::RepeatedTrackedDefault => write!( + f, + "refusing to store an account reference list holding more than one tracked default" + ), } } } @@ -90,6 +111,21 @@ impl TryFrom> for StorableAccountReferenceList { return Err(StorableAccountReferenceListError::Empty); } + // A reference is identified by the account it names, and the numberless one names + // the tracked default — so one pass over `Option` catches a repeated + // number and a second tracked default alike. + let mut seen = BTreeSet::new(); + for reference in &value { + if !seen.insert(reference.account_number) { + return Err(match reference.account_number { + Some(account_number) => { + StorableAccountReferenceListError::RepeatedAccountNumber(account_number) + } + None => StorableAccountReferenceListError::RepeatedTrackedDefault, + }); + } + } + Ok(StorableAccountReferenceList( value .iter() @@ -129,6 +165,27 @@ mod tests { assert_eq!(Vec::::from(stored), references); } + #[test] + fn refuses_the_same_account_twice() { + assert_eq!( + StorableAccountReferenceList::try_from(vec![ + reference(None), + reference(Some(7)), + reference(Some(7)), + ]) + .err(), + Some(StorableAccountReferenceListError::RepeatedAccountNumber(7)) + ); + } + + #[test] + fn refuses_a_second_tracked_default() { + assert_eq!( + StorableAccountReferenceList::try_from(vec![reference(None), reference(None)]).err(), + Some(StorableAccountReferenceListError::RepeatedTrackedDefault) + ); + } + #[test] fn a_list_without_a_tracked_default_is_storable() { // Not a tombstone: the default was named, so the list legitimately holds only From b67df08e755ba806e70f014f82de2e5d458b2168 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 16:10:12 +0200 Subject: [PATCH 214/298] fix(internet_identity): derive the default account from the reference list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default account at an origin is a value derived from that origin's account reference list, and it was the last such value maintained outside this write. Validation built the list and then, lines later, took the caller's config exactly as given without ever relating the two. Two directions, one repair. A caller changing the default supplies a config; a write that removes what the default named supplies none, which means "leave it alone" — and leaving it alone must not mean leaving it naming an account that has gone. So the target is taken from the caller's config where there is one and from the stored config otherwise, and where the list being written does not hold it the default moves to the first reference that list still has. Repaired rather than refused: refusing would mean a write that drops a reference is rejected unless its caller also remembered to move the default, which is the hand-maintenance this layer exists to remove. The same shape as the browser cap dropping the sessions of a browser that falls out of it. The repair is settled before the decision about whether a list is written, which reads whether a config was supplied — settled after, a write carrying only a config the repair then dropped would still materialise a list for nothing. `set_default_account` stops building a config and moves one field of the stored one instead, as does the arm that derives a default from a freshly named account. Building one decides every field it leaves out, which is wrong the day the config holds a second. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 9 +- src/internet_identity/src/storage.rs | 114 +++++++++++++----- src/internet_identity/src/storage/tests.rs | 84 ++++++++++++- 3 files changed, 170 insertions(+), 37 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index c6470bdfbd..5c431d540b 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -1052,10 +1052,11 @@ fn should_fall_back_to_the_tracked_default_when_the_reservation_is_stale() { storage_borrow_mut(|storage| storage.write(anchor)).unwrap(); create_account_for_origin(anchor_number, origin.clone(), "Alice".to_string()).unwrap(); - // A number this identity does not hold, which is the shape a stale reservation has. - // Written through storage rather than through `set_default_account_for_origin`, - // which reads the account first and so cannot produce this state — a rename or a - // move is what leaves it behind. + // A number this identity does not hold. The write does not store it: the default is + // related to the account reference list and moved to a reference that is there, which + // is the tracked default here. So this asserts the repair rather than a tolerance — + // the read below answers from a default that exists, because no other kind was left + // behind. storage_borrow_mut(|storage| { storage.set_default_account(anchor_number, origin.clone(), Some(9_999)) }) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index d43b8b38c2..3890fcc20f 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1771,6 +1771,14 @@ impl Storage { let stored = stored_number.and_then(|application_number| { self.stored_account_references(anchor_number, application_number) }); + // Read because a caller changing nothing about the default passes no config, and + // "leave it alone" must not be allowed to mean "leave it naming an account that + // has gone". The repair below needs to know what is there to decide that. + let stored_config = stored_number + .map(|application_number| { + self.lookup_anchor_application_config(anchor_number, application_number) + }) + .unwrap_or_default(); let previous_holds_tracked_default = match &stored { Some(references) => references .iter() @@ -1796,6 +1804,64 @@ impl Storage { .map(|write| write.account_reference.clone()) .collect(); + // Naming a tracked default is what makes the named account this identity's + // default here: the tracked default is gone and the account that replaced it is + // what the identity signs in with. The caller cannot say so itself, because the + // number was minted above — so it is derived from the write rather than stated. + // Adding a named account beside a default that is still there is not this: the + // list still holds a numberless account reference. + // + // Mutating what is stored rather than building a config: this owns one field of + // it and must not decide the rest by omission. + let config = match config { + Some(config) => Some(config), + None if previous_holds_tracked_default + && minted.len() == 1 + && !references + .iter() + .any(|reference| reference.account_number.is_none()) => + { + let mut config = stored_config.clone(); + config.default_account_number = Some(minted[0]); + Some(config) + } + None => None, + }; + + // A config names this identity's default here, so it can only name an account + // reference this write leaves behind. Repaired rather than refused: an account + // reference that goes takes the default with it, and the default moves to the + // first reference still there instead of being left naming nothing. Refusing + // would mean a write that drops a reference is rejected unless its caller also + // remembered to move the default — which is the hand-maintenance this layer + // exists to remove. + // + // The target is the caller's where it supplied a config and the stored one + // otherwise, so both directions go through the same repair: a caller changing the + // default, and a write that leaves the default alone while removing what it named. + // + // `first()` rather than an index, because nothing here establishes that the list + // is non-empty — that is settled further down, in another file. The answer for an + // empty list is never applied: a list that cannot be stored refuses the write, and + // a refusal stores nothing. + let target = config + .as_ref() + .map_or(stored_config.default_account_number, |config| { + config.default_account_number + }); + let config = if references + .iter() + .any(|reference| reference.account_number == target) + { + config + } else { + references.first().map(|reference| { + let mut config = config.unwrap_or(stored_config); + config.default_account_number = reference.account_number; + config + }) + }; + // Nothing changed, so nothing is written and nothing about it is checked. // // Against the *normalised* previous rather than the stored one: at an origin @@ -1828,27 +1894,6 @@ impl Storage { }) .collect(); - // Naming a tracked default is what makes the named account this identity's - // default here: the tracked default is gone and the account that replaced it is - // what the identity signs in with. The caller cannot say so itself, because the - // number was minted above — so it is derived from the write rather than stated. - // Adding a named account beside a default that is still there is not this: the - // list still holds a numberless account reference. - let config = match config { - Some(config) => Some(config), - None if previous_holds_tracked_default - && minted.len() == 1 - && !references - .iter() - .any(|reference| reference.account_number.is_none()) => - { - Some(AnchorApplicationConfig { - default_account_number: Some(minted[0]), - }) - } - None => None, - }; - // Only a list or a config is stored against an application: a record is keyed by // its account number and needs none. So a rename, which leaves every account // reference where it was, does not check the application either — which is what @@ -1991,6 +2036,7 @@ impl Storage { /// Anything else is left alone rather than refused. Eviction is housekeeping that runs /// alongside a sign-in, and refusing the whole write because one victim went stale /// would fail the sign-in that triggered it. + fn validate_removal( &self, anchor_number: AnchorNumber, @@ -2537,8 +2583,10 @@ impl Storage { /// Points this identity's default at `origin` to `account_number`, or clears it /// where that is `None`. /// - /// The config and the reference list go through one write, so a config naming a - /// number no reference names cannot be left behind. + /// A number no account reference names is not stored: the write relates the config to + /// the list and moves the default to a reference that is there. So this cannot leave + /// behind a default naming nothing, and neither can a write that removes what one + /// named. pub fn set_default_account( &mut self, anchor_number: AnchorNumber, @@ -2548,17 +2596,19 @@ impl Storage { check_frontend_length(&origin); let (account_references, _) = self.account_state_for_origin(anchor_number, &origin); + // The stored config with one field moved, rather than a config built here: this + // knows about the default account and nothing else the config may come to hold, + // and building one would decide those fields by leaving them out. + let mut config = self + .lookup_application_number_with_origin(&origin) + .map(|application_number| { + self.lookup_anchor_application_config(anchor_number, application_number) + }) + .unwrap_or_default(); + config.default_account_number = account_number; self.write_account_state( anchor_number, - BTreeMap::from([( - origin, - Some(( - account_references, - Some(AnchorApplicationConfig { - default_account_number: account_number, - }), - )), - )]), + BTreeMap::from([(origin, Some((account_references, Some(config))))]), )?; Ok(()) } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 44341fd18f..899aafb46f 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3048,11 +3048,12 @@ mod account_reference_state_tests { use crate::storage::account::{Account, AccountKey, AccountReference}; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::storable::application::StorableApplication; - use crate::storage::StorageError; + use crate::storage::{AccountReferenceWrite, StorageError}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::{AccountNumber, AnchorNumber}; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; const ORIGIN: &str = "https://example.com"; @@ -3228,6 +3229,87 @@ mod account_reference_state_tests { ); } + #[test] + fn a_default_naming_an_account_the_list_does_not_hold_moves_to_one_it_does() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + storage + .create_account(anchor_number, origin.clone(), "named".to_string()) + .unwrap(); + + // A number this identity does not hold at this origin, which is what a caller + // that had gone stale would ask for. + storage + .set_default_account(anchor_number, origin.clone(), Some(9_999)) + .unwrap(); + + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + let references = storage + .stored_account_references(anchor_number, application_number) + .unwrap(); + assert_eq!( + storage + .lookup_anchor_application_config(anchor_number, application_number) + .default_account_number, + references[0].account_number, + "the default should have moved to the first reference the list still holds" + ); + } + + #[test] + fn dropping_the_reference_a_default_names_moves_the_default() { + let (mut storage, anchor_number) = storage_with_anchor(); + let origin = ORIGIN.to_string(); + let named = storage + .create_account(anchor_number, origin.clone(), "named".to_string()) + .unwrap(); + let account_number = named.account_number.unwrap(); + storage + .set_default_account(anchor_number, origin.clone(), Some(account_number)) + .unwrap(); + let application_number = storage + .lookup_application_number_with_origin(&origin) + .unwrap(); + assert_eq!( + storage + .lookup_anchor_application_config(anchor_number, application_number) + .default_account_number, + Some(account_number) + ); + + // The write says what the identity holds afterwards, and this one no longer holds + // the account the default names. No caller says anything about the default: the + // point is that it moves without being told to. + storage + .write_account_state( + anchor_number, + BTreeMap::from([( + origin.clone(), + Some(( + vec![AccountReferenceWrite { + account_reference: AccountReference { + account_number: None, + last_used: None, + }, + record: None, + }], + None, + )), + )]), + ) + .unwrap(); + + assert_eq!( + storage + .lookup_anchor_application_config(anchor_number, application_number) + .default_account_number, + None, + "the default should have moved to the tracked default the list still holds" + ); + } + #[test] fn naming_a_default_keeps_its_reference_in_place() { let (mut storage, anchor_number) = storage_with_anchor(); From b5f7a49b9f33bb676b1158f49ffa0bbdeeaf69f5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 16:10:25 +0200 Subject: [PATCH 215/298] docs(internet_identity): name what retiring a list does, and where the cap sits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three answers to review questions that the code had but never said. `ReferenceListDeltas::removing` becomes `retiring`. Its own doc comment already said retiring, and retire is this file's word for it everywhere else — an application no counter will ever retire, retirement running off a write to account state, a list retired when a live tracked default is all it holds. Only the name said otherwise, and "removing" reads as a process without a subject. `validate_removal` said when a list may be removed and never what goes with it. Four things do, and one of them is visible to a person: evicting an idle origin's list signs that origin's sessions out. `MAX_EVICTABLE_DEFAULT_ACCOUNTS` called itself a per-anchor cap, which invited the question of why the count settles elsewhere. It is where eviction triggers; the pass trims to the watermark below, and the origins the triggering write is touching are not candidates for it, so the count comes to rest a little above the watermark and not at either number. The test that asserts exactly that had the arithmetic in a bare `- 1`, which is now spelled out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 18 ++++++++++++++---- src/internet_identity/src/storage/tests.rs | 3 +++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 3890fcc20f..86eabee891 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -304,8 +304,13 @@ const BUCKET_SIZE_IN_PAGES: u16 = 128; const MAX_MANAGED_MEMORY_SIZE: u64 = 256 * GB; const MAX_MANAGED_WASM_PAGES: u64 = MAX_MANAGED_MEMORY_SIZE / WASM_PAGE_SIZE_IN_BYTES; -/// Per-anchor cap on account reference lists that hold nothing but a tracked default +/// Per-anchor bound on account reference lists that hold nothing but a tracked default /// account. +/// +/// Where eviction triggers, not where the count comes to rest: reaching this starts a pass +/// that trims to the watermark below, and the origins the triggering write is touching are +/// not candidates for it, so the count settles a little above that watermark rather than at +/// either number. const MAX_EVICTABLE_DEFAULT_ACCOUNTS: u64 = 500; /// Eviction target, below the cap. @@ -2036,7 +2041,12 @@ impl Storage { /// Anything else is left alone rather than refused. Eviction is housekeeping that runs /// alongside a sign-in, and refusing the whole write because one victim went stale /// would fail the sign-in that triggered it. - + /// + /// Four things go, not one: the list, the config keyed beside it, the reference and + /// tombstone counters, and every session the list held. The last is the only one a + /// person can see — evicting an idle origin's list signs that origin's sessions out — + /// and it is the reason a list holding anything more than a tracked default is never + /// a candidate. fn validate_removal( &self, anchor_number: AnchorNumber, @@ -2069,7 +2079,7 @@ impl Storage { .get(&application_number) .ok_or(StorageError::OriginNotFoundForApplicationNumber { application_number })?; - let deltas = ReferenceListDeltas::removing(&previous); + let deltas = ReferenceListDeltas::retiring(&previous); let (application_accounts, application_references) = deltas.apply( ReferenceCounter::Application { application_number }, application.stored_accounts, @@ -3085,7 +3095,7 @@ impl ReferenceListDeltas { /// Separate from [`Self::between`] rather than a write of an empty list, because /// an empty list cannot be written at all: a list holding nothing is a tombstone /// and stays, so only an outright removal gets to zero these out. - fn removing(previous: &[AccountReference]) -> Self { + fn retiring(previous: &[AccountReference]) -> Self { let removed = Self::between(Some(&[]), previous); Self { accounts: removed.accounts.saturating_neg(), diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 899aafb46f..903e89ed42 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3826,6 +3826,9 @@ mod tracked_default_eviction_tests { sign_in_at(&mut storage, anchor_number, index); } + // One above the watermark, not at it: the origin the triggering write touched is + // never a candidate for its own eviction, so it survives on top of what the pass + // trims the rest down to. let evicted = MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; assert_eq!( storage.evictable_default_lists(anchor_number).len() as u64, From d5ca19bab14ccc7bcbe963909000208b9895e05e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 16:14:06 +0200 Subject: [PATCH 216/298] perf(internet_identity): derive only the accounts a write moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping the principal index in step derived both lists in full, so adding the 500th account at an origin cost a stable read, a seed hash and a principal derivation for roughly a thousand references in order to change one index entry. A principal comes from the salt, the origin and the account the reference names, never from its name or its last use, so a reference whose number is in both lists derives the same principal against the same locator and needs nothing done to it. The number is the whole key: `seed_from_anchor`, the only other thing a seed is taken from, is written where a number is minted and nowhere else, so it cannot move under a number that already exists. Naming a tracked default still works for that reason rather than in spite of it — naming mints a number, so the key moves and both sides are derived, which is what lets the locator move under a principal that does not. What this gives up is repair. Deriving both lists re-asserted the entry of every account at the origin on every write, so a drifted one was fixed by the next unrelated write. The sweep that runs after every upgrade is the repair now: the backfill's completion is heap state, so it is forgotten at each upgrade and walks every list again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 41 +++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 017987e792..93f1a97fc0 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2353,8 +2353,22 @@ impl Storage { AnchorApplicationConfig::default() } - /// Keeps the principal index in step with one reference-list write, diffing values - /// rather than keys. + /// Keeps the principal index in step with one reference-list write, deriving only the + /// accounts the write moved. + /// + /// A principal comes from the salt, the origin and the account the reference names, + /// and never from its name or its last use, so a reference whose number is in both + /// lists derives the same principal against the same locator and needs nothing done + /// to it. The number is the whole key: `seed_from_anchor`, the only other thing a + /// seed is taken from, is written where a number is minted and nowhere else, so it + /// cannot move under a number that already exists. + /// + /// The cost of that is what this no longer does. It used to derive both lists in + /// full, which re-asserted the entry of every account at the origin on every write + /// and so repaired a drifted one for free. It does not any more, and the sweep that + /// runs after each upgrade is the repair — see the account-principal index backfill, + /// whose completion is heap state and so is forgotten at every upgrade. + /// /// Takes the salt and origin its caller already resolved, so everything that could /// refuse has refused before this writes anything. fn sync_account_principal_index( @@ -2366,10 +2380,29 @@ impl Storage { previous: &[AccountReference], current: &[AccountReference], ) { + let previous_numbers: BTreeSet<_> = previous + .iter() + .map(|reference| reference.account_number) + .collect(); + let current_numbers: BTreeSet<_> = current + .iter() + .map(|reference| reference.account_number) + .collect(); + let gone: Vec = previous + .iter() + .filter(|reference| !current_numbers.contains(&reference.account_number)) + .cloned() + .collect(); + let arrived: Vec = current + .iter() + .filter(|reference| !previous_numbers.contains(&reference.account_number)) + .cloned() + .collect(); + let previous_entries = - self.account_principals(anchor_number, application_number, origin, salt, previous); + self.account_principals(anchor_number, application_number, origin, salt, &gone); let current_entries = - self.account_principals(anchor_number, application_number, origin, salt, current); + self.account_principals(anchor_number, application_number, origin, salt, &arrived); for (principal, locator) in &previous_entries { if current_entries.contains_key(principal) { From 92d9040fcb71972bb12c10e8bdcffddffad01132 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 16:44:35 +0200 Subject: [PATCH 217/298] test(internet_identity): build the default-repair reference through its constructor The test arrived lower in the stack, where an account reference is a number and a last-used stamp and a struct literal is the only way to write one. This branch gives the reference its sessions and a constructor that starts them empty, so the literal no longer compiles and the constructor is what the test should have been using from here up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 19c063dfb1..24fb5e390d 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3246,10 +3246,7 @@ mod account_reference_state_tests { origin.clone(), Some(( vec![AccountReferenceWrite { - account_reference: AccountReference { - account_number: None, - last_used: None, - }, + account_reference: AccountReference::new(None, None), record: None, }], None, From 0e812a42ea970f6cde0e6d41e7bef18d185e371f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 17:58:13 +0200 Subject: [PATCH 218/298] fix(internet_identity): ask for the salt at install, and let asking twice succeed Two loose ends from taking the salt off the delegation paths. `init_salt` trapped with "Salt already set". A caller asking for a salt wants the canister to have one, and a canister that already does is that answer rather than a fault, so it says so by returning. The trapping wrapper is gone, leaving one function that does what both were for. The integration tests ask for the salt at install and at upgrade, in the two helpers every test funnels through, rather than in each test that happens to need a seed. That is what covers the upgraded canister: a release that derived its salt on the first delegation leaves none behind where it never issued one, and no lifecycle hook can derive one, so the upgraded canister is asked the same way a fresh one is. The eight per-test calls are redundant now and gone. Production is covered by the install timer, which stays in `init` alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/canister_tests/src/framework.rs | 14 ++++++++++++++ src/internet_identity/src/main.rs | 11 +++++++---- src/internet_identity/src/state.rs | 19 +++++-------------- .../activity_stats/authn_methods.rs | 1 - .../anchor_management/device_management.rs | 1 - .../anchor_management/last_usage_timestamp.rs | 1 - .../anchor_management/registration/mod.rs | 1 - .../tests/integration/delegation.rs | 2 -- .../tests/integration/openid.rs | 4 ---- .../tests/integration/rollback.rs | 1 - .../tests/integration/vc_mvp.rs | 1 - 11 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/canister_tests/src/framework.rs b/src/canister_tests/src/framework.rs index efd7ffa149..d4b47ff430 100644 --- a/src/canister_tests/src/framework.rs +++ b/src/canister_tests/src/framework.rs @@ -188,6 +188,14 @@ pub fn install_ii_canister_with_arg_and_cycles( let canister_id = env.create_canister(); env.add_cycles(canister_id, amount); env.install_canister(canister_id, wasm, bytes, None); + // Every seed is hashed against the salt, so a canister needs one before anything can + // sign in or derive a principal. Asked for here rather than in each test that happens + // to need it, so it is set at a fixed point in every test's timeline. + // + // Attempted rather than demanded: some tests install a canister that has no such + // method, or an older release that refuses a salt it already has. A test that needs a + // salt and did not get one fails on its own, saying so. + let _ = api::internet_identity::init_salt(env, canister_id); canister_id } @@ -319,6 +327,12 @@ pub fn upgrade_ii_canister_with_arg( let byts = candid::encode_one(arg).expect("error encoding II upgrade arg as candid"); env.upgrade_canister(canister_id, wasm, byts, None)?; + // An upgrade from a release that derived the salt lazily leaves a canister without one + // where it never issued a delegation, and no lifecycle hook can derive one, so the + // upgraded canister is asked for its salt the same way a fresh one is. Attempted + // rather than demanded for the same reason as at install — a rollback lands on a + // release that refuses a salt it already has. + let _ = api::internet_identity::init_salt(env, canister_id); let post_upgrade_module_hash = env .canister_status(canister_id, None) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 0289ebd654..0e1d458742 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -93,7 +93,7 @@ const ID_AI_ORIGIN: &str = "https://id.ai"; #[update] async fn init_salt() { - state::init_salt().await; + state::set_salt_if_unset().await; } #[update] @@ -812,9 +812,12 @@ fn init(maybe_arg: Option) { state::init_new(); initialize(maybe_arg); // A salt is derived from raw randomness, which takes an await this hook cannot make, so - // the derivation runs the moment the install returns instead. Only a first install needs - // one: the salt lives in stable memory, which is what an upgrade preserves, so - // `post_upgrade` has nothing to do here. + // the derivation runs the moment the install returns instead. + // + // There is deliberately no equivalent in `post_upgrade`. An upgrade keeps the salt, + // because stable memory is what an upgrade keeps. The one canister that would want it + // there is one upgraded from a release that derived its salt on the first delegation + // and never issued one, which has to be asked for a salt rather than given one. set_timer(Duration::ZERO, || spawn(state::set_salt_if_unset())); } diff --git a/src/internet_identity/src/state.rs b/src/internet_identity/src/state.rs index 2e24e65dd5..61528b459c 100644 --- a/src/internet_identity/src/state.rs +++ b/src/internet_identity/src/state.rs @@ -245,22 +245,13 @@ struct State { event_data_uniqueness_counter: Cell, } -pub async fn init_salt() { - storage_borrow(|storage| { - if storage.salt().is_some() { - trap("Salt already set"); - } - }); - - set_salt_if_unset().await; -} - /// Gives the canister a salt where it has none, and does nothing where it already has one. /// -/// Deriving one needs raw randomness, so it needs an await that neither `init` nor -/// `post_upgrade` can make; a zero-delay timer installed from there calls this instead. -/// Being a no-op on a canister that already carries a salt is what lets that timer be -/// installed from both without asking which case it is in. +/// Deriving one needs raw randomness, so it needs an await that `init` cannot make; a +/// zero-delay timer installed from there calls this instead. +/// +/// A canister that already has a salt is the answer this asks for, not a fault, so it says +/// so by returning rather than by trapping a caller who wanted exactly that. pub async fn set_salt_if_unset() { if storage_borrow(|storage| storage.salt().is_some()) { return; diff --git a/src/internet_identity/tests/integration/activity_stats/authn_methods.rs b/src/internet_identity/tests/integration/activity_stats/authn_methods.rs index 17a12eb926..1f99d5e680 100644 --- a/src/internet_identity/tests/integration/activity_stats/authn_methods.rs +++ b/src/internet_identity/tests/integration/activity_stats/authn_methods.rs @@ -232,7 +232,6 @@ fn should_report_active_openid_authn_methods() { // Create II instance that mocks Google certs let env = env(); let canister_id = openid::setup_canister(&env); - api::init_salt(&env, canister_id).unwrap(); // Using Google as OpenID provider, but we could have used any other configured OpenID provider. let (jwt, salt, _claims, test_time, test_principal, _test_authn_method) = diff --git a/src/internet_identity/tests/integration/anchor_management/device_management.rs b/src/internet_identity/tests/integration/anchor_management/device_management.rs index 2391f060e0..642f575a8e 100644 --- a/src/internet_identity/tests/integration/anchor_management/device_management.rs +++ b/src/internet_identity/tests/integration/anchor_management/device_management.rs @@ -17,7 +17,6 @@ use std::collections::HashMap; fn should_lookup() -> Result<(), RejectResponse> { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); - api::init_salt(&env, canister_id)?; let user_number = flows::register_anchor(&env, canister_id); api::add( &env, diff --git a/src/internet_identity/tests/integration/anchor_management/last_usage_timestamp.rs b/src/internet_identity/tests/integration/anchor_management/last_usage_timestamp.rs index 38988aa87d..6d2949d944 100644 --- a/src/internet_identity/tests/integration/anchor_management/last_usage_timestamp.rs +++ b/src/internet_identity/tests/integration/anchor_management/last_usage_timestamp.rs @@ -223,7 +223,6 @@ fn should_set_last_usage_on_prepare_delegation() -> Result<(), RejectResponse> { let canister_id = install_ii_with_archive(&env, None, None); // initialize the salt otherwise prepare_delegation will take two execution rounds // throwing off the expected timestamp - api::init_salt(&env, canister_id)?; let user_number = flows::register_anchor(&env, canister_id); let pub_session_key = ByteBuf::from("session public key"); diff --git a/src/internet_identity/tests/integration/anchor_management/registration/mod.rs b/src/internet_identity/tests/integration/anchor_management/registration/mod.rs index fd6c059a54..a38c66fad7 100644 --- a/src/internet_identity/tests/integration/anchor_management/registration/mod.rs +++ b/src/internet_identity/tests/integration/anchor_management/registration/mod.rs @@ -19,7 +19,6 @@ use std::time::Duration; fn should_register_new_anchor() -> Result<(), RejectResponse> { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); - api::init_salt(&env, canister_id)?; let user_number = flows::register_anchor(&env, canister_id); let anchor_credentials = api::get_anchor_credentials(&env, canister_id, user_number)?; diff --git a/src/internet_identity/tests/integration/delegation.rs b/src/internet_identity/tests/integration/delegation.rs index a36ce1f77a..d8a0ad7680 100644 --- a/src/internet_identity/tests/integration/delegation.rs +++ b/src/internet_identity/tests/integration/delegation.rs @@ -498,7 +498,6 @@ fn get_principal_should_match_prepare_delegation() -> Result<(), RejectResponse> fn should_return_different_principals_for_different_frontends() -> Result<(), RejectResponse> { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); - api::init_salt(&env, canister_id)?; let user_number = flows::register_anchor(&env, canister_id); let frontend_hostname_1 = "https://dapp1.com"; let frontend_hostname_2 = "https://dapp2.com"; @@ -528,7 +527,6 @@ fn should_return_different_principals_for_different_frontends() -> Result<(), Re fn should_return_different_principals_for_different_users() -> Result<(), RejectResponse> { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); - api::init_salt(&env, canister_id)?; let user_number_1 = flows::register_anchor_with(&env, canister_id, principal_1(), &device_data_1()); let user_number_2 = diff --git a/src/internet_identity/tests/integration/openid.rs b/src/internet_identity/tests/integration/openid.rs index a1db37d425..1fbe3b31c3 100644 --- a/src/internet_identity/tests/integration/openid.rs +++ b/src/internet_identity/tests/integration/openid.rs @@ -1624,10 +1624,6 @@ mod sso_gating { .unwrap() }) .expect("II-client SSO credential add failed"); - // Initialize the salt so `prepare_icrc3_attributes`'s SSO-session seed - // computation has a salt to hash against; otherwise it traps with - // "Salt is not set". - api::init_salt(env, canister_id).unwrap(); identity_number } diff --git a/src/internet_identity/tests/integration/rollback.rs b/src/internet_identity/tests/integration/rollback.rs index 361fb82c8b..161174dac0 100644 --- a/src/internet_identity/tests/integration/rollback.rs +++ b/src/internet_identity/tests/integration/rollback.rs @@ -59,7 +59,6 @@ fn should_keep_new_anchor_across_rollback() -> Result<(), RejectResponse> { // start with the previous release let canister_id = install_ii_canister(&env, II_WASM_PREVIOUS.clone()); - api::init_salt(&env, canister_id)?; // use the new version to register an anchor upgrade_ii_canister(&env, canister_id, II_WASM.clone()); diff --git a/src/internet_identity/tests/integration/vc_mvp.rs b/src/internet_identity/tests/integration/vc_mvp.rs index ab573915ec..9e2b713c50 100644 --- a/src/internet_identity/tests/integration/vc_mvp.rs +++ b/src/internet_identity/tests/integration/vc_mvp.rs @@ -668,7 +668,6 @@ fn should_not_get_id_alias_for_different_user() -> Result<(), RejectResponse> { fn should_not_get_id_alias_if_not_prepared() -> Result<(), RejectResponse> { let env = env(); let canister_id = install_ii_with_archive(&env, None, None); - api::init_salt(&env, canister_id)?; let identity_number = flows::register_anchor(&env, canister_id); let relying_party = FrontendHostname::from("https://some-dapp.com"); let issuer = FrontendHostname::from("https://some-issuer.com"); From 7f3fb7676b72369a258d5eac097912f30d80b0e4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 20:16:06 +0200 Subject: [PATCH 219/298] test(internet_identity): regenerate the ICRC-3 vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recorded issuance timestamps move by two nanoseconds. They track how many PocketIC rounds the setup spends before the test pins its clock, and the salt is no longer asked for by each test that needs a seed, so the setup spends two fewer. Only the timestamps change, in the three places each vector encodes one — the value, the message and the signed message. The certificate, root key and canister signature key in the file change too and mean nothing: they are environment-dependent and stripped before the comparison. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- docs/icrc3-test-vectors.json | 84 ++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/icrc3-test-vectors.json b/docs/icrc3-test-vectors.json index e0e7ea1b66..51ed5d8d82 100644 --- a/docs/icrc3-test-vectors.json +++ b/docs/icrc3-test-vectors.json @@ -1,6 +1,6 @@ { - "canister_sig_pk_hex": "303c300c060a2b0601040183b8430102032c000affffffffffe000000101b091234978ebad9d4983ea726fec97bb449b1be71bb0b575d504db72e4707e47", - "root_key_hex": "308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361008b52b4994f94c7ce4be1c1542d7c81dc79fea17d49efe8fa42e8566373581d4b969c4a59e96a0ef51b711fe5027ec01601182519d0a788f4bfe388e593b97cd1d7e44904de79422430bca686ac8c21305b3397b5ba4d7037d17877312fb7ee34", + "canister_sig_pk_hex": "303c300c060a2b0601040183b8430102032c000a7fffffffffe000000101b79e1b4a7aca4df243123bdcfc958dafe02ebb8e866564a0a8bee7fe22ce1ce8", + "root_key_hex": "308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100aaf6e68ffd561dc013196681ca9a8dd542a003f101b3b9babd0d96b845991f5c1a92d304939f56fbf06cfb31a1baa43d0731cf5466025348da45a68f72e50c9cf56cdc8d1f0f79dff2df115722e8ae8211883805e99120877e1d3ce97f3d6057", "origin": "https://some-dapp.com", "issuer": "https://accounts.google.com", "email": "alice.example@icrc3-test.invalid", @@ -8,73 +8,73 @@ "vectors": [ { "label": "Single email attribute with scoped key", - "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_020_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028290afdeb3cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028290afdeb3cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f64617461820358202f70e53b72f5189b439f19768c7146cd55879264c9b166072d4cc0c722974b9582045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf82045820b6d15fe8d6df9cc78e48cdf75cede8ab209075c2786339d49840e025571e8e75830182045820745254113a6f3b02465d6e1c768ac2fdb255c0154260d1485c43de7efaccf94983024474696d658203498290afdeb3cfb8fd18697369676e61747572655830836337360f72ceea89c33a532c5b0b258980c8ac3dfb424fae58491cf22815f1959a0eb5025959cb63e98011bcb629b464747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c768301820458205897aa297a10f99c25b3ea3c8ae1144b9864e84ef9f371451c1dca517f618c2f8302582096563307008282c3db5b810b0383b085690fe3433924287eb7ee3f402a83e3f8820340" + "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_020_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028090afdeb3cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028090afdeb3cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f6461746182035820a0b4ba7f229d9f404144091b9834b64d070870454b451a7b7753f2a56544629c82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb820458202c69a608f26be826f72b3f9ae53a7bbd8c69e25f8fb71a83a19c2ceef35721b3830182045820e95fab660765ae056dff74cc6a4840b882ea96a7f50379044863535f5c04b9748301820458203e2d2ff8639179b831d648d1599ba931723c22d206acd7c9304547c8d4a4237483024474696d658203498090afdeb3cfb8fd18697369676e61747572655830ab23f81dfbbc3ea369b7be86fc9201605f478427f0abdb669997f08e813f5d13f75ab2f9c17ef50d8d119e005d07654864747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b8301820458208a8b02c1b83f769881de24d4d95f6f4959d54232f9fb231d05c6a29cf88c23f58302582070bd29094b480ec552fd081b6f56175f0e7a4815a05b59e62687706e315a5bdb820340" }, { "label": "Single email attribute with unscoped key", - "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_025_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010405656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282f4c6aec6cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010405656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282f4c6aec6cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f64617461820358207e0933d7f7d830cb3b8380669d194a1de73aef1cccb04e5f4a998e499bb6674e82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf8204582048db9b3ed8359ce89c9c3d6d043d1fcaec880cb99e97a8e3708f073809f30239830182045820051785b397d2e483199a07d595d06f2fbfc3e2830cd47eb6d25ff8f5bb44b5e083024474696d6582034982f4c6aec6cfb8fd18697369676e61747572655830a2d93f2ae2bc6c0c3e21cdf1c4a4dd9fe789f888beff8d8956049a3b20e1b619932d3542476d4bb6b64ed6b342d2ac6364747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c768301820458205897aa297a10f99c25b3ea3c8ae1144b9864e84ef9f371451c1dca517f618c2f8301830258208dc47f7d10576c87ff7bb989b7edc03f4e35d1489296dae1c0495a010f29681082034082045820e194008883065e8735a14e4038a4bbe5b3c654bdcaa9f26576b1bde3d6f7a76d" + "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_025_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010405656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280f4c6aec6cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010405656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280f4c6aec6cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f64617461820358204396934a7f560ca5bbe21d3ab4b17e37d45cef5fdc15993dd714139c831b947d82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb82045820742530b29917345e0a8b401be1a25165efb9af5dbdd18d99fecd855f535248748301820458209e3cc4b590f3377732fb3729ff02d9013338a2a1f8657f0f09b289b89826346e830182045820d5d18a3eedc0f660de46f967a17ec187be05bc206c42e25840ee6192f7e05c0083024474696d6582034980f4c6aec6cfb8fd18697369676e6174757265583091b6b96d8ba108f98f751ffcd827a58114b77d10f530ac49611a7a4674a8c52b30e6ccdf402efecbd4a033c82eb7476064747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b8301820458208a8b02c1b83f769881de24d4d95f6f4959d54232f9fb231d05c6a29cf88c23f583018204582031c82146f5d7391ba7c81c0379a0a7a5f9cf6e58dd89534e5b218dff1717c64683025820abcdceb7f629740c855001172130d1d35b2c5786dc2125cb34ccca98ceb72c57820340" }, { "label": "Single name attribute with scoped key", - "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_030_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282d8defed8cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282d8defed8cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f64617461820358207c57a2c01c0434e66f8d2e1797f59500b93990959a151de3bb91d70f15866c1482045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf82045820700efeda1c6a8f206623ce6d82b1f15ea8f0700369d7c3894eaf49dd2d186a8c8301820458209bd8328a74fc724afa4e2370fa9a3df854f52d0a52f2e58afbe154ad15b386ab83024474696d6582034982d8defed8cfb8fd18697369676e61747572655830a82810050958e57246dd369254c96e6c9c49df5c68e02990951f853e39e66d71407d044a535d1b101c54acc0de34353564747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c7683018301830258203189d829ec145b4b20ad0aee9a23ca4cbe658b3c51c02d12d48870480fa77aa1820340820458205897aa297a10f99c25b3ea3c8ae1144b9864e84ef9f371451c1dca517f618c2f820458203bf18a30262e3e2ca279c4cbb91b141396158328689b08977cd891f563533483" + "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_030_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280d8defed8cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280d8defed8cfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f6461746182035820016bfb8e7202cc8a17d428d1614e3b904084b60a750137838245c765b86bc24a82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb820458202b2625ce7c8a0bd72c6e452bb91ace558829ef65171890e67263fb3041939a54830182045820dd966b14fafc68a831ab88d4a5c60f17f39a6e938e7366f1b1ac46eb3667a45d8301820458206091e4ca876dfe6d2238ace6417276674be7122303b33f3c56bef2121b44eaf783024474696d6582034980d8defed8cfb8fd18697369676e61747572655830ac109a7b6dfadea7bcd6646531420d6fdf4a1b6aff3016576d96931ae932cf2e8f1a4175df6856f000c2b8c311bce86364747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b83018301830258200ab2b938f7ce89fa54050694714b15ab478a4fb0e53ec37f0ebca84d7777805e820340820458208a8b02c1b83f769881de24d4d95f6f4959d54232f9fb231d05c6a29cf88c23f582045820edf823a3e78ddfb3cde2ba2a780ef87743893ade93d04d39054caefc012193df" }, { "label": "Email and name with scoped keys", - "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_035_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001051f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282bcf6ceebcfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001051f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282bcf6ceebcfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f64617461820358200a0f8da3c70e93070f8ae238f802b49c473f8af94117703fbdb8b4dfe35a2d1182045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf8204582037c76b55b4e9be29694c8039affd071f010ecd957c0371449039182af5f708d2830182045820b6454aeb60c411aee1673ede883a23e931541f59c8cad222c20392a048a6027983024474696d6582034982bcf6ceebcfb8fd18697369676e617475726558308924335854332a8dc4595bb49b54e34e2c96f3da057e8a9e4c5607a91c22852e3e7904058d10069fba532b0f39595dec64747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c768301830182045820701c9c0aaf79e8cbe425ea5d1651af81dee6ab7029107149b0d695126f9e7f358301820458205897aa297a10f99c25b3ea3c8ae1144b9864e84ef9f371451c1dca517f618c2f830258207ebb5e03850fa607f51275804b50833c17ade837606a969c628a7b9bbd634be4820340820458203bf18a30262e3e2ca279c4cbb91b141396158328689b08977cd891f563533483" + "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_035_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001051f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280bcf6ceebcfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001051f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280bcf6ceebcfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f6461746182035820c27fe8a6859e5c9ebd1d2778bb95168d06b97b50b5810f8825ed731b21acd7b882045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb820458209d25e1190ac117fe0477690677d6386de98f9b1bb8823f14c4b411f93dfe0594830182045820a4f442764894c9f3e5a659ae796b6264c6669c95b163cf291a273bd8dc9c5cef830182045820cd7b680aaf3f4a899da09ce4c36444a05cc6b6e69e09ea7db0d7ed33381308d683024474696d6582034980bcf6ceebcfb8fd18697369676e61747572655830806d04d7d0344b7323f4b8c4a8de4226d48fc6cdb852567b109feabeb4d646984630cbe2fbd20b4ab9400cb6c393767d64747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b8301830182045820e169775c3941bbeb139d461bd924d106c98564ac287f34567a88e925c76c689b83018302582012a9aa9af5da7e10b5a906674d989165d74e0091fd35118d0d854eccc436a567820340820458208a8b02c1b83f769881de24d4d95f6f4959d54232f9fb231d05c6a29cf88c23f582045820edf823a3e78ddfb3cde2ba2a780ef87743893ade93d04d39054caefc012193df" }, { "label": "Email and name with unscoped keys", - "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_040_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"name\": Text(\"Alice Example\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282a08e9ffecfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d046e616d65040d416c696365204578616d706c65", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282a08e9ffecfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d046e616d65040d416c696365204578616d706c65", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f6461746182035820e2e8b332dda55dc06d087cc9bd4a3fefad3ce3e0472e7a59a55781dcf523df7282045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf820458205704331de05629600bef57910a4839bf499710d473605136b4257922f0d26ed2830182045820e798f955758cc8b3af05834609a9141a08ba52ec3ba01a780a29d1c5cba21b0183024474696d6582034982a08e9ffecfb8fd18697369676e61747572655830a7b32f2705fc2d1a8622cb1b8c9a4f3de98d20e53b5bb6400394073da764ab38777e300dcf16af7406f33c15c41df55064747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c76830183018301830258200a72bb261170f35e12377190f29029b90402b3bfc2b8f9924c6f277ddfd646f982034082045820701c9c0aaf79e8cbe425ea5d1651af81dee6ab7029107149b0d695126f9e7f3582045820e5cf9e92f71f795a7be2d52a8df07c4c23db5eeb7aa0f123f65f2e0fec0c12bc820458203bf18a30262e3e2ca279c4cbb91b141396158328689b08977cd891f563533483" + "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_040_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"name\": Text(\"Alice Example\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280a08e9ffecfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d046e616d65040d416c696365204578616d706c65", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280a08e9ffecfb8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d046e616d65040d416c696365204578616d706c65", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f6461746182035820f8f3c061e4f1cc5ffad6df1d7058c73a7eb16d3fde5667c8137f7b6ab4c2215782045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb82045820c55142e335cbbbec0c2e65a49056099ad2374f701be3ea0140a42a8e54c32bb58301820458206a1722c0ede050b729f5fc3d04202530415250f2aa92256dbba096d2a7c5b4b88301820458209c9a3305997403ac3fc6498f2c754ff7f877d9e060f0a5d27b9c35a9c938f35c83024474696d6582034980a08e9ffecfb8fd18697369676e6174757265583099357d78d24ea9097563d675f502818be731f79195703293c4ac9186e5c9830c14bae097985f457295096d1d85abd1b364747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b83018204582009bfa68919ba4acc40bb66f75b2c11d8af17c438a6052836e4038500f976fe1483018204582031c82146f5d7391ba7c81c0379a0a7a5f9cf6e58dd89534e5b218dff1717c646830183025820931410c5454fc7c98d1f66bb4cac509d5a901767428e22185f963b81f1b5a7e58203408204582064a0b3534b8c9b5100ed238fcce17ff730d71b2aca53beb0428b32bce0edc3a1" }, { "label": "Email unscoped and name scoped", - "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_045_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028284a6ef90d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028284a6ef90d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f646174618203582071b7ebf730208d3604bcaeb43842dd31d2d7a450a54841df95de944e54db9e2782045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf8204582030891acd2bec106cc382303e9ada5fe46b373bfbf753980e99af91037e83a1108301820458205ee3998f54c75bb96b0429f58090010986111e1b60efaa5baefaacff3f96253583024474696d658203498284a6ef90d0b8fd18697369676e61747572655830a363f324b673cd9c37ff5d3d11edb1db52984c690811116e9b8078fb9d67718ffc360a7da39846d5c27f733ca450c4c464747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c768301830182045820a8445f0fd4bbb28e703fcc79b1877f1e2ab43b310f1bd74ead7b2249a8179f1c83018302582024cebd5ae5143c9703c1e5dffe16a3e0b2c4fd8d86249a98adf61417ac5abcc382034082045820701c9c0aaf79e8cbe425ea5d1651af81dee6ab7029107149b0d695126f9e7f3582045820d2d014c812691388aa1d7bba623d6f2d0ea66fdc7e20d0360d0125de2c8f413d" + "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_045_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028084a6ef90d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e73028084a6ef90d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f64617461820358200af4885571137b88ec7080570163a4bc501abd5929918841ef79d8fa59b7edaa82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb82045820fc12b4e40d5c24a2b1f64ed39d74e8d9fae4bcd3e9919ad5d2afcd85b93b0fb883018204582010c07e38648ee2b8d3e751fad5d0b9c00e0717f50939645e5f4ae33ff890689d8301820458205465f48e3ab840ab77f83bdf524274f9392b06f11a715d3033ac10756791556f83024474696d658203498084a6ef90d0b8fd18697369676e6174757265583086a5eaa60ab9a065704f0799c537787d3d8e0ade0a3c0e4c6c0aa447dcc9abf91bab3393d004ac3100578625df4ee61664747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b8301830182045820e169775c3941bbeb139d461bd924d106c98564ac287f34567a88e925c76c689b8301820458203f1b156be2a41a2fb8006715b7f478481bb4050c749e35be2c97ccea54cbc5678301820458208a8b02c1b83f769881de24d4d95f6f4959d54232f9fb231d05c6a29cf88c23f583025820465bdd2b4561f01f3972904a084bbc9d8e31e5bb525925b0337a667c4156fc9182034082045820a3210d1355b39063739efbe7dd22c5ad1621de6d56b54d834d71527d072a587e" }, { "label": "Email with value validation", - "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_050_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282e8bdbfa3d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282e8bdbfa3d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f64617461820358205d176468372f1718c5f0277dd34b53b224bf7a680deefc54b307996d19093cd182045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf82045820f4dd0f79333afc24287b31f64f6ec6bd574ddc5f07ce89ad1a635a6af81d5c53830182045820045bf60a0d68298a8f2b87104ed8bed5da64277c9a44c57025f16b535b5e607c83024474696d6582034982e8bdbfa3d0b8fd18697369676e617475726558308ed5fbdf8419af07006ebd5516710d7ba7e3efcb8232712786ba8298507c9744ed67f175252d2a2c4d27cc4dad9a58c664747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c7683018301830182045820a8445f0fd4bbb28e703fcc79b1877f1e2ab43b310f1bd74ead7b2249a8179f1c830258201fe68f4c4257333e0b6ab83502a36293ef12a0e73bb8370b726199c8b0cca9e382034082045820b45baab6470fa2555a65c51f33246c3cf0507f3ec1920652546c89a8502313c182045820d2d014c812691388aa1d7bba623d6f2d0ea66fdc7e20d0360d0125de2c8f413d" + "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_050_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280e8bdbfa3d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280e8bdbfa3d0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f64617461820358200c7e59d44165612ed88f98ca2f04f924047e756c9b6411e2647a9d7b6292712082045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb820458208dc6bbc4352477efd8060ecebdeb550b19a15b8468b4884f4e2b2951b50f49ee8301820458201195f7568d35665c6ad75155d00e735e8067562e204e0b4af63a205306aadc898301820458209d55c452c17a108f0fd126092518610bf7d4488b3398bcb88ddc72df2b1ffb8083024474696d6582034980e8bdbfa3d0b8fd18697369676e6174757265583093dd3c0452455e7ea69d0798b6b5fddceca0eda639c6f28856d4231f4490db556cc837f559b48dc6d40051d5482b90cd64747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b8301820458202cda0582782f9ccc19de0e8e74bdcc681e411aa76b76fceccee6ca33fcb7f0f783018204582031c82146f5d7391ba7c81c0379a0a7a5f9cf6e58dd89534e5b218dff1717c64683018302582074bb787f11e02287689aad17b7e5ad92ac1407be09186da490b3733f1976a478820340820458208d75ecb810352bc9ea279a09d40376344c23f4f3389545f468e5c3c344ee3bfb" }, { "label": "Single email with specific nonce", - "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_055_000_000_002), \"implicit:nonce\": Blob(hex\"5f87b8f041d8e1121d5a7d0360a02213e4b7b3b44b25d0c7f070c7e2b694b29c\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282ccd58fb6d0b8fd180e696d706c696369743a6e6f6e636503205f87b8f041d8e1121d5a7d0360a02213e4b7b3b44b25d0c7f070c7e2b694b29c0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282ccd58fb6d0b8fd180e696d706c696369743a6e6f6e636503205f87b8f041d8e1121d5a7d0360a02213e4b7b3b44b25d0c7f070c7e2b694b29c0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f64617461820358200617a78bf7a823190ff3872114fce85983cf963a7fc20e23744363e6e001f48b82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf82045820700e493cb6442860150b81606c275ea66662374bfa54b03417a22f524cf74dbb8301820458205f74bcaaeafede93f88a2f4928cfa448b1c5cc6ab995761e4c734d00c37fa63583024474696d6582034982ccd58fb6d0b8fd18697369676e61747572655830831545f0b464c2204f621562192dc8b45b68555bfe268a2f0b9501b21173b4a51f50a141b3e20802c47b76a94e153f0764747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c7683018204582052915fcc3baad4965ac4104c9102caa3b37c66b8facbba69d1d1e798e9556dc98301820458205897aa297a10f99c25b3ea3c8ae1144b9864e84ef9f371451c1dca517f618c2f8301820458207b1ec489a7c4a522f77cd0613a0f5834be58fcba70adc1e48659eb4b1d11d3808301820458200e5d0069d172672ab5b9b0656bbc359a01c8fab39bdc91dd2d5e0b4da7a62a46830182045820e194008883065e8735a14e4038a4bbe5b3c654bdcaa9f26576b1bde3d6f7a76d83025820a5b2581cea48545798c262aab8a65aef70e0807dfb45dedb7749eb27470c6adb820340" + "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_055_000_000_000), \"implicit:nonce\": Blob(hex\"5f87b8f041d8e1121d5a7d0360a02213e4b7b3b44b25d0c7f070c7e2b694b29c\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:email\": Text(\"alice.example@icrc3-test.invalid\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280ccd58fb6d0b8fd180e696d706c696369743a6e6f6e636503205f87b8f041d8e1121d5a7d0360a02213e4b7b3b44b25d0c7f070c7e2b694b29c0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001041f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280ccd58fb6d0b8fd180e696d706c696369743a6e6f6e636503205f87b8f041d8e1121d5a7d0360a02213e4b7b3b44b25d0c7f070c7e2b694b29c0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d286f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c6964", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f6461746182035820d1b4da74ab4437d6615f130902f24f85ad89062080f45193f7e27cd37ef4914182045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb82045820f5fadf0ab9d40818b818bb57e9ceb756c5797067fdfb417795fb06d1d3d1492483018204582017572657e94a17ca9215ff8697a25aa2c7de62b42fb6ee41879902629713c016830182045820d46ff6334e77f0287a61cc71df43a2a0a2994d050cd0e17563b443eeeb4f589f83024474696d6582034980ccd58fb6d0b8fd18697369676e61747572655830b36d8d88af13c5ab5e62a11dc43894f0fde0ad169df062754d5a33c428dd65cfd9b7022d8e61c7a1a99a3c8a9f39567d64747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b830183018204582009bfa68919ba4acc40bb66f75b2c11d8af17c438a6052836e4038500f976fe14830182045820a935fa66662349d29bbf52309fcf8e22570dfede859c05ee568e6fb0fbdd8bc583025820622ba9c4ed04fa5f46bab96ede90aa2fa6cb3d401deabc2cfb3a2206c6f985b2820340820458208c07dee4df1524d1227c306b9be2f4abfaf37790945806c8aec7270f172c227a" }, { "label": "Email unscoped + name scoped with specific nonce", - "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_060_000_000_002), \"implicit:nonce\": Blob(hex\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282b0eddfc8d0b8fd180e696d706c696369743a6e6f6e63650320aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730282b0eddfc8d0b8fd180e696d706c696369743a6e6f6e63650320aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f6461746182035820a70b799bbeae5ca719511771d3be821d4dead992559afeb74ad0a864078cde4882045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf82045820cc2be6d61184d7a6b84673332cb2625b3b992ca71b582c5db8355666f91fd2928301820458201ff886d79d0b58e9f235fca679f3a7d1db23c72870928bf3371cd8e7ffcfb46a83024474696d6582034982b0eddfc8d0b8fd18697369676e61747572655830ab8d5c175246a778717e7337847bdda84ec1df67f697e3cfd3cd09368ee82d108ceac30ce73bc8c5a8af5f5c3a3b0ded64747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c7683018204582052915fcc3baad4965ac4104c9102caa3b37c66b8facbba69d1d1e798e9556dc98301820458205897aa297a10f99c25b3ea3c8ae1144b9864e84ef9f371451c1dca517f618c2f8301820458205f69ba039fdf324c3f97a4d2725991467c22d697fe46505cbf52a4bf6168e40f8301820458208c0a164a4945a0bdbcac136e354751e87988b269d0444be6ba107648bbd7c50883025820e3321eb212809e52d2a4a7a4e0ea67fbdfb58207c82ca35ffa1f86862dbd1a37820340" + "icrc3_value": "Map({ \"email\": Text(\"alice.example@icrc3-test.invalid\"), \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_060_000_000_000), \"implicit:nonce\": Blob(hex\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"), \"implicit:origin\": Text(\"https://some-dapp.com\"), \"openid:https://accounts.google.com:name\": Text(\"Alice Example\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280b0eddfc8d0b8fd180e696d706c696369743a6e6f6e63650320aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d000100010505656d61696c0420616c6963652e6578616d706c654069637263332d746573742e696e76616c69641f696d706c696369743a6973737565645f61745f74696d657374616d705f6e730280b0eddfc8d0b8fd180e696d706c696369743a6e6f6e63650320aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d276f70656e69643a68747470733a2f2f6163636f756e74732e676f6f676c652e636f6d3a6e616d65040d416c696365204578616d706c65", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f6461746182035820ea20f427dd79b0229e707adebe628d23c3e0dbb1f6ad63b879db1cb8989d5c5a82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb820458209145c1bd7efd34c65b3db5d90b08698c33a2c1a1f7af7e20c63adca6876257af8301820458209267d9451255cb221b79068951790a94c60582e0488487d82eb10bb955e2b329830182045820f93ddfc104589a780bf0bac9d5163c2d0fb5fcb2c8ddb9519ceb9dcc9737702f83024474696d6582034980b0eddfc8d0b8fd18697369676e61747572655830a84dd62bfc43403e00997352ad24f8d750574ad7380690f1d96fc20fa4846240fcf95821da5b22db2f5ada8f4377f85f64747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b830182045820caaa34bc4dffd12eba4708afbf019176fe0bd9d63fd3188f14cf5c214803aaaa83018204582031c82146f5d7391ba7c81c0379a0a7a5f9cf6e58dd89534e5b218dff1717c646830182045820c661306072021b3aeb975fdc27028c4a32a7125cd9799a7b59266df02d7c121683018204582072454c8f3e1558def8c328b26ad81d82db5625768db5227bfacb0ef3c71cbece83018204582064a0b3534b8c9b5100ed238fcce17ff730d71b2aca53beb0428b32bce0edc3a183025820e5e83250f3facb8a5bc48675f3a79d4510a6f9b2a21b055651ce992a81a345e6820340" }, { "label": "No user attributes, only implicit entries", - "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_065_000_000_002), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\") })", - "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001031f696d706c696369743a6973737565645f61745f74696d657374616d705f6e7302829485b0dbd0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", - "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001031f696d706c696369743a6973737565645f61745f74696d657374616d705f6e7302829485b0dbd0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", - "certificate_cbor_hex": "d9d9f7a26b636572746966696361746559018bd9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024affffffffffe0000001018301830183024e6365727469666965645f6461746182035820a79538af37f14d3856b5ac5476ea8dacfd76f49b7ffd02395c5093ab287fa91f82045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820736d1dd880fd5f3a65ea338af29adb3bb275c94dc45e618a51bc4dbc882a3aff820458205184953fab6b8f8ae5a111c036a1bb2c9a8eec0614e4b82307a9b0541af4b8bf82045820c65e43eff2c80a70c83fb597881cd49b7d7dba7bb0e5e52a1a619630f35d6eae830182045820085535c3c3ee1677175d897f6158963da38b527af55eddf4bf009be64e2a092883024474696d65820349829485b0dbd0b8fd18697369676e617475726558308fc0eca12f995be2a7fa199921d2681949d9383a4f225f1ff452800b3d80f69d29913d825d0005355f12fa1ad461b83764747265658301820458208a833d32734da1e61fc06a468ee0816aa0282111fec997d0a7a902160585966783024373696783025820d39da4048de3a1665af82aea20c00ab7fd55064ec2572672a7cfd836c26f8c768301830182045820eb1e53b781470da6691631ee4a54e3c467e2f0e43883237a8dc89aa9a80a5636830182045820f5b3762c73ac996451b502db8553767b548fc3ad1265de931165aaa45473674a830182045820701c9c0aaf79e8cbe425ea5d1651af81dee6ab7029107149b0d695126f9e7f35830258205881090cb2448770794b6f127f303d2faae226c43e17f8f5e7f413a3ab9cbef2820340820458206f8dd82fe4085a2d81da32c480c1aaf358e7d2fea7fcef771538ea5df511d8c8" + "icrc3_value": "Map({ \"implicit:issued_at_timestamp_ns\": Nat(1_800_000_065_000_000_000), \"implicit:nonce\": Blob(hex\"0000000000000000000000000000000000000000000000000000000000000000\"), \"implicit:origin\": Text(\"https://some-dapp.com\") })", + "message_hex": "4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001031f696d706c696369743a6973737565645f61745f74696d657374616d705f6e7302809485b0dbd0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", + "signed_message_hex": "0e69632d73656e6465722d696e666f4449444c056b06cf89df017cfc84eb0101c189ee017dfdd2c9df0203cdf1cbbe0371f9baf3c50b046d026c02007101006d7b6d00010001031f696d706c696369743a6973737565645f61745f74696d657374616d705f6e7302809485b0dbd0b8fd180e696d706c696369743a6e6f6e6365032000000000000000000000000000000000000000000000000000000000000000000f696d706c696369743a6f726967696e041568747470733a2f2f736f6d652d646170702e636f6d", + "certificate_cbor_hex": "d9d9f7a26b63657274696669636174655901b1d9d9f7a2647472656583018301830182045820267d108b825149c889227f220ca201138ce621c31c7c3cf174117e7015110bb783024863616e6973746572830183024a7fffffffffe0000001018301830183024e6365727469666965645f64617461820358204f5ea1a948506b4f42307652c44973a41d9de78e076c3d45392113e3ba084de182045820eeebdca966b95b97cecdf624e005758a11b5d031b574818565faaa89fc5a258b82045820ee898a2b1259ab0e1f889a1f7b26d3c6343fa04cc086768cd335e50ba4107b22820458206dd9ce016375a46fe56f3d9722f3ed5be3f4ab7f005f5472392cc2263f7f2acb82045820fd0b7ab7bb63bffbd4e41ba622afbc0bb43950643559994c39e61cae9c8146b38301820458202081dc144edb142c46d34a41f716b10d108cbb3f0e0b947f3793d0eda53f6688830182045820128776670f4dbcee35a04ba6e5ae5cc9579b987c601a00a1b87a08bd215f854d83024474696d65820349809485b0dbd0b8fd18697369676e61747572655830b1f3bea679dce2fe394b653469a7836dd5b73ab2e553005e94ce859a136c7e7cec1f71f3eff26d680b47966c870311b464747265658301820458201dcef3723b14f290caee88395ccde2155d1537751f941c41cc37a9d2630df36783024373696783025820113294a8e3bb4af088bc59354b1207726b2479582133f7e5bab813588cba003b830182045820caaa34bc4dffd12eba4708afbf019176fe0bd9d63fd3188f14cf5c214803aaaa83018204582031c82146f5d7391ba7c81c0379a0a7a5f9cf6e58dd89534e5b218dff1717c646830182045820dbdebc895f1aec32cb60858af438c3a3c4f574ac1a54c0b4776dc64d074da881830183025820b9a723c764fd2a36fc44fbb84f80b0720bc0c01a744a9d85d056bdeef83f77de820340820458203032f184dce46c226711aa2d30a8976f2e951a6ef9ffcc6ff062e93da1233e12" } ] } From def7645c4b6976fe44fe222247f926d8f4bb5735 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 20:25:53 +0200 Subject: [PATCH 220/298] fix(internet_identity): the account cap refuses growth, not every write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap asked only about the count the write leaves behind, so it would refuse a write that added nothing — a sign-in stamp goes through here with a zero delta — and a write that removed accounts while the count was still over, which is the one write that would fix being over. Neither state is reachable while the cap is fixed. Lowering `MAX_ANCHOR_ACCOUNTS` is what would reach them, and then an identity above the new cap would lose its sign-in rather than just its ability to add another account. The session cap beside it already had this right, refusing only where the count moved. This goes one better and refuses only where it grew. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 6637c3a6fa..95423cc12b 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1644,7 +1644,16 @@ impl Storage { // not a question for a caller to ask first. Refusing here costs nothing, because // nothing has been stored — which is the only reason a rule can live at the end of // a write rather than in front of it. - if anchor_accounts > MAX_ANCHOR_ACCOUNTS { + // + // A cap bounds growth, so only a write that grows the count can be refused by it. + // Asking about the resulting count alone would refuse a write that adds nothing — + // a sign-in stamp has a zero delta and goes through here — and would refuse a write + // that *removes* accounts while the count was still over, which is the one write + // that would fix being over. Nothing reaches either state while this cap is fixed, + // and lowering it is what would: the identities above the new cap would keep their + // accounts, as a lowered cap should mean, rather than lose their sign-in. + let accounts_delta: i64 = validated.iter().map(|one| one.deltas.accounts).sum(); + if accounts_delta > 0 && anchor_accounts > MAX_ANCHOR_ACCOUNTS { return Err(StorageError::AccountLimitReached { anchor_number }); } From 19bd01fa9aa5323e29da7186e0a78a77bf0df20d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 20:27:43 +0200 Subject: [PATCH 221/298] fix(internet_identity): the session cap refuses growth, not every move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It refused on any write that moved the count and left it over the cap, so a revocation that brought the count down while it was still over would be turned away — the one write that would fix being over. The guard is on the refusal rather than on `session_count`, which is also the number this write stores: a revocation still has to store where the count moved to, it just cannot be refused for a total it is reducing. Not reachable while the cap is fixed; lowering it is what would reach it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index fb901c049f..e954cc7b5b 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1888,7 +1888,11 @@ impl Storage { moved } }); - if session_count.is_some_and(|after| after > MAX_SESSIONS_PER_ANCHOR) { + // Only a write that grows the count can be refused by a cap on it. The guard is on + // the refusal rather than on `session_count`, which is also the number this write + // stores: a revocation moves the count and has to keep storing where it moved to, + // it just cannot be turned away for a total it is bringing down. + if session_delta > 0 && session_count.is_some_and(|after| after > MAX_SESSIONS_PER_ANCHOR) { return Err(StorageError::SessionCapNotReclaimed { anchor_number }); } From 3d896709adcc7d463f67f73708f07c3fb6775c80 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 23:08:06 +0200 Subject: [PATCH 222/298] fix(internet_identity): evict at the cap, and take the default from the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things this write got wrong about tracked defaults. Eviction triggered at the watermark rather than at the cap, so it began fifty lists early. The bound before it cannot stand in for the rule: it comes from counters, which know how many references and accounts an identity has and nothing about how they are spread across lists, so it counts every numberless reference including those in lists that also hold named accounts, which are never evictable. That makes it an upper bound, which is all it needs to be to keep the scan off the sign-in path — and it is why the rule itself belongs where the lists are. Evicting early is not free: an evicted list takes that origin's sessions with it, so an identity under the cap was being signed out of apps for nothing. Repairing a default that named an account the list no longer holds took whatever was first, which took the list's order for the rule. A write is stored in the order it was given, so a list whose numberless reference was not first would move the default onto a named account while the tracked default was still there to fall back to. The tracked default is now found wherever it sits, and position decides only when there is none. Comparing a write against what is stored cloned the stored list to do it. It only needs to borrow, and to build the derived default in the one branch that has no stored list to compare against. This is every sign-in, on a list that can hold five hundred references, each owning its sessions. `tombstones` becomes `stored_tombstones`, beside `stored_accounts` and `stored_account_references`, and its doc leads with what it counts rather than with why. The name on the wire is `#[n(3)]` and does not move. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 47 +++++++++++++------ .../src/storage/account/tests.rs | 2 +- .../src/storage/storable/application.rs | 9 ++-- src/internet_identity/src/storage/tests.rs | 28 ++++++----- 4 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 8b84db4c1a..c71b2d2ec2 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1754,7 +1754,15 @@ impl Storage { )) }) .collect(); - if candidates.len() as u64 <= EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK { + // The cap, not the watermark. The watermark is where a pass stops, and reading it + // as where a pass starts began evicting fifty lists early. The bound above cannot + // stand in for this: it comes from counters, which know how many references and + // accounts an identity has and nothing about how they are spread across lists, so + // it counts every numberless reference — including those in lists that also hold + // named accounts, which are never evictable. That makes it an upper bound, which is + // all it needs to be to keep the scan off the sign-in path, and it is why the rule + // itself has to be asked here, of the lists. + if (candidates.len() as u64) < MAX_EVICTABLE_DEFAULT_ACCOUNTS { return Vec::new(); } @@ -1869,11 +1877,20 @@ impl Storage { { config } else { - references.first().map(|reference| { - let mut config = config.unwrap_or(stored_config); - config.default_account_number = reference.account_number; - config - }) + // The tracked default wherever it sits, and only then whatever is first. + // Picking by position would have taken the list's order for the rule: a write + // is stored in the order it was given, so a list whose numberless reference is + // not first would move the default onto a named account while the tracked + // default was still there to fall back to. + references + .iter() + .find(|reference| reference.account_number.is_none()) + .or_else(|| references.first()) + .map(|reference| { + let mut config = config.unwrap_or(stored_config); + config.default_account_number = reference.account_number; + config + }) }; // Nothing changed, so nothing is written and nothing about it is checked. @@ -1887,10 +1904,10 @@ impl Storage { // An empty list is not covered by this and must not be: it is a tombstone, the // opposite of absence, and nothing may create one yet. It differs from the // derived default, so it falls through to the refusal below. - let unchanged = references - == stored - .clone() - .unwrap_or_else(Self::derived_default_references); + let unchanged = match &stored { + Some(stored) => &references == stored, + None => references == Self::derived_default_references(), + }; // A config names this identity's default at an origin, and a config with no // account reference list behind it names nothing — so setting one materialises // the list it implies, even where that list is only the derived default. This is @@ -1952,7 +1969,7 @@ impl Storage { origin: origin.clone(), stored_accounts: 0, stored_account_references: 0, - tombstones: 0, + stored_tombstones: 0, }, }; @@ -1982,7 +1999,7 @@ impl Storage { let application_tombstones = deltas.apply_one( ReferenceCounter::Application { application_number }, ReferenceCount::Tombstones, - application.tombstones, + application.stored_tombstones, )?; Ok(ValidatedAccountReferenceListWrite { @@ -1998,7 +2015,7 @@ impl Storage { origin: application.origin, stored_accounts: application_accounts, stored_account_references: application_references, - tombstones: application_tombstones, + stored_tombstones: application_tombstones, }, ), list, @@ -2097,7 +2114,7 @@ impl Storage { let application_tombstones = deltas.apply_one( ReferenceCounter::Application { application_number }, ReferenceCount::Tombstones, - application.tombstones, + application.stored_tombstones, )?; Ok(ValidatedAccountReferenceListWrite { @@ -2113,7 +2130,7 @@ impl Storage { origin: application.origin, stored_accounts: application_accounts, stored_account_references: application_references, - tombstones: application_tombstones, + stored_tombstones: application_tombstones, }, ), list: ListWrite::Removed, diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index c72008dd89..cf5e1affe2 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -71,7 +71,7 @@ fn should_create_a_named_account() { origin: origin.clone(), stored_accounts: 1, stored_account_references: 2, - tombstones: 0, + stored_tombstones: 0, } ); assert_eq!( diff --git a/src/internet_identity/src/storage/storable/application.rs b/src/internet_identity/src/storage/storable/application.rs index 76c4653c31..7651b4f807 100644 --- a/src/internet_identity/src/storage/storable/application.rs +++ b/src/internet_identity/src/storage/storable/application.rs @@ -17,7 +17,8 @@ pub struct StorableApplication { pub stored_accounts: u64, #[n(2)] pub stored_account_references: u64, - /// Lists that exist here while holding no reference at all. + /// How many identities hold an emptied list here, counted separately because an + /// emptied list is invisible to the reference count. /// /// A list holding nothing is a tombstone: it says every account an identity had at /// this origin was moved away and its default must never be derived again. It @@ -31,7 +32,7 @@ pub struct StorableApplication { /// written. `default` is what makes that absence decode rather than trap. #[n(3)] #[cbor(default)] - pub tombstones: u64, + pub stored_tombstones: u64, } impl Storable for StorableApplication { @@ -122,7 +123,7 @@ mod tests { origin: "https://example.com".to_string(), stored_accounts: 3, stored_account_references: 4, - tombstones: 0, + stored_tombstones: 0, } ); } @@ -133,7 +134,7 @@ mod tests { origin: "https://example.com".to_string(), stored_accounts: 1, stored_account_references: 2, - tombstones: 5, + stored_tombstones: 5, }; assert_eq!( diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 903e89ed42..d8ae927cd1 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3086,7 +3086,7 @@ mod account_reference_state_tests { storage.stable_application_memory.insert( application_number, StorableApplication { - tombstones: application.tombstones + 1, + stored_tombstones: application.stored_tombstones + 1, ..application }, ); @@ -3458,7 +3458,7 @@ mod application_number_allocator_tests { origin: origin.to_string(), stored_accounts: 0, stored_account_references: 0, - tombstones: 0, + stored_tombstones: 0, } } @@ -3822,17 +3822,19 @@ mod tracked_default_eviction_tests { fn evicting_drops_the_least_recently_used_down_to_the_watermark() { let (mut storage, anchor_number) = storage_with_anchor(); - for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + // One sign-in past the cap. The origin a write is touching is never a candidate for + // its own eviction, so the pass first runs when the *other* lists reach the cap — + // one sign-in later than the cap itself. + let signed_in_at = MAX_EVICTABLE_DEFAULT_ACCOUNTS + 1; + for index in 0..signed_in_at { sign_in_at(&mut storage, anchor_number, index); } - // One above the watermark, not at it: the origin the triggering write touched is - // never a candidate for its own eviction, so it survives on top of what the pass - // trims the rest down to. - let evicted = MAX_EVICTABLE_DEFAULT_ACCOUNTS - 1 - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; + // Down to the watermark, and then the origin that triggered the pass on top of it. + let evicted = MAX_EVICTABLE_DEFAULT_ACCOUNTS - EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK; assert_eq!( storage.evictable_default_lists(anchor_number).len() as u64, - MAX_EVICTABLE_DEFAULT_ACCOUNTS - evicted + EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK + 1 ); for index in 0..evicted { @@ -3840,7 +3842,7 @@ mod tracked_default_eviction_tests { .lookup_application_number_with_origin(&origin_of(index)) .is_none()); } - for index in evicted..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + for index in evicted..signed_in_at { let application_number = storage .lookup_application_number_with_origin(&origin_of(index)) .unwrap(); @@ -3907,7 +3909,7 @@ mod tracked_default_eviction_tests { origin, stored_accounts: 0, stored_account_references: 1, - tombstones: 0, + stored_tombstones: 0, }, ); storage.stable_account_reference_list_memory.insert( @@ -4302,7 +4304,7 @@ mod application_removal_tests { .get(&application_number) .map(|application| ( application.stored_account_references, - application.tombstones + application.stored_tombstones )), Some((0, 1)) ); @@ -4338,7 +4340,7 @@ mod application_removal_tests { storage .stable_application_memory .get(&application_number) - .map(|application| application.tombstones), + .map(|application| application.stored_tombstones), Some(0) ); @@ -4382,7 +4384,7 @@ mod application_removal_tests { stored_accounts: application.stored_accounts - named, stored_account_references: application.stored_account_references - moved_away.len() as u64, - tombstones: application.tombstones + 1, + stored_tombstones: application.stored_tombstones + 1, ..application }, ); From 7925ec331915d31f7212b807325e65a70de4fb16 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 23:10:10 +0200 Subject: [PATCH 223/298] perf(internet_identity): stop copying the stored list, and bound the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping the principal index in step read the stored reference list and cloned it to keep it, when nothing wanted it afterwards. This is every write that changes a list, on up to five hundred references each owning its sessions, so the copy is deeper than a vector of pointers. `StorableAccountKey` becomes a CBOR array and declares its bound. The array drops three integer keys that exist so a field can be added and older records still decode — which is what authoritative state needs, and this is not that: every entry is reconstructible from the reference lists, and the backfill sweep already does exactly that, so a shape change here is a rebuild. The bound is the part that pays. A map sizes its pages from its key and value bounds only when both are declared, and falls back to a default otherwise; `Principal` is already bounded, so declaring this one takes the page from 1024 bytes to 614. It can only be declared now, because the page size is computed once, when the map is created. `is_fixed_size` stays false: a small number encodes shorter, and it is read for keys alone anyway. A test pins the maximum. A declared bound is a promise the derive does not keep — exceed it and the write panics — so a fourth field should break a test rather than a canister. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 2 +- .../src/storage/storable/account_key.rs | 57 ++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 5a43952828..7e63a15860 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2016,7 +2016,7 @@ impl Storage { // number, so a write that leaves every account number in place — a `last_used` // stamp, which is every sign-in — cannot have changed one. Skipping the sync // there keeps the hottest write in the system off a per-account hash. - let previous_references = stored.clone().unwrap_or_default(); + let previous_references = stored.unwrap_or_default(); let accounts_changed = writes_a_list && (previous_references.len() != references.len() || previous_references diff --git a/src/internet_identity/src/storage/storable/account_key.rs b/src/internet_identity/src/storage/storable/account_key.rs index c888e5a01f..532f329ec7 100644 --- a/src/internet_identity/src/storage/storable/account_key.rs +++ b/src/internet_identity/src/storage/storable/account_key.rs @@ -15,7 +15,12 @@ use std::borrow::Cow; /// what leaves is the `AccountKey` it maps to. Absent account number means the tracked /// default. #[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] -#[cbor(map)] +// An array rather than a map: a map's integer keys buy the ability to add a field and still +// decode records written before it, which is what authoritative state needs. This is a +// derived index — every entry is reconstructible from the account reference lists that hold +// the truth, and the backfill sweep does exactly that — so a shape change here is a rebuild, +// and the three key bytes are not worth paying for. +#[cbor(array)] pub struct StorableAccountKey { #[n(0)] pub anchor_number: StorableAnchorNumber, @@ -36,5 +41,53 @@ impl Storable for StorableAccountKey { minicbor::decode(&bytes).expect("failed to decode StorableAccountKey") } - const BOUND: Bound = Bound::Unbounded; + /// The array header, then three maximal `u64`s at nine bytes each. Declared rather + /// than left unbounded because a bounded key and value are together what let the map + /// size its pages to what it stores, instead of falling back to a default four + /// hundred bytes larger — and because the bound can only be declared before the map + /// holds anything, since it is what the page size was computed from. + /// + /// `is_fixed_size` is false: a small number encodes shorter. It would not help anyway, + /// being read for keys alone, and this is a value. + const BOUND: Bound = Bound::Bounded { + max_size: 28, + is_fixed_size: false, + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_stable_structures::storable::Bound; + use pretty_assertions::assert_eq; + + /// A declared bound is a promise the derive does not keep: exceed it and the write + /// panics rather than the type failing to compile. So the maximum is asserted here, + /// where adding a field breaks a test instead of a canister. + #[test] + fn the_largest_key_fits_the_declared_bound() { + let largest = StorableAccountKey { + anchor_number: u64::MAX, + application_number: u64::MAX, + account_number: Some(u64::MAX), + }; + + let Bound::Bounded { max_size, .. } = StorableAccountKey::BOUND else { + panic!("the bound is what this test is about"); + }; + assert_eq!(largest.to_bytes().len() as u32, max_size); + } + + /// The page size a map derives from the bound is only a saving while the bound is + /// tight, so an absent account number must not be paid for like a present one. + #[test] + fn a_tracked_default_encodes_shorter_than_the_bound() { + let tracked_default = StorableAccountKey { + anchor_number: 10_000, + application_number: 1, + account_number: None, + }; + + assert!(tracked_default.to_bytes().len() < 28); + } } From 46f414b5627d0c7bc24371c529446e35dda49a97 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 23:12:22 +0200 Subject: [PATCH 224/298] perf(internet_identity): bound the session index too `StorableSessionHandle` becomes a CBOR array and declares its bound, for the reason `StorableAccountKey` did: a map's integer keys buy decoding records written before a field existed, which authoritative state needs and a derived index does not. Nothing sweeps this index today, but the sessions are held on the account references, so it can be rebuilt from them if a shape change ever calls for it. Twenty-nine bytes of principal with a two-byte string header, a maximal `u64`, and the array header: forty-one. Declaring it takes the map's page from 1024 bytes to 721, and can only be done while the map is empty. A test pins the maximum, as on the other index. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/storage/storable/session_handle.rs | 54 ++++++++++++++++++- src/internet_identity/src/storage/tests.rs | 2 +- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage/storable/session_handle.rs b/src/internet_identity/src/storage/storable/session_handle.rs index 84bbe0ff81..fb61be06d3 100644 --- a/src/internet_identity/src/storage/storable/session_handle.rs +++ b/src/internet_identity/src/storage/storable/session_handle.rs @@ -13,7 +13,11 @@ use std::borrow::Cow; /// The session itself is named by its id, which is an input 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)] +// An array rather than a map, for the reason given on `StorableAccountKey`: a map's keys buy +// decoding records written before a field existed, which a derived index does not need. +// Nothing sweeps this one today, but the sessions themselves are held on the account +// references, so it can be rebuilt from them if a shape change ever calls for it. +#[cbor(array)] pub struct StorableSessionHandle { #[cbor(n(0), with = "minicbor::bytes")] pub account_principal: Vec, @@ -32,5 +36,51 @@ impl Storable for StorableSessionHandle { minicbor::decode(&bytes).expect("failed to decode StorableSessionHandle") } - const BOUND: Bound = Bound::Unbounded; + /// The array header, then the principal as a byte string — twenty-nine bytes and a + /// two-byte header, a self-authenticating principal being a hash and a tag — then a + /// maximal `u64` at nine. Declared so the map sizes its pages to what it stores rather + /// than to a default, which is only possible before it holds anything. + /// + /// `is_fixed_size` is false: the session id encodes shorter when it is small, and the + /// flag is read for keys alone in any case. + const BOUND: Bound = Bound::Bounded { + max_size: 41, + is_fixed_size: false, + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use candid::Principal; + use ic_stable_structures::storable::Bound; + use pretty_assertions::assert_eq; + + /// A declared bound is a promise the derive does not keep: exceed it and the write + /// panics rather than the type failing to compile. So the maximum is asserted here, + /// where adding a field breaks a test instead of a canister. + #[test] + fn the_largest_handle_fits_the_declared_bound() { + let largest = StorableSessionHandle { + account_principal: vec![0xff; Principal::MAX_LENGTH_IN_BYTES], + session_id: u64::MAX, + }; + + let Bound::Bounded { max_size, .. } = StorableSessionHandle::BOUND else { + panic!("the bound is what this test is about"); + }; + assert_eq!(largest.to_bytes().len() as u32, max_size); + } + + /// What the index actually holds: `canister_sig_principal` is self-authenticating, so + /// the principal is always the full twenty-nine bytes and only the id varies. + #[test] + fn a_real_handle_is_shorter_than_the_bound() { + let handle = StorableSessionHandle { + account_principal: vec![0x01; Principal::MAX_LENGTH_IN_BYTES], + session_id: 1, + }; + + assert!(handle.to_bytes().len() < 41); + } } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 887c76ef76..a4851b2e12 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -62,7 +62,7 @@ pub(crate) fn plant_application( origin: origin.clone(), stored_accounts: 0, stored_account_references: 0, - tombstones: 0, + stored_tombstones: 0, }, ); storage.lookup_application_with_origin_memory.insert( From 983626fb603193508aaa0e706bb57aac282bbda6 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Mon, 7 Sep 2026 23:26:59 +0200 Subject: [PATCH 225/298] fix(internet_identity): rename the tombstone count at this helper too The field became `stored_tombstones` further down the stack, and this helper builds a `StorableApplication` directly, so it did not move with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 2c78fb3977..46bde2d445 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -62,7 +62,7 @@ pub(crate) fn plant_application( origin: origin.clone(), stored_accounts: 0, stored_account_references: 0, - tombstones: 0, + stored_tombstones: 0, }, ); storage.lookup_application_with_origin_memory.insert( From 34f6a8b3430b5a2a7be4a310c2316df8a29b68f9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 00:25:55 +0200 Subject: [PATCH 226/298] test(internet_identity): sign in past the cap before expecting an eviction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eviction now starts at the cap rather than at the watermark, and the origin a write is touching is never a candidate for its own eviction — so signing in at exactly the cap leaves one short of triggering, and nothing is reclaimed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/tests/integration/accounts.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 8d10f41ac8..3570c435cb 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -1656,7 +1656,9 @@ fn should_remove_unreferenced_applications_an_anchor_stops_referencing( let evicted_origin = "https://dapp-0.com".to_string(); let mut user_key_before_eviction = None; - for index in 0..EVICTABLE_DEFAULT_ACCOUNTS_CAP { + // One past the cap: the origin a write is touching is never a candidate for its own + // eviction, so the pass first runs when the *other* origins reach the cap. + for index in 0..=EVICTABLE_DEFAULT_ACCOUNTS_CAP { let params = AccountDelegationParams::new( &env, canister_id, From 47abea1bd40e803181b414d14b0b9a06306b00d4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 12:52:15 +0200 Subject: [PATCH 227/298] fix(internet_identity): a backfill timer that fires after finishing is a fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching the early return means a timer fired after the sweep completed, so the clear on completion did not take — an id that outlived its slot, or a second timer installed over the first. Returning alone left it firing every second forever, doing nothing. It now repeats the cleanup instead. Kept in both places rather than moved: clearing only on the early return would mean every completed sweep fires once more before stopping, and completion should stop it where it happens. The two callers share one helper so there is one description of what stopping means. And the installer now says when it can go: once every deployment has upgraded through a build carrying the sweep — not once it has run, since its completion flag is heap state and it runs again after every upgrade. With a note that removing it also removes the only thing that repairs a drifted index entry, because the write path stopped re-asserting entries it did not move on the grounds that this sweep comes back around. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 33 ++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index bf055a784b..a9a59e76f1 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -845,6 +845,15 @@ fn initialize(maybe_arg: Option) { openid::setup(openid_configs); } + // TEMPORARY: remove once every deployment has upgraded through a build that carries + // this sweep. Not "once it has run" — its completion flag is heap state, so it runs + // again after every upgrade, and a canister that skipped this build entirely would + // never have indexed the accounts it already held. + // + // Taking it out also takes out the only thing that repairs a drifted index entry: the + // write path derives principals for the accounts a write moves and no longer + // re-asserts the rest, on the grounds that this sweep comes back around. Something + // has to replace that, or the reasoning behind it has to change. init_account_principal_index_backfill_timer(); } @@ -876,6 +885,13 @@ fn account_principal_index_backfill_status() -> (u64, u64, bool) { fn run_account_principal_index_backfill_batch() { if ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.with_borrow(|done| *done) { + // Reaching this means a timer fired after the sweep finished, so the clear below + // did not take — an id that outlived its slot, or a second timer installed over + // the first. Returning alone would leave it firing every second forever, doing + // nothing, so the cleanup is repeated rather than assumed. It stays below as well: + // clearing only here would mean every completed sweep fires once more before + // stopping, and completion should stop it where it happens. + clear_account_principal_index_backfill_timer(); return; } @@ -897,11 +913,7 @@ fn run_account_principal_index_backfill_batch() { if outcome.is_done { ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.replace(true); - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID.with_borrow_mut(|id_slot| { - if let Some(timer_id) = id_slot.take() { - ic_cdk_timers::clear_timer(timer_id); - } - }); + clear_account_principal_index_backfill_timer(); let indexed = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow(|indexed| *indexed); let skipped = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED.with_borrow(|skipped| *skipped); ic_cdk::println!( @@ -910,6 +922,17 @@ fn run_account_principal_index_backfill_batch() { } } +/// Stops the sweep's timer and forgets its id. Does nothing where there is no id, so it +/// is safe to call from either the completion it belongs to or a firing that should not +/// have happened. +fn clear_account_principal_index_backfill_timer() { + ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID.with_borrow_mut(|id_slot| { + if let Some(timer_id) = id_slot.take() { + ic_cdk_timers::clear_timer(timer_id); + } + }); +} + /// Safe to call from both `init` and `post_upgrade`: with nothing to index the /// first batch immediately reports completion. A batch before the salt exists /// indexes nothing and leaves the sweep running, so it resumes once it is set. From 66b2d67c040ac8090e92838809e3284aca43cca3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 13:07:45 +0200 Subject: [PATCH 228/298] docs(internet_identity): the backfill re-running is a cost, not a purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note on the sweep claimed that removing it would remove the only thing repairing a drifted index entry. That was wrong: the sweep is a one-time migration to index the accounts that predate the index, and its running again after every upgrade is an accepted cost of keeping its completion flag on the heap — not a guarantee anything is entitled to rely on. It still says when it can go, which is later than it looks: once every deployment has upgraded through a build carrying it, rather than once it has finished. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index a9a59e76f1..5e39e71e21 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -845,15 +845,12 @@ fn initialize(maybe_arg: Option) { openid::setup(openid_configs); } - // TEMPORARY: remove once every deployment has upgraded through a build that carries - // this sweep. Not "once it has run" — its completion flag is heap state, so it runs - // again after every upgrade, and a canister that skipped this build entirely would - // never have indexed the accounts it already held. - // - // Taking it out also takes out the only thing that repairs a drifted index entry: the - // write path derives principals for the accounts a write moves and no longer - // re-asserts the rest, on the grounds that this sweep comes back around. Something - // has to replace that, or the reasoning behind it has to change. + // TEMPORARY: a one-time migration, to index the accounts that predate the index. + // Remove once every deployment has upgraded through a build that carries it — not + // once it has finished, because its completion flag is heap state, so an upgrade + // forgets it and the sweep walks every list again. That re-running is a cost, not a + // purpose: nothing depends on it, and the write path keeps the index in step by + // itself. init_account_principal_index_backfill_timer(); } From 18f27c7913bb6fc2f30f262242fa794d4303d968 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 13:09:23 +0200 Subject: [PATCH 229/298] docs(internet_identity): the index sync gives up redundancy, not repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving both lists re-asserted the entry of every account at the origin on every write. That would have papered over a bug in this function; it was never repairing a drift that something else causes, and nothing causes one — apply cannot fail, every refusal having happened before the first store, so an entry is written with its list or neither is. What it replaces said the backfill sweep was the repair, on the grounds that it re-runs after every upgrade. Wrong twice over: that sweep is a one-time migration whose re-running is an accepted cost rather than a guarantee, and it is meant to be deleted once every deployment has passed through a build carrying it — so the argument rested on something designed to disappear. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 7e63a15860..20e1870170 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2389,11 +2389,12 @@ impl Storage { /// seed is taken from, is written where a number is minted and nowhere else, so it /// cannot move under a number that already exists. /// - /// The cost of that is what this no longer does. It used to derive both lists in - /// full, which re-asserted the entry of every account at the origin on every write - /// and so repaired a drifted one for free. It does not any more, and the sweep that - /// runs after each upgrade is the repair — see the account-principal index backfill, - /// whose completion is heap state and so is forgotten at every upgrade. + /// What this gives up is redundancy, not repair. Deriving both lists re-asserted the + /// entry of every account at the origin on every write, which would have papered over + /// a bug here — it was never fixing a drift that something else causes, and nothing + /// causes one: apply cannot fail, every refusal having happened before the first + /// store, so an entry is written with its list or neither is. Correctness rests on + /// this function, which is where it rests for any index. /// /// Takes the salt and origin its caller already resolved, so everything that could /// refuse has refused before this writes anything. From 327c8ddb5db7b3fbc04ec8856d867b80e9251ee9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 13:29:12 +0200 Subject: [PATCH 230/298] fix(internet_identity): report the application-number allocator's memory `internet_identity_virtual_memory_size_pages` is meant to account for the whole of the managed memory, and this region was missing from it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index e3e8856e75..1e7ee04769 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -381,6 +381,8 @@ pub struct Storage { ManagedMemory, >, stable_account_counter_memory: StableCell>, + /// Memory wrapper used to report the size of the application-number allocator. + next_application_number_memory_wrapper: MemoryWrapper>, next_application_number_memory: StableCell>, /// 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: @@ -600,6 +602,9 @@ impl Storage { StorableAccountsCounter::default(), ) .expect("stable_account_counter_memory"), + next_application_number_memory_wrapper: MemoryWrapper::new( + next_application_number_memory.clone(), + ), next_application_number_memory: StableCell::init(next_application_number_memory, 0) .expect("next_application_number_memory"), stable_account_counter_discrepancy_counter_memory: StableCell::init( @@ -2441,6 +2446,10 @@ impl Storage { "stable_account_reference_list".to_string(), self.stable_account_reference_list_memory_wrapper.size(), ), + ( + "next_application_number".to_string(), + self.next_application_number_memory_wrapper.size(), + ), ( "stable_anchor_application_config".to_string(), self.stable_anchor_application_config_memory_wrapper.size(), From d5218b471c3e7d3ddf142be6a37e53ee429b5c67 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 13:30:37 +0200 Subject: [PATCH 231/298] fix(internet_identity): report the session index and id allocator's memory `internet_identity_virtual_memory_size_pages` is meant to account for the whole of the managed memory, and these two regions were missing from it. The session index is the one that matters: it holds an entry per live session, so it is the only region here that grows with use. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 9ff6bc20c4..87c30e9e69 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -423,6 +423,8 @@ pub struct Storage { /// Memory wrapper used to report the size of the application-number allocator. next_application_number_memory_wrapper: MemoryWrapper>, next_application_number_memory: StableCell>, + /// Memory wrapper used to report the size of the session-id allocator. + next_session_id_memory_wrapper: MemoryWrapper>, next_session_id_memory: StableCell>, lookup_account_with_principal_memory_wrapper: MemoryWrapper>, lookup_account_with_principal_memory: @@ -430,6 +432,8 @@ pub struct Storage { /// 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. + /// Memory wrapper used to report the size of the session index. + lookup_session_with_principal_memory_wrapper: MemoryWrapper>, 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 @@ -660,11 +664,15 @@ impl Storage { ), next_application_number_memory: StableCell::init(next_application_number_memory, 0) .expect("next_application_number_memory"), + next_session_id_memory_wrapper: MemoryWrapper::new(next_session_id_memory.clone()), next_session_id_memory: StableCell::init(next_session_id_memory, 0) .expect("next_session_id_memory"), lookup_account_with_principal_memory_wrapper: MemoryWrapper::new( lookup_account_with_principal_memory.clone(), ), + lookup_session_with_principal_memory_wrapper: MemoryWrapper::new( + lookup_session_with_principal_memory.clone(), + ), lookup_session_with_principal_memory: StableBTreeMap::init( lookup_session_with_principal_memory, ), @@ -3687,6 +3695,14 @@ impl Storage { "next_application_number".to_string(), self.next_application_number_memory_wrapper.size(), ), + ( + "lookup_session_with_principal".to_string(), + self.lookup_session_with_principal_memory_wrapper.size(), + ), + ( + "next_session_id".to_string(), + self.next_session_id_memory_wrapper.size(), + ), ( "stable_anchor_application_config".to_string(), self.stable_anchor_application_config_memory_wrapper.size(), From 98197300d1ead3edbd45b85873e1d619d82d232b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 16:25:40 +0200 Subject: [PATCH 232/298] refactor(internet_identity): a session records the browser it came from The identifier a session record carries names a browser, which is what the registry above this stores and what the settings page shows. Naming it here keeps the rename out of the branch that introduces the registry, and away from `device`, which this repository already uses for a passkey. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/account.rs | 12 ++++++------ src/internet_identity/src/storage/storable.rs | 2 +- .../src/storage/storable/browser_id.rs | 1 + .../src/storage/storable/session_device_id.rs | 1 - .../src/storage/storable/session_record.rs | 8 ++++---- src/internet_identity/src/storage/tests.rs | 8 ++++---- .../src/internet_identity/types.rs | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/browser_id.rs delete mode 100644 src/internet_identity/src/storage/storable/session_device_id.rs diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index 595a1302f5..eca9e1846c 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -7,8 +7,8 @@ use crate::{ use ic_cdk::trap; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ - AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, FrontendHostname, - SessionDeviceId, Timestamp, UserKey, + AccountInfo, AccountNameValidationError, AccountNumber, AnchorNumber, BrowserId, + FrontendHostname, Timestamp, UserKey, }; use serde::{Deserialize, Serialize}; @@ -61,7 +61,7 @@ pub struct SessionRecord { pub valid_till_ns: Timestamp, pub max_idle_ns: u64, pub last_refreshed_ns: Option, - pub device_id: SessionDeviceId, + pub browser_id: BrowserId, pub read_only: bool, } @@ -96,13 +96,13 @@ impl SessionRecord { /// /// The extension is what separates an app in weekly use from one opened once and /// abandoned, which recency alone gets backwards — the abandoned one was touched more - /// recently. `device_id` only makes the order total. - pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, SessionDeviceId) { + /// recently. `browser_id` only makes the order total. + pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, BrowserId) { let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); ( !self.is_over(now), last_used.saturating_add(self.demonstrated_use()), - self.device_id, + self.browser_id, ) } } diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index e74a495836..6017e62c69 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -10,6 +10,7 @@ pub mod anchor_number; pub mod anchor_number_list; pub mod application; pub mod application_number; +pub mod browser_id; pub mod credential_id; pub mod discrepancy_counter; pub mod duration; @@ -25,7 +26,6 @@ pub mod openid_credential_key; pub mod openid_jwks; pub mod passkey_credential; pub mod recovery_key; -pub mod session_device_id; pub mod session_record; pub mod special_device_migration; pub mod sso_stable_id_key; diff --git a/src/internet_identity/src/storage/storable/browser_id.rs b/src/internet_identity/src/storage/storable/browser_id.rs new file mode 100644 index 0000000000..2d83877047 --- /dev/null +++ b/src/internet_identity/src/storage/storable/browser_id.rs @@ -0,0 +1 @@ +pub type StorableBrowserId = u32; diff --git a/src/internet_identity/src/storage/storable/session_device_id.rs b/src/internet_identity/src/storage/storable/session_device_id.rs deleted file mode 100644 index 1da8905dd8..0000000000 --- a/src/internet_identity/src/storage/storable/session_device_id.rs +++ /dev/null @@ -1 +0,0 @@ -pub type StorableSessionDeviceId = u32; diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session_record.rs index 0feda7eef6..aa6c647947 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session_record.rs @@ -1,6 +1,6 @@ use crate::storage::account::SessionRecord; +use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::duration::StorableDuration; -use crate::storage::storable::session_device_id::StorableSessionDeviceId; use crate::storage::storable::timestamp::StorableTimestamp; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; @@ -19,7 +19,7 @@ pub struct StorableSessionRecord { #[n(3)] pub last_refreshed_ns: Option, #[n(4)] - pub device_id: StorableSessionDeviceId, + pub browser_id: StorableBrowserId, #[n(5)] pub read_only: bool, } @@ -45,7 +45,7 @@ impl From for SessionRecord { valid_till_ns: value.valid_till_ns, last_refreshed_ns: value.last_refreshed_ns, max_idle_ns: value.max_idle_ns, - device_id: value.device_id, + browser_id: value.browser_id, read_only: value.read_only, } } @@ -58,7 +58,7 @@ impl From for StorableSessionRecord { valid_till_ns: value.valid_till_ns, last_refreshed_ns: value.last_refreshed_ns, max_idle_ns: value.max_idle_ns, - device_id: value.device_id, + browser_id: value.browser_id, read_only: value.read_only, } } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index b0b9db2540..a980ba9812 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5062,7 +5062,7 @@ mod session_record_tests { valid_till_ns, max_idle_ns: NEVER_IDLE, last_refreshed_ns: None, - device_id: 1, + browser_id: 1, read_only: false, } } @@ -5090,7 +5090,7 @@ mod session_record_tests { valid_till_ns: 22, max_idle_ns: 33, last_refreshed_ns: Some(44), - device_id: 55, + browser_id: 55, read_only: false, }, SessionRecord { @@ -5098,7 +5098,7 @@ mod session_record_tests { valid_till_ns: 77, max_idle_ns: 88, last_refreshed_ns: None, - device_id: 99, + browser_id: 99, read_only: true, }, ], @@ -5242,7 +5242,7 @@ mod session_record_tests { // order would protect it. let flood: Vec = (0..500) .map(|index| SessionRecord { - device_id: index, + browser_id: index, ..session(now - 1, now + DAY_NS) }) .collect(); diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 4dcf0b549a..8f2f160fc2 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -15,7 +15,7 @@ pub type FrontendHostname = String; pub type ApplicationNumber = u64; pub type Timestamp = u64; // in nanos since epoch /// Per-anchor label for one browser, so a browser's sessions can be revoked together. -pub type SessionDeviceId = u32; +pub type BrowserId = u32; pub type Signature = ByteBuf; pub type DeviceConfirmationCode = String; pub type FailedAttemptsCounter = u8; From 8ca86a097c1054e7bf0bb423ad058d1385fdd358 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 16:54:03 +0200 Subject: [PATCH 233/298] feat(internet_identity): count each browser's sessions where they are written The settings page needs to say whether a browser is signed in to anything, and a reload has to give the same answer, so the count is stored rather than remembered by the page. Counting the reference lists on read was measured at around 90% added cost to `identity_info` for an identity with 60 browsers and 220 sessions, which is the reason it is not derived there. Kept beside the identity's own session count, in the write that changes the lists holding the sessions: the records carry the browser they came from, the browser entries are on the identity record that write already stores, and a write is the only thing that can change what is stored. A counter maintained by its callers would drift, and a count the page trusts is worse wrong than absent. A delta against a browser no entry holds is dropped, because the registry can retire an entry while sessions it opened are still stored. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/generated/internet_identity_idl.js | 1 + .../generated/internet_identity_types.d.ts | 4 + src/internet_identity/internet_identity.did | 2 + src/internet_identity/src/main.rs | 11 +- src/internet_identity/src/storage.rs | 29 +++- src/internet_identity/src/storage/anchor.rs | 27 ++- .../src/storage/storable/browser.rs | 4 + src/internet_identity/src/storage/tests.rs | 158 ++++++++++++++++++ .../src/internet_identity/types/api_v2.rs | 2 + 9 files changed, 231 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index c6bcfb9e57..d4a819e8da 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -561,6 +561,7 @@ export const idlFactory = ({ IDL }) => { }); const BrowserInfo = IDL.Record({ 'id' : IDL.Nat32, + 'session_count' : IDL.Nat32, 'name' : IDL.Text, 'created_at' : Timestamp, 'last_used' : Timestamp, 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 d8cd1b3069..8718f2d13c 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1541,6 +1541,10 @@ export type SessionDelegationError = { 'NoSuchDelegation' : null } | */ export interface BrowserInfo { 'id' : number, + /** + * Sessions this browser holds. Zero means it is signed in to nothing. + */ + 'session_count' : number, 'name' : string, 'created_at' : Timestamp, /** diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 70461d4c52..d2ce359358 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1015,6 +1015,8 @@ type BrowserInfo = record { created_at : Timestamp; // Advanced by a sign-in from this browser and by every session refresh it drives. last_used : Timestamp; + // Sessions this browser holds. Zero means it is signed in to nothing. + session_count : nat32; }; type IdentityInfo = record { diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index ffca6f96e6..d3e0ba4b1a 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -1202,11 +1202,12 @@ mod v2_api { let stored_browsers: Vec = state::anchor(identity_number) .browsers() .iter() - .map(|device| BrowserInfo { - id: device.id, - name: device.name.clone(), - created_at: device.created_at, - last_used: device.last_used, + .map(|browser| BrowserInfo { + id: browser.id, + name: browser.name.clone(), + created_at: browser.created_at, + last_used: browser.last_used, + session_count: browser.session_count, }) .collect(); let browsers = if stored_browsers.is_empty() { diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 87c30e9e69..94081ac97a 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2321,8 +2321,10 @@ impl Storage { // Moved once for the whole call rather than once per origin: the count lives on // the anchor, and an operation spanning several origins would otherwise read, - // change and write the same record several times over. + // change and write the same record several times over. The same is true of the + // per-browser counts, which live on the browser entries of that same record. let mut session_delta = 0i64; + let mut browser_deltas: BTreeMap = BTreeMap::new(); let mut written = BTreeMap::new(); for one in writes { @@ -2395,6 +2397,11 @@ impl Storage { &previous_references, ¤t_references, ); + Self::accumulate_browser_deltas( + &mut browser_deltas, + &previous_references, + ¤t_references, + ); } } @@ -2426,6 +2433,7 @@ impl Storage { anchor.session_count.saturating_add(session_delta as u32) }; } + anchor.move_browser_session_counts(&browser_deltas); // Taking the identity record is taking the storing of it, so it is stored whatever // was changed on it — the count above, or anything a caller changed before giving @@ -3098,6 +3106,25 @@ impl Storage { ids } + /// Adds what one reference-list write does to each browser's session count. + /// + /// Accumulated across the origins of a single write rather than returned per origin, + /// because a browser signed in at several origins is one entry on the identity + /// record, and the record is stored once. + fn accumulate_browser_deltas( + deltas: &mut BTreeMap, + previous: &[AccountReference], + current: &[AccountReference], + ) { + for (references, sign) in [(previous, -1i64), (current, 1i64)] { + for reference in references { + for session in &reference.sessions { + *deltas.entry(session.browser_id).or_default() += sign; + } + } + } + } + /// Keeps the session index in step with one reference-list write, and reports what /// the write does to the identity's session count. /// diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 6de25f56fa..260946e72b 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -17,7 +17,7 @@ use internet_identity_interface::internet_identity::types::openid::OpenIdCredent use internet_identity_interface::internet_identity::types::verified_email::VerifiedEmail; use internet_identity_interface::internet_identity::types::*; use serde_bytes::ByteBuf; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; #[cfg(test)] @@ -88,6 +88,9 @@ pub struct Browser { pub name: String, pub created_at: Timestamp, pub last_used: Timestamp, + /// Sessions this browser holds. Maintained by the write that changes the reference + /// lists holding them, so it counts stored records rather than live ones. + pub session_count: u32, } impl From for Browser { @@ -99,6 +102,7 @@ impl From for Browser { name: value.name, created_at: value.created_at, last_used: value.last_used, + session_count: value.session_count, } } } @@ -112,6 +116,7 @@ impl From for StorableBrowser { name: value.name, created_at: value.created_at, last_used: value.last_used, + session_count: value.session_count, } } } @@ -736,6 +741,25 @@ impl Anchor { &self.browsers } + /// Moves each browser's session count by what a write added to or took from it. + /// + /// A delta against a browser no entry holds is dropped: the cap can retire an entry + /// while sessions it opened are still stored, and a count belongs to an entry that + /// exists. + pub fn move_browser_session_counts(&mut self, deltas: &BTreeMap) { + for browser in &mut self.browsers { + let Some(delta) = deltas.get(&browser.id) else { + continue; + }; + browser.session_count = match delta { + 0.. => browser.session_count.saturating_add(*delta as u32), + _ => browser + .session_count + .saturating_sub(delta.unsigned_abs() as u32), + }; + } + } + /// Resolves the browser a sign-in came from by the public key it proved possession of. /// /// An entry is reached only by the successor it announced. Presenting it promotes that @@ -804,6 +828,7 @@ impl Anchor { name, created_at: now, last_used: now, + session_count: 0, }); let mut dropped = vec![]; diff --git a/src/internet_identity/src/storage/storable/browser.rs b/src/internet_identity/src/storage/storable/browser.rs index 0beeb19edf..6730c9002a 100644 --- a/src/internet_identity/src/storage/storable/browser.rs +++ b/src/internet_identity/src/storage/storable/browser.rs @@ -20,6 +20,10 @@ pub struct StorableBrowser { pub current_browser_key: Vec, #[cbor(n(5), with = "minicbor::bytes")] pub next_browser_key: Vec, + /// Sessions this browser holds, counted where the reference lists holding them are + /// written. + #[n(6)] + pub session_count: u32, } impl Storable for StorableBrowser { diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index a4851b2e12..1bb7d82f07 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6112,3 +6112,161 @@ mod session_consent_change_tests { assert_eq!(held, vec![false, true]); } } + +mod browser_session_count_tests { + use super::{params, params_at}; + use crate::storage::anchor::MAX_BROWSERS; + use crate::storage::CreateSessionParams; + use crate::Storage; + use ic_stable_structures::VectorMemory; + use internet_identity_interface::internet_identity::types::{AnchorNumber, BrowserId}; + use pretty_assertions::assert_eq; + use std::collections::BTreeMap; + + const SALT: [u8; 32] = [17u8; 32]; + + 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) + } + + /// What each registered browser says it holds, and what the identity says it holds. + /// Asserted together throughout: the per-browser counts are the same fact as the + /// identity's total at a finer grain, and a test that checked one without the other + /// would pass while they disagreed. + fn counts( + storage: &Storage, + anchor_number: AnchorNumber, + ) -> (BTreeMap, u32) { + let anchor = storage.read(anchor_number).unwrap(); + let per_browser = anchor + .browsers() + .iter() + .map(|browser| (browser.id, browser.session_count)) + .collect(); + (per_browser, anchor.session_count) + } + + #[test] + fn a_session_counts_against_the_browser_it_came_from() { + let (mut storage, anchor_number) = storage_with_anchor(); + + storage + .create_session(params(anchor_number, 7, 1_000)) + .unwrap(); + assert_eq!( + counts(&storage, anchor_number), + (BTreeMap::from([(0, 1)]), 1) + ); + + // A second browser, which is a second entry rather than a second session on the + // first. + storage + .create_session(params(anchor_number, 9, 2_000)) + .unwrap(); + assert_eq!( + counts(&storage, anchor_number), + (BTreeMap::from([(0, 1), (1, 1)]), 2) + ); + + // The first browser signing in again at the same origin, presenting the successor + // it announced. A ceremony replaces what that browser held there rather than + // adding to it, so the counts stand still — which is the case a counter kept by + // incrementing at the call site would get wrong. + storage + .create_session(params_at(anchor_number, 7, 1, 3_000)) + .unwrap(); + assert_eq!( + counts(&storage, anchor_number), + (BTreeMap::from([(0, 1), (1, 1)]), 2) + ); + } + + #[test] + fn sessions_at_several_origins_add_up_on_one_browser() { + let (mut storage, anchor_number) = storage_with_anchor(); + + storage + .create_session(params(anchor_number, 7, 1_000)) + .unwrap(); + storage + .create_session(CreateSessionParams { + origin: "https://elsewhere.example".to_string(), + ..params_at(anchor_number, 7, 1, 2_000) + }) + .unwrap(); + + // One entry, two origins: the count belongs to the browser, not to a list. + assert_eq!( + counts(&storage, anchor_number), + (BTreeMap::from([(0, 2)]), 2) + ); + } + + #[test] + fn revoking_a_browsers_sessions_returns_its_count_to_zero() { + let (mut storage, anchor_number) = storage_with_anchor(); + + storage + .create_session(params(anchor_number, 7, 1_000)) + .unwrap(); + storage + .create_session(CreateSessionParams { + origin: "https://elsewhere.example".to_string(), + ..params_at(anchor_number, 7, 1, 2_000) + }) + .unwrap(); + storage + .create_session(params(anchor_number, 9, 3_000)) + .unwrap(); + + assert_eq!( + storage.revoke_browser_sessions(anchor_number, 0).unwrap(), + 2 + ); + + // Zero is the whole point of the counter: it is what the settings page reads to + // say a browser is signed in to nothing, and it survives a reload because it was + // stored here rather than remembered by the page. + assert_eq!( + counts(&storage, anchor_number), + (BTreeMap::from([(0, 0), (1, 1)]), 1) + ); + } + + #[test] + fn a_browser_the_registry_gave_up_leaves_no_count_behind() { + let (mut storage, anchor_number) = storage_with_anchor(); + + // Two sessions on the browser that will be given up, so a count that outlived its + // entry would be visible rather than indistinguishable from a fresh one. + storage + .create_session(params(anchor_number, 7, 1_000)) + .unwrap(); + storage + .create_session(params_at(anchor_number, 7, 1, 1_000)) + .unwrap(); + + for index in 0..MAX_BROWSERS { + storage + .create_session(params(anchor_number, 100 + index as u8, 2_000)) + .unwrap(); + } + + let (per_browser, total) = counts(&storage, anchor_number); + assert!( + !per_browser.contains_key(&0), + "the dropped browser is still counted: {per_browser:?}" + ); + assert_eq!(per_browser.len(), MAX_BROWSERS); + assert!( + per_browser.values().all(|count| *count == 1), + "one session each for the browsers still registered: {per_browser:?}" + ); + assert_eq!(total as usize, MAX_BROWSERS); + } +} diff --git a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs index af2ddcd254..d3edcea4f6 100644 --- a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs +++ b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs @@ -84,6 +84,8 @@ pub struct BrowserInfo { pub name: String, pub created_at: Timestamp, pub last_used: Timestamp, + /// Sessions this browser holds, counted from the stored records. + pub session_count: u32, } #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] From 13f85083824a30295a35e945a7eec33903d96856 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 17:09:07 +0200 Subject: [PATCH 234/298] style(frontend): wrap the session-delegation import Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../routes/(new-styling)/authorize/views/ContinueView.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 441c57ffe8..738e65de82 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte @@ -18,7 +18,10 @@ authenticationStore, isAuthenticatedStore, } from "$lib/stores/authentication.store"; - import { actorForIdentity, forgetIdentity } from "$lib/stores/session-delegation.store"; + import { + actorForIdentity, + forgetIdentity, + } from "$lib/stores/session-delegation.store"; import { throwCanisterError, isCanisterError } from "$lib/utils/utils"; import type { ActorSubclass } from "@icp-sdk/core/agent"; import type { From ef8c2184bc0ca1f89360e88bfc28c207b868027a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 17:31:13 +0200 Subject: [PATCH 235/298] chore(internet_identity): retire the account principal index backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep has shipped and run everywhere it had to: it was in release-2026-09-08, and the write path has kept the index in step by itself since the index existed. What is left is a timer that walks every reference list after each upgrade — its completion flag is heap state, so an upgrade forgets it — to write entries that are already there. Goes together with the monitoring query, the cursor and outcome types, and the tests that covered them. The index itself and the write-path maintenance stay: those are what keep it true. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/api/internet_identity/api_v2.rs | 16 -- src/internet_identity/src/main.rs | 104 +------- src/internet_identity/src/storage.rs | 151 ----------- .../src/storage/storable/account_key.rs | 4 +- src/internet_identity/src/storage/tests.rs | 237 ------------------ .../tests/integration/accounts.rs | 66 +---- 6 files changed, 10 insertions(+), 568 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 fdcd1d1067..166229bd56 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -746,19 +746,3 @@ pub fn get_account_delegation_with_read_only( ) .map(|(x,)| x) } - -/// Hidden monitoring endpoint: `(indexed_entries, skipped_rows, is_done)` for the -/// account principal index backfill. -pub fn account_principal_index_backfill_status( - env: &PocketIc, - canister_id: CanisterId, - sender: Principal, -) -> Result<(u64, u64, bool), RejectResponse> { - query_candid_as( - env, - canister_id, - sender, - "account_principal_index_backfill_status", - (), - ) -} diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 5e39e71e21..0e1d458742 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -16,7 +16,7 @@ use ic_cdk::api::{caller, set_certified_data, time, trap}; use ic_cdk::call; use ic_cdk::spawn; use ic_cdk_macros::{init, post_upgrade, pre_upgrade, query, update}; -use ic_cdk_timers::{set_timer, TimerId}; +use ic_cdk_timers::set_timer; use internet_identity_interface::archive::types::{BufferedEntry, Operation}; use internet_identity_interface::http_gateway::{HttpRequest, HttpResponse}; use internet_identity_interface::internet_identity::types::attributes::{ @@ -39,11 +39,10 @@ use internet_identity_interface::internet_identity::types::vc_mvp::{ }; use internet_identity_interface::internet_identity::types::*; use serde_bytes::ByteBuf; -use std::cell::RefCell; use std::collections::HashMap; use std::time::Duration; use storage::account::{AccountDelegationError, PrepareAccountDelegation}; -use storage::{AccountPrincipalIndexBackfillCursor, Salt, Storage}; +use storage::{Salt, Storage}; mod account_management; mod anchor_management; @@ -844,105 +843,6 @@ fn initialize(maybe_arg: Option) { if let Some(openid_configs) = config.openid_configs { openid::setup(openid_configs); } - - // TEMPORARY: a one-time migration, to index the accounts that predate the index. - // Remove once every deployment has upgraded through a build that carries it — not - // once it has finished, because its completion flag is heap state, so an upgrade - // forgets it and the sweep walks every list again. That re-running is a cost, not a - // purpose: nothing depends on it, and the write path keeps the index in step by - // itself. - init_account_principal_index_backfill_timer(); -} - -const ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BACKOFF: Duration = Duration::from_secs(1); - -const ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BATCH_SIZE: u64 = 2_000; - -thread_local! { - static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR: RefCell> = const { RefCell::new(None) }; - static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE: RefCell = const { RefCell::new(false) }; - static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED: RefCell = const { RefCell::new(0) }; - static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED: RefCell = const { RefCell::new(0) }; - static ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID: RefCell> = const { RefCell::new(None) }; -} - -/// Returns `(indexed_entries, skipped_lists, is_done)` so monitoring can track the sweep. -/// -/// A non-zero skip count is not progress: it is account reference lists whose application -/// is gone, which the sweep cannot derive a principal for. A run that reports nothing -/// indexed and nothing skipped had nothing to do; one that reports skips did not. -#[query(hidden = true)] -fn account_principal_index_backfill_status() -> (u64, u64, bool) { - ( - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow(|indexed| *indexed), - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED.with_borrow(|skipped| *skipped), - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.with_borrow(|done| *done), - ) -} - -fn run_account_principal_index_backfill_batch() { - if ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.with_borrow(|done| *done) { - // Reaching this means a timer fired after the sweep finished, so the clear below - // did not take — an id that outlived its slot, or a second timer installed over - // the first. Returning alone would leave it firing every second forever, doing - // nothing, so the cleanup is repeated rather than assumed. It stays below as well: - // clearing only here would mean every completed sweep fires once more before - // stopping, and completion should stop it where it happens. - clear_account_principal_index_backfill_timer(); - return; - } - - let cursor = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR.with_borrow(|cursor| *cursor); - let outcome = state::storage_borrow_mut(|storage| { - storage.backfill_account_principal_index_batch( - cursor, - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BATCH_SIZE, - ) - }); - - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow_mut(|indexed| { - *indexed = indexed.saturating_add(outcome.indexed); - }); - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED.with_borrow_mut(|skipped| { - *skipped = skipped.saturating_add(outcome.skipped); - }); - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_CURSOR.replace(outcome.next_cursor); - - if outcome.is_done { - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_DONE.replace(true); - clear_account_principal_index_backfill_timer(); - let indexed = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_INDEXED.with_borrow(|indexed| *indexed); - let skipped = ACCOUNT_PRINCIPAL_INDEX_BACKFILL_SKIPPED.with_borrow(|skipped| *skipped); - ic_cdk::println!( - "Account principal index backfill COMPLETED ({indexed} entries, {skipped} skipped)." - ); - } -} - -/// Stops the sweep's timer and forgets its id. Does nothing where there is no id, so it -/// is safe to call from either the completion it belongs to or a firing that should not -/// have happened. -fn clear_account_principal_index_backfill_timer() { - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID.with_borrow_mut(|id_slot| { - if let Some(timer_id) = id_slot.take() { - ic_cdk_timers::clear_timer(timer_id); - } - }); -} - -/// Safe to call from both `init` and `post_upgrade`: with nothing to index the -/// first batch immediately reports completion. A batch before the salt exists -/// indexes nothing and leaves the sweep running, so it resumes once it is set. -fn init_account_principal_index_backfill_timer() { - let timer_id = ic_cdk_timers::set_timer_interval( - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_BACKOFF, - run_account_principal_index_backfill_batch, - ); - ACCOUNT_PRINCIPAL_INDEX_BACKFILL_TIMER_ID.with_borrow_mut(|id_slot| { - if let Some(old_id) = id_slot.replace(timer_id) { - ic_cdk_timers::clear_timer(old_id); - } - }); } fn apply_install_arg(maybe_arg: Option) { diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 57e366fdfc..5040b66581 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2501,130 +2501,6 @@ impl Storage { .collect() } - /// Indexes one batch of existing account reference lists. Entries are only inserted, - /// never removed, so a batch that runs twice writes the same values. - /// - /// `batch_size` bounds **derivations**, not lists. One list is an identity's references - /// at one origin and holds up to [`MAX_ANCHOR_ACCOUNTS`] of them, each costing a seed - /// hash, a principal derivation and a stable write — so a list-bounded batch is only - /// bounded in the shape of data that happens to be common. A batch stops mid-list and - /// the cursor says where, which is why it carries an offset into the list. - pub fn backfill_account_principal_index_batch( - &mut self, - cursor: Option, - batch_size: u64, - ) -> AccountPrincipalIndexBackfillOutcome { - let mut outcome = AccountPrincipalIndexBackfillOutcome { - next_cursor: cursor, - ..Default::default() - }; - - // Examining nothing is not finishing. Reporting completion here would stop a - // sweep that has not read a single list, and a lookup miss would then be taken as - // proof no account has that principal. - if batch_size == 0 { - return outcome; - } - - use std::ops::Bound as RangeBound; - // Inclusive of the cursor's own list: a batch may have stopped part-way through - // it, and the offset says how far it got. - let range = match cursor { - Some(cursor) => (RangeBound::Included(cursor.list()), RangeBound::Unbounded), - None => (RangeBound::Unbounded, RangeBound::Unbounded), - }; - - // Read far enough ahead to spend the budget and no further, so the lists behind - // this batch are never materialised. The borrow ends here, which is what lets the - // indexing below write. - let mut outstanding = batch_size; - let mut ran_out = false; - let mut lists: Vec<( - AnchorNumber, - ApplicationNumber, - Vec, - usize, - )> = vec![]; - for (key, list) in self.stable_account_reference_list_memory.range(range) { - let references = Vec::::from(list); - let already_done = match cursor { - Some(cursor) if cursor.list() == key => cursor.references_done, - _ => 0, - }; - let left_in_list = references.len().saturating_sub(already_done) as u64; - lists.push((key.0, key.1, references, already_done)); - if left_in_list >= outstanding { - ran_out = true; - break; - } - outstanding -= left_in_list; - } - - // Nothing left to index, whatever else is true of this canister. Checked before - // the salt, because a fresh install has no salt until its first sign-in and no - // lists either — and a sweep that waits for the salt there never reports done and - // ticks its timer for the life of the canister. - if lists.is_empty() { - outcome.is_done = true; - return outcome; - } - - // Not done, so the caller comes back. A canister whose salt is unset has not - // finished starting up rather than finished backfilling. - let Some(salt) = self.salt().copied() else { - return outcome; - }; - - outcome.is_done = !ran_out; - - let mut budget = batch_size; - for (anchor_number, application_number, references, already_done) in lists { - let Some(origin) = self - .stable_application_memory - .get(&application_number) - .map(|application| application.origin) - else { - outcome.skipped += 1; - outcome.next_cursor = Some(AccountPrincipalIndexBackfillCursor { - anchor_number, - application_number, - references_done: references.len(), - }); - continue; - }; - - let taking = (budget as usize).min(references.len().saturating_sub(already_done)); - for (principal, locator) in self.account_principals( - anchor_number, - application_number, - &origin, - &salt, - &references[already_done..already_done + taking], - ) { - if self.lookup_account_with_principal_memory.get(&principal) - == Some(locator.clone()) - { - continue; - } - self.lookup_account_with_principal_memory - .insert(principal, locator); - outcome.indexed += 1; - } - - budget -= taking as u64; - outcome.next_cursor = Some(AccountPrincipalIndexBackfillCursor { - anchor_number, - application_number, - references_done: already_done + taking, - }); - if budget == 0 { - break; - } - } - - outcome - } - /// Retires an application no anchor references any more. The number is never /// reissued. fn remove_unreferenced_application( @@ -3215,33 +3091,6 @@ impl Storage { } } -/// How far the sweep has got: which list, and how many of that list's references are -/// already indexed. The offset is what lets a batch stop inside a list that holds more -/// references than one message can derive principals for. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct AccountPrincipalIndexBackfillCursor { - pub anchor_number: AnchorNumber, - pub application_number: ApplicationNumber, - pub references_done: usize, -} - -impl AccountPrincipalIndexBackfillCursor { - fn list(&self) -> (AnchorNumber, ApplicationNumber) { - (self.anchor_number, self.application_number) - } -} - -#[derive(Debug, Default)] -pub struct AccountPrincipalIndexBackfillOutcome { - pub next_cursor: Option, - pub indexed: u64, - /// Lists whose application is gone, so no principal can be derived for them. A list - /// in that state is an inconsistency rather than a normal skip, and a run that - /// silently indexes nothing would otherwise look like a run with nothing to do. - pub skipped: u64, - pub is_done: bool, -} - #[cfg(not(test))] fn canister_id() -> Principal { ic_cdk::id() diff --git a/src/internet_identity/src/storage/storable/account_key.rs b/src/internet_identity/src/storage/storable/account_key.rs index 532f329ec7..7ab38f899d 100644 --- a/src/internet_identity/src/storage/storable/account_key.rs +++ b/src/internet_identity/src/storage/storable/account_key.rs @@ -18,8 +18,8 @@ use std::borrow::Cow; // An array rather than a map: a map's integer keys buy the ability to add a field and still // decode records written before it, which is what authoritative state needs. This is a // derived index — every entry is reconstructible from the account reference lists that hold -// the truth, and the backfill sweep does exactly that — so a shape change here is a rebuild, -// and the three key bytes are not worth paying for. +// the truth — so a shape change here means writing a rebuild, and the three key bytes are +// not worth paying for. #[cbor(array)] pub struct StorableAccountKey { #[n(0)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 46bde2d445..c4e1a92efa 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -8,8 +8,6 @@ use crate::storage::account::{Account, AccountKey}; use crate::storage::anchor::{Anchor, Device}; use crate::storage::storable::account::StorableAccount; use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; -use crate::storage::storable::application::StorableApplication; -use crate::storage::StorableOriginSha256; use crate::storage::{AccountReferenceListWrite, AccountReferenceWrite}; use crate::storage::{Header, StorageError, MAX_ENTRIES}; use crate::Storage; @@ -48,30 +46,6 @@ fn record_use( const HEADER_SIZE: usize = 58; -/// Puts an application straight into the maps, for the few tests that must not go through -/// the write path — the write path derives principals, and those tests are about not -/// having a salt. -pub(crate) fn plant_application( - storage: &mut Storage, - origin: &FrontendHostname, -) -> ApplicationNumber { - let application_number = storage.get_total_application_count(); - storage.stable_application_memory.insert( - application_number, - StorableApplication { - origin: origin.clone(), - stored_accounts: 0, - stored_account_references: 0, - stored_tombstones: 0, - }, - ); - storage.lookup_application_with_origin_memory.insert( - StorableOriginSha256::from_origin(origin), - application_number, - ); - application_number -} - /// Removing what an identity holds at one origin, in the shape the gate takes it. pub(crate) fn remove_at( origin: &FrontendHostname, @@ -4969,214 +4943,3 @@ mod account_principal_index_tests { ); } } - -mod account_principal_index_backfill_tests { - use super::{plant_application, write_at}; - use crate::delegation::canister_sig_principal; - use crate::storage::account::{Account, AccountReference}; - use crate::storage::canister_id; - use crate::storage::storable::account_reference_list::StorableAccountReferenceList; - use crate::Storage; - use candid::Principal; - use ic_stable_structures::VectorMemory; - use internet_identity_interface::internet_identity::types::AnchorNumber; - use pretty_assertions::assert_eq; - - const SALT: [u8; 32] = [17u8; 32]; - - fn storage_with_lists(lists: u64) -> (Storage, Vec) { - let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); - storage.update_salt(SALT); - let mut anchors = vec![]; - for index in 0..lists { - let anchor = storage.allocate_anchor(0).unwrap(); - let anchor_number = anchor.anchor_number(); - storage.write(anchor).unwrap(); - anchors.push(anchor_number); - storage - .write_account_state( - anchor_number, - write_at( - &format!("https://d-{index}.com"), - vec![AccountReference { - account_number: None, - last_used: Some(index + 1), - }], - None, - ), - ) - .unwrap(); - } - (storage, anchors) - } - - fn clear_index(storage: &mut Storage) { - let keys: Vec = storage - .lookup_account_with_principal_memory - .iter() - .map(|(key, _)| key) - .collect(); - for key in keys { - storage.lookup_account_with_principal_memory.remove(&key); - } - } - - #[test] - fn a_sweep_indexes_every_pre_existing_list() { - let (mut storage, anchors) = storage_with_lists(5); - clear_index(&mut storage); - assert_eq!(storage.lookup_account_with_principal_memory.len(), 0); - - let outcome = storage.backfill_account_principal_index_batch(None, 100); - - assert!(outcome.is_done); - assert_eq!(outcome.indexed, 5); - assert_eq!(storage.lookup_account_with_principal_memory.len(), 5); - for (index, anchor_number) in anchors.iter().enumerate() { - let account = - Account::new(*anchor_number, format!("https://d-{index}.com"), None, None); - let principal = canister_sig_principal( - canister_id(), - account.calculate_seed_with_salt(&SALT).to_vec(), - ); - assert_eq!( - storage - .lookup_account_with_principal_memory - .get(&principal) - .unwrap() - .anchor_number, - *anchor_number - ); - } - } - - #[test] - fn a_sweep_resumes_from_its_cursor() { - let (mut storage, _) = storage_with_lists(5); - clear_index(&mut storage); - - let first = storage.backfill_account_principal_index_batch(None, 2); - assert!(!first.is_done); - assert_eq!(first.indexed, 2); - - let second = storage.backfill_account_principal_index_batch(first.next_cursor, 2); - assert!(!second.is_done); - assert_eq!(second.indexed, 2); - - let third = storage.backfill_account_principal_index_batch(second.next_cursor, 2); - assert!(third.is_done); - assert_eq!(third.indexed, 1); - assert_eq!(storage.lookup_account_with_principal_memory.len(), 5); - } - - #[test] - fn a_repeated_sweep_writes_nothing_new() { - let (mut storage, _) = storage_with_lists(3); - - let outcome = storage.backfill_account_principal_index_batch(None, 100); - - assert!(outcome.is_done); - assert_eq!(outcome.indexed, 0); - assert_eq!(storage.lookup_account_with_principal_memory.len(), 3); - } - - #[test] - fn a_sweep_without_a_salt_indexes_nothing_and_stays_unfinished() { - let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); - let anchor = storage.allocate_anchor(0).unwrap(); - let anchor_number = anchor.anchor_number(); - storage.write(anchor).unwrap(); - // Written straight into the map: the write path derives principals, so it needs - // the salt this test is about not having. - let application_number = plant_application(&mut storage, &"https://d-0.com".to_string()); - storage.stable_account_reference_list_memory.insert( - (anchor_number, application_number), - StorableAccountReferenceList::try_from(vec![AccountReference { - account_number: None, - last_used: Some(1), - }]) - .unwrap(), - ); - - let outcome = storage.backfill_account_principal_index_batch(None, 100); - - assert!(!outcome.is_done); - assert_eq!(outcome.indexed, 0); - } - - /// A canister that has never been signed in to has no salt and no lists, and the sweep - /// has to finish on the second of those. Waiting for the salt would leave its timer - /// running for the life of the canister. - #[test] - fn a_sweep_with_nothing_to_index_finishes_without_a_salt() { - let mut storage = Storage::new((10_000, 3_784_873), VectorMemory::default()); - - let outcome = storage.backfill_account_principal_index_batch(None, 100); - - assert!(outcome.is_done); - assert_eq!(outcome.indexed, 0); - } - - /// The one answer this sweep must never give without looking: a lookup miss is only - /// meaningful once the sweep says it is finished. - #[test] - fn an_empty_batch_size_does_not_report_completion() { - let (mut storage, _) = storage_with_lists(3); - clear_index(&mut storage); - - let outcome = storage.backfill_account_principal_index_batch(None, 0); - - assert!(!outcome.is_done); - assert_eq!(outcome.indexed, 0); - } - - /// A list can hold up to `MAX_ANCHOR_ACCOUNTS` references, so a batch that stopped only - /// on list boundaries would derive that many principals in one message however small - /// the batch. It stops inside the list and the cursor says where. - #[test] - fn a_batch_stops_inside_a_list_too_big_to_finish() { - let (mut storage, anchors) = storage_with_lists(1); - let anchor_number = anchors[0]; - let origin = "https://d-0.com".to_string(); - let mut references = vec![AccountReference { - account_number: None, - last_used: Some(1), - }]; - for _ in 0..4 { - let account = storage - .create_account(anchor_number, origin.clone(), "named".to_string()) - .unwrap(); - references.push(AccountReference { - account_number: account.account_number, - last_used: None, - }); - } - clear_index(&mut storage); - - let first = storage.backfill_account_principal_index_batch(None, 2); - - assert!(!first.is_done); - assert_eq!(first.indexed, 2); - assert_eq!( - first.next_cursor.map(|cursor| cursor.references_done), - Some(2), - "the cursor should point inside the list, not past it" - ); - assert_eq!(storage.lookup_account_with_principal_memory.len(), 2); - - let second = storage.backfill_account_principal_index_batch(first.next_cursor, 2); - - assert!(!second.is_done); - assert_eq!(second.indexed, 2); - assert_eq!(storage.lookup_account_with_principal_memory.len(), 4); - - let third = storage.backfill_account_principal_index_batch(second.next_cursor, 2); - - assert!(third.is_done); - assert_eq!(third.indexed, 1); - assert_eq!( - storage.lookup_account_with_principal_memory.len() as usize, - references.len() - ); - } -} diff --git a/src/internet_identity/tests/integration/accounts.rs b/src/internet_identity/tests/integration/accounts.rs index 7f9ba8bfe9..57559c66d6 100644 --- a/src/internet_identity/tests/integration/accounts.rs +++ b/src/internet_identity/tests/integration/accounts.rs @@ -2,18 +2,17 @@ use candid::Principal; use canister_tests::{ api::internet_identity::{ api_v2::{ - account_principal_index_backfill_status, create_account, get_account_delegation, - get_account_delegation_with_read_only, get_accounts, get_default_account, - prepare_account_delegation, prepare_account_delegation_with_read_only, - set_default_account, update_account, AccountDelegationParams, + create_account, get_account_delegation, get_account_delegation_with_read_only, + get_accounts, get_default_account, prepare_account_delegation, + prepare_account_delegation_with_read_only, set_default_account, update_account, + AccountDelegationParams, }, get_delegation, init_salt, prepare_delegation, }, flows, framework::{ - device_data_2, env, get_metrics, install_ii_canister, install_ii_with_archive, - parse_metric, principal_1, principal_2, time, upgrade_ii_canister, verify_delegation, - II_WASM, II_WASM_PREVIOUS, + device_data_2, env, get_metrics, install_ii_with_archive, parse_metric, principal_1, + principal_2, time, verify_delegation, }, }; use internet_identity_interface::internet_identity::types::{ @@ -1719,56 +1718,3 @@ fn should_remove_unreferenced_applications_an_anchor_stops_referencing( Ok(()) } - -/// Verifies that account references written before the principal index existed are -/// swept into it, so a lookup miss is unambiguous once the sweep reports completion. -#[test] -fn should_backfill_the_account_principal_index_after_an_upgrade() -> Result<(), RejectResponse> { - let env = env(); - // Installed from the release that has no index, so its account references are the - // ones the sweep has to pick up. - let canister_id = install_ii_canister(&env, II_WASM_PREVIOUS.clone()); - let identity_number = flows::register_anchor(&env, canister_id); - - for index in 0..3 { - create_account( - &env, - canister_id, - principal_1(), - identity_number, - format!("https://dapp-{index}.com"), - format!("account-{index}"), - )? - .unwrap(); - } - - let params = AccountDelegationParams::new( - &env, - canister_id, - principal_1(), - identity_number, - "https://dapp-0.com".to_string(), - None, - ByteBuf::from(vec![1; 32]), - ); - prepare_account_delegation(¶ms, None)?.unwrap(); - - upgrade_ii_canister(&env, canister_id, II_WASM.clone()); - - env.advance_time(Duration::from_secs(5)); - for _ in 0..5 { - env.tick(); - } - - let (indexed, skipped, is_done) = - account_principal_index_backfill_status(&env, canister_id, principal_1())?; - assert!(is_done, "the backfill should report completion"); - assert_eq!( - indexed, 6, - "three named accounts, each alongside the default reference backfilled with it" - ); - // Every row had its application, so nothing was passed over. - assert_eq!(skipped, 0); - - Ok(()) -} From 4835dcf3149340b97a97bcdf18fe6430ec6a430c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 20:58:56 +0200 Subject: [PATCH 236/298] feat(internet_identity): a browser describes itself in tokens, not a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings list needs to name a browser the user will recognise, and the client is the only party that can see what it is running on. It used to send a composed label, which froze the wording at registration: a record written before "Chrome OS" became "ChromeOS" would read the old name for as long as it existed, and nothing could re-derive it. So the client sends what it resolved rather than what it wrote — a brand, an operating system, a form factor, and the hardware model where it can name one — and the wording lives in the frontend, where changing it reaches every stored record at once. `Other` carries a token this list does not name, because an unrecognised browser is worth seeing rather than worth hiding behind a generic label. The description is taken only where a sign-in registers a browser. An entry that is advanced keeps what it was registered with, so what a browser reports is a fact about a registration rather than about the last sign-in. `BrowserBrand` rather than `Browser` because that is what the client hints call it, and because `Browser` names the registry entry it describes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/generated/internet_identity_idl.js | 35 ++- .../generated/internet_identity_types.d.ts | 59 +++- src/internet_identity/internet_identity.did | 38 ++- src/internet_identity/src/main.rs | 10 +- src/internet_identity/src/storage/anchor.rs | 22 +- .../src/storage/anchor/tests.rs | 123 +++++--- src/internet_identity/src/storage/storable.rs | 1 + .../src/storage/storable/browser.rs | 6 +- .../storage/storable/browser_description.rs | 268 ++++++++++++++++++ .../src/internet_identity/types.rs | 68 +++++ .../src/internet_identity/types/api_v2.rs | 8 +- 11 files changed, 567 insertions(+), 71 deletions(-) create mode 100644 src/internet_identity/src/storage/storable/browser_description.rs diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index c6bcfb9e57..e5581f1a0d 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -559,9 +559,42 @@ export const idlFactory = ({ IDL }) => { 'address' : IDL.Text, 'last_used' : IDL.Opt(Timestamp), }); + const OperatingSystem = IDL.Variant({ + 'Ios' : IDL.Null, + 'Linux' : IDL.Null, + 'Android' : IDL.Null, + 'Macos' : IDL.Null, + 'ChromeOs' : IDL.Null, + 'Windows' : IDL.Null, + 'Other' : IDL.Text, + 'Ipados' : IDL.Null, + }); + const FormFactor = IDL.Variant({ + 'Unknown' : IDL.Null, + 'Tablet' : IDL.Null, + 'Desktop' : IDL.Null, + 'Mobile' : IDL.Null, + }); + const BrowserBrand = IDL.Variant({ + 'Vivaldi' : IDL.Null, + 'Edge' : IDL.Null, + 'Firefox' : IDL.Null, + 'Brave' : IDL.Null, + 'Safari' : IDL.Null, + 'SamsungInternet' : IDL.Null, + 'Opera' : IDL.Null, + 'Other' : IDL.Text, + 'Chrome' : IDL.Null, + }); + const BrowserDescription = IDL.Record({ + 'os' : OperatingSystem, + 'model' : IDL.Opt(IDL.Text), + 'form_factor' : FormFactor, + 'brand' : BrowserBrand, + }); const BrowserInfo = IDL.Record({ 'id' : IDL.Nat32, - 'name' : IDL.Text, + 'description' : BrowserDescription, 'created_at' : Timestamp, 'last_used' : Timestamp, }); 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 d8cd1b3069..dce40ddb49 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -678,6 +678,14 @@ export interface EmailRecoveryGetDelegationArgs { 'expiration' : Timestamp, 'nonce' : string, } +/** + * Reported where the client can state it and inferred where it cannot, so unknown is a + * real answer: the browsers exposing no client hints are the ones this is least sure of. + */ +export type FormFactor = { 'Unknown' : null } | + { 'Tablet' : null } | + { 'Desktop' : null } | + { 'Mobile' : null }; export type FrontendHostname = string; export type GetAccountError = { 'NoSuchOrigin' : { 'anchor_number' : UserNumber } @@ -1303,6 +1311,14 @@ export interface OpenIdPrepareDelegationResponse { 'expiration' : Timestamp, 'anchor_number' : UserNumber, } +export type OperatingSystem = { 'Ios' : null } | + { 'Linux' : null } | + { 'Android' : null } | + { 'Macos' : null } | + { 'ChromeOs' : null } | + { 'Windows' : null } | + { 'Other' : string } | + { 'Ipados' : null }; /** * The delegation permissions a caller requests, mirroring the ICP protocol's * request-delegation `permissions` values. `queries` yields a queries-only @@ -1535,13 +1551,48 @@ export type SessionDelegationError = { 'NoSuchDelegation' : null } | { 'InternalCanisterError' : string } | { 'Unauthorized' : Principal }; /** - * A browser an anchor has signed in from. The name is self-reported by the - * client, so it is a label for the user rather than evidence about where a - * session came from. + * Which browser a sign-in came from, as a token rather than a name to show. Products + * get renamed — "Chrome OS" became "ChromeOS", "Mac OS X" became "macOS" — so the name + * the user reads is derived in the frontend, where a rename reaches every stored record + * at once. "Brand" is what the client hints call this, and BrowserInfo below is the + * entry it describes. + */ +export type BrowserBrand = { 'Vivaldi' : null } | + { 'Edge' : null } | + { 'Firefox' : null } | + { 'Brave' : null } | + { 'Safari' : null } | + { 'SamsungInternet' : null } | + { 'Opera' : null } | + { + /** + * A browser this list does not name, shown as the client resolved it. Worth seeing + * rather than hiding behind a generic label. + */ + 'Other' : string + } | + { 'Chrome' : null }; +/** + * What a browser reported about itself when it registered. Self-reported, so it is + * something the user reads to recognise their own browser rather than evidence about + * where a session came from. The canister stores these and never interprets them. */ +export interface BrowserDescription { + 'os' : OperatingSystem, + /** + * The hardware, where the client can name it — Android is the only place that does. + */ + 'model' : [] | [string], + 'form_factor' : FormFactor, + 'brand' : BrowserBrand, +} export interface BrowserInfo { 'id' : number, - 'name' : string, + /** + * Fixed at registration. A sign-in reporting something else registers its own entry, + * so this describes a registration rather than the last sign-in. + */ + 'description' : BrowserDescription, 'created_at' : Timestamp, /** * Advanced by a sign-in from this browser and by every session refresh it drives. diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 70461d4c52..78d51de0ce 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1006,12 +1006,42 @@ type IdentityAuthnInfo = record { recovery_authn_methods : vec AuthnMethod; }; -// A browser an anchor has signed in from. The name is self-reported by the -// client, so it is a label for the user rather than evidence about where a -// session came from. +// Which browser a sign-in came from, as a token rather than a name to show. Products +// get renamed — "Chrome OS" became "ChromeOS", "Mac OS X" became "macOS" — so the name +// the user reads is derived in the frontend, where a rename reaches every stored record +// at once. "Brand" is what the client hints call this, and BrowserInfo below is the +// entry it describes. +type BrowserBrand = variant { + Chrome; Safari; Firefox; Edge; Opera; SamsungInternet; Vivaldi; Brave; + // A browser this list does not name, shown as the client resolved it. Worth seeing + // rather than hiding behind a generic label. + Other : text; +}; + +type OperatingSystem = variant { + Macos; Ios; Ipados; Windows; Android; ChromeOs; Linux; Other : text; +}; + +// Reported where the client can state it and inferred where it cannot, so unknown is a +// real answer: the browsers exposing no client hints are the ones this is least sure of. +type FormFactor = variant { Desktop; Mobile; Tablet; Unknown }; + +// What a browser reported about itself when it registered. Self-reported, so it is +// something the user reads to recognise their own browser rather than evidence about +// where a session came from. The canister stores these and never interprets them. +type BrowserDescription = record { + brand : BrowserBrand; + os : OperatingSystem; + form_factor : FormFactor; + // The hardware, where the client can name it — Android is the only place that does. + model : opt text; +}; + type BrowserInfo = record { id : nat32; - name : text; + // Fixed at registration. A sign-in reporting something else registers its own entry, + // so this describes a registration rather than the last sign-in. + description : BrowserDescription; created_at : Timestamp; // Advanced by a sign-in from this browser and by every session refresh it drives. last_used : Timestamp; diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 16a49974b0..1e41d1a891 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -1101,11 +1101,11 @@ mod v2_api { let stored_browsers: Vec = state::anchor(identity_number) .browsers() .iter() - .map(|device| BrowserInfo { - id: device.id, - name: device.name.clone(), - created_at: device.created_at, - last_used: device.last_used, + .map(|browser| BrowserInfo { + id: browser.id, + description: browser.description.clone(), + created_at: browser.created_at, + last_used: browser.last_used, }) .collect(); let browsers = if stored_browsers.is_empty() { diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 1e4579a9fe..5c5a49cf15 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -3,6 +3,7 @@ use crate::ii_domain::IIDomain; use crate::openid::{OpenIdCredential, OpenIdCredentialKey}; use crate::storage::storable::anchor::StorableAnchor; use crate::storage::storable::browser::StorableBrowser; +use crate::storage::storable::browser_description::StorableBrowserDescription; use crate::storage::storable::email_recovery_credential::StorableEmailRecoveryCredential; use crate::storage::storable::fixed_anchor::StorableFixedAnchor; use crate::storage::storable::passkey_credential::StorablePasskeyCredential; @@ -76,7 +77,11 @@ pub enum BrowserError { StaleBrowserKey, } -/// A browser this anchor has signed in from. The name is self-reported by the client. +/// A browser this anchor has signed in from, as it described itself when it registered. +/// +/// The description is fixed at registration: a sign-in that reports something else is +/// treated as a browser this anchor has not seen, and the client is the party that +/// decides so by presenting a key pair no entry holds. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Browser { pub id: BrowserId, @@ -84,7 +89,8 @@ pub struct Browser { pub current_browser_key: PublicKey, /// The successor the browser announced at its last sign-in, also accepted as a proof. pub next_browser_key: PublicKey, - pub name: String, + /// What this browser reported about itself when it registered. + pub description: BrowserDescription, pub created_at: Timestamp, pub last_used: Timestamp, } @@ -95,7 +101,7 @@ impl From for Browser { id: value.id, current_browser_key: ByteBuf::from(value.current_browser_key), next_browser_key: ByteBuf::from(value.next_browser_key), - name: value.name, + description: BrowserDescription::from(value.description), created_at: value.created_at, last_used: value.last_used, } @@ -108,7 +114,7 @@ impl From for StorableBrowser { id: value.id, current_browser_key: value.current_browser_key.into_vec(), next_browser_key: value.next_browser_key.into_vec(), - name: value.name, + description: StorableBrowserDescription::from(value.description), created_at: value.created_at, last_used: value.last_used, } @@ -739,13 +745,17 @@ impl Anchor { /// browser that lost a response is told to promote its own successor instead of /// becoming a second list. A key no entry holds at all registers a new browser. /// + /// `description` is taken only where this registers a new browser. An entry that is + /// advanced keeps the description it was registered with, so what a browser reports is + /// a fact about a registration rather than about the last sign-in. + /// /// At the cap the least recently used records are dropped, and their ids returned so /// the caller can end their sessions too. pub fn resolve_browser( &mut self, current_browser_key: PublicKey, next_browser_key: PublicKey, - name: String, + description: BrowserDescription, now: Timestamp, ) -> Result<(BrowserId, Vec), BrowserError> { if current_browser_key == next_browser_key { @@ -794,7 +804,7 @@ impl Anchor { id, current_browser_key, next_browser_key, - name, + description, created_at: now, last_used: now, }); diff --git a/src/internet_identity/src/storage/anchor/tests.rs b/src/internet_identity/src/storage/anchor/tests.rs index 21bb6ac225..6dedacd86a 100644 --- a/src/internet_identity/src/storage/anchor/tests.rs +++ b/src/internet_identity/src/storage/anchor/tests.rs @@ -1270,12 +1270,25 @@ mod mirror_verified_email_tests { mod browser_tests { use super::*; use crate::storage::anchor::{BrowserError, MAX_BROWSERS}; - use internet_identity_interface::internet_identity::types::PublicKey; + use internet_identity_interface::internet_identity::types::{ + BrowserBrand, BrowserDescription, FormFactor, OperatingSystem, PublicKey, + }; fn anchor() -> Anchor { Anchor::new(10_000, 0) } + /// A description that differs by `label`, for tests that only need to tell entries + /// apart. The label rides in `model`, the description's one free-text field. + fn description(label: &str) -> BrowserDescription { + BrowserDescription { + brand: BrowserBrand::Chrome, + os: OperatingSystem::Macos, + form_factor: FormFactor::Desktop, + model: Some(label.to_string()), + } + } + fn browser_key(seed: u8) -> PublicKey { ByteBuf::from(vec![seed; 91]) } @@ -1301,26 +1314,29 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome on MacBook".to_string(), + description("Chrome on MacBook"), 1_000, ) .unwrap(); assert_eq!(id, 0); assert_eq!(anchor.browsers().len(), 1); - assert_eq!(anchor.browsers()[0].name, "Chrome on MacBook"); + assert_eq!( + anchor.browsers()[0].description, + description("Chrome on MacBook") + ); assert_eq!(anchor.browsers()[0].current_browser_key, browser_key(1)); assert_eq!(anchor.browsers()[0].created_at, 1_000); } #[test] - fn a_browser_that_rotates_reuses_the_device_and_leaves_its_name_alone() { + fn a_browser_that_rotates_reuses_its_entry_and_keeps_its_description() { let mut anchor = anchor(); let (id, _) = anchor .resolve_browser( browser_key(1), successor_key(1), - "Chrome on MacBook".to_string(), + description("Chrome on MacBook"), 1_000, ) .unwrap(); @@ -1329,14 +1345,17 @@ mod browser_tests { .resolve_browser( successor_key(1), browser_key(2), - "Something else".to_string(), + description("Something else"), 2_000, ) .unwrap(); assert_eq!(again, id); assert_eq!(anchor.browsers().len(), 1); - assert_eq!(anchor.browsers()[0].name, "Chrome on MacBook"); + assert_eq!( + anchor.browsers()[0].description, + description("Chrome on MacBook") + ); } #[test] @@ -1347,7 +1366,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1363,7 +1382,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1372,7 +1391,7 @@ mod browser_tests { .resolve_browser( successor_key(1), browser_key(2), - "Chrome".to_string(), + description("Chrome"), 5_000, ) .unwrap(); @@ -1388,7 +1407,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1397,7 +1416,7 @@ mod browser_tests { .resolve_browser( browser_key(2), successor_key(2), - "Firefox".to_string(), + description("Firefox"), 2_000, ) .unwrap(); @@ -1413,7 +1432,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1423,7 +1442,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 2_000, ) .unwrap(); @@ -1440,7 +1459,7 @@ mod browser_tests { .resolve_browser( browser_key(index as u8), successor_key(index as u8), - format!("device-{index}"), + description(&format!("device-{index}")), index as u64 + 1, ) .unwrap(); @@ -1450,15 +1469,21 @@ mod browser_tests { .resolve_browser( browser_key(200), successor_key(200), - "newest".to_string(), + description("newest"), 10_000, ) .unwrap(); assert_eq!(anchor.browsers().len(), MAX_BROWSERS); assert!(anchor.browsers().iter().any(|d| d.id == newest)); - assert!(!anchor.browsers().iter().any(|d| d.name == "device-0")); - assert!(anchor.browsers().iter().any(|d| d.name == "device-1")); + assert!(!anchor + .browsers() + .iter() + .any(|b| b.description == description("device-0"))); + assert!(anchor + .browsers() + .iter() + .any(|b| b.description == description("device-1"))); assert_eq!(dropped, vec![0]); } @@ -1466,14 +1491,14 @@ mod browser_tests { fn the_cap_evicts_on_use_rather_than_on_enrolment() { let mut anchor = anchor(); let (first, _) = anchor - .resolve_browser(browser_key(0), successor_key(0), "oldest".to_string(), 1) + .resolve_browser(browser_key(0), successor_key(0), description("oldest"), 1) .unwrap(); for index in 1..MAX_BROWSERS { anchor .resolve_browser( browser_key(index as u8), successor_key(index as u8), - format!("device-{index}"), + description(&format!("device-{index}")), index as u64 + 1, ) .unwrap(); @@ -1482,7 +1507,7 @@ mod browser_tests { .resolve_browser( successor_key(0), rotating_key(0, 200), - "oldest".to_string(), + description("oldest"), 9_000, ) .unwrap(); @@ -1491,7 +1516,7 @@ mod browser_tests { .resolve_browser( browser_key(200), successor_key(200), - "newest".to_string(), + description("newest"), 10_000, ) .unwrap(); @@ -1505,14 +1530,14 @@ mod browser_tests { let mut anchor = anchor(); // The phone rotates on every sign-in, as a browser that kept its storage does. let (kept, _) = anchor - .resolve_browser(browser_key(0), rotating_key(0, 1), "phone".to_string(), 1) + .resolve_browser(browser_key(0), rotating_key(0, 1), description("phone"), 1) .unwrap(); for wipe in 0..MAX_BROWSERS as u64 { anchor .resolve_browser( rotating_key(0, wipe as u8 + 1), rotating_key(0, wipe as u8 + 2), - "phone".to_string(), + description("phone"), 1_000 + wipe * 10, ) .unwrap(); @@ -1522,7 +1547,7 @@ mod browser_tests { .resolve_browser( browser_key(wipe as u8 + 1), successor_key(wipe as u8 + 1), - format!("wiped-{wipe}"), + description(&format!("wiped-{wipe}")), 1_001 + wipe * 10, ) .unwrap(); @@ -1538,7 +1563,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1547,7 +1572,7 @@ mod browser_tests { .resolve_browser( browser_key(2), successor_key(2), - "Chrome".to_string(), + description("Chrome"), 2_000, ) .unwrap(); @@ -1563,7 +1588,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1572,7 +1597,7 @@ mod browser_tests { .resolve_browser( successor_key(1), browser_key(2), - "Chrome".to_string(), + description("Chrome"), 2_000, ) .unwrap(); @@ -1590,7 +1615,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1598,7 +1623,7 @@ mod browser_tests { .resolve_browser( successor_key(1), browser_key(2), - "Chrome".to_string(), + description("Chrome"), 2_000, ) .unwrap(); @@ -1607,7 +1632,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(3), - "Chrome".to_string(), + description("Chrome"), 3_000, ) .unwrap(); @@ -1627,7 +1652,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1637,7 +1662,7 @@ mod browser_tests { let retried = anchor.resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 2_000, ); @@ -1655,7 +1680,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1664,7 +1689,7 @@ mod browser_tests { .resolve_browser( successor_key(1), successor_key(2), - "Chrome".to_string(), + description("Chrome"), 2_000, ) .unwrap(); @@ -1679,7 +1704,7 @@ mod browser_tests { fn rotating_repeatedly_keeps_the_same_browser() { let mut anchor = anchor(); let (id, _) = anchor - .resolve_browser(browser_key(0), browser_key(1), "Chrome".to_string(), 1) + .resolve_browser(browser_key(0), browser_key(1), description("Chrome"), 1) .unwrap(); for step in 1..10u8 { @@ -1687,7 +1712,7 @@ mod browser_tests { .resolve_browser( browser_key(step), browser_key(step + 1), - "Chrome".to_string(), + description("Chrome"), step as u64 * 100, ) .unwrap(); @@ -1706,17 +1731,21 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); - let stealing_the_key = - anchor.resolve_browser(browser_key(2), browser_key(1), "Firefox".to_string(), 2_000); + let stealing_the_key = anchor.resolve_browser( + browser_key(2), + browser_key(1), + description("Firefox"), + 2_000, + ); let stealing_the_successor = anchor.resolve_browser( browser_key(2), successor_key(1), - "Firefox".to_string(), + description("Firefox"), 2_000, ); @@ -1736,7 +1765,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1746,7 +1775,7 @@ mod browser_tests { .resolve_browser( browser_key(7), successor_key(7), - "Chrome".to_string(), + description("Chrome"), 2_000, ) .unwrap(); @@ -1763,7 +1792,7 @@ mod browser_tests { // announced the key it is presenting would keep it alive for as long as it kept // asking, and so would whoever leaked it. assert_eq!( - anchor.resolve_browser(browser_key(1), browser_key(1), "Chrome".to_string(), 1_000), + anchor.resolve_browser(browser_key(1), browser_key(1), description("Chrome"), 1_000), Err(BrowserError::SuccessorMatchesCurrent) ); assert!(anchor.browsers().is_empty()); @@ -1776,7 +1805,7 @@ mod browser_tests { .resolve_browser( browser_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 1_000, ) .unwrap(); @@ -1785,7 +1814,7 @@ mod browser_tests { anchor.resolve_browser( successor_key(1), successor_key(1), - "Chrome".to_string(), + description("Chrome"), 2_000 ), Err(BrowserError::SuccessorMatchesCurrent) diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 4183dc9746..d1a81cb9cb 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -11,6 +11,7 @@ pub mod anchor_number_list; pub mod application; pub mod application_number; pub mod browser; +pub mod browser_description; pub mod browser_id; pub mod credential_id; pub mod discrepancy_counter; diff --git a/src/internet_identity/src/storage/storable/browser.rs b/src/internet_identity/src/storage/storable/browser.rs index 0beeb19edf..c6d2d2d8fd 100644 --- a/src/internet_identity/src/storage/storable/browser.rs +++ b/src/internet_identity/src/storage/storable/browser.rs @@ -1,3 +1,4 @@ +use crate::storage::storable::browser_description::StorableBrowserDescription; use crate::storage::storable::browser_id::StorableBrowserId; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; @@ -10,8 +11,11 @@ use std::borrow::Cow; pub struct StorableBrowser { #[n(0)] pub id: StorableBrowserId, + /// What this browser reported about itself when it registered. Immutable: a + /// sign-in that reports something else is a browser this anchor has not seen, and + /// registers under its own entry. #[n(1)] - pub name: String, + pub description: StorableBrowserDescription, #[n(2)] pub created_at: Timestamp, #[n(3)] diff --git a/src/internet_identity/src/storage/storable/browser_description.rs b/src/internet_identity/src/storage/storable/browser_description.rs new file mode 100644 index 0000000000..ceaf7556b4 --- /dev/null +++ b/src/internet_identity/src/storage/storable/browser_description.rs @@ -0,0 +1,268 @@ +use internet_identity_interface::internet_identity::types::{ + BrowserBrand, BrowserDescription, FormFactor, OperatingSystem, +}; +use minicbor::{Decode, Encode}; + +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[cbor(map)] +pub struct StorableBrowserDescription { + #[n(0)] + pub brand: StorableBrowserBrand, + #[n(1)] + pub os: StorableOperatingSystem, + #[n(2)] + pub form_factor: StorableFormFactor, + #[n(3)] + pub model: Option, +} + +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum StorableBrowserBrand { + #[n(0)] + Chrome, + #[n(1)] + Safari, + #[n(2)] + Firefox, + #[n(3)] + Edge, + #[n(4)] + Opera, + #[n(5)] + SamsungInternet, + #[n(6)] + Vivaldi, + #[n(7)] + Brave, + #[n(8)] + Other(#[n(0)] String), +} + +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum StorableOperatingSystem { + #[n(0)] + Macos, + #[n(1)] + Ios, + #[n(2)] + Ipados, + #[n(3)] + Windows, + #[n(4)] + Android, + #[n(5)] + ChromeOs, + #[n(6)] + Linux, + #[n(7)] + Other(#[n(0)] String), +} + +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[cbor(index_only)] +pub enum StorableFormFactor { + #[n(0)] + Desktop, + #[n(1)] + Mobile, + #[n(2)] + Tablet, + #[n(3)] + Unknown, +} + +impl From for StorableBrowserBrand { + fn from(value: BrowserBrand) -> Self { + match value { + BrowserBrand::Chrome => Self::Chrome, + BrowserBrand::Safari => Self::Safari, + BrowserBrand::Firefox => Self::Firefox, + BrowserBrand::Edge => Self::Edge, + BrowserBrand::Opera => Self::Opera, + BrowserBrand::SamsungInternet => Self::SamsungInternet, + BrowserBrand::Vivaldi => Self::Vivaldi, + BrowserBrand::Brave => Self::Brave, + BrowserBrand::Other(token) => Self::Other(token), + } + } +} + +impl From for BrowserBrand { + fn from(value: StorableBrowserBrand) -> Self { + match value { + StorableBrowserBrand::Chrome => Self::Chrome, + StorableBrowserBrand::Safari => Self::Safari, + StorableBrowserBrand::Firefox => Self::Firefox, + StorableBrowserBrand::Edge => Self::Edge, + StorableBrowserBrand::Opera => Self::Opera, + StorableBrowserBrand::SamsungInternet => Self::SamsungInternet, + StorableBrowserBrand::Vivaldi => Self::Vivaldi, + StorableBrowserBrand::Brave => Self::Brave, + StorableBrowserBrand::Other(token) => Self::Other(token), + } + } +} + +impl From for StorableOperatingSystem { + fn from(value: OperatingSystem) -> Self { + match value { + OperatingSystem::Macos => Self::Macos, + OperatingSystem::Ios => Self::Ios, + OperatingSystem::Ipados => Self::Ipados, + OperatingSystem::Windows => Self::Windows, + OperatingSystem::Android => Self::Android, + OperatingSystem::ChromeOs => Self::ChromeOs, + OperatingSystem::Linux => Self::Linux, + OperatingSystem::Other(token) => Self::Other(token), + } + } +} + +impl From for OperatingSystem { + fn from(value: StorableOperatingSystem) -> Self { + match value { + StorableOperatingSystem::Macos => Self::Macos, + StorableOperatingSystem::Ios => Self::Ios, + StorableOperatingSystem::Ipados => Self::Ipados, + StorableOperatingSystem::Windows => Self::Windows, + StorableOperatingSystem::Android => Self::Android, + StorableOperatingSystem::ChromeOs => Self::ChromeOs, + StorableOperatingSystem::Linux => Self::Linux, + StorableOperatingSystem::Other(token) => Self::Other(token), + } + } +} + +impl From for StorableFormFactor { + fn from(value: FormFactor) -> Self { + match value { + FormFactor::Desktop => Self::Desktop, + FormFactor::Mobile => Self::Mobile, + FormFactor::Tablet => Self::Tablet, + FormFactor::Unknown => Self::Unknown, + } + } +} + +impl From for FormFactor { + fn from(value: StorableFormFactor) -> Self { + match value { + StorableFormFactor::Desktop => Self::Desktop, + StorableFormFactor::Mobile => Self::Mobile, + StorableFormFactor::Tablet => Self::Tablet, + StorableFormFactor::Unknown => Self::Unknown, + } + } +} + +impl From for StorableBrowserDescription { + fn from(value: BrowserDescription) -> Self { + StorableBrowserDescription { + brand: value.brand.into(), + os: value.os.into(), + form_factor: value.form_factor.into(), + model: value.model, + } + } +} + +impl From for BrowserDescription { + fn from(value: StorableBrowserDescription) -> Self { + BrowserDescription { + brand: value.brand.into(), + os: value.os.into(), + form_factor: value.form_factor.into(), + model: value.model, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn round_trip(description: StorableBrowserDescription) { + let mut buffer = Vec::new(); + minicbor::encode(&description, &mut buffer).unwrap(); + assert_eq!( + minicbor::decode::(&buffer).unwrap(), + description + ); + } + + #[test] + fn a_description_of_named_tokens_round_trips() { + round_trip(StorableBrowserDescription { + brand: StorableBrowserBrand::Brave, + os: StorableOperatingSystem::Ipados, + form_factor: StorableFormFactor::Tablet, + model: None, + }); + } + + /// The variants that carry a token are the ones a decoder could get wrong, because + /// the payload has to be read back with the variant that named it. + #[test] + fn a_description_of_unrecognised_tokens_round_trips() { + round_trip(StorableBrowserDescription { + brand: StorableBrowserBrand::Other("YaBrowser".to_string()), + os: StorableOperatingSystem::Other("HarmonyOS".to_string()), + form_factor: StorableFormFactor::Unknown, + model: Some("Pixel 9".to_string()), + }); + } + + /// Every token the interface can hold has a storable counterpart and comes back as + /// itself. Written as a sweep so adding a variant to one side without the other + /// fails here rather than silently mapping to something else. + #[test] + fn every_token_survives_the_trip_through_storage() { + let brands = [ + BrowserBrand::Chrome, + BrowserBrand::Safari, + BrowserBrand::Firefox, + BrowserBrand::Edge, + BrowserBrand::Opera, + BrowserBrand::SamsungInternet, + BrowserBrand::Vivaldi, + BrowserBrand::Brave, + BrowserBrand::Other("Arc".to_string()), + ]; + for brand in brands { + assert_eq!( + BrowserBrand::from(StorableBrowserBrand::from(brand.clone())), + brand + ); + } + + let systems = [ + OperatingSystem::Macos, + OperatingSystem::Ios, + OperatingSystem::Ipados, + OperatingSystem::Windows, + OperatingSystem::Android, + OperatingSystem::ChromeOs, + OperatingSystem::Linux, + OperatingSystem::Other("HarmonyOS".to_string()), + ]; + for os in systems { + assert_eq!( + OperatingSystem::from(StorableOperatingSystem::from(os.clone())), + os + ); + } + + for form_factor in [ + FormFactor::Desktop, + FormFactor::Mobile, + FormFactor::Tablet, + FormFactor::Unknown, + ] { + assert_eq!( + FormFactor::from(StorableFormFactor::from(form_factor.clone())), + form_factor + ); + } + } +} diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 8f2f160fc2..c1de8d3d1d 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -16,6 +16,74 @@ pub type ApplicationNumber = u64; pub type Timestamp = u64; // in nanos since epoch /// Per-anchor label for one browser, so a browser's sessions can be revoked together. pub type BrowserId = u32; + +/// Which browser a sign-in came from, as a token rather than a name to show. +/// +/// `Brand` because that is what the client hints call it, and because `Browser` names the +/// registry entry these describe. +/// +/// Tokens rather than display strings because products get renamed — "Chrome OS" became +/// "ChromeOS", "Mac OS X" became "macOS" — and the stored record has to be able to +/// outlive that. The name a user reads is derived in the frontend, so a rename reaches +/// every stored record at once. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum BrowserBrand { + Chrome, + Safari, + Firefox, + Edge, + Opera, + SamsungInternet, + Vivaldi, + Brave, + /// Whatever the client resolved for a browser this list does not name. Shown as it + /// arrived: an unrecognised browser is worth seeing, not worth hiding behind a + /// generic label. + Other(String), +} + +/// The operating system a sign-in came from. A token, for the same reason as +/// [`BrowserBrand`]. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum OperatingSystem { + Macos, + Ios, + Ipados, + Windows, + Android, + ChromeOs, + Linux, + Other(String), +} + +/// What shape of thing a browser is running on. +/// +/// Reported where the client can state it and inferred where it cannot, so `Unknown` is a +/// real answer rather than a failure: the browsers that expose no client hints are the +/// ones this is least certain about. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum FormFactor { + Desktop, + Mobile, + Tablet, + Unknown, +} + +/// What a browser reported about itself when it registered. +/// +/// Self-reported, so it is something the user reads to recognise their own browser rather +/// than evidence about where a session came from. Resolved by the client into the tokens +/// above; the canister stores them and never interprets them. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct BrowserDescription { + pub brand: BrowserBrand, + pub os: OperatingSystem, + pub form_factor: FormFactor, + /// The hardware, where the client can name it — Android is the only place that does. + /// Free text because it is a product name reported verbatim, and unlike a brand a + /// shipped model never gets renamed. + pub model: Option, +} pub type Signature = ByteBuf; pub type DeviceConfirmationCode = String; pub type FailedAttemptsCounter = u8; diff --git a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs index af2ddcd254..c0d9c59f8b 100644 --- a/src/internet_identity_interface/src/internet_identity/types/api_v2.rs +++ b/src/internet_identity_interface/src/internet_identity/types/api_v2.rs @@ -1,5 +1,7 @@ use crate::internet_identity::types::openid::OpenIdCredentialData; -use crate::internet_identity::types::{BrowserId, CredentialId, PublicKey, Timestamp}; +use crate::internet_identity::types::{ + BrowserDescription, BrowserId, CredentialId, PublicKey, Timestamp, +}; use candid::{CandidType, Deserialize, Principal}; use serde_bytes::ByteBuf; use std::collections::HashMap; @@ -77,11 +79,11 @@ pub struct IdentityAuthnInfo { pub recovery_authn_methods: Vec, } -/// A browser this anchor has signed in from. The name is self-reported by the client. +/// A browser this anchor has signed in from, as it described itself when it registered. #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub struct BrowserInfo { pub id: BrowserId, - pub name: String, + pub description: BrowserDescription, pub created_at: Timestamp, pub last_used: Timestamp, } From 38532bc106aa16a6bc6c8302b995764577e6466d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:12:59 +0200 Subject: [PATCH 237/298] feat(internet_identity): a sign-in reports what the browser is, in tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request named a `browser_name` the client had composed. It now carries the description the registry stores, so the wording is the frontend's throughout and the canister holds only what it was told. The single 128-byte cap on the composed label becomes a bound on each token a client writes for itself — the `Other` brand, the `Other` system, and the model. The named variants carry no text, so a description of nothing but those is within the limit whatever it says. Refused rather than truncated: a cut-off token would put a value in the record that no parser ever produced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/generated/internet_identity_idl.js | 2 +- .../generated/internet_identity_types.d.ts | 7 +- src/internet_identity/internet_identity.did | 7 +- src/internet_identity/src/sessions.rs | 99 +++++++++++++++++-- .../tests/integration/sessions.rs | 30 ++++-- .../src/internet_identity/types.rs | 2 +- 6 files changed, 126 insertions(+), 21 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index f88a2cdf74..794bdcaf6f 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -747,7 +747,7 @@ export const idlFactory = ({ IDL }) => { 'valid_for' : IDL.Opt(IDL.Nat64), 'origin' : FrontendHostname, 'current_browser_key_signature' : IDL.Vec(IDL.Nat8), - 'browser_name' : IDL.Text, + 'browser_description' : BrowserDescription, 'account_number' : IDL.Opt(AccountNumber), 'identity_number' : UserNumber, 'next_browser_key' : PublicKey, 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 bc4e37d584..f1bcd65435 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1396,9 +1396,12 @@ export interface PrepareAccountSessionRequest { */ 'current_browser_key_signature' : Uint8Array | number[], /** - * Labels the browser in the user's session list, e.g. "Chrome on MacBook". + * What this browser is, for the user's session list. Taken only where this sign-in + * registers a browser: an entry that is advanced keeps what it was registered with, + * so a browser reporting something else presents a key pair no entry holds and + * registers under its own. */ - 'browser_name' : string, + 'browser_description' : BrowserDescription, 'account_number' : [] | [AccountNumber], 'identity_number' : UserNumber, /** diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 2881c1b0d3..5469e416c3 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1055,8 +1055,11 @@ type PrepareAccountSessionRequest = record { 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". - browser_name : text; + // What this browser is, for the user's session list. Taken only where this sign-in + // registers a browser: an entry that is advanced keeps what it was registered with, + // so a browser reporting something else presents a key pair no entry holds and + // registers under its own. + browser_description : BrowserDescription; // 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. current_browser_key : PublicKey; diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 4a5bdd30bd..155b399fd7 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -19,9 +19,9 @@ use ic_canister_sig_creation::DELEGATION_SIG_DOMAIN; use ic_cdk::api::time; use ic_certification::Hash; use internet_identity_interface::internet_identity::types::{ - AccountNumber, AccountSessionError, AnchorNumber, Delegation, FrontendHostname, - GetAccountSessionRequest, GetAccountSessionResponse, PrepareAccountSessionRequest, - PrepareAccountSessionResponse, SignedDelegation, Timestamp, + AccountNumber, AccountSessionError, AnchorNumber, BrowserBrand, BrowserDescription, Delegation, + FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, OperatingSystem, + PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp, }; use serde_bytes::ByteBuf; @@ -29,8 +29,27 @@ 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 browser name is a label the user reads, never anything the canister acts on. -const MAX_BROWSER_NAME_BYTES: usize = 128; +/// A bound on each token a browser reports about itself. Refused rather than truncated: +/// a client sending something this long is sending something wrong, and a cut-off token +/// would put a value in the record that no parser ever produced. +const MAX_BROWSER_TOKEN_BYTES: usize = 64; + +/// Whether every token in a description is short enough to store. +/// +/// Only the tokens a client writes itself can be too long. The named variants carry no +/// text, so a description of nothing but those is within the limit whatever it says. +fn browser_description_within_limits(description: &BrowserDescription) -> bool { + let within = |token: &str| token.len() <= MAX_BROWSER_TOKEN_BYTES; + let brand = match &description.brand { + BrowserBrand::Other(token) => within(token), + _ => true, + }; + let os = match &description.os { + OperatingSystem::Other(token) => within(token), + _ => true, + }; + brand && os && description.model.as_deref().is_none_or(within) +} impl From for AccountSessionError { fn from(err: AuthorizationError) -> Self { @@ -68,7 +87,7 @@ pub fn prepare_account_session( origin, account_number, session_key, - browser_name, + browser_description, current_browser_key, next_browser_key, current_browser_key_signature, @@ -80,9 +99,9 @@ pub fn prepare_account_session( check_authz_and_record_activity(identity_number)?; check_frontend_length(&origin); - if browser_name.len() > MAX_BROWSER_NAME_BYTES { + if !browser_description_within_limits(&browser_description) { return Err(AccountSessionError::InternalCanisterError( - "browser name exceeds the limit".to_string(), + "browser description exceeds the limit".to_string(), )); } if !verify_browser_keys( @@ -130,7 +149,7 @@ pub fn prepare_account_session( account_number, current_browser_key, next_browser_key, - browser_name, + browser_description, valid_till_ns: valid_till, max_idle_ns: max_idle, read_only, @@ -293,3 +312,65 @@ fn account_principal( account.calculate_seed_with_salt(&salt).to_vec(), )) } + +#[cfg(test)] +mod tests { + use super::*; + use internet_identity_interface::internet_identity::types::FormFactor; + + fn description( + brand: BrowserBrand, + os: OperatingSystem, + model: Option<&str>, + ) -> BrowserDescription { + BrowserDescription { + brand, + os, + form_factor: FormFactor::Desktop, + model: model.map(str::to_string), + } + } + + /// The named variants carry no text of their own, so nothing about them can be too + /// long however many of them there are. + #[test] + fn a_description_of_named_tokens_is_always_within_the_limit() { + assert!(browser_description_within_limits(&description( + BrowserBrand::Brave, + OperatingSystem::Ipados, + None + ))); + } + + #[test] + fn each_token_a_client_writes_is_bounded() { + let long = "x".repeat(MAX_BROWSER_TOKEN_BYTES + 1); + let at_limit = "x".repeat(MAX_BROWSER_TOKEN_BYTES); + + assert!(browser_description_within_limits(&description( + BrowserBrand::Other(at_limit.clone()), + OperatingSystem::Other(at_limit.clone()), + Some(&at_limit) + ))); + + for over in [ + description( + BrowserBrand::Other(long.clone()), + OperatingSystem::Macos, + None, + ), + description( + BrowserBrand::Chrome, + OperatingSystem::Other(long.clone()), + None, + ), + description(BrowserBrand::Chrome, OperatingSystem::Macos, Some(&long)), + ] { + assert!( + !browser_description_within_limits(&over), + "a token of {} bytes should be refused: {over:?}", + long.len() + ); + } + } +} diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 85abb0a677..9e9cc7fcb4 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -9,8 +9,8 @@ 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, + AccountSessionError, BrowserBrand, BrowserDescription, FormFactor, GetAccountSessionRequest, + OperatingSystem, PrepareAccountSessionRequest, PrepareAccountSessionResponse, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; @@ -18,6 +18,24 @@ use serde_bytes::ByteBuf; const ORIGIN: &str = "https://some-dapp.com"; +fn chrome_on_a_mac() -> BrowserDescription { + BrowserDescription { + brand: BrowserBrand::Chrome, + os: OperatingSystem::Macos, + form_factor: FormFactor::Desktop, + model: None, + } +} + +fn firefox_on_linux() -> BrowserDescription { + BrowserDescription { + brand: BrowserBrand::Firefox, + os: OperatingSystem::Linux, + form_factor: FormFactor::Desktop, + model: None, + } +} + fn session_request(identity_number: u64) -> PrepareAccountSessionRequest { session_request_from(identity_number, &BrowserKey::new(1)) } @@ -34,7 +52,7 @@ fn session_request_from( identity_number, origin: ORIGIN.to_string(), account_number: None, - browser_name: "Chrome on MacBook".to_string(), + browser_description: chrome_on_a_mac(), current_browser_key: browser.public_key(), current_browser_key_signature: browser.sign(&session_key, &next_browser_key), next_browser_key_signature: browser @@ -237,7 +255,7 @@ fn should_register_a_second_browser_for_a_key_it_has_not_seen() -> Result<(), Re .unwrap(); let mut second = session_request_from(identity_number, &BrowserKey::new(2)); - second.browser_name = "Firefox on Linux".to_string(); + second.browser_description = firefox_on_linux(); prepare_account_session(&env, canister_id, principal_1(), second)?.unwrap(); let devices = identity_info(&env, canister_id, principal_1(), identity_number)? @@ -246,8 +264,8 @@ fn should_register_a_second_browser_for_a_key_it_has_not_seen() -> Result<(), Re .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_eq!(devices[0].description, chrome_on_a_mac()); + assert_eq!(devices[1].description, firefox_on_linux()); assert_ne!(devices[0].id, devices[1].id); Ok(()) diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index de923d61b5..f034be6e25 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -837,7 +837,7 @@ pub struct PrepareAccountSessionRequest { pub account_number: Option, pub session_key: SessionKey, /// Labels the browser in the user's session list, e.g. "Chrome on MacBook". - pub browser_name: String, + pub browser_description: BrowserDescription, /// 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 current_browser_key: PublicKey, From 84789427ae78b40984f8c154be2bb70f93d9348c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:24:05 +0200 Subject: [PATCH 238/298] feat(frontend): describe the browser in tokens instead of composing a label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This composed the string the canister stored, which fixed the wording at the moment of sign-in: a browser that registered before "Chrome OS" became "ChromeOS" would read the old name forever, and the agent it came from was not kept, so nothing could re-derive it. It now resolves the four things the record holds — brand, system, form factor, and the hardware model where one is reported — and the wording moves to where the list is read. Adding a brand or fixing a name then reaches every stored record, rather than only the browsers that sign in afterwards. Two sources, because neither covers the field alone. The agent carries the brand and the system on every engine, and its table has to stay ordered most specific first: a Chromium agent carries `Safari/` and `Chrome/` as well. The client hints, Chromium-only, give what no agent can — the hardware model, and a form factor the browser states rather than one inferred. Brave is asked directly. It ships a plain Chrome agent on purpose and strips the hints that would give it away, so without its own check its owner sees a row that says Chrome. DuckDuckGo names itself but has no variant here, so it travels as the token it gave. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/describeBrowser.test.ts | 244 ++++++++++++++---- .../stores/channelHandlers/describeBrowser.ts | 196 +++++++++----- 2 files changed, 333 insertions(+), 107 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts index 160a89a1d5..373c5920e4 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it } from "vitest"; -import { browserLabel, describeBrowser } from "./describeBrowser"; +import { describeBrowser } from "./describeBrowser"; +import type { + BrowserBrand, + FormFactor, + OperatingSystem, +} from "$lib/generated/internet_identity_types"; 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"; @@ -8,87 +13,179 @@ const FIREFOX_MAC = 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][] = [ +/** + * Every agent this frontend is expected to recognise, with what it resolves to. The + * agents are real strings, because the ordering of the brand table is what makes them + * come out right: a Chromium agent carries `Safari/` and `Chrome/` too. + */ +const AGENTS: [ + string, + string, + number, + BrowserBrand, + OperatingSystem, + FormFactor, +][] = [ [ "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, + { Chrome: null }, + { Ios: null }, + { Mobile: null }, ], [ "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, + { Firefox: null }, + { Ios: null }, + { Mobile: null }, ], [ "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, + { Edge: null }, + { Ios: null }, + { Mobile: null }, ], [ "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, + { Opera: null }, + { Ios: null }, + { Mobile: null }, ], [ "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: null }, + { Ios: null }, + { Mobile: null }, ], [ "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: null }, + { Ipados: null }, + { Tablet: null }, + ], + [ + "Safari on iPad", + IPAD_DESKTOP_MODE, + 5, + { Safari: null }, + { Ipados: null }, + { Tablet: null }, + ], + [ + "Safari on Mac", + IPAD_DESKTOP_MODE, + 0, + { Safari: null }, + { Macos: null }, + { Desktop: null }, + ], + [ + "Firefox on Mac", + FIREFOX_MAC, + 0, + { Firefox: null }, + { Macos: null }, + { Desktop: null }, + ], + [ + "Chrome on Android", + CHROME_ANDROID, + 5, + { Chrome: null }, + { Android: null }, + { Mobile: null }, ], - ["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, + { Firefox: null }, + { Android: null }, + { Mobile: null }, ], [ "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, + { SamsungInternet: null }, + { Android: null }, + { Mobile: null }, ], [ "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, + { Edge: null }, + { Android: null }, + { Mobile: null }, ], [ "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, + { Other: "DuckDuckGo" }, + { Android: null }, + { Mobile: null }, ], [ "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, + { Edge: null }, + { Windows: null }, + { Desktop: null }, ], [ "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, + { Opera: null }, + { Windows: null }, + { Desktop: null }, ], [ "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, + { Chrome: null }, + { Windows: null }, + { Desktop: null }, ], [ "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, + { Vivaldi: null }, + { Linux: null }, + { Desktop: null }, ], [ "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, + { Chrome: null }, + { ChromeOs: null }, + { Desktop: null }, + ], + [ + "Browser on an unknown device", + "curl/8.4.0", + 0, + { Other: "curl/8.4.0" }, + { Other: "curl/8.4.0" }, + { Unknown: null }, ], - ["Browser on an unknown device", "curl/8.4.0", 0], ]; const stub = (props: Record): void => { @@ -97,82 +194,131 @@ const stub = (props: Record): void => { } }; -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 }); + stub({ userAgentData: undefined, brave: undefined }); }); - it("appends the model the platform reports", async () => { + it.each(AGENTS)( + "reads %s", + async (_label, agent, touchPoints, brand, os, form_factor) => { + stub({ userAgent: agent, maxTouchPoints: touchPoints }); + + await expect(describeBrowser()).resolves.toEqual({ + brand, + os, + form_factor, + model: [], + }); + }, + ); + + it("takes the model the platform reports", async () => { stub({ userAgent: CHROME_ANDROID, maxTouchPoints: 5, userAgentData: { + mobile: true, getHighEntropyValues: () => Promise.resolve({ model: "Pixel 5" }), }, }); - await expect(describeBrowser()).resolves.toBe("Chrome on Pixel 5"); + await expect(describeBrowser()).resolves.toMatchObject({ + model: ["Pixel 5"], + }); }); - it("falls back to the platform when no model is available", async () => { + /// Android reports the field with nothing in it off a phone, and an absent model has + /// to stay absent rather than becoming an empty string in the record. + it("treats an empty model as no model", async () => { stub({ userAgent: CHROME_ANDROID, maxTouchPoints: 5, userAgentData: { + mobile: true, getHighEntropyValues: () => Promise.resolve({ model: "" }), }, }); - await expect(describeBrowser()).resolves.toBe("Chrome on Android"); + await expect(describeBrowser()).resolves.toMatchObject({ model: [] }); }); - it("falls back when the platform refuses the question", async () => { + it("still describes the browser when the platform refuses the question", async () => { stub({ userAgent: CHROME_ANDROID, maxTouchPoints: 5, userAgentData: { + mobile: true, getHighEntropyValues: () => Promise.reject(new Error("not allowed")), }, }); - await expect(describeBrowser()).resolves.toBe("Chrome on Android"); + await expect(describeBrowser()).resolves.toEqual({ + brand: { Chrome: null }, + os: { Android: null }, + form_factor: { Mobile: null }, + model: [], + }); + }); + + /// The current spec says `formFactors`; the versions that shipped it first said + /// `formFactor`. A tablet has to read as one on both. + it("takes a stated form factor in either shape", async () => { + for (const high of [ + { formFactors: ["Tablet"] }, + { formFactor: "Tablet" }, + ]) { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + mobile: true, + getHighEntropyValues: () => Promise.resolve(high), + }, + }); + + await expect(describeBrowser()).resolves.toMatchObject({ + form_factor: { Tablet: null }, + }); + } + }); + + /// Brave sends a plain Chrome agent and strips the hints that would give it away, so + /// without asking it directly its owner sees a row that says Chrome. + it("names Brave, which its agent does not", async () => { + stub({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + maxTouchPoints: 0, + brave: { isBrave: () => Promise.resolve(true) }, + }); + + await expect(describeBrowser()).resolves.toMatchObject({ + brand: { Brave: null }, + os: { Macos: null }, + }); + }); + + it("reads a browser that refuses the Brave question as its agent says", async () => { + stub({ + userAgent: FIREFOX_MAC, + maxTouchPoints: 0, + brave: { + isBrave: () => Promise.reject(new Error("no")), + }, + }); + + await expect(describeBrowser()).resolves.toMatchObject({ + brand: { Firefox: null }, + }); }); - it("falls back on a browser without the API", async () => { - stub({ userAgent: FIREFOX_MAC, maxTouchPoints: 0 }); + /// The canister refuses a token over its cap, so a resolver must never offer one. + it("caps a token it did not recognise", async () => { + stub({ userAgent: "x".repeat(500), maxTouchPoints: 0 }); - await expect(describeBrowser()).resolves.toBe("Firefox on Mac"); + const description = await describeBrowser(); + const token = "Other" in description.brand ? description.brand.Other : ""; + expect(new TextEncoder().encode(token).length).toBeLessThanOrEqual(64); }); }); diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts index be73f585e1..e645103579 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts @@ -1,82 +1,162 @@ +import type { + BrowserBrand, + BrowserDescription, + FormFactor, + OperatingSystem, +} from "$lib/generated/internet_identity_types"; + /** - * The label a browser gives itself when it registers a browser. + * What this browser is, resolved into the tokens the canister stores. + * + * Resolved here rather than read back and parsed later: the canister keeps tokens and + * never interprets them, so the wording a user reads lives entirely in this frontend and + * a rename reaches every stored record at once. * - * Self-reported, so it is something the user reads rather than evidence about where a - * session came from. + * The user agent carries the brand and the system on every engine. Client hints are + * Chromium-only — Safari and Firefox expose nothing — so they are used for the two things + * an agent cannot give: the hardware model, and a form factor the browser states rather + * than one inferred from its agent. */ +/** The canister refuses a longer token, so a resolver never offers one. */ +const MAX_BROWSER_TOKEN_BYTES = 64; + /** 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 BRANDS: [RegExp, BrowserBrand][] = [ + [/CriOS\//, { Chrome: null }], + [/FxiOS\//, { Firefox: null }], + [/EdgiOS\//, { Edge: null }], + [/OPiOS\/|OPT\//, { Opera: null }], + [/Firefox\//, { Firefox: null }], + [/EdgA\/|Edg\//, { Edge: null }], + [/OPR\//, { Opera: null }], + [/SamsungBrowser\//, { SamsungInternet: null }], + [/Vivaldi\//, { Vivaldi: null }], + // Names itself but has no variant of its own, so it travels as the token it gave. + [/DuckDuckGo\//, { Other: "DuckDuckGo" }], + [/Chrome\//, { Chrome: null }], + [/Safari\//, { Safari: null }], ]; -const MAX_BROWSER_NAME_BYTES = 128; +/** Truncated on a character boundary, because the cap the canister enforces is in bytes. */ +const capped = (token: string): string => { + const encoder = new TextEncoder(); + let capped = token; + while (encoder.encode(capped).length > MAX_BROWSER_TOKEN_BYTES) { + capped = capped.slice(0, -1); + } + return capped; +}; -const browserOf = (agent: string): string => - BROWSERS.find(([token]) => token.test(agent))?.[1] ?? "Browser"; +/** + * Brave ships a plain Chrome agent on purpose and strips the hints that would give it + * away, so its own check is the only thing that names it. A browser that hides is worth + * asking directly, because otherwise its owner sees a row that says Chrome. + */ +const isBrave = async (): Promise => { + const brave = ( + navigator as Navigator & { brave?: { isBrave?: () => Promise } } + ).brave; + try { + return (await brave?.isBrave?.()) === true; + } catch { + return false; + } +}; -/** 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 brandOf = async (agent: string): Promise => { + if (await isBrave()) { + return { Brave: null }; + } + return ( + BRANDS.find(([token]) => token.test(agent))?.[1] ?? { Other: capped(agent) } + ); }; -const withinLimit = (label: string): boolean => - new TextEncoder().encode(label).length <= MAX_BROWSER_NAME_BYTES; +const systemOf = (agent: string, touchPoints: number): OperatingSystem => { + if (/CrOS/.test(agent)) return { ChromeOs: null }; + if (/Android/.test(agent)) return { Android: null }; + if (/iPhone|iPod/.test(agent)) return { Ios: null }; + if (/iPad/.test(agent)) return { Ipados: null }; + // An iPad in desktop mode sends a Mac agent and exposes no hints, so the touch points + // are the only thing that tells it from a Mac. A Mac reports none. + if (/Macintosh|Mac OS X/.test(agent)) + return touchPoints > 0 ? { Ipados: null } : { Macos: null }; + if (/Windows/.test(agent)) return { Windows: null }; + if (/Linux|X11/.test(agent)) return { Linux: null }; + return { Other: capped(agent) }; +}; -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)}`; +const formFactorOf = ( + agent: string, + system: OperatingSystem, + hints: { mobile?: boolean; formFactors?: string[] }, +): FormFactor => { + if (hints.formFactors?.includes("Tablet")) return { Tablet: null }; + if ("Ipados" in system) return { Tablet: null }; + if (hints.mobile === true) return { Mobile: null }; + if ("Ios" in system) return { Mobile: null }; + if ("Android" in system) + return /Mobile/.test(agent) ? { Mobile: null } : { Tablet: null }; + if (hints.mobile === false) return { Desktop: null }; + if ( + "Macos" in system || + "Windows" in system || + "Linux" in system || + "ChromeOs" in system + ) + return { Desktop: null }; + return { Unknown: null }; }; -/** Populated on Android, and the only thing that names the device itself. */ -const modelOf = async (): Promise => { - const userAgentData = ( +/** + * The hints an agent cannot supply. Absent off Chromium, and the call can be refused, + * so every field is optional and a refusal is the same answer as no support. + */ +const highEntropyHints = async (): Promise<{ + mobile?: boolean; + formFactors?: string[]; + model?: string; +}> => { + const data = ( navigator as Navigator & { userAgentData?: { - getHighEntropyValues?: (hints: string[]) => Promise<{ model?: string }>; + mobile?: boolean; + getHighEntropyValues?: (hints: string[]) => Promise<{ + model?: string; + formFactors?: string[]; + formFactor?: string; + }>; }; } ).userAgentData; + if (data === undefined) { + return {}; + } try { - return (await userAgentData?.getHighEntropyValues?.(["model"]))?.model; + const high = await data.getHighEntropyValues?.(["model", "formFactors"]); + return { + mobile: data.mobile, + // Plural in the current spec, singular in the versions that shipped it first. + formFactors: + high?.formFactors ?? + (high?.formFactor === undefined ? undefined : [high.formFactor]), + // Empty off Android, which reports the field but has no model to put in it. + model: high?.model === "" ? undefined : high?.model, + }; } catch { - return undefined; + return { mobile: data.mobile }; } }; -export const describeBrowser = async (): Promise => - browserLabel({ - agent: navigator.userAgent, - touchPoints: navigator.maxTouchPoints, - model: await modelOf(), - }); +export const describeBrowser = async (): Promise => { + const agent = navigator.userAgent; + const hints = await highEntropyHints(); + const os = systemOf(agent, navigator.maxTouchPoints); + return { + brand: await brandOf(agent), + os, + form_factor: formFactorOf(agent, os, hints), + model: hints.model === undefined ? [] : [capped(hints.model)], + }; +}; From d12543e2877dfe721797ccae3e69de4f369386ac Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:27:09 +0200 Subject: [PATCH 239/298] feat(frontend): a browser that no longer matches signs in as a new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registered entry keeps the description it was created with — the canister ignores what a sign-in reports once an entry is being advanced — so a browser whose brand, system, form factor or model has changed would otherwise keep rotating an entry that describes something it no longer is. So the description is stored beside the key pair and compared before anything is sent. Where it differs this presents a fresh key pair, which no entry holds and which therefore registers under its own. Deciding here rather than being told by the canister keeps the operation a pure local one: nothing has been sent when the comparison happens, so the entry being left behind is untouched and a retry hits the same state and takes the same branch. Stored with the key pair and never on its own, so the comparison is always against what the canister was actually sent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/browser-key.store.test.ts | 78 +++++++++++++++++-- .../src/lib/stores/browser-key.store.ts | 67 +++++++++++++++- 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts index e8237368f0..9606416ace 100644 --- a/src/frontend/src/lib/stores/browser-key.store.test.ts +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -21,6 +21,7 @@ import { StaleBrowserKeyError, withBrowserProof, } from "./browser-key.store"; +import type { BrowserDescription } from "$lib/generated/internet_identity_types"; /// 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"); @@ -68,16 +69,42 @@ const sessionKey = (seed: number) => new Uint8Array(62).fill(seed); const IDENTITY = BigInt(10_000); +const CHROME_ON_A_MAC: BrowserDescription = { + brand: { Chrome: null }, + os: { Macos: null }, + form_factor: { Desktop: null }, + model: [], +}; + +const BRAVE_ON_A_MAC: BrowserDescription = { + ...CHROME_ON_A_MAC, + brand: { Brave: null }, +}; + /** Signs in and rotates, the way a successful ceremony does. */ -const signIn = (identityNumber: bigint, seed: number, browserId = 1) => - withBrowserProof(identityNumber, sessionKey(seed), async (proof) => { - await proof.accept(browserId); - return proof; - }); +const signIn = ( + identityNumber: bigint, + seed: number, + browserId = 1, + description: BrowserDescription = CHROME_ON_A_MAC, +) => + withBrowserProof( + identityNumber, + sessionKey(seed), + description, + async (proof) => { + await proof.accept(browserId); + 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) => +const attempt = ( + identityNumber: bigint, + seed: number, + description: BrowserDescription = CHROME_ON_A_MAC, +) => + withBrowserProof(identityNumber, sessionKey(seed), description, (proof) => Promise.resolve(proof), ); @@ -175,6 +202,7 @@ describe("browser key", () => { const proof = await withBrowserProof( IDENTITY, sessionKey(2), + CHROME_ON_A_MAC, (attempted) => { seen += 1; if (seen === 1) { @@ -207,6 +235,7 @@ describe("browser key", () => { const proof = await withBrowserProof( IDENTITY, sessionKey(1), + CHROME_ON_A_MAC, (attempted) => { seen += 1; return seen === 1 @@ -223,7 +252,7 @@ describe("browser key", () => { let seen = 0; await expect( - withBrowserProof(IDENTITY, sessionKey(1), () => { + withBrowserProof(IDENTITY, sessionKey(1), CHROME_ON_A_MAC, () => { seen += 1; return Promise.reject(new Error("network")); }), @@ -231,6 +260,39 @@ describe("browser key", () => { expect(seen).toBe(1); }); + /// A registered entry keeps the description it was created with, so a browser that + /// reports something else is one the canister has not seen. Presenting a key pair no + /// entry holds is what registers it under its own, and it is the client that decides + /// so — the canister ignores the description on a sign-in that advances an entry. + it("signs in with a new key pair once the description changes", async () => { + const first = await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); + + const second = await signIn(IDENTITY, 2, 2, BRAVE_ON_A_MAC); + + expect(second.publicKey).not.toEqual(first.publicKey); + expect(second.publicKey).not.toEqual(first.nextPublicKey); + }); + + it("keeps rotating while the description is the one it registered with", async () => { + const first = await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); + + const second = await signIn(IDENTITY, 2, 1, CHROME_ON_A_MAC); + + expect(second.publicKey).toEqual(first.nextPublicKey); + }); + + /// The description is stored with the key pair, so the comparison is always against + /// what the canister was actually sent. A browser that changed twice starts over once + /// per change rather than once per sign-in. + it("settles on the new description after it has changed", async () => { + await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); + const forked = await signIn(IDENTITY, 2, 2, BRAVE_ON_A_MAC); + + const after = await signIn(IDENTITY, 3, 2, BRAVE_ON_A_MAC); + + expect(after.publicKey).toEqual(forked.nextPublicKey); + }); + it("holds a separate key per identity", async () => { const first = await attempt(IDENTITY, 1); diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts index 1fe0acf623..6743057b2e 100644 --- a/src/frontend/src/lib/stores/browser-key.store.ts +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -1,4 +1,5 @@ import { createStore, get as idbGet, set as idbSet } from "idb-keyval"; +import type { BrowserDescription } from "$lib/generated/internet_identity_types"; /** * The key this browser proves itself with when it creates a session, and the id the @@ -18,6 +19,10 @@ interface BrowserKeyRecord { announced?: CryptoKeyPair; /** Absent until a sign-in has told us which browser we are. */ browserId?: number; + /** What was reported when this browser registered, so a change can be noticed. + * Written with the key pair and never on its own: compared against what the canister + * stored, it has to be what we actually sent when the entry was created. */ + description?: BrowserDescription; } /** @@ -125,6 +130,47 @@ const exclusively = async ( return await locks.request(`ii-browser-key:${identityNumber}`, run); }; +/** The variant's tag and its payload, which is all a description is made of. */ +const token = (variant: object): string => Object.entries(variant)[0].join(":"); + +const sameDescription = ( + one: BrowserDescription, + other: BrowserDescription, +): boolean => + token(one.brand) === token(other.brand) && + token(one.os) === token(other.os) && + token(one.form_factor) === token(other.form_factor) && + (one.model[0] ?? "") === (other.model[0] ?? ""); + +/** + * The record to sign in with, which is a fresh one where this browser no longer matches + * what it registered as. + * + * A registered entry keeps the description it was created with, so a browser reporting + * something else is one the canister has not seen. Rather than ask for an entry to be + * changed, this presents a key pair no entry holds, which registers under its own — and + * because nothing has been sent yet, a browser that gets this far has not disturbed the + * entry it is leaving behind. + */ +const forDescription = async ( + identityNumber: bigint, + description: BrowserDescription, +): Promise => { + const stored = await read(identityNumber); + if ( + stored?.description === undefined || + sameDescription(stored.description, description) + ) { + return stored; + } + const fresh: BrowserKeyRecord = { + keyPair: await generate(), + announced: await generate(), + }; + await write(identityNumber, fresh); + return fresh; +}; + /** The record a sign-in proves with: what is stored, completed with whatever it lacks. */ const prepared = async ( identityNumber: bigint, @@ -147,6 +193,7 @@ const prepared = async ( const attempt = async ( identityNumber: bigint, sessionKey: Uint8Array, + description: BrowserDescription, signIn: (proof: BrowserProof) => Promise, from?: BrowserKeyRecord, ): Promise => { @@ -176,7 +223,7 @@ const attempt = async ( signature, nextSignature, accept: (browserId) => - write(identityNumber, { keyPair: successor, browserId }), + write(identityNumber, { keyPair: successor, browserId, description }), }); }; @@ -195,11 +242,19 @@ const attempt = async ( export const withBrowserProof = ( identityNumber: bigint, sessionKey: Uint8Array, + description: BrowserDescription, signIn: (proof: BrowserProof) => Promise, ): Promise => exclusively(identityNumber, async () => { + const from = await forDescription(identityNumber, description); try { - return await attempt(identityNumber, sessionKey, signIn); + return await attempt( + identityNumber, + sessionKey, + description, + signIn, + from, + ); } catch (error) { if (!(error instanceof StaleBrowserKeyError)) { throw error; @@ -215,7 +270,13 @@ export const withBrowserProof = ( // Carried into the retry rather than read back, so a storage failure costs the // rotation and not the sign-in. await write(identityNumber, promoted); - return await attempt(identityNumber, sessionKey, signIn, promoted); + return await attempt( + identityNumber, + sessionKey, + description, + signIn, + promoted, + ); } }); From d15a2abda49a6375959380b1d82a64478440c3e1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:29:07 +0200 Subject: [PATCH 240/298] feat(frontend): send what the browser is, not a label for it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index b2efbb8a1b..5ac13ee020 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -241,11 +241,12 @@ const createSession = async ( const key = { identityNumber, accountNumber, origin: effectiveOrigin }; const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); const iiPublicKey = new Uint8Array(iiKey.getPublicKey().toDer()); - const browserName = await describeBrowser(); + const browserDescription = await describeBrowser(); const prepared = await withBrowserProof( identityNumber, iiPublicKey, + browserDescription, async (browser) => { const prepared = await actor .prepare_account_session({ @@ -253,7 +254,7 @@ const createSession = async ( origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], session_key: iiPublicKey, - browser_name: browserName, + browser_description: browserDescription, current_browser_key: browser.publicKey, next_browser_key: browser.nextPublicKey, current_browser_key_signature: browser.signature, From 543b2c923a6ec57e21adf298d6e998f09fdc7956 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:30:56 +0200 Subject: [PATCH 241/298] feat(frontend): name a browser where the list is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's name comes from the tokens the record holds rather than from a label stored at sign-in, so renaming a product — or teaching the client to recognise one — renames every row that already exists instead of only the browsers that sign in afterwards. The names it produces are the ones the client used to compose and send. That is deliberate: what changed is where the wording lives, not what it says. The platform word is not the system's own name, because nobody says they are on "Chrome OS" or "iPadOS". The hardware wins where the client could name it, since "Chrome on Pixel 9" is what its owner recognises. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(authenticated)/settings/browsers.test.ts | 120 ++++++++++++------ .../(authenticated)/settings/browsers.ts | 53 +++++++- 2 files changed, 131 insertions(+), 42 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts index 9559e3f28a..c486957e1d 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts @@ -2,23 +2,70 @@ import { describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; import type { ActorSubclass } from "@icp-sdk/core/agent"; import type { _SERVICE } from "$lib/generated/internet_identity_types"; -import { - fromCanisterBrowsers, - signOutBrowser, -} from "./browsers"; +import { fromCanisterBrowsers, nameOf, signOutBrowser } from "./browsers"; +import type { + BrowserBrand, + BrowserDescription, + OperatingSystem, +} from "$lib/generated/internet_identity_types"; + +const describing = ( + brand: BrowserBrand, + os: OperatingSystem, + model: [] | [string] = [], +): BrowserDescription => ({ brand, os, form_factor: { Desktop: null }, model }); + +const CHROME_ON_A_MAC = describing({ Chrome: null }, { Macos: null }); const browser = ( id: number, - name: string, createdAtNanos: bigint, lastUsedNanos: bigint = createdAtNanos, + description: BrowserDescription = CHROME_ON_A_MAC, ) => ({ id, - name, + description, + session_count: 0, created_at: createdAtNanos, last_used: lastUsedNanos, }); +describe("nameOf", () => { + /// The names these produce are the ones the client used to compose and send. Kept the + /// same on purpose: the change is where the wording lives, not what it says. + it.each([ + [describing({ Chrome: null }, { Android: null }), "Chrome on Android"], + [describing({ Safari: null }, { Ios: null }), "Safari on iPhone"], + [describing({ Safari: null }, { Ipados: null }), "Safari on iPad"], + [describing({ Safari: null }, { Macos: null }), "Safari on Mac"], + [describing({ Edge: null }, { Windows: null }), "Edge on Windows"], + [describing({ Vivaldi: null }, { Linux: null }), "Vivaldi on Linux"], + [describing({ Chrome: null }, { ChromeOs: null }), "Chrome on Chromebook"], + [ + describing({ SamsungInternet: null }, { Android: null }), + "Samsung Internet on Android", + ], + [describing({ Brave: null }, { Macos: null }), "Brave on Mac"], + ])("names %o as %s", (description, expected) => { + expect(nameOf(description)).toBe(expected); + }); + + /// What the owner recognises. The platform word only stands in where no model came. + it("names the hardware where the client could name it", () => { + expect( + nameOf(describing({ Chrome: null }, { Android: null }, ["Pixel 9"])), + ).toBe("Chrome on Pixel 9"); + }); + + /// A browser or system this frontend does not name is the row the list exists for, so + /// it shows the token that arrived rather than a generic word. + it("shows an unrecognised token as it arrived", () => { + expect( + nameOf(describing({ Other: "YaBrowser" }, { Other: "HarmonyOS" })), + ).toBe("YaBrowser on HarmonyOS"); + }); +}); + describe("fromCanisterBrowsers", () => { it("reports no browsers for an identity that has never created a session", () => { expect(fromCanisterBrowsers([])).toEqual([]); @@ -28,39 +75,36 @@ describe("fromCanisterBrowsers", () => { expect( fromCanisterBrowsers([ [ - browser(1, "Firefox on Linux", BigInt(1_000_000_000)), - browser(2, "Chrome on macOS", BigInt(3_000_000_000)), - browser(3, "Safari on iOS", BigInt(2_000_000_000)), + browser(1, BigInt(1_000_000_000)), + browser(2, BigInt(3_000_000_000)), + browser(3, BigInt(2_000_000_000)), ], - ]).map((entry) => entry.name), - ).toEqual(["Chrome on macOS", "Safari on iOS", "Firefox on Linux"]); + ]).map((entry) => entry.id), + ).toEqual([2, 3, 1]); }); it("orders on use rather than on registration", () => { expect( fromCanisterBrowsers([ [ - browser( - 1, - "enrolled first, still in use", - BigInt(1), - BigInt(9_000_000_000), - ), - browser(2, "enrolled later, gone quiet", BigInt(5_000_000_000)), + // Registered first and still in use. + browser(1, BigInt(1), BigInt(9_000_000_000)), + // Registered later and gone quiet since. + browser(2, BigInt(5_000_000_000)), ], - ]).map((entry) => entry.name), - ).toEqual(["enrolled first, still in use", "enrolled later, gone quiet"]); + ]).map((entry) => entry.id), + ).toEqual([1, 2]); }); it("converts both timestamps to milliseconds", () => { expect( fromCanisterBrowsers([ - [browser(1, "Chrome", BigInt(1_500_000_000), BigInt(4_200_000_000))], + [browser(1, BigInt(1_500_000_000), BigInt(4_200_000_000))], ]), ).toEqual([ { id: 1, - name: "Chrome", + name: "Chrome on Mac", createdAtMillis: 1_500, lastUsedMillis: 4_200, isCurrent: false, @@ -70,12 +114,7 @@ describe("fromCanisterBrowsers", () => { it("marks the browser being read from, so two of one name can be told apart", () => { const marked = fromCanisterBrowsers( - [ - [ - browser(1, "Chrome on Mac", BigInt(1_000_000_000)), - browser(2, "Chrome on Mac", BigInt(2_000_000_000)), - ], - ], + [[browser(1, BigInt(1_000_000_000)), browser(2, BigInt(2_000_000_000))]], 2, ); @@ -87,19 +126,18 @@ describe("fromCanisterBrowsers", () => { it("marks nothing when this browser has never created a session", () => { expect( - fromCanisterBrowsers([ - [browser(1, "Chrome on Mac", BigInt(1_000_000_000))], - ]).some((entry) => entry.isCurrent), + fromCanisterBrowsers([[browser(1, BigInt(1_000_000_000))]]).some( + (entry) => entry.isCurrent, + ), ).toBe(false); }); /// An id from another browser's record must not mark an entry here. it("marks nothing when the id is one this identity does not hold", () => { expect( - fromCanisterBrowsers( - [[browser(1, "Chrome on Mac", BigInt(1_000_000_000))]], - 99, - ).some((entry) => entry.isCurrent), + fromCanisterBrowsers([[browser(1, BigInt(1_000_000_000))]], 99).some( + (entry) => entry.isCurrent, + ), ).toBe(false); }); }); @@ -125,9 +163,9 @@ describe("signOutBrowser", () => { Promise.resolve({ Err: { InternalCanisterError: "boom" } }), } as unknown as ActorSubclass<_SERVICE>; - await expect( - signOutBrowser(actor, BigInt(10_000), 3), - ).rejects.toThrow("boom"); + await expect(signOutBrowser(actor, BigInt(10_000), 3)).rejects.toThrow( + "boom", + ); }); it("surfaces an unauthorized refusal", async () => { @@ -136,9 +174,9 @@ describe("signOutBrowser", () => { Promise.resolve({ Err: { Unauthorized: "2vxsx-fae" } }), } as unknown as ActorSubclass<_SERVICE>; - await expect( - signOutBrowser(actor, BigInt(10_000), 3), - ).rejects.toThrow(/Not authorized/); + await expect(signOutBrowser(actor, BigInt(10_000), 3)).rejects.toThrow( + /Not authorized/, + ); }); /// Which browser is signing out is read from the key record, not passed in: the list diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts index 08af1f317f..b54a35de20 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts @@ -3,12 +3,16 @@ import { purgeAppSessions } from "$lib/stores/app-session.store"; import { currentBrowserId } from "$lib/stores/browser-key.store"; import type { _SERVICE, + BrowserBrand, + BrowserDescription, BrowserInfo, + OperatingSystem, } from "$lib/generated/internet_identity_types"; import { nanosToMillis } from "$lib/utils/time"; export interface Browser { id: number; + /** Derived here rather than stored, so renaming a product renames every row at once. */ name: string; createdAtMillis: number; lastUsedMillis: number; @@ -16,6 +20,53 @@ export interface Browser { isCurrent: boolean; } +const BRAND_NAMES: Record = { + Chrome: "Chrome", + Safari: "Safari", + Firefox: "Firefox", + Edge: "Edge", + Opera: "Opera", + SamsungInternet: "Samsung Internet", + Vivaldi: "Vivaldi", + Brave: "Brave", +}; + +/** + * What the owner calls the thing, which is not the system's own name: nobody says they + * are on "Chrome OS" or "iPadOS". + */ +const PLATFORM_WORDS: Record = { + Macos: "Mac", + Ios: "iPhone", + Ipados: "iPad", + Windows: "Windows", + Android: "Android", + ChromeOs: "Chromebook", + Linux: "Linux", +}; + +/** The token a variant carries where it names one, and its tag where it does not. */ +const named = ( + variant: BrowserBrand | OperatingSystem, + names: Record, +): string => { + const [tag, value] = Object.entries(variant)[0]; + return tag === "Other" ? String(value) : (names[tag] ?? tag); +}; + +/** + * How a browser is named in the list. + * + * The hardware wins where the client could name it, because "Chrome on Pixel 9" is what + * its owner recognises; the platform word stands in everywhere else. An unrecognised + * token is shown as it arrived rather than as a generic fallback — a browser the user + * does not recognise is the row this list exists for. + */ +export const nameOf = (description: BrowserDescription): string => + `${named(description.brand, BRAND_NAMES)} on ${ + description.model[0] ?? named(description.os, PLATFORM_WORDS) + }`; + export const fromCanisterBrowsers = ( browsers: [] | [BrowserInfo[]], currentBrowserId?: number, @@ -23,7 +74,7 @@ export const fromCanisterBrowsers = ( (browsers[0] ?? []) .map((browser) => ({ id: browser.id, - name: browser.name, + name: nameOf(browser.description), createdAtMillis: nanosToMillis(browser.created_at), lastUsedMillis: nanosToMillis(browser.last_used), isCurrent: browser.id === currentBrowserId, From 7ca07ee6bc0576b87ce1a2ccc7da361c68561408 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:31:48 +0200 Subject: [PATCH 242/298] style(frontend): pass browsers by the shorthand prettier expects Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(new-styling)/manage/(authenticated)/settings/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte index 09b6506c5e..49796b0f1d 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte @@ -50,6 +50,6 @@ /> From ea660adcb5b79ab419619a33bbb5ec721582532b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 21:37:04 +0200 Subject: [PATCH 243/298] test(frontend): the browser proof takes a description Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/channelHandlers/sessionDelegation.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 8183069755..e3be5d8a72 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -46,6 +46,7 @@ vi.mock("$lib/stores/browser-key.store", async (importOriginal) => ({ withBrowserProof: ( _identityNumber: bigint, _sessionKey: Uint8Array, + _description: unknown, signIn: (proof: unknown) => Promise, ) => signIn({ From 7ba2298ef27a36384f9a2e09131c46f08f763301 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 22:21:38 +0200 Subject: [PATCH 244/298] docs(frontend): say what the test guards, not what it once caught Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../manage/(authenticated)/settings/browsers.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts index c486957e1d..cde5d647e8 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts @@ -31,8 +31,8 @@ const browser = ( }); describe("nameOf", () => { - /// The names these produce are the ones the client used to compose and send. Kept the - /// same on purpose: the change is where the wording lives, not what it says. + /// Pinned, because these strings are what a user reads to recognise their own browser: + /// a token resolving to a different word is a row they no longer know themselves by. it.each([ [describing({ Chrome: null }, { Android: null }), "Chrome on Android"], [describing({ Safari: null }, { Ios: null }), "Safari on iPhone"], @@ -180,8 +180,9 @@ describe("signOutBrowser", () => { }); /// Which browser is signing out is read from the key record, not passed in: the list - /// renders that flag from a promise, and a click landing before it resolved used to - /// leave this browser's own chains behind — the one thing signing out must not do. + /// renders that flag from a promise, so a click landing before it resolves would pass + /// `false` for the user's own browser and leave its chains behind — the one thing + /// signing out must not do. it("discards this browser's stored chains, and another browser's not", async () => { const { storeAppSession, appSessionsForOrigin } = await import("$lib/stores/app-session.store"); From e4e03b754fccd261a4b17bd9834eade5ce41518e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 22:22:38 +0200 Subject: [PATCH 245/298] docs(internet_identity): state why one write, without narrating the loop Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 38023b6269..9b0978494b 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5283,10 +5283,10 @@ mod session_creation_tests { /// A browser the registry gave up takes its sessions with it, wherever they were, in /// the write that made room for the browser replacing it. /// - /// It used to be a loop at the caller: register the browser, write the anchor, then one - /// `revoke_browser_sessions` per browser dropped. That is a browser gone in one write - /// and its sessions ended in others — and on the IC an `Err` from a later one commits - /// the earlier ones, so a browser could end up gone with its sessions still live. + /// One write, not a loop at the caller: registering the browser, storing the anchor, + /// then one `revoke_browser_sessions` per browser dropped would end a browser in one + /// write and its sessions in others — and on the IC an `Err` from a later one commits + /// the earlier ones, so a browser could be left gone with its sessions still live. #[test] fn a_dropped_browser_takes_its_sessions_with_it() { let (mut storage, anchor_number) = storage_with_anchor(); From 7b775e87d36e10d73133c081dfb1a56d5bf04e8d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 8 Sep 2026 22:23:27 +0200 Subject: [PATCH 246/298] docs(internet_identity): say why the property test exists, not what preceded it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 4a7bedd124..c2cb7516aa 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6617,11 +6617,10 @@ mod session_revocation_tests { /// Everything the write path derives, checked against the account reference lists it /// derived them from, after an arbitrary sequence of writes. /// -/// This is the check that stands where the counter repair path used to. The gate's whole -/// job is deriving values — the counters, both principal indices, the session count — from -/// the pair of lists a write holds, and every defect the review of this stack turned up was -/// one of those maintained by hand and forgotten at one write site. A test per operation -/// catches the site it names; this catches the ones nobody thought to name. +/// The gate's whole job is deriving values — the counters, both principal indices, the +/// session count — from the pair of lists a write holds, and a derived value that is +/// instead maintained by hand is one forgotten write site away from drifting. A test per +/// operation catches the site it names; this catches the ones nobody thought to name. mod write_path_property_tests { use super::params_at; use super::record_use; From 22e46121c2a7f839d5702e7074f0f4306963173b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:18:49 +0200 Subject: [PATCH 247/298] feat(internet_identity): name the six browsers that earn an icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A variant is what earns an icon on the devices list, and an icon nobody recognises is worse than the name written out — so the named set is the six that hold 97.5% of the web between them, measured worldwide in August 2026: Chrome, Safari, Edge, Firefox, Samsung Internet, Opera. Brave and Vivaldi go with that bar. Brave sits at 0.57% and its agent says Chrome, which is now what it reads as. Vivaldi does not reach StatCounter's top nineteen at all and was only ever here because the agent table listed it; it self-identifies, so it arrives as `Other("Vivaldi")` — named, no icon. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/generated/internet_identity_idl.js | 2 -- .../src/lib/generated/internet_identity_types.d.ts | 7 +++---- src/internet_identity/internet_identity.did | 5 +++-- .../src/storage/storable/browser_description.rs | 12 +----------- .../src/internet_identity/types.rs | 6 ++++-- 5 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index e5581f1a0d..bf914b7ebd 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -576,10 +576,8 @@ export const idlFactory = ({ IDL }) => { 'Mobile' : IDL.Null, }); const BrowserBrand = IDL.Variant({ - 'Vivaldi' : IDL.Null, 'Edge' : IDL.Null, 'Firefox' : IDL.Null, - 'Brave' : IDL.Null, 'Safari' : IDL.Null, 'SamsungInternet' : IDL.Null, 'Opera' : IDL.Null, 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 dce40ddb49..9b922ade20 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -1557,17 +1557,16 @@ export type SessionDelegationError = { 'NoSuchDelegation' : null } | * at once. "Brand" is what the client hints call this, and BrowserInfo below is the * entry it describes. */ -export type BrowserBrand = { 'Vivaldi' : null } | - { 'Edge' : null } | +export type BrowserBrand = { 'Edge' : null } | { 'Firefox' : null } | - { 'Brave' : null } | { 'Safari' : null } | { 'SamsungInternet' : null } | { 'Opera' : null } | { /** * A browser this list does not name, shown as the client resolved it. Worth seeing - * rather than hiding behind a generic label. + * rather than hiding behind a generic label. Named variants are the six that hold + * 97% of the web between them, because a variant is what earns an icon. */ 'Other' : string } | diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 78d51de0ce..2809b7a9cf 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1012,9 +1012,10 @@ type IdentityAuthnInfo = record { // at once. "Brand" is what the client hints call this, and BrowserInfo below is the // entry it describes. type BrowserBrand = variant { - Chrome; Safari; Firefox; Edge; Opera; SamsungInternet; Vivaldi; Brave; + Chrome; Safari; Firefox; Edge; Opera; SamsungInternet; // A browser this list does not name, shown as the client resolved it. Worth seeing - // rather than hiding behind a generic label. + // rather than hiding behind a generic label. Named variants are the six that hold + // 97% of the web between them, because a variant is what earns an icon. Other : text; }; diff --git a/src/internet_identity/src/storage/storable/browser_description.rs b/src/internet_identity/src/storage/storable/browser_description.rs index ceaf7556b4..c0e6cda663 100644 --- a/src/internet_identity/src/storage/storable/browser_description.rs +++ b/src/internet_identity/src/storage/storable/browser_description.rs @@ -31,10 +31,6 @@ pub enum StorableBrowserBrand { #[n(5)] SamsungInternet, #[n(6)] - Vivaldi, - #[n(7)] - Brave, - #[n(8)] Other(#[n(0)] String), } @@ -80,8 +76,6 @@ impl From for StorableBrowserBrand { BrowserBrand::Edge => Self::Edge, BrowserBrand::Opera => Self::Opera, BrowserBrand::SamsungInternet => Self::SamsungInternet, - BrowserBrand::Vivaldi => Self::Vivaldi, - BrowserBrand::Brave => Self::Brave, BrowserBrand::Other(token) => Self::Other(token), } } @@ -96,8 +90,6 @@ impl From for BrowserBrand { StorableBrowserBrand::Edge => Self::Edge, StorableBrowserBrand::Opera => Self::Opera, StorableBrowserBrand::SamsungInternet => Self::SamsungInternet, - StorableBrowserBrand::Vivaldi => Self::Vivaldi, - StorableBrowserBrand::Brave => Self::Brave, StorableBrowserBrand::Other(token) => Self::Other(token), } } @@ -194,7 +186,7 @@ mod tests { #[test] fn a_description_of_named_tokens_round_trips() { round_trip(StorableBrowserDescription { - brand: StorableBrowserBrand::Brave, + brand: StorableBrowserBrand::Safari, os: StorableOperatingSystem::Ipados, form_factor: StorableFormFactor::Tablet, model: None, @@ -225,8 +217,6 @@ mod tests { BrowserBrand::Edge, BrowserBrand::Opera, BrowserBrand::SamsungInternet, - BrowserBrand::Vivaldi, - BrowserBrand::Brave, BrowserBrand::Other("Arc".to_string()), ]; for brand in brands { diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index c1de8d3d1d..1f69430997 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -19,6 +19,10 @@ pub type BrowserId = u32; /// Which browser a sign-in came from, as a token rather than a name to show. /// +/// Named for the six that hold 97% of the web between them; everything else arrives as +/// `Other`, which still names it. A variant is what earns an icon, and an icon nobody +/// recognises is worse than the name written out. +/// /// `Brand` because that is what the client hints call it, and because `Browser` names the /// registry entry these describe. /// @@ -34,8 +38,6 @@ pub enum BrowserBrand { Edge, Opera, SamsungInternet, - Vivaldi, - Brave, /// Whatever the client resolved for a browser this list does not name. Shown as it /// arrived: an unrecognised browser is worth seeing, not worth hiding behind a /// generic label. From 8e7f06522de7974fb0ec2b8bcc909576012eed2b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:20:48 +0200 Subject: [PATCH 248/298] test(internet_identity): pick a named brand that still exists Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 155b399fd7..e36b8829b1 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -336,7 +336,7 @@ mod tests { #[test] fn a_description_of_named_tokens_is_always_within_the_limit() { assert!(browser_description_within_limits(&description( - BrowserBrand::Brave, + BrowserBrand::Safari, OperatingSystem::Ipados, None ))); From 621382245903989272a846dc15ab6be8167fa104 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:21:43 +0200 Subject: [PATCH 249/298] feat(frontend): resolve the six named brands, and name the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brave's own check goes with its variant: its agent says Chrome, so that is what it reads as. Vivaldi keeps its table entry but resolves to `Other("Vivaldi")`, which names it without claiming an icon — the same treatment DuckDuckGo already had. `brandOf` stops being async, since nothing is asked of the platform. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/describeBrowser.test.ts | 34 ++----------------- .../stores/channelHandlers/describeBrowser.ts | 30 +++------------- 2 files changed, 6 insertions(+), 58 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts index 373c5920e4..8d295e8753 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts @@ -166,7 +166,7 @@ const AGENTS: [ "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, - { Vivaldi: null }, + { Other: "Vivaldi" }, { Linux: null }, { Desktop: null }, ], @@ -196,7 +196,7 @@ const stub = (props: Record): void => { describe("describeBrowser", () => { afterEach(() => { - stub({ userAgentData: undefined, brave: undefined }); + stub({ userAgentData: undefined }); }); it.each(AGENTS)( @@ -283,36 +283,6 @@ describe("describeBrowser", () => { } }); - /// Brave sends a plain Chrome agent and strips the hints that would give it away, so - /// without asking it directly its owner sees a row that says Chrome. - it("names Brave, which its agent does not", async () => { - stub({ - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - maxTouchPoints: 0, - brave: { isBrave: () => Promise.resolve(true) }, - }); - - await expect(describeBrowser()).resolves.toMatchObject({ - brand: { Brave: null }, - os: { Macos: null }, - }); - }); - - it("reads a browser that refuses the Brave question as its agent says", async () => { - stub({ - userAgent: FIREFOX_MAC, - maxTouchPoints: 0, - brave: { - isBrave: () => Promise.reject(new Error("no")), - }, - }); - - await expect(describeBrowser()).resolves.toMatchObject({ - brand: { Firefox: null }, - }); - }); - /// The canister refuses a token over its cap, so a resolver must never offer one. it("caps a token it did not recognise", async () => { stub({ userAgent: "x".repeat(500), maxTouchPoints: 0 }); diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts index e645103579..51cf38324a 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts @@ -31,8 +31,8 @@ const BRANDS: [RegExp, BrowserBrand][] = [ [/EdgA\/|Edg\//, { Edge: null }], [/OPR\//, { Opera: null }], [/SamsungBrowser\//, { SamsungInternet: null }], - [/Vivaldi\//, { Vivaldi: null }], // Names itself but has no variant of its own, so it travels as the token it gave. + [/Vivaldi\//, { Other: "Vivaldi" }], [/DuckDuckGo\//, { Other: "DuckDuckGo" }], [/Chrome\//, { Chrome: null }], [/Safari\//, { Safari: null }], @@ -48,30 +48,8 @@ const capped = (token: string): string => { return capped; }; -/** - * Brave ships a plain Chrome agent on purpose and strips the hints that would give it - * away, so its own check is the only thing that names it. A browser that hides is worth - * asking directly, because otherwise its owner sees a row that says Chrome. - */ -const isBrave = async (): Promise => { - const brave = ( - navigator as Navigator & { brave?: { isBrave?: () => Promise } } - ).brave; - try { - return (await brave?.isBrave?.()) === true; - } catch { - return false; - } -}; - -const brandOf = async (agent: string): Promise => { - if (await isBrave()) { - return { Brave: null }; - } - return ( - BRANDS.find(([token]) => token.test(agent))?.[1] ?? { Other: capped(agent) } - ); -}; +const brandOf = (agent: string): BrowserBrand => + BRANDS.find(([token]) => token.test(agent))?.[1] ?? { Other: capped(agent) }; const systemOf = (agent: string, touchPoints: number): OperatingSystem => { if (/CrOS/.test(agent)) return { ChromeOs: null }; @@ -154,7 +132,7 @@ export const describeBrowser = async (): Promise => { const hints = await highEntropyHints(); const os = systemOf(agent, navigator.maxTouchPoints); return { - brand: await brandOf(agent), + brand: brandOf(agent), os, form_factor: formFactorOf(agent, os, hints), model: hints.model === undefined ? [] : [capped(hints.model)], From c40904299228aee9792f3338d5f60d5a3b527ed9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:23:01 +0200 Subject: [PATCH 250/298] feat(frontend): name only the six, and let the rest arrive as tokens Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../manage/(authenticated)/settings/browsers.test.ts | 5 +++-- .../manage/(authenticated)/settings/browsers.ts | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts index cde5d647e8..ecce88ae2a 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts @@ -39,13 +39,11 @@ describe("nameOf", () => { [describing({ Safari: null }, { Ipados: null }), "Safari on iPad"], [describing({ Safari: null }, { Macos: null }), "Safari on Mac"], [describing({ Edge: null }, { Windows: null }), "Edge on Windows"], - [describing({ Vivaldi: null }, { Linux: null }), "Vivaldi on Linux"], [describing({ Chrome: null }, { ChromeOs: null }), "Chrome on Chromebook"], [ describing({ SamsungInternet: null }, { Android: null }), "Samsung Internet on Android", ], - [describing({ Brave: null }, { Macos: null }), "Brave on Mac"], ])("names %o as %s", (description, expected) => { expect(nameOf(description)).toBe(expected); }); @@ -63,6 +61,9 @@ describe("nameOf", () => { expect( nameOf(describing({ Other: "YaBrowser" }, { Other: "HarmonyOS" })), ).toBe("YaBrowser on HarmonyOS"); + expect(nameOf(describing({ Other: "Vivaldi" }, { Linux: null }))).toBe( + "Vivaldi on Linux", + ); }); }); diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts index b54a35de20..80de58e481 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts @@ -27,8 +27,6 @@ const BRAND_NAMES: Record = { Edge: "Edge", Opera: "Opera", SamsungInternet: "Samsung Internet", - Vivaldi: "Vivaldi", - Brave: "Brave", }; /** From 2e79018f5656f89a1baa6c17d2e5be93cddfd38d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:23:19 +0200 Subject: [PATCH 251/298] test(frontend): fork against a brand that is still named Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/lib/stores/browser-key.store.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts index 9606416ace..b2dbf7e42e 100644 --- a/src/frontend/src/lib/stores/browser-key.store.test.ts +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -76,9 +76,9 @@ const CHROME_ON_A_MAC: BrowserDescription = { model: [], }; -const BRAVE_ON_A_MAC: BrowserDescription = { +const FIREFOX_ON_A_MAC: BrowserDescription = { ...CHROME_ON_A_MAC, - brand: { Brave: null }, + brand: { Firefox: null }, }; /** Signs in and rotates, the way a successful ceremony does. */ @@ -267,7 +267,7 @@ describe("browser key", () => { it("signs in with a new key pair once the description changes", async () => { const first = await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); - const second = await signIn(IDENTITY, 2, 2, BRAVE_ON_A_MAC); + const second = await signIn(IDENTITY, 2, 2, FIREFOX_ON_A_MAC); expect(second.publicKey).not.toEqual(first.publicKey); expect(second.publicKey).not.toEqual(first.nextPublicKey); @@ -286,9 +286,9 @@ describe("browser key", () => { /// per change rather than once per sign-in. it("settles on the new description after it has changed", async () => { await signIn(IDENTITY, 1, 1, CHROME_ON_A_MAC); - const forked = await signIn(IDENTITY, 2, 2, BRAVE_ON_A_MAC); + const forked = await signIn(IDENTITY, 2, 2, FIREFOX_ON_A_MAC); - const after = await signIn(IDENTITY, 3, 2, BRAVE_ON_A_MAC); + const after = await signIn(IDENTITY, 3, 2, FIREFOX_ON_A_MAC); expect(after.publicKey).toEqual(forked.nextPublicKey); }); From e9a932ff61316c95de1de906b1e45df5a46fbcb8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:50:00 +0200 Subject: [PATCH 252/298] feat: give signed-in browsers their own page The list of browsers moves out of Settings, where it sat below CLI access and trusted MCP servers, onto a page of its own reached from the nav. A row now carries what the browser is rather than only what it is called: the form factor picks the glyph, the brand picks the mark beside it, and a browser this identity has not used in thirty days is called out, because a browser nobody recognises is the row the page exists for. The browser being read from leads, under its own heading, and a browser holding no sessions reads as signed out rather than disappearing. The browser reading the page is named even when the canister holds no record for it: it can describe itself, and a page that says nothing about the machine in front of you is worse than one that says "Never". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/components/ui/Badge.svelte | 4 +- .../manage/(authenticated)/+layout.svelte | 10 + .../(authenticated)/devices/+page.svelte | 212 ++++++++++++++++++ .../{settings => devices}/browsers.test.ts | 64 +++++- .../{settings => devices}/browsers.ts | 70 ++++++ .../devices/components/DeviceRow.svelte | 136 +++++++++++ .../(authenticated)/devices/icons/chrome.svg | 1 + .../(authenticated)/devices/icons/edge.svg | 1 + .../(authenticated)/devices/icons/firefox.svg | 1 + .../(authenticated)/devices/icons/opera.svg | 1 + .../(authenticated)/devices/icons/safari.svg | 1 + .../devices/icons/samsung-internet.svg | 1 + .../(authenticated)/settings/+page.svelte | 20 -- .../components/BrowsersSection.svelte | 134 ----------- 14 files changed, 500 insertions(+), 156 deletions(-) create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte rename src/frontend/src/routes/(new-styling)/manage/(authenticated)/{settings => devices}/browsers.test.ts (80%) rename src/frontend/src/routes/(new-styling)/manage/(authenticated)/{settings => devices}/browsers.ts (60%) create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/chrome.svg create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/edge.svg create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/firefox.svg create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/opera.svg create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/safari.svg create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/samsung-internet.svg delete mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte diff --git a/src/frontend/src/lib/components/ui/Badge.svelte b/src/frontend/src/lib/components/ui/Badge.svelte index e665645539..b71d7d29a6 100644 --- a/src/frontend/src/lib/components/ui/Badge.svelte +++ b/src/frontend/src/lib/components/ui/Badge.svelte @@ -1,7 +1,7 @@ + +
    +

    {$t`Devices`}

    +

    + See where you're signed in to apps and sign out remotely. +

    +
    + +
    +
    +

    {$t`This device`}

    +
    + {#if thisBrowser !== undefined} + (confirming = thisBrowser)} + /> + {:else if thisDescription !== undefined} + + + {/if} +
    +
    + +
    +

    {$t`Other devices`}

    + {#if otherBrowsers.length > 0} +
      + {#each otherBrowsers as browser, index (browser.id)} +
    • 0 ? "border-border-tertiary border-t" : ""}> + (confirming = browser)} + /> +
    • + {/each} +
    + {:else} +
    + No other devices are signed in to apps with this identity. +
    + {/if} +
    + +

    + + Don't recognize a device? Sign it out, then + review your access methods. + +

    +
    + +{#if confirming !== undefined} + {@const target = confirming} + (confirming = undefined)} width="wider"> +
    + + + + +

    + {$t`Sign out of all apps?`} +

    + +

    + {#if target.isCurrent} + + Every app you opened from this device will ask you to sign in again. + You'll stay signed in to Internet Identity here. + + {:else} + {$t`${target.name} will lose access to all apps signed in with this identity.`} + {/if} +

    + + +
    +
    +{/if} diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts similarity index 80% rename from src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts rename to src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts index ecce88ae2a..ec89d56de9 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; import type { ActorSubclass } from "@icp-sdk/core/agent"; import type { _SERVICE } from "$lib/generated/internet_identity_types"; -import { fromCanisterBrowsers, nameOf, signOutBrowser } from "./browsers"; +import { + INACTIVE_AFTER_DAYS, + fromCanisterBrowsers, + inactiveDays, + kindOf, + nameOf, + signOutBrowser, +} from "./browsers"; import type { BrowserBrand, BrowserDescription, @@ -67,6 +74,51 @@ describe("nameOf", () => { }); }); +describe("kindOf", () => { + const withFormFactor = (form_factor: BrowserDescription["form_factor"]) => ({ + ...CHROME_ON_A_MAC, + form_factor, + }); + + it.each([ + [withFormFactor({ Mobile: null }), "phone"], + [withFormFactor({ Tablet: null }), "tablet"], + [withFormFactor({ Desktop: null }), "laptop"], + [withFormFactor({ Other: "watch" }), "unknown"], + ])("draws %o as a %s", (description, kind) => { + expect(kindOf(description)).toBe(kind); + }); +}); + +describe("inactiveDays", () => { + const DAY = 86_400_000; + const now = Date.UTC(2026, 0, 31); + const lastUsed = (daysAgo: number) => ({ + ...fromCanisterBrowsers([ + [browser(1, BigInt(now - daysAgo * DAY) * 1_000_000n)], + ])[0], + }); + + it("says nothing about a browser used within the window", () => { + expect( + inactiveDays(lastUsed(INACTIVE_AFTER_DAYS - 1), now), + ).toBeUndefined(); + }); + + it("counts whole days once the window has passed", () => { + expect(inactiveDays(lastUsed(INACTIVE_AFTER_DAYS), now)).toBe( + INACTIVE_AFTER_DAYS, + ); + expect(inactiveDays(lastUsed(90), now)).toBe(90); + }); + + it("rounds down, so a browser is never reported as idle for longer than it was", () => { + expect( + inactiveDays({ ...lastUsed(45), lastUsedMillis: now - 45.9 * DAY }, now), + ).toBe(45); + }); +}); + describe("fromCanisterBrowsers", () => { it("reports no browsers for an identity that has never created a session", () => { expect(fromCanisterBrowsers([])).toEqual([]); @@ -106,13 +158,23 @@ describe("fromCanisterBrowsers", () => { { id: 1, name: "Chrome on Mac", + description: CHROME_ON_A_MAC, createdAtMillis: 1_500, lastUsedMillis: 4_200, + sessionCount: 0, isCurrent: false, }, ]); }); + it("carries the session count, which is what tells a signed-out browser apart", () => { + const [entry] = fromCanisterBrowsers([ + [{ ...browser(1, BigInt(1_000_000_000)), session_count: 3 }], + ]); + + expect(entry.sessionCount).toBe(3); + }); + it("marks the browser being read from, so two of one name can be told apart", () => { const marked = fromCanisterBrowsers( [[browser(1, BigInt(1_000_000_000)), browser(2, BigInt(2_000_000_000))]], diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.ts similarity index 60% rename from src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts rename to src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.ts index 80de58e481..9dc280dc9f 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/browsers.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.ts @@ -14,12 +14,80 @@ export interface Browser { id: number; /** Derived here rather than stored, so renaming a product renames every row at once. */ name: string; + description: BrowserDescription; createdAtMillis: number; lastUsedMillis: number; + /** Counts stored records, so a browser long gone still reads as signed in until some + * write prunes its expired sessions. That is what the inactive badge explains. */ + sessionCount: number; /** Several browsers report the same name, so the list marks the one being read from. */ isCurrent: boolean; } +/** Which glyph stands for the machine. Four, because that is what the data can tell. */ +export type DeviceKind = "laptop" | "phone" | "tablet" | "unknown"; + +/** + * `Desktop` draws a laptop: nothing reported separates a laptop from a tower, and of the + * two a laptop is the likelier machine to be reading this page. + */ +export const kindOf = (description: BrowserDescription): DeviceKind => + "Mobile" in description.form_factor + ? "phone" + : "Tablet" in description.form_factor + ? "tablet" + : "Desktop" in description.form_factor + ? "laptop" + : "unknown"; + +const BRAND_ICONS = import.meta.glob("./icons/*.svg", { + eager: true, + query: "?url", + import: "default", +}) as Record; + +const iconFor = (file: string): string | undefined => + Object.entries(BRAND_ICONS).find(([path]) => path.endsWith(`/${file}`))?.[1]; + +const BRAND_ICON_FILES: Record = { + Chrome: "chrome.svg", + Safari: "safari.svg", + Firefox: "firefox.svg", + Edge: "edge.svg", + Opera: "opera.svg", + SamsungInternet: "samsung-internet.svg", +}; + +/** + * The brand mark for the badge, where the brand is one of the six that has one. + * + * A browser resolved to `Other` has a name but no icon, which is the point of the bar for + * a named variant: an icon nobody recognises says less than the name written out. + */ +export const brandIconOf = ( + description: BrowserDescription, +): string | undefined => { + const [tag] = Object.entries(description.brand)[0]; + const file = BRAND_ICON_FILES[tag]; + return file === undefined ? undefined : iconFor(file); +}; + +/** A browser this identity has not been near in this long is worth a second look. */ +export const INACTIVE_AFTER_DAYS = 30; + +/** + * Whole days since a browser last did anything, or `undefined` where that is not long + * enough to say. `last_used` advances on every session refresh, so this measures the + * browser rather than any one session. + */ +export const inactiveDays = ( + browser: Browser, + now: number, +): number | undefined => { + const days = Math.floor((now - browser.lastUsedMillis) / 86_400_000); + return days >= INACTIVE_AFTER_DAYS ? days : undefined; +}; + const BRAND_NAMES: Record = { Chrome: "Chrome", Safari: "Safari", @@ -73,8 +141,10 @@ export const fromCanisterBrowsers = ( .map((browser) => ({ id: browser.id, name: nameOf(browser.description), + description: browser.description, createdAtMillis: nanosToMillis(browser.created_at), lastUsedMillis: nanosToMillis(browser.last_used), + sessionCount: browser.session_count, isCurrent: browser.id === currentBrowserId, })) .sort((a, b) => b.lastUsedMillis - a.lastUsedMillis); diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte new file mode 100644 index 0000000000..941e03a99b --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte @@ -0,0 +1,136 @@ + + +
    + + + +
    +
    + + + {name} + + {#if inactiveDays !== undefined} + + + + {$t`Inactive for ${inactiveDays} days`} + + + {/if} + + + + + {$t`Last used`} + {lastUsed} + + + {$t`First seen`} + {firstSeen} + + +
    + + +
    + + + {#if action === "sign-out"} + + {:else if action === "signing-out"} + {$t`Signing out…`} + {:else if action === "signed-out"} + {$t`Signed out`} + {/if} + +
    diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/chrome.svg b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/chrome.svg new file mode 100644 index 0000000000..ccf7582d4e --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/chrome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/edge.svg b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/edge.svg new file mode 100644 index 0000000000..9d9deeda0f --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/edge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/firefox.svg b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/firefox.svg new file mode 100644 index 0000000000..ae16324435 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/firefox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/opera.svg b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/opera.svg new file mode 100644 index 0000000000..674605cd2d --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/opera.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/safari.svg b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/safari.svg new file mode 100644 index 0000000000..1a0a1963c4 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/safari.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/samsung-internet.svg b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/samsung-internet.svg new file mode 100644 index 0000000000..8793596f2e --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/icons/samsung-internet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte index 49796b0f1d..d5040d20be 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte @@ -5,9 +5,6 @@ import { fromCanisterMcpConfig } from "$lib/utils/mcpConfig"; import CliAccessSection from "./components/CliAccessSection.svelte"; import McpTrustedServersSection from "./components/McpTrustedServersSection.svelte"; - import BrowsersSection from "./components/BrowsersSection.svelte"; - import { fromCanisterBrowsers } from "./browsers"; - import { currentBrowserId } from "$lib/stores/browser-key.store"; import type { PageProps } from "./$types"; const { data }: PageProps = $props(); @@ -18,19 +15,6 @@ const mcpConfig = $derived( fromCanisterMcpConfig(data.identityInfo.mcp_config), ); - - // Read from this browser's own key record rather than from the canister, which has no - // way to tell which browser is asking: `identity_info` is signed by an access method. - let thisBrowser = $state(undefined); - $effect(() => { - void currentBrowserId($authenticatedStore.identityNumber).then( - (id) => (thisBrowser = id), - ); - }); - - const browsers = $derived( - fromCanisterBrowsers(data.identityInfo.browsers, thisBrowser), - );
    @@ -48,8 +32,4 @@ identityNumber={$authenticatedStore.identityNumber} {mcpConfig} /> - diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte deleted file mode 100644 index 9259096edd..0000000000 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/BrowsersSection.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - -
    - - -
    -
    -

    - {$t`Signed-in browsers`} -

    -

    - {#if browsers.length === 0} - - Apps you sign in to from a browser will show up here, so you can end - their access at any time. - - {:else} - - Signing a browser out ends its access to every app it is signed in - to. - - {/if} -

    -
    - - {#if browsers.length > 0} -
      - {#each browsers as browser (browser.id)} - {@const lastUsed = new Date(browser.lastUsedMillis)} -
    • -
      - - - {browser.name} - - {#if browser.isCurrent} - - {$t`This browser`} - - {/if} - - - - - {$t`Last used ${$formatRelative(lastUsed, { style: "long" })}`} - - - - #{browser.id} - -
      - {#if signedOut.includes(browser.id)} - - {$t`Signed out`} - - {:else} - - {/if} -
    • - {/each} -
    - {/if} -
    -
    From 22fef02e6b775cd90201b0606714db529a610887 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 02:54:36 +0200 Subject: [PATCH 253/298] test: type the two new cases against the generated interface `FormFactor` has an `Unknown` case rather than an open one, and the config these tests build under predates bigint literals. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../manage/(authenticated)/devices/browsers.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts index ec89d56de9..d1c55f024a 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts @@ -84,7 +84,7 @@ describe("kindOf", () => { [withFormFactor({ Mobile: null }), "phone"], [withFormFactor({ Tablet: null }), "tablet"], [withFormFactor({ Desktop: null }), "laptop"], - [withFormFactor({ Other: "watch" }), "unknown"], + [withFormFactor({ Unknown: null }), "unknown"], ])("draws %o as a %s", (description, kind) => { expect(kindOf(description)).toBe(kind); }); @@ -93,11 +93,10 @@ describe("kindOf", () => { describe("inactiveDays", () => { const DAY = 86_400_000; const now = Date.UTC(2026, 0, 31); - const lastUsed = (daysAgo: number) => ({ - ...fromCanisterBrowsers([ - [browser(1, BigInt(now - daysAgo * DAY) * 1_000_000n)], - ])[0], - }); + const lastUsed = (daysAgo: number) => + fromCanisterBrowsers([ + [browser(1, BigInt(now - daysAgo * DAY) * BigInt(1_000_000))], + ])[0]; it("says nothing about a browser used within the window", () => { expect( From 5680c83f4042102e04101a3a909976992e83b958 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 03:02:02 +0200 Subject: [PATCH 254/298] fix(fe): handle the absent form-factor hint explicitly Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts index 51cf38324a..2077060440 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts +++ b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts @@ -70,7 +70,7 @@ const formFactorOf = ( system: OperatingSystem, hints: { mobile?: boolean; formFactors?: string[] }, ): FormFactor => { - if (hints.formFactors?.includes("Tablet")) return { Tablet: null }; + if (hints.formFactors?.includes("Tablet") === true) return { Tablet: null }; if ("Ipados" in system) return { Tablet: null }; if (hints.mobile === true) return { Mobile: null }; if ("Ios" in system) return { Mobile: null }; From 9de8210daa29afc4c765fdf65b8e3abcf55b2fa4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 12:09:21 +0200 Subject: [PATCH 255/298] fix(be): a sign-in writes its own origin, and the gate sweeps the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_session` handed the gate the whole of `account_state`, which made every origin the identity holds an origin this write was changing — and an origin a write is changing is never a candidate for its own eviction. So the one write that creates tracked defaults was the one write that could never evict them, and nothing else on a sign-in-only identity ever runs that pass. The sign-in now writes the origin it signs in at. The sessions of a browser the registry gave up are a consequence of the write rather than something the caller reaches across the identity to do, so the gate derives them from the registry the write carries and sweeps them in the same write. It adds only the origins that hold such a session, and only looks for them when a browser was actually given up — a registry below `MAX_BROWSERS` cannot have dropped one, so it answers without reading the stored anchor and an ordinary sign-in reads the one origin it names. Also from the same review: - The anchor's session counter counts stored records, not live ones, which is what the rest of its own comment says. - `a_browser_the_registry_gave_up_leaves_no_count_behind` signed in twice from one browser at one origin, so the second replaced the first and the browser held one session where the test says two. The second session moves to another origin, and the setup is asserted. Tests: `a_sign_in_evicts_the_stale_defaults_too`, which fails without the first change because eviction never runs on that path — 501 lists where the watermark plus the new one is 451; and `replacing_a_session_takes_its_principal_out_of_the_index`, for the one session-principal index transition nothing observed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 116 ++++++++++++++---- .../src/storage/storable/anchor.rs | 2 +- src/internet_identity/src/storage/tests.rs | 75 ++++++++++- 3 files changed, 166 insertions(+), 27 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index ff8e07bda2..d482d000f0 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -111,7 +111,7 @@ use crate::stats::event_stats::{EventData, EventKey}; use crate::storage::account::{ AccountReference, SessionRecord, DEFAULT_SESSION_IDLE_NS, MIN_SESSION_IDLE_NS, }; -use crate::storage::anchor::{Anchor, BrowserError}; +use crate::storage::anchor::{Anchor, BrowserError, MAX_BROWSERS}; use crate::storage::memory_wrapper::MemoryWrapper; use crate::storage::registration_rates::RegistrationRates; use crate::storage::storable::account::StorableAccount; @@ -1695,10 +1695,34 @@ impl Storage { anchor: Anchor, writes: BTreeMap, ) -> Result, StorageError> { - let validated = self.validate_account_state(anchor.anchor_number(), writes)?; + let given_up = self.browsers_given_up(&anchor)?; + let validated = self.validate_account_state(anchor.anchor_number(), &given_up, writes)?; Ok(self.apply_account_state(anchor, validated)) } + /// Browsers this write gives up, whose sessions have to go with them. + /// + /// Derived from the registry the write carries rather than stated by a caller: a + /// caller that has to say so is a caller that can forget to, and every path that + /// changes the registry passes through here. + /// + /// Only a full registry gives a browser up, so a smaller one answers without reading + /// the stored anchor — which keeps the anchor off the path of every write that cannot + /// have dropped anything, delegation refreshes included. + fn browsers_given_up(&self, anchor: &Anchor) -> Result, StorageError> { + if anchor.browsers().len() < MAX_BROWSERS { + return Ok(BTreeSet::new()); + } + let held: BTreeSet = anchor.browsers().iter().map(|one| one.id).collect(); + Ok(self + .read(anchor.anchor_number())? + .browsers() + .iter() + .map(|one| one.id) + .filter(|id| !held.contains(id)) + .collect()) + } + /// [`Self::write_account_state`] for a test that has an anchor number rather than the /// anchor. Production takes the anchor itself, so that a caller cannot hold a copy /// across the write and put the session count back afterwards. @@ -1712,11 +1736,63 @@ impl Storage { self.write_account_state(anchor, writes) } + /// `writes`, less every session held by a browser this write gives up. + /// + /// The sessions of a browser that is gone are gone with it, wherever they are, and in + /// the same write — a browser retired while its sessions still minted delegations + /// would go on being signed in from a list nothing shows. + /// + /// Only the origins that hold such a session are added. Adding the rest would cost + /// nothing to store and everything to the eviction rule, which spares an origin the + /// write is already changing; and the scan itself only happens when a browser was + /// actually given up, so an ordinary sign-in still reads the one origin it names. + fn without_sessions_of( + &self, + anchor_number: AnchorNumber, + browsers_given_up: &BTreeSet, + mut writes: BTreeMap, + ) -> BTreeMap { + if browsers_given_up.is_empty() { + return writes; + } + + let holds_one = |held: &AccountReferenceListWrite| { + held.as_ref().is_some_and(|(account_references, _)| { + account_references.iter().any(|write| { + write + .account_reference + .sessions + .iter() + .any(|session| browsers_given_up.contains(&session.browser_id)) + }) + }) + }; + for (origin, held) in self.account_state(anchor_number) { + if holds_one(&held) { + writes.entry(origin).or_insert(held); + } + } + + for held in writes.values_mut() { + let Some((account_references, _)) = held else { + continue; + }; + for write in account_references.iter_mut() { + write + .account_reference + .sessions + .retain(|session| !browsers_given_up.contains(&session.browser_id)); + } + } + writes + } + /// Everything that can refuse. Reads what is stored, works out what would be minted /// without minting it, and hands apply something that cannot fail. fn validate_account_state( &self, anchor_number: AnchorNumber, + browsers_given_up: &BTreeSet, writes: BTreeMap, ) -> Result { let mut minting = MintingState { @@ -1724,6 +1800,7 @@ impl Storage { global: self.stable_account_counter_memory.get().clone(), }; + let writes = self.without_sessions_of(anchor_number, browsers_given_up, writes); let written_origins: BTreeSet = writes.keys().cloned().collect(); let mut validated = Vec::with_capacity(writes.len()); for (origin, write) in writes { @@ -2781,7 +2858,7 @@ impl Storage { // // After the refusals above, so a ceremony that cannot happen registers nothing — // the record reaches storage only through the write at the end. - let (browser_id, dropped_browsers) = anchor + let (browser_id, _) = anchor .resolve_browser( current_browser_key, next_browser_key, @@ -2790,27 +2867,18 @@ impl Storage { ) .map_err(StorageError::Browser)?; - // The whole of what the identity holds, not just this origin: a browser the - // registry gave up to make room for this one may hold sessions anywhere, and those - // have to go in the same write as the browser that held them. - let mut state = self.account_state(anchor_number); - if !state.contains_key(&origin) { - let held = self.account_state_for_origin(anchor_number, &origin); - state.insert(origin.clone(), Some(held)); - } - if !dropped_browsers.is_empty() { - for held in state.values_mut() { - let Some((account_references, _)) = held else { - continue; - }; - for write in account_references.iter_mut() { - write - .account_reference - .sessions - .retain(|session| !dropped_browsers.contains(&session.browser_id)); - } - } - } + // This origin, and only this one. A browser the registry gave up to make room for + // this one may hold sessions anywhere, but that is a consequence of the write + // rather than something this function reaches across the identity to do: the gate + // derives it from the registry this write carries and sweeps them in the same + // write. Handing the gate every origin instead would make each of them an origin + // this write is changing, and an origin a write is changing is never a candidate + // for its own eviction — so the one write that creates tracked defaults would be + // the one write that can never evict them. + let mut state = BTreeMap::from([( + origin.clone(), + Some(self.account_state_for_origin(anchor_number, &origin)), + )]); let (account_references, _) = state .get_mut(&origin) diff --git a/src/internet_identity/src/storage/storable/anchor.rs b/src/internet_identity/src/storage/storable/anchor.rs index a0ba78f275..ebdb080336 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -40,7 +40,7 @@ pub struct StorableAnchor { /// Monotonic per-anchor allocator for `browsers`. Ids are never reused. #[n(8)] pub next_browser_id: Option, - /// Live sessions this anchor holds, as a trigger for the session cap rather than a + /// Stored 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)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 9b0978494b..9774373463 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -3842,6 +3842,7 @@ mod default_account_tracking_tests { mod tracked_default_eviction_tests { use super::application_number_for; use super::held_references; + use super::params; use super::record_use; use super::remove_at; use super::write_at; @@ -3875,6 +3876,32 @@ mod tracked_default_eviction_tests { record_use(storage, anchor_number, origin_of(index), None, index + 1).unwrap(); } + /// Eviction on the sign-in path, which every other test here reaches through a + /// one-origin write instead. A sign-in that hands the gate every origin it holds + /// makes each of them a written origin, and a written origin is never a candidate for + /// its own eviction — so nothing is ever evicted by the one write that creates the + /// tracked defaults eviction exists to bound. + #[test] + fn a_sign_in_evicts_the_stale_defaults_too() { + let (mut storage, anchor_number) = storage_with_anchor(); + + // The cap reached by other origins, so the sign-in below is the write that has to + // make room rather than the list being made room for. + for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { + sign_in_at(&mut storage, anchor_number, index); + } + + let mut params = params(anchor_number, 1, 1_000); + params.origin = origin_of(MAX_EVICTABLE_DEFAULT_ACCOUNTS); + storage.create_session(params).unwrap(); + + // Down to the watermark, and then the origin that triggered the pass on top of it. + assert_eq!( + storage.evictable_default_lists(anchor_number).len() as u64, + EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK + 1 + ); + } + #[test] fn evicting_drops_the_least_recently_used_down_to_the_watermark() { let (mut storage, anchor_number) = storage_with_anchor(); @@ -5264,6 +5291,7 @@ mod session_creation_tests { use crate::storage::CreateSessionParams; use crate::storage::StorageError; use crate::{Storage, DAY_NS, MINUTE_NS}; + use candid::Principal; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; use pretty_assertions::assert_eq; @@ -5280,6 +5308,43 @@ mod session_creation_tests { (storage, anchor_number) } + /// A replaced session stops resolving, which nothing else here observes: creation + /// puts a principal in the index and revocation takes it out, and both are asked + /// about elsewhere, but a sign-in that supersedes a session removes the old principal + /// while inserting the new one in the same write. A superseded principal left behind + /// would still resolve to a record no list holds. + #[test] + fn replacing_a_session_takes_its_principal_out_of_the_index() { + let (mut storage, anchor_number) = storage_with_anchor(); + + storage + .create_session(params(anchor_number, 7, 1_000)) + .unwrap(); + let superseded: Vec = storage + .lookup_session_with_principal_memory + .iter() + .map(|(principal, _)| principal) + .collect(); + assert_eq!(superseded.len(), 1); + + // The same browser, the same origin, presenting the successor it announced: this + // replaces the session rather than adding one. + storage + .create_session(params_at(anchor_number, 7, 1, 2_000)) + .unwrap(); + + let held: Vec = storage + .lookup_session_with_principal_memory + .iter() + .map(|(principal, _)| principal) + .collect(); + assert_eq!(held.len(), 1, "one session, so one principal: {held:?}"); + assert!( + !held.contains(&superseded[0]), + "the superseded session's principal still resolves: {held:?}" + ); + } + /// A browser the registry gave up takes its sessions with it, wherever they were, in /// the write that made room for the browser replacing it. /// @@ -6029,13 +6094,19 @@ mod browser_session_count_tests { let (mut storage, anchor_number) = storage_with_anchor(); // Two sessions on the browser that will be given up, so a count that outlived its - // entry would be visible rather than indistinguishable from a fresh one. + // entry would be visible rather than indistinguishable from a fresh one. At two + // origins, because a second sign-in at the same one replaces the session already + // there and would leave this browser holding one. storage .create_session(params(anchor_number, 7, 1_000)) .unwrap(); storage - .create_session(params_at(anchor_number, 7, 1, 1_000)) + .create_session(CreateSessionParams { + origin: "https://elsewhere.example".to_string(), + ..params_at(anchor_number, 7, 1, 1_000) + }) .unwrap(); + assert_eq!(counts(&storage, anchor_number).0.get(&0), Some(&2)); for index in 0..MAX_BROWSERS { storage From bdd4d47c6adbf2373d1eff7f0c40831811ff479a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 12:47:55 +0200 Subject: [PATCH 256/298] fix(be): the write gate reclaims sessions, and counts them in i64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session cap moves in with the counters and the tracked-default eviction it sits beside. `create_session` no longer reaches across the identity to make room for itself: it writes the origin it signs in at, and the gate — which already reads the stored count to enforce the cap — reclaims when a write would take the identity past it. Reclaim is gated on a write that grows the stored count, which is what makes this safe on the hot paths. A delegation refresh and a revocation cannot grow it, so neither reaches the scan; nor does a sign-in that only replaces the session this browser already held at this origin, which is the case Mario raised on this PR: at the cap it used to reclaim fifty records to make room it did not need. Victims are selected from what is stored, so the session a write is creating is never given up by the pass that made room for it. The selection returns ids and one sweep applies them, the same sweep that takes the sessions of a browser the registry gave up — both rules mean "these sessions go, wherever they are". `now` reaches the gate from the endpoint, the way the rest of storage takes its time: read once per request in `main.rs`, passed down. `TEST_NOW` names the clock for the writes whose timing is not what a test is about. The counter is computed in `i64` and recounted whichever end it fails to fit, rather than saturating at `u32`. An over-count already recounted; an under-count clamped to zero and stayed there, and a counter under the truth is the one that lets the lists past the cap. Neither state is reachable today — every session write derives the count from the lists it changed — so this is about what the code says rather than a bug it fixes. `sync_session_index` no longer returns a delta nobody reads. Tests: `the_session_cap_reclaims_to_the_watermark` now reaches the cap and asserts it clears to the watermark, which is what its name always claimed and what the test it duplicated never checked; `an_under_counting_anchor_is_corrected_rather_than_clamped` covers the other direction of the drift. `reclaiming_spans_every_list_and_takes_the_expired_ones_first` signs in at a third origin, because a sign-in at either list under test prunes that list's expired record on the way past and leaves the identity holding what it started with. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/account_management.rs | 162 +++++++-- src/internet_identity/src/main.rs | 18 +- src/internet_identity/src/storage.rs | 221 +++++++----- .../src/storage/account/tests.rs | 56 ++- src/internet_identity/src/storage/tests.rs | 334 +++++++++++++----- 5 files changed, 569 insertions(+), 222 deletions(-) diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index cd56607f4b..47604d844a 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -140,6 +140,7 @@ pub fn set_default_account_for_origin( anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, + now: Timestamp, ) -> Result { let account = try_read_account_info(anchor_number, &origin, account_number).map_err(|_| { SetDefaultAccountError::NoSuchAccount { @@ -149,7 +150,7 @@ pub fn set_default_account_for_origin( })?; storage_borrow_mut(|storage| { - storage.set_default_account(anchor_number, origin, account_number) + storage.set_default_account(anchor_number, origin, account_number, now) }) .map_err(|err| SetDefaultAccountError::InternalCanisterError(err.to_string()))?; @@ -160,11 +161,12 @@ pub fn create_account_for_origin( anchor_number: AnchorNumber, origin: FrontendHostname, name: String, + now: Timestamp, ) -> Result { validate_account_name(&name).map_err(Into::::into)?; let created_account = storage_borrow_mut(|storage| { storage - .create_account(anchor_number, origin, name.clone()) + .create_account(anchor_number, origin, name.clone(), now) .map_err(|err| match err { // The cap is the write path's rule, so it says so rather than the caller // asking first — and naming a tracked default reaches it the same way, @@ -189,6 +191,7 @@ pub fn update_account_for_origin( account_number: Option, origin: FrontendHostname, update: AccountUpdate, + now: Timestamp, ) -> Result { match update.name { Some(new_name) => { @@ -212,7 +215,7 @@ pub fn update_account_for_origin( let mut renamed_account = old_account.clone(); renamed_account.name = Some(new_name.clone()); let updated_account = storage - .write_account(renamed_account) + .write_account(renamed_account, now) .map_err(|err| match err { StorageError::AccountLimitReached { .. } => { UpdateAccountError::AccountLimitReached @@ -307,7 +310,7 @@ pub fn prepare_account_delegation( storage_borrow_mut(|storage| { let mut used_account = account; used_account.last_used = Some(now); - storage.write_account(used_account) + storage.write_account(used_account, now) }) .map_err(|err| AccountDelegationError::InternalCanisterError(err.to_string()))?; @@ -409,7 +412,12 @@ fn should_create_account_for_origin() { let name = "Alice".to_string(); assert_eq!( - create_account_for_origin(anchor.anchor_number(), origin.clone(), name.clone()), + create_account_for_origin( + anchor.anchor_number(), + origin.clone(), + name.clone(), + crate::storage::TEST_NOW + ), Ok(Account::new_full( anchor.anchor_number(), origin, @@ -435,8 +443,12 @@ fn should_fail_to_create_accounts_above_max() { let name = "Alice".to_string(); for i in 0..=MAX_ANCHOR_ACCOUNTS { let origin = format!("https://example-{i}.com"); - let result = - create_account_for_origin(anchor.anchor_number(), origin.clone(), name.clone()); + let result = create_account_for_origin( + anchor.anchor_number(), + origin.clone(), + name.clone(), + crate::storage::TEST_NOW, + ); if i == MAX_ANCHOR_ACCOUNTS { assert_eq!(result, Err(CreateAccountError::AccountLimitReached)) } else { @@ -459,8 +471,12 @@ fn should_fail_to_update_default_accounts_above_max() { let name = "Alice".to_string(); for i in 0..MAX_ANCHOR_ACCOUNTS { let origin = format!("https://example-{i}.com"); - let create_result = - create_account_for_origin(anchor.anchor_number(), origin.clone(), name.clone()); + let create_result = create_account_for_origin( + anchor.anchor_number(), + origin.clone(), + name.clone(), + crate::storage::TEST_NOW, + ); assert!(create_result.is_ok()) } @@ -471,6 +487,7 @@ fn should_fail_to_update_default_accounts_above_max() { AccountUpdate { name: Some("Gabriel".to_string()), }, + crate::storage::TEST_NOW, ); assert_eq!(result, Err(UpdateAccountError::AccountLimitReached)) } @@ -490,8 +507,18 @@ fn should_get_accounts_for_origin() { let name_two = "Bob".to_string(); let anchor_number = anchor.anchor_number(); - let _ = create_account_for_origin(anchor_number, origin.clone(), name.clone()); - let _ = create_account_for_origin(anchor_number, origin.clone(), name_two.clone()); + let _ = create_account_for_origin( + anchor_number, + origin.clone(), + name.clone(), + crate::storage::TEST_NOW, + ); + let _ = create_account_for_origin( + anchor_number, + origin.clone(), + name_two.clone(), + crate::storage::TEST_NOW, + ); assert_eq!( get_accounts_for_origin(anchor_number, &origin), @@ -538,8 +565,18 @@ fn should_only_get_own_accounts_for_origin() { let anchor_number = anchor.anchor_number(); let anchor_number_two = anchor_two.anchor_number(); - let _ = create_account_for_origin(anchor_number, origin.clone(), name.clone()); - let _ = create_account_for_origin(anchor_number_two, origin.clone(), name_two.clone()); + let _ = create_account_for_origin( + anchor_number, + origin.clone(), + name.clone(), + crate::storage::TEST_NOW, + ); + let _ = create_account_for_origin( + anchor_number_two, + origin.clone(), + name_two.clone(), + crate::storage::TEST_NOW, + ); assert_eq!( get_accounts_for_origin(anchor_number, &origin), @@ -587,8 +624,18 @@ fn should_update_account_for_origin() { let name_two = "Bob".to_string(); let anchor_number = anchor.anchor_number(); - let _ = create_account_for_origin(anchor_number, origin.clone(), name.clone()); - let _ = create_account_for_origin(anchor_number, origin.clone(), name_two.clone()); + let _ = create_account_for_origin( + anchor_number, + origin.clone(), + name.clone(), + crate::storage::TEST_NOW, + ); + let _ = create_account_for_origin( + anchor_number, + origin.clone(), + name_two.clone(), + crate::storage::TEST_NOW, + ); assert_eq!( get_accounts_for_origin(anchor_number, &origin), @@ -620,7 +667,8 @@ fn should_update_account_for_origin() { origin.clone(), AccountUpdate { name: Some("Becky".to_string()) - } + }, + crate::storage::TEST_NOW ), Ok(Account::new_full( anchor_number, @@ -671,8 +719,18 @@ fn should_update_default_account_for_origin() { let name_two = "Bob".to_string(); let anchor_number = anchor.anchor_number(); - let _ = create_account_for_origin(anchor_number, origin.clone(), name.clone()); - let _ = create_account_for_origin(anchor_number, origin.clone(), name_two.clone()); + let _ = create_account_for_origin( + anchor_number, + origin.clone(), + name.clone(), + crate::storage::TEST_NOW, + ); + let _ = create_account_for_origin( + anchor_number, + origin.clone(), + name_two.clone(), + crate::storage::TEST_NOW, + ); assert_eq!( get_accounts_for_origin(anchor_number, &origin), @@ -704,7 +762,8 @@ fn should_update_default_account_for_origin() { origin.clone(), AccountUpdate { name: Some("Becky".to_string()) - } + }, + crate::storage::TEST_NOW ), Ok(Account::new_full( anchor_number, @@ -759,7 +818,13 @@ fn naming_a_tracked_default_at_the_account_limit_is_refused() { anchor }); let origin = "https://example.com".to_string(); - create_account_for_origin(anchor.anchor_number(), origin.clone(), "first".to_string()).unwrap(); + create_account_for_origin( + anchor.anchor_number(), + origin.clone(), + "first".to_string(), + crate::storage::TEST_NOW, + ) + .unwrap(); // At the limit, and naming another account is the one thing that cannot be done — // said by the write itself rather than by a caller asking first. @@ -772,7 +837,12 @@ fn naming_a_tracked_default_at_the_account_limit_is_refused() { }); assert_eq!( - create_account_for_origin(anchor.anchor_number(), origin, "second".to_string()), + create_account_for_origin( + anchor.anchor_number(), + origin, + "second".to_string(), + crate::storage::TEST_NOW + ), Err(CreateAccountError::AccountLimitReached) ); } @@ -809,6 +879,7 @@ fn a_drifted_account_counter_is_not_repaired_and_costs_the_identity_its_limit() anchor.anchor_number(), "https://example.com".to_string(), name, + crate::storage::TEST_NOW, ), Err(CreateAccountError::AccountLimitReached) ); @@ -827,8 +898,20 @@ fn should_get_default_account_for_origin() { let origin = "https://example.com".to_string(); let anchor_number = anchor.anchor_number(); - create_account_for_origin(anchor_number, origin.clone(), "Alice".to_string()).unwrap(); - create_account_for_origin(anchor_number, origin.clone(), "Bob".to_string()).unwrap(); + create_account_for_origin( + anchor_number, + origin.clone(), + "Alice".to_string(), + crate::storage::TEST_NOW, + ) + .unwrap(); + create_account_for_origin( + anchor_number, + origin.clone(), + "Bob".to_string(), + crate::storage::TEST_NOW, + ) + .unwrap(); // Smoke test assert_eq!( @@ -958,6 +1041,7 @@ fn should_get_default_account_for_origin() { anchor_number, origin.clone(), default_account_number, + crate::storage::TEST_NOW, ); assert_eq!( @@ -1018,6 +1102,7 @@ fn should_get_updated_default_account_after_modification() { AccountUpdate { name: Some("Default Account".to_string()), }, + crate::storage::TEST_NOW, ) .unwrap(); @@ -1081,7 +1166,13 @@ fn should_fall_back_to_the_tracked_default_when_the_reservation_is_stale() { let anchor_number = anchor.anchor_number(); let origin = "https://example.com".to_string(); storage_borrow_mut(|storage| storage.write(anchor)).unwrap(); - create_account_for_origin(anchor_number, origin.clone(), "Alice".to_string()).unwrap(); + create_account_for_origin( + anchor_number, + origin.clone(), + "Alice".to_string(), + crate::storage::TEST_NOW, + ) + .unwrap(); // A number this identity does not hold. The write does not store it: the default is // related to the account reference list and moved to a reference that is there, which @@ -1089,7 +1180,12 @@ fn should_fall_back_to_the_tracked_default_when_the_reservation_is_stale() { // the read below answers from a default that exists, because no other kind was left // behind. storage_borrow_mut(|storage| { - storage.set_default_account(anchor_number, origin.clone(), Some(9_999)) + storage.set_default_account( + anchor_number, + origin.clone(), + Some(9_999), + crate::storage::TEST_NOW, + ) }) .unwrap(); @@ -1121,8 +1217,20 @@ fn should_get_default_account_for_different_origins() { let anchor_number = anchor.anchor_number(); // Create accounts for both origins - create_account_for_origin(anchor_number, origin1.clone(), "Alice".to_string()).unwrap(); - create_account_for_origin(anchor_number, origin2.clone(), "Bob".to_string()).unwrap(); + create_account_for_origin( + anchor_number, + origin1.clone(), + "Alice".to_string(), + crate::storage::TEST_NOW, + ) + .unwrap(); + create_account_for_origin( + anchor_number, + origin2.clone(), + "Bob".to_string(), + crate::storage::TEST_NOW, + ) + .unwrap(); // Run code under test let result1 = get_default_account_for_origin(anchor_number, origin1.clone()); diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index d9bdb8d12f..19e096a66c 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -419,8 +419,13 @@ fn create_account( match check_authorization(anchor_number) { Ok(_) => { // check if this anchor and acc are actually linked - account_management::create_account_for_origin(anchor_number, origin, name) - .map(|acc| acc.to_info()) + account_management::create_account_for_origin( + anchor_number, + origin, + name, + ic_cdk::api::time(), + ) + .map(|acc| acc.to_info()) } Err(err) => Err(CreateAccountError::Unauthorized(err.principal)), } @@ -439,6 +444,7 @@ fn update_account( account_number, origin, update, + ic_cdk::api::time(), ) .map(|acc| acc.to_info()), Err(err) => Err(UpdateAccountError::Unauthorized(err.principal)), @@ -481,8 +487,12 @@ fn set_default_account( ) -> Result { check_authz_and_record_activity(anchor_number).map_err(SetDefaultAccountError::from)?; - let result = - account_management::set_default_account_for_origin(anchor_number, origin, account_number)?; + let result = account_management::set_default_account_for_origin( + anchor_number, + origin, + account_number, + ic_cdk::api::time(), + )?; anchor_management::post_operation_bookkeeping(anchor_number, Operation::SetDefaultAccount); Ok(result) } diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 302af2dacb..9b7241860c 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -347,6 +347,11 @@ const EVICTABLE_DEFAULT_ACCOUNTS_WATERMARK: u64 = MAX_EVICTABLE_DEFAULT_ACCOUNTS /// 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; + +/// The clock a test writes at when the write's timing is not what it is about. +/// Production reads one at the endpoint and passes it down. +#[cfg(test)] +pub(crate) const TEST_NOW: Timestamp = 1_000; /// Reclaiming goes down to here rather than to the cap, so the pass that walks an identity's /// lists runs once and then not again for the next fifty sign-ins. pub const SESSIONS_WATERMARK_PER_ANCHOR: u32 = 450; @@ -1644,56 +1649,45 @@ impl Storage { /// Triggered on what is *stored*, expired records included, because that is what /// occupies the cap: a session can expire with no write anywhere, so nothing has /// removed it and it still holds its slot. - fn reclaim_sessions( - state: &mut BTreeMap, + fn sessions_to_reclaim( + &self, + anchor_number: AnchorNumber, now: Timestamp, - ) { - fn sessions_of( - state: &BTreeMap, - ) -> impl Iterator { - state - .values() - .flatten() - .flat_map(|(account_references, _)| account_references) - .flat_map(|write| &write.account_reference.sessions) - } - - fn retain( - state: &mut BTreeMap, - mut keep: impl FnMut(&SessionRecord) -> bool, - ) { - for held in state.values_mut() { - let Some((account_references, _)) = held else { - continue; - }; - for write in account_references.iter_mut() { - write.account_reference.sessions.retain(&mut keep); - } - } - } - - if (sessions_of(state).count() as u32) < MAX_SESSIONS_PER_ANCHOR { - return; - } + ) -> BTreeSet { + let stored: Vec = self + .stable_account_reference_list_memory + .range( + (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), + ) + .flat_map(|(_, list)| Vec::::from(list)) + .flat_map(|reference| reference.sessions) + .collect(); - retain(state, |session| !session.is_over(now)); + // Selected from what is stored, so the session the write is creating is never a + // candidate for the pass that made room for it. + let (over, live): (Vec, Vec) = + stored.into_iter().partition(|session| session.is_over(now)); - let mut live: Vec<((bool, Timestamp, SessionId), SessionId)> = sessions_of(state) - .map(|session| (session.reclaim_order(now), session.session_id)) - .collect(); + let mut giving_up: BTreeSet = + over.into_iter().map(|session| session.session_id).collect(); if (live.len() as u32) <= SESSIONS_WATERMARK_PER_ANCHOR { - return; + return giving_up; } // Ascending, so the least demonstrated use comes first and is given up first. - live.sort(); - let over_watermark = live.len() - SESSIONS_WATERMARK_PER_ANCHOR as usize; - let giving_up: BTreeSet = live - .into_iter() - .take(over_watermark) - .map(|(_, session_id)| session_id) + let mut ordered: Vec<((bool, Timestamp, SessionId), SessionId)> = live + .iter() + .map(|session| (session.reclaim_order(now), session.session_id)) .collect(); - retain(state, |session| !giving_up.contains(&session.session_id)); + ordered.sort(); + let over_watermark = ordered.len() - SESSIONS_WATERMARK_PER_ANCHOR as usize; + giving_up.extend( + ordered + .into_iter() + .take(over_watermark) + .map(|(_, session_id)| session_id), + ); + giving_up } /// How many sessions a set of account references holds. @@ -1795,6 +1789,7 @@ impl Storage { fn write_account_state( &mut self, anchor: Anchor, + now: Timestamp, writes: BTreeMap, ) -> Result, StorageError> { let given_up = self.browsers_given_up(&anchor)?; @@ -1802,6 +1797,7 @@ impl Storage { anchor.anchor_number(), anchor.session_count, &given_up, + now, writes, )?; Ok(self.apply_account_state(anchor, validated)) @@ -1840,38 +1836,31 @@ impl Storage { writes: BTreeMap, ) -> Result, StorageError> { let anchor = self.read(anchor_number)?; - self.write_account_state(anchor, writes) + self.write_account_state(anchor, TEST_NOW, writes) } - /// `writes`, less every session held by a browser this write gives up. + /// `writes`, less every session `gone` names, wherever it is held. /// - /// The sessions of a browser that is gone are gone with it, wherever they are, and in - /// the same write — a browser retired while its sessions still minted delegations - /// would go on being signed in from a list nothing shows. + /// Two rules need this and both mean the same thing: a browser the registry gave up + /// takes its sessions with it, and a session the cap reclaims goes. Either way the + /// sessions leave in the write that decided they should, because a session outliving + /// the write that ended it goes on minting delegations from a list nothing shows. /// - /// Only the origins that hold such a session are added. Adding the rest would cost + /// Only the origins that actually hold one are added. Adding the rest would cost /// nothing to store and everything to the eviction rule, which spares an origin the - /// write is already changing; and the scan itself only happens when a browser was - /// actually given up, so an ordinary sign-in still reads the one origin it names. - fn without_sessions_of( + /// write is already changing. Callers ask only when something is actually gone, so an + /// ordinary sign-in never reaches the scan and reads the one origin it names. + fn without_sessions( &self, anchor_number: AnchorNumber, - browsers_given_up: &BTreeSet, + gone: impl Fn(&SessionRecord) -> bool, mut writes: BTreeMap, ) -> BTreeMap { - if browsers_given_up.is_empty() { - return writes; - } - let holds_one = |held: &AccountReferenceListWrite| { held.as_ref().is_some_and(|(account_references, _)| { - account_references.iter().any(|write| { - write - .account_reference - .sessions - .iter() - .any(|session| browsers_given_up.contains(&session.browser_id)) - }) + account_references + .iter() + .any(|write| write.account_reference.sessions.iter().any(&gone)) }) }; for (origin, held) in self.account_state(anchor_number) { @@ -1888,12 +1877,37 @@ impl Storage { write .account_reference .sessions - .retain(|session| !browsers_given_up.contains(&session.browser_id)); + .retain(|session| !gone(session)); } } writes } + /// How many sessions `writes` adds, over what the origins it names hold now. + /// + /// Negative where it removes more than it adds. Only the origins in the write are + /// read: nothing else can change, so nothing else has to be looked at. + fn sessions_added( + &self, + anchor_number: AnchorNumber, + writes: &BTreeMap, + ) -> i64 { + writes + .iter() + .map(|(origin, held)| { + let current = held.as_ref().map_or(0, |(account_references, _)| { + account_references + .iter() + .map(|write| write.account_reference.sessions.len() as u32) + .sum() + }); + let stored = + Self::sessions_in(&self.account_references_for_origin(anchor_number, origin)); + current as i64 - stored as i64 + }) + .sum() + } + /// Everything that can refuse. Reads what is stored, works out what would be minted /// without minting it, and hands apply something that cannot fail. fn validate_account_state( @@ -1901,6 +1915,7 @@ impl Storage { anchor_number: AnchorNumber, stored_sessions: u32, browsers_given_up: &BTreeSet, + now: Timestamp, writes: BTreeMap, ) -> Result { let mut minting = MintingState { @@ -1908,7 +1923,40 @@ impl Storage { global: self.stable_account_counter_memory.get().clone(), }; - let writes = self.without_sessions_of(anchor_number, browsers_given_up, writes); + // Both sweeps run before anything is validated, because they change what the + // lists hold and validation is what turns that into counters and refusals. + let writes = if browsers_given_up.is_empty() { + writes + } else { + self.without_sessions( + anchor_number, + |session| browsers_given_up.contains(&session.browser_id), + writes, + ) + }; + + // Room for the sessions this write adds, made before it is refused for not having + // any. Only a write that adds one can need it, which is why a revocation or a + // `last_used` stamp never reaches the scan — and why a sign-in that replaces this + // browser's own session does not either. + let writes = match self.sessions_added(anchor_number, &writes) { + added + if added > 0 && stored_sessions as i64 + added > MAX_SESSIONS_PER_ANCHOR as i64 => + { + let giving_up = self.sessions_to_reclaim(anchor_number, now); + if giving_up.is_empty() { + writes + } else { + self.without_sessions( + anchor_number, + |session| giving_up.contains(&session.session_id), + writes, + ) + } + } + _ => writes, + }; + let written_origins: BTreeSet = writes.keys().cloned().collect(); let mut validated = Vec::with_capacity(writes.len()); for (origin, write) in writes { @@ -1974,12 +2022,17 @@ impl Storage { // stored number back to the truth. let session_delta: i64 = validated.iter().map(|one| one.session_delta).sum(); let session_count = (session_delta != 0).then(|| { - let moved = stored_sessions.saturating_add_signed(session_delta as i32); - if moved > MAX_SESSIONS_PER_ANCHOR { - self.stored_session_count(anchor_number) - .saturating_add_signed(session_delta as i32) - } else { - moved + // In `i64`, so neither end of `u32` is reached by a clamp that would store a + // number nobody counted: a delta that takes the total below zero says the + // stored counter was already under what the lists hold, and one that takes it + // past the cap says it was over. Either way the answer is the same, and it is + // the count itself — which is also what brings the counter back to the truth. + let moved = stored_sessions as i64 + session_delta; + match u32::try_from(moved) { + Ok(moved) if moved <= MAX_SESSIONS_PER_ANCHOR => moved, + _ => self + .stored_session_count(anchor_number) + .saturating_add_signed(session_delta as i32), } }); // Only a write that grows the count can be refused by a cap on it. The guard is on @@ -3017,12 +3070,6 @@ impl Storage { Some(self.account_state_for_origin(anchor_number, &origin)), )]); - // Before the session is added, because the room has to exist for it: dead sessions - // everywhere, and where that is not enough the least used give way down to the - // watermark. Nothing is stored yet, so a state still over the cap after this - // refuses in the write below rather than half-way through. - Self::reclaim_sessions(&mut state, now_ns); - let (account_references, _) = state .get_mut(&origin) .and_then(Option::as_mut) @@ -3084,7 +3131,7 @@ impl Storage { // One write for all of it: the session created here, the dead ones pruned above, // the sessions of every browser the registry gave up, the account reference list // this origin gets if it did not have one, and the identity's session count. - self.write_account_state(anchor, state)?; + self.write_account_state(anchor, now_ns, state)?; let key = SessionRecordKey { anchor_number, @@ -3118,6 +3165,7 @@ impl Storage { &mut self, anchor_number: AnchorNumber, browser_id: BrowserId, + now: Timestamp, ) -> Result { // Read what the identity holds, take the browser's sessions out of it, write it // back. Nothing here ranges over storage itself and no application number reaches @@ -3142,7 +3190,7 @@ impl Storage { } } - self.write_account_state(anchor, state)?; + self.write_account_state(anchor, now, state)?; Ok(revoked) } @@ -3232,7 +3280,7 @@ impl Storage { salt: &[u8; 32], previous: &[AccountReference], current: &[AccountReference], - ) -> i64 { + ) { let before = self.session_entries(anchor_number, application_number, origin, salt, previous); let after = self.session_entries(anchor_number, application_number, origin, salt, current); @@ -3258,8 +3306,6 @@ impl Storage { self.lookup_session_with_principal_memory .insert(*principal, handle.clone()); } - - after.len() as i64 - before.len() as i64 } /// Retires an application no anchor references any more. The number is never @@ -3416,6 +3462,7 @@ impl Storage { anchor_number: AnchorNumber, origin: FrontendHostname, name: String, + now: Timestamp, ) -> Result { check_frontend_length(&origin); @@ -3444,6 +3491,7 @@ impl Storage { let written = self.write_account_state( anchor, + now, BTreeMap::from([(origin.clone(), Some((account_references, config)))]), )?; @@ -3470,7 +3518,11 @@ impl Storage { /// create. `update_account_for_origin` takes its account number straight from the /// client, so a write that adopted an unreferenced number would hand a caller a /// reference to another identity's account, and with it that account's principal. - pub fn write_account(&mut self, account: Account) -> Result { + pub fn write_account( + &mut self, + account: Account, + now: Timestamp, + ) -> Result { check_frontend_length(&account.origin); let Account { @@ -3545,6 +3597,7 @@ impl Storage { let written = self.write_account_state( anchor, + now, BTreeMap::from([(origin.clone(), Some((account_references, config)))]), )?; let write = &written[&origin] @@ -3584,6 +3637,7 @@ impl Storage { anchor_number: AnchorNumber, origin: FrontendHostname, account_number: Option, + now: Timestamp, ) -> Result<(), StorageError> { check_frontend_length(&origin); @@ -3601,6 +3655,7 @@ impl Storage { config.default_account_number = account_number; self.write_account_state( anchor, + now, BTreeMap::from([(origin, Some((account_references, Some(config))))]), )?; Ok(()) diff --git a/src/internet_identity/src/storage/account/tests.rs b/src/internet_identity/src/storage/account/tests.rs index 7326295dc2..56a87bbe27 100644 --- a/src/internet_identity/src/storage/account/tests.rs +++ b/src/internet_identity/src/storage/account/tests.rs @@ -2,6 +2,7 @@ use crate::storage::account::Account; use crate::storage::account::AccountKey; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::storable::application::StorableApplication; +use crate::storage::TEST_NOW; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::{AnchorNumber, FrontendHostname}; @@ -54,7 +55,12 @@ fn should_create_a_named_account() { // 3. Create additional account storage - .create_account(anchor_number, origin.clone(), account_name.clone()) + .create_account( + anchor_number, + origin.clone(), + account_name.clone(), + TEST_NOW, + ) .unwrap(); // 5. Check that read_account returns additional account, creates application and updates counters. @@ -126,7 +132,12 @@ fn should_list_accounts() { ); let expected_default_account = Account::synthetic(anchor_number, origin.clone()); storage - .create_account(anchor_number, origin.clone(), account_name.clone()) + .create_account( + anchor_number, + origin.clone(), + account_name.clone(), + TEST_NOW, + ) .unwrap(); // 5. List accounts returns default account @@ -187,7 +198,12 @@ fn should_list_all_identity_accounts() { // 4. Create additional account storage - .create_account(anchor_number, origin.clone(), account_name.clone()) + .create_account( + anchor_number, + origin.clone(), + account_name.clone(), + TEST_NOW, + ) .unwrap(); // 5. List accounts returns default account @@ -197,7 +213,12 @@ fn should_list_all_identity_accounts() { // 6. Create additional account storage - .create_account(anchor_number, origin_2.clone(), account_name.clone()) + .create_account( + anchor_number, + origin_2.clone(), + account_name.clone(), + TEST_NOW, + ) .unwrap(); // 7. List accounts returns default account @@ -249,7 +270,7 @@ fn should_update_default_account() { }) .unwrap(); account_to_update.name = Some(account_name.clone()); - let new_account = storage.write_account(account_to_update).unwrap(); + let new_account = storage.write_account(account_to_update, TEST_NOW).unwrap(); // 4. Check that the default account has been created with the updated values. assert_eq!( @@ -315,7 +336,12 @@ fn should_update_additional_account() { // 3. Create additional account storage - .create_account(anchor_number, origin.clone(), account_name.clone()) + .create_account( + anchor_number, + origin.clone(), + account_name.clone(), + TEST_NOW, + ) .unwrap(); assert!(storage.read_account(&read_params).is_some()); @@ -328,7 +354,7 @@ fn should_update_additional_account() { }) .unwrap(); account_to_update.name = Some(new_account_name.clone()); - let updated_account = storage.write_account(account_to_update).unwrap(); + let updated_account = storage.write_account(account_to_update, TEST_NOW).unwrap(); // 5. Check that the additional account has been created with the updated values. assert_eq!( @@ -399,7 +425,12 @@ fn should_count_accounts_different_anchors() { // Create an additional account for anchor 1 storage - .create_account(anchor_number_1, origin_1.clone(), account_name_1.clone()) + .create_account( + anchor_number_1, + origin_1.clone(), + account_name_1.clone(), + TEST_NOW, + ) .unwrap(); // List accounts for anchor 1 - should return 2 @@ -454,7 +485,12 @@ fn should_count_accounts_different_anchors() { // Create an additional account for anchor 2 storage - .create_account(anchor_number_2, origin_2.clone(), account_name_2.clone()) + .create_account( + anchor_number_2, + origin_2.clone(), + account_name_2.clone(), + TEST_NOW, + ) .unwrap(); // List accounts for anchor 2 - should return 2 @@ -563,7 +599,7 @@ fn should_not_read_account_from_wrong_anchor() { // 2. Create account for first anchor storage - .create_account(anchor_number_1, origin.clone(), account_name) + .create_account(anchor_number_1, origin.clone(), account_name, TEST_NOW) .unwrap(); // 3. Try to read the account with second anchor diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index ad39f2cafa..25cf94ecdb 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -9,7 +9,7 @@ use crate::storage::anchor::{Anchor, Device}; use crate::storage::storable::account::StorableAccount; use crate::storage::storable::anchor_application_config::AnchorApplicationConfig; use crate::storage::{AccountReferenceListWrite, AccountReferenceWrite}; -use crate::storage::{CreateSessionParams, Header, StorageError, MAX_ENTRIES}; +use crate::storage::{CreateSessionParams, Header, StorageError, MAX_ENTRIES, TEST_NOW}; use crate::Storage; use candid::Principal; use ic_stable_structures::{Memory, VectorMemory}; @@ -42,7 +42,7 @@ fn record_use( return Ok(None); }; account.last_used = Some(now); - storage.write_account(account).map(Some) + storage.write_account(account, TEST_NOW).map(Some) } const HEADER_SIZE: usize = 58; @@ -604,7 +604,12 @@ fn should_record_that_a_named_account_was_used() { storage.write(anchor).unwrap(); let account = storage - .create_account(anchor_number, origin.clone(), "Test Account".to_string()) + .create_account( + anchor_number, + origin.clone(), + "Test Account".to_string(), + TEST_NOW, + ) .unwrap(); let key = AccountKey { anchor_number, @@ -617,7 +622,7 @@ fn should_record_that_a_named_account_was_used() { for timestamp in [123456789u64, 987654321u64] { let mut account = storage.read_account(&key).unwrap(); account.last_used = Some(timestamp); - storage.write_account(account).unwrap(); + storage.write_account(account, TEST_NOW).unwrap(); assert_eq!( storage.read_account(&key).unwrap().last_used, @@ -646,7 +651,7 @@ fn should_track_the_default_account_on_first_use() { let mut account = storage.read_account(&key).unwrap(); account.last_used = Some(timestamp); - storage.write_account(account).unwrap(); + storage.write_account(account, TEST_NOW).unwrap(); // Nothing was stored at this origin, so recording the use is what gives the // default a list: the timestamp has nowhere else to live. @@ -676,7 +681,12 @@ fn should_record_that_a_tracked_default_was_used() { // A named account gives the origin a list, which the default is tracked in. storage - .create_account(anchor_number, origin.clone(), "Test Account".to_string()) + .create_account( + anchor_number, + origin.clone(), + "Test Account".to_string(), + TEST_NOW, + ) .unwrap(); let key = AccountKey { @@ -688,7 +698,7 @@ fn should_record_that_a_tracked_default_was_used() { let mut account = storage.read_account(&key).unwrap(); account.last_used = Some(timestamp); - storage.write_account(account).unwrap(); + storage.write_account(account, TEST_NOW).unwrap(); assert_eq!( storage.read_account(&key).unwrap().last_used, @@ -709,7 +719,12 @@ fn should_refuse_to_write_an_account_no_reference_names() { storage.write(anchor).unwrap(); storage - .create_account(anchor_number, origin.clone(), "Test Account".to_string()) + .create_account( + anchor_number, + origin.clone(), + "Test Account".to_string(), + TEST_NOW, + ) .unwrap(); let unheld_account_number = 99_999u64; @@ -730,7 +745,8 @@ fn should_refuse_to_write_an_account_no_reference_names() { None, Some(unheld_account_number), Some(123_456u64), - )), + ), + TEST_NOW), Err(StorageError::AccountNotFound { account_number }) if account_number == unheld_account_number )); @@ -748,13 +764,16 @@ fn should_refuse_to_write_an_account_at_an_unknown_origin() { let unknown_origin = "https://nonexistent.com".to_string(); assert!(matches!( - storage.write_account(Account::new_with_last_used( - anchor_number, - unknown_origin, - None, - Some(99_999u64), - Some(123_456u64), - )), + storage.write_account( + Account::new_with_last_used( + anchor_number, + unknown_origin, + None, + Some(99_999u64), + Some(123_456u64), + ), + TEST_NOW + ), Err(StorageError::AccountNotFound { .. }) )); } @@ -2350,6 +2369,7 @@ fn test_anchor_storage_migration_round_trip() { mod reference_list_write_path_tests { use super::application_number_for; + use super::TEST_NOW; use super::{write_at, write_at_with_record}; use crate::storage::account::Account; use crate::storage::account::AccountReference; @@ -2383,7 +2403,12 @@ mod reference_list_write_path_tests { let never_allocated = anchor_number + 1; let origin = "https://example.com".to_string(); - let result = storage.create_account(never_allocated, origin.clone(), "named".to_string()); + let result = storage.create_account( + never_allocated, + origin.clone(), + "named".to_string(), + TEST_NOW, + ); assert!(matches!(result, Err(StorageError::BadAnchorNumber(_)))); // Refused before anything was written, the application included. @@ -2415,7 +2440,7 @@ mod reference_list_write_path_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let before = storage .stable_anchor_account_counter_memory @@ -2467,7 +2492,7 @@ mod reference_list_write_path_tests { let origin = "https://example.com".to_string(); storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let application_number = storage @@ -2581,7 +2606,7 @@ mod reference_list_write_path_tests { // Also a mint, but the tracked default is still there afterwards, so this is a // new account rather than the default being named. storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let application_number = storage @@ -2701,7 +2726,7 @@ mod reference_list_write_path_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = "https://example.com".to_string(); storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); // Force the divergence: the stored list holds references the counter no longer // knows about, so dropping one under-runs it. @@ -2752,6 +2777,7 @@ mod reference_list_write_path_tests { anchor_number, "https://named.com".to_string(), "named".to_string(), + TEST_NOW, ) .unwrap(); @@ -2787,6 +2813,7 @@ mod reference_list_write_path_tests { anchor_number, "https://example.com".to_string(), "named".to_string(), + TEST_NOW, ); assert!(matches!(result, Err(StorageError::AccountsCounterOverflow))); @@ -2809,12 +2836,15 @@ mod reference_list_write_path_tests { let config_before = storage.lookup_anchor_application_config(anchor_number, application_number); - let result = storage.write_account(Account::new( - anchor_number, - origin.clone(), - Some("named".to_string()), - None, - )); + let result = storage.write_account( + Account::new( + anchor_number, + origin.clone(), + Some("named".to_string()), + None, + ), + TEST_NOW, + ); assert!(matches!(result, Err(StorageError::MissingAccount { .. }))); // No account number burned, no account stored, no config rewritten. @@ -3091,10 +3121,20 @@ mod reference_list_write_path_tests { for origin in ["https://a.com", "https://b.com", "https://c.com"] { let origin = origin.to_string(); storage - .create_account(anchor_number, origin.clone(), "account".to_string()) + .create_account( + anchor_number, + origin.clone(), + "account".to_string(), + TEST_NOW, + ) .unwrap(); storage - .create_account(anchor_number, origin, "another account".to_string()) + .create_account( + anchor_number, + origin, + "another account".to_string(), + TEST_NOW, + ) .unwrap(); } @@ -3124,6 +3164,7 @@ mod reference_list_write_path_tests { mod account_reference_state_tests { use super::application_number_for; use super::write_at; + use super::TEST_NOW; use crate::storage::account::{Account, AccountKey, AccountReference}; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::storable::application::StorableApplication; @@ -3224,7 +3265,7 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let account_number = account.account_number.unwrap(); @@ -3254,7 +3295,7 @@ mod account_reference_state_tests { plant_tombstone(&mut storage, anchor_number); let account = storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let application_number = storage @@ -3279,12 +3320,15 @@ mod account_reference_state_tests { plant_tombstone(&mut storage, anchor_number); let counter_before = storage.get_total_accounts_counter().clone(); - let result = storage.write_account(Account::new( - anchor_number, - origin.clone(), - Some("named default".to_string()), - None, - )); + let result = storage.write_account( + Account::new( + anchor_number, + origin.clone(), + Some("named default".to_string()), + None, + ), + TEST_NOW, + ); assert!(matches!(result, Err(StorageError::MissingAccount { .. }))); // Refused before the allocation, so no account number was spent on a record @@ -3313,13 +3357,13 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); // A number this identity does not hold at this origin, which is what a caller // that had gone stale would ask for. storage - .set_default_account(anchor_number, origin.clone(), Some(9_999)) + .set_default_account(anchor_number, origin.clone(), Some(9_999), TEST_NOW) .unwrap(); let application_number = storage @@ -3342,11 +3386,16 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let named = storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let account_number = named.account_number.unwrap(); storage - .set_default_account(anchor_number, origin.clone(), Some(account_number)) + .set_default_account( + anchor_number, + origin.clone(), + Some(account_number), + TEST_NOW, + ) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -3365,6 +3414,7 @@ mod account_reference_state_tests { storage .write_account_state( anchor, + TEST_NOW, BTreeMap::from([( origin.clone(), Some(( @@ -3392,7 +3442,7 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let named = storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let stamped_at = 123456u64; let default_key = AccountKey { @@ -3402,11 +3452,11 @@ mod account_reference_state_tests { }; let mut used_default = storage.read_account(&default_key).unwrap(); used_default.last_used = Some(stamped_at); - storage.write_account(used_default).unwrap(); + storage.write_account(used_default, TEST_NOW).unwrap(); let mut default_to_name = storage.read_account(&default_key).unwrap(); default_to_name.name = Some("named default".to_string()); - let default = storage.write_account(default_to_name).unwrap(); + let default = storage.write_account(default_to_name, TEST_NOW).unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -3430,7 +3480,7 @@ mod account_reference_state_tests { let (mut storage, anchor_number) = storage_with_anchor(); let origin = ORIGIN.to_string(); let account = storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let account_number = account.account_number.unwrap(); let application_number = storage @@ -3456,7 +3506,7 @@ mod account_reference_state_tests { }) .unwrap(); account_to_rename.name = Some("renamed".to_string()); - let renamed = storage.write_account(account_to_rename).unwrap(); + let renamed = storage.write_account(account_to_rename, TEST_NOW).unwrap(); assert_eq!(renamed.name, Some("renamed".to_string())); assert_eq!( @@ -3476,31 +3526,37 @@ mod account_reference_state_tests { }; let origin = ORIGIN.to_string(); let account = storage - .create_account(owner, origin.clone(), "named".to_string()) + .create_account(owner, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let account_number = account.account_number.unwrap(); // The other identity has a list of its own at this origin, so what refuses the // attempts below is the list not naming this account rather than there being no // list to look in. storage - .create_account(other, origin.clone(), "mine".to_string()) + .create_account(other, origin.clone(), "mine".to_string(), TEST_NOW) .unwrap(); - let rename = storage.write_account(Account::new( - other, - origin.clone(), - Some("stolen".to_string()), - Some(account_number), - )); + let rename = storage.write_account( + Account::new( + other, + origin.clone(), + Some("stolen".to_string()), + Some(account_number), + ), + TEST_NOW, + ); assert!(matches!(rename, Err(StorageError::AccountNotFound { .. }))); - let stamp = storage.write_account(Account::new_with_last_used( - other, - origin.clone(), - None, - Some(account_number), - Some(123456), - )); + let stamp = storage.write_account( + Account::new_with_last_used( + other, + origin.clone(), + None, + Some(account_number), + Some(123456), + ), + TEST_NOW, + ); assert!(matches!(stamp, Err(StorageError::AccountNotFound { .. }))); // The owner's record and reference are untouched by either attempt: recording @@ -3684,6 +3740,7 @@ mod default_account_tracking_tests { use super::held_references; use super::record_use; use super::write_at; + use super::TEST_NOW; use crate::storage::account::{AccountKey, AccountReference}; use crate::Storage; use ic_stable_structures::VectorMemory; @@ -3793,7 +3850,7 @@ mod default_account_tracking_tests { let origin = "https://example.com".to_string(); storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let default_account = storage @@ -3813,7 +3870,7 @@ mod default_account_tracking_tests { let application_number = application_number_for(&mut storage, &origin); storage - .set_default_account(anchor_number, origin.clone(), None) + .set_default_account(anchor_number, origin.clone(), None, TEST_NOW) .unwrap(); assert_eq!( @@ -3830,7 +3887,7 @@ mod default_account_tracking_tests { record_use(&mut storage, anchor_number, origin.clone(), None, 7_000).unwrap(); storage - .set_default_account(anchor_number, origin.clone(), None) + .set_default_account(anchor_number, origin.clone(), None, TEST_NOW) .unwrap(); let references = held_references(&storage, anchor_number, application_number); @@ -3846,6 +3903,7 @@ mod tracked_default_eviction_tests { use super::record_use; use super::remove_at; use super::write_at; + use super::TEST_NOW; use crate::storage::account::{AccountKey, AccountReference}; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use crate::storage::storable::accounts_counter::StorableAccountsCounter; @@ -3943,7 +4001,7 @@ mod tracked_default_eviction_tests { for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS * 2 { storage - .set_default_account(anchor_number, origin_of(index), None) + .set_default_account(anchor_number, origin_of(index), None, TEST_NOW) .unwrap(); } @@ -4098,7 +4156,7 @@ mod tracked_default_eviction_tests { let never_used_origin = "https://never-used.com".to_string(); let never_used_application = application_number_for(&mut storage, &never_used_origin); storage - .set_default_account(anchor_number, never_used_origin.clone(), None) + .set_default_account(anchor_number, never_used_origin.clone(), None, TEST_NOW) .unwrap(); for index in 0..MAX_EVICTABLE_DEFAULT_ACCOUNTS { @@ -4116,7 +4174,12 @@ mod tracked_default_eviction_tests { let (mut storage, anchor_number) = storage_with_anchor(); let shared_origin = "https://has-a-named-account.com".to_string(); storage - .create_account(anchor_number, shared_origin.clone(), "named".to_string()) + .create_account( + anchor_number, + shared_origin.clone(), + "named".to_string(), + TEST_NOW, + ) .unwrap(); let shared_application = storage .lookup_application_number_with_origin(&shared_origin) @@ -4137,7 +4200,7 @@ mod tracked_default_eviction_tests { let origin = "https://example.com".to_string(); record_use(&mut storage, anchor_number, origin.clone(), None, 1_000).unwrap(); storage - .set_default_account(anchor_number, origin.clone(), None) + .set_default_account(anchor_number, origin.clone(), None, TEST_NOW) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -4243,7 +4306,12 @@ mod tracked_default_eviction_tests { let (mut storage, anchor_number) = storage_with_anchor(); for index in 0..3 { storage - .create_account(anchor_number, origin_of(index), format!("named-{index}")) + .create_account( + anchor_number, + origin_of(index), + format!("named-{index}"), + TEST_NOW, + ) .unwrap(); } @@ -4302,6 +4370,7 @@ mod application_removal_tests { use super::application_number_for; use super::remove_at; use super::write_at; + use super::TEST_NOW; use crate::storage::storable::account_reference_list::StorableAccountReferenceList; use super::record_use; @@ -4582,7 +4651,7 @@ mod application_removal_tests { let (mut storage, anchor_number, other_anchor_number) = storage_with_anchors(); let origin = "https://example.com".to_string(); storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -4615,7 +4684,7 @@ mod application_removal_tests { // A default alongside a named account. Retiring the list would drop a reference // nothing else records, so it is refused even though the caller asked. storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -4664,7 +4733,7 @@ mod application_removal_tests { // the config that goes with it. Nobody else holds this application, so retiring // the list retires it too. storage - .set_default_account(anchor_number, origin.clone(), None) + .set_default_account(anchor_number, origin.clone(), None, TEST_NOW) .unwrap(); let application_number = storage .lookup_application_number_with_origin(&origin) @@ -4715,6 +4784,7 @@ mod account_principal_index_tests { use super::record_use; use super::remove_at; use super::write_at; + use super::TEST_NOW; use crate::delegation::canister_sig_principal; use crate::storage::account::{Account, AccountReference}; use crate::storage::storable::account_key::StorableAccountKey; @@ -4777,12 +4847,15 @@ mod account_principal_index_tests { .unwrap(); let materialized = storage - .write_account(Account::new( - anchor_number, - origin.clone(), - Some("named default".to_string()), - None, - )) + .write_account( + Account::new( + anchor_number, + origin.clone(), + Some("named default".to_string()), + None, + ), + TEST_NOW, + ) .unwrap(); assert_eq!( @@ -4801,7 +4874,7 @@ mod account_principal_index_tests { let origin = "https://example.com".to_string(); let named = storage - .create_account(anchor_number, origin.clone(), "named".to_string()) + .create_account(anchor_number, origin.clone(), "named".to_string(), TEST_NOW) .unwrap(); let application_number = storage @@ -5286,6 +5359,7 @@ mod session_creation_tests { use super::application_number_for; use super::held_references; use super::write_at; + use super::TEST_NOW; use super::{params, params_at}; use crate::delegation::calculate_session_seed_with_salt; use crate::storage::account::{ @@ -5710,8 +5784,8 @@ mod session_creation_tests { assert!(sessions.iter().any(|s| s.browser_id == 0)); } - /// The per-identity cap reclaims to a watermark rather than blocking, taking expired - /// records first and then the least recently used. + /// A counter that says the identity is at the cap when the lists say otherwise is + /// answered by counting, and the count is what gets stored. #[test] fn an_over_counting_anchor_is_corrected_rather_than_denied() { let (mut storage, anchor_number) = storage_with_anchor(); @@ -5759,29 +5833,75 @@ mod session_creation_tests { } } + /// The per-identity cap reclaims to the watermark rather than blocking, and stops + /// there: clearing all the way to the cap would have the next few sign-ins each + /// sweep again, and clearing less would. #[test] fn the_session_cap_reclaims_to_the_watermark() { let (mut storage, anchor_number) = storage_with_anchor(); - let _session = storage - .create_session(params(anchor_number, 7, 1_000)) - .unwrap() - .1; - let _application_number = storage - .lookup_application_number_with_origin(&ORIGIN.to_string()) + + // Sign-ins at fresh origins until the identity is exactly at the cap, so the next + // one is the write that has to make room. Every session is live and none has ever + // been used, so the pass has nothing expired to take and reclaims on order alone. + for sign_in in 0..MAX_SESSIONS_PER_ANCHOR { + let mut params = params_at(anchor_number, 1, sign_in as u16, 600_000 + sign_in as u64); + params.origin = format!("https://app-{sign_in}.example.com"); + params.valid_till_ns = 100_000_000; + storage.create_session(params).unwrap(); + } + assert_eq!( + all_sessions_of(&storage, anchor_number).len() as u32, + MAX_SESSIONS_PER_ANCHOR + ); + + let mut params = params_at(anchor_number, 1, MAX_SESSIONS_PER_ANCHOR as u16, 700_000); + params.origin = "https://one-more.example.com".to_string(); + params.valid_till_ns = 100_000_000; + storage.create_session(params).unwrap(); + + // The watermark, and then the session that triggered the pass on top of it. + assert_eq!( + all_sessions_of(&storage, anchor_number).len() as u32, + SESSIONS_WATERMARK_PER_ANCHOR + 1 + ); + assert_eq!( + storage.read(anchor_number).unwrap().session_count, + SESSIONS_WATERMARK_PER_ANCHOR + 1 + ); + } + + /// The counter drifting the other way — below what the lists hold — is corrected by + /// the same count. Left to a saturating subtraction it would clamp to zero, and a + /// counter under the truth is the one that lets the lists past the cap. + #[test] + fn an_under_counting_anchor_is_corrected_rather_than_clamped() { + let (mut storage, anchor_number) = storage_with_anchor(); + storage + .create_session(params(anchor_number, 1, 1_000)) .unwrap(); + storage + .create_session(CreateSessionParams { + origin: "https://elsewhere.example".to_string(), + ..params(anchor_number, 2, 2_000) + }) + .unwrap(); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); - // Nothing observes a session expiring, so the count drifts up. The cap must be - // enforced against what the lists hold, not against the drift. let mut anchor = storage.read(anchor_number).unwrap(); - anchor.session_count = MAX_SESSIONS_PER_ANCHOR; + anchor.session_count = 0; storage.write(anchor).unwrap(); - storage - .create_session(params(anchor_number, 2, 2_000)) - .unwrap(); + // A write that removes one session while the counter says the identity holds + // none: the second browser is the one that signed in at the other origin. + assert_eq!( + storage + .revoke_browser_sessions(anchor_number, 1, TEST_NOW) + .unwrap(), + 1 + ); - assert_eq!(sessions_of(&storage, anchor_number).len(), 2); - assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); + assert_eq!(all_sessions_of(&storage, anchor_number).len(), 1); + assert_eq!(storage.read(anchor_number).unwrap().session_count, 1); } /// Two lists, both holding a default account, and both holding sessions for the same @@ -5836,7 +5956,12 @@ mod session_creation_tests { .unwrap(); } + // At a third origin, so the sign-in genuinely adds a session: one at either list + // above would prune that list's expired record on the way past and leave the + // identity holding what it started with, which is not a write the cap has + // anything to say about. let mut params = params(anchor_number, 199, 600_000); + params.origin = "https://third.example".to_string(); params.valid_till_ns = 100_000_000; storage.create_session(params).unwrap(); @@ -5869,8 +5994,13 @@ mod session_creation_tests { assert!(second_devices.contains(&(FABRICATED + PER_LIST - 1))); assert_eq!( first_devices.len() + second_devices.len(), + SESSIONS_WATERMARK_PER_ANCHOR as usize, + "cleared to the watermark across both lists" + ); + assert_eq!( + all_sessions_of(&storage, anchor_number).len(), SESSIONS_WATERMARK_PER_ANCHOR as usize + 1, - "cleared to the watermark across both lists, then the session it made room for" + "and the session the pass made room for" ); } @@ -5936,7 +6066,12 @@ mod session_creation_tests { fn a_named_account_can_hold_its_own_sessions() { let (mut storage, anchor_number) = storage_with_anchor(); let named = storage - .create_account(anchor_number, ORIGIN.to_string(), "named".to_string()) + .create_account( + anchor_number, + ORIGIN.to_string(), + "named".to_string(), + TEST_NOW, + ) .unwrap(); let mut p = params(anchor_number, 1, 1_000); p.account_number = named.account_number; @@ -6180,6 +6315,7 @@ mod session_consent_change_tests { } mod browser_session_count_tests { + use super::TEST_NOW; use super::{params, params_at}; use crate::storage::anchor::MAX_BROWSERS; use crate::storage::CreateSessionParams; @@ -6291,7 +6427,9 @@ mod browser_session_count_tests { .unwrap(); assert_eq!( - storage.revoke_browser_sessions(anchor_number, 0).unwrap(), + storage + .revoke_browser_sessions(anchor_number, 0, TEST_NOW) + .unwrap(), 2 ); From ac2855c7d7be673c1ce71a232461049d905ba146 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 12:56:17 +0200 Subject: [PATCH 257/298] fix(be): the refresh write passes the clock the gate now takes Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index fea82e4d0f..2478dfef7c 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3411,6 +3411,7 @@ impl Storage { anchor.stamp_browser_use(browser_id, now); self.write_account_state( anchor, + now, BTreeMap::from([(origin.clone(), Some((account_references, config)))]), )?; Ok(true) From 2ab2f07d6b6caa1f9995b1e0125dca69417d79c9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 12:57:34 +0200 Subject: [PATCH 258/298] fix(be): signing a session out passes the clock the gate now takes Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 2 +- src/internet_identity/src/sessions.rs | 4 ++-- src/internet_identity/src/storage.rs | 7 ++++++- src/internet_identity/src/storage/tests.rs | 9 +++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 0d6caa0c63..bdc0385ff4 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -520,7 +520,7 @@ fn app_prepare_delegation( #[update] fn app_revoke_session() { - sessions::app_revoke_session() + sessions::app_revoke_session(ic_cdk::api::time()) } #[query] diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 5ae2bdacc8..4b14e55a6a 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -413,7 +413,7 @@ fn authorize_session( /// 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() { +pub fn app_revoke_session(now: Timestamp) { // Matched rather than authorized: a session past its bounds is still the caller's to // sign out, and refusing here would leave its record and index entry behind. let Ok((key, _, _)) = match_session() else { @@ -422,7 +422,7 @@ pub fn app_revoke_session() { // 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.revoke_session(&key)) + storage_borrow_mut(|storage| storage.revoke_session(&key, now)) .expect("failed to revoke a session that was just matched"); } diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index a7f4c78bc3..0a2c937d5c 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3421,7 +3421,11 @@ impl Storage { /// /// The key names one session by its id, so a key for a session that was replaced since /// finds nothing rather than taking its successor down with it. - pub fn revoke_session(&mut self, key: &SessionRecordKey) -> Result { + pub fn revoke_session( + &mut self, + key: &SessionRecordKey, + now: Timestamp, + ) -> Result { if self .lookup_application_number_with_origin(&key.origin) .is_none() @@ -3453,6 +3457,7 @@ impl Storage { self.write_account_state( anchor, + now, BTreeMap::from([(key.origin.clone(), Some((account_references, config)))]), )?; Ok(true) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index c2a1e0748d..2252da325a 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6544,6 +6544,7 @@ mod session_refresh_stamp_tests { mod session_removal_tests { use super::held_references; use super::params; + use super::TEST_NOW; use crate::storage::account::SessionRecordKey; use crate::storage::CreateSessionParams; use crate::Storage; @@ -6604,7 +6605,7 @@ mod session_removal_tests { fn removing_a_session_leaves_the_others() { let (mut storage, anchor_number, _, keys) = storage_with_sessions(&[1, 2, 3]); - let removed = storage.revoke_session(&keys[1]).unwrap(); + let removed = storage.revoke_session(&keys[1], TEST_NOW).unwrap(); assert!(removed); // Ids in registration order, so the seeds 1, 2, 3 became 0, 1, 2. @@ -6614,9 +6615,9 @@ mod session_removal_tests { #[test] fn removing_a_session_twice_reports_nothing_removed() { let (mut storage, anchor_number, _, keys) = storage_with_sessions(&[1]); - storage.revoke_session(&keys[0]).unwrap(); + storage.revoke_session(&keys[0], TEST_NOW).unwrap(); - let removed = storage.revoke_session(&keys[0]).unwrap(); + let removed = storage.revoke_session(&keys[0], TEST_NOW).unwrap(); assert!(!removed); assert_eq!(sessions(&storage, anchor_number), Vec::::new()); @@ -6626,7 +6627,7 @@ mod session_removal_tests { fn removing_the_last_session_keeps_the_reference() { let (mut storage, anchor_number, application_number, keys) = storage_with_sessions(&[1]); - storage.revoke_session(&keys[0]).unwrap(); + storage.revoke_session(&keys[0], TEST_NOW).unwrap(); assert_ne!( storage.stored_account_references(anchor_number, application_number), From d7be132db64ab16703987b11919b517d05d6ea1d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 13:12:12 +0200 Subject: [PATCH 259/298] refactor(be): name what a session record answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_over` left the caller to find out which bound it meant, and its doc had to say "on either bound" to make up for it — so the name says it and the doc explains why it is one question. `demonstrated_use` returns a span rather than a use, and `reclaim_order` returns a key rather than an order. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/account.rs | 12 +++++------ src/internet_identity/src/storage/tests.rs | 22 ++++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index eca9e1846c..fa86b2764d 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -66,8 +66,6 @@ pub struct SessionRecord { } impl SessionRecord { - /// Whether this session is finished, on either bound. - /// /// One question rather than two, because a caller has no use for the halves /// apart: a session past its lifetime and one nobody has used for longer than /// it was allowed are equally over. Asking separately is how a caller ends up @@ -76,7 +74,7 @@ impl SessionRecord { /// Idleness is measured from the last mint, or from creation where nothing has /// minted yet, so a session abandoned immediately after sign-in is bounded like /// any other. - pub fn is_over(&self, now: Timestamp) -> bool { + pub fn is_expired_or_idle(&self, now: Timestamp) -> bool { if self.valid_till_ns <= now { return true; } @@ -86,7 +84,7 @@ impl SessionRecord { /// How long this session stayed in service: the span from its creation to the last time /// its app asked for a delegation. Bounded by the session's own lifetime. - pub fn demonstrated_use(&self) -> u64 { + pub fn time_in_service_ns(&self) -> u64 { self.last_refreshed_ns .map_or(0, |refreshed| refreshed.saturating_sub(self.created_at_ns)) } @@ -97,11 +95,11 @@ impl SessionRecord { /// The extension is what separates an app in weekly use from one opened once and /// abandoned, which recency alone gets backwards — the abandoned one was touched more /// recently. `browser_id` only makes the order total. - pub fn reclaim_order(&self, now: Timestamp) -> (bool, Timestamp, BrowserId) { + pub fn reclaim_sort_key(&self, now: Timestamp) -> (bool, Timestamp, BrowserId) { let last_used = self.last_refreshed_ns.unwrap_or(self.created_at_ns); ( - !self.is_over(now), - last_used.saturating_add(self.demonstrated_use()), + !self.is_expired_or_idle(now), + last_used.saturating_add(self.time_in_service_ns()), self.browser_id, ) } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index a7caff6e0d..0951bcd215 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4890,10 +4890,10 @@ mod session_record_tests { fn a_bound_further_out_than_the_session_never_bites() { let record = session(0, DAY_NS); - assert!(!record.is_over(0)); + assert!(!record.is_expired_or_idle(0)); // Past its own lifetime, so over on the other bound — which is the point: // one question, answered by whichever bound is reached first. - assert!(record.is_over(DAY_NS)); + assert!(record.is_expired_or_idle(DAY_NS)); } #[test] @@ -4904,9 +4904,9 @@ mod session_record_tests { ..session(0, DAY_NS) }; - assert!(!record.is_over(39 * MINUTE_NS)); + assert!(!record.is_expired_or_idle(39 * MINUTE_NS)); // Still inside its absolute lifetime, and over anyway: either bound ends it. - assert!(record.is_over(40 * MINUTE_NS)); + assert!(record.is_expired_or_idle(40 * MINUTE_NS)); } #[test] @@ -4919,8 +4919,8 @@ mod session_record_tests { // Otherwise a session abandoned straight after sign-in would sit unbounded // until its lifetime ran out, which is the case the bound exists for. - assert!(!record.is_over(34 * MINUTE_NS)); - assert!(record.is_over(35 * MINUTE_NS)); + assert!(!record.is_expired_or_idle(34 * MINUTE_NS)); + assert!(record.is_expired_or_idle(35 * MINUTE_NS)); } #[test] @@ -4987,7 +4987,7 @@ mod session_record_tests { // Both are inside their lifetime, so ranking on that alone would have them // compete for a slot. One of them is finished. - assert!(idle.reclaim_order(now) < live.reclaim_order(now)); + assert!(idle.reclaim_sort_key(now) < live.reclaim_sort_key(now)); } #[test] @@ -5001,8 +5001,8 @@ mod session_record_tests { }; let live_untouched = session(400, 10_000); - assert!(expired.reclaim_order(now) < live.reclaim_order(now)); - assert!(expired.reclaim_order(now) < live_untouched.reclaim_order(now)); + assert!(expired.reclaim_sort_key(now) < live.reclaim_sort_key(now)); + assert!(expired.reclaim_sort_key(now) < live_untouched.reclaim_sort_key(now)); } #[test] @@ -5024,7 +5024,7 @@ mod session_record_tests { assert!(flood .iter() - .all(|session| session.reclaim_order(now) < held.reclaim_order(now))); + .all(|session| session.reclaim_sort_key(now) < held.reclaim_sort_key(now))); } #[test] @@ -5044,7 +5044,7 @@ mod session_record_tests { }; assert!( - one_sitting.reclaim_order(now) < weekly.reclaim_order(now), + one_sitting.reclaim_sort_key(now) < weekly.reclaim_sort_key(now), "the more recently touched session goes first, having stayed in service for minutes" ); } From 3a3539cc8da190a1c5d4f7043095ad3047ddfdac Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 13:14:44 +0200 Subject: [PATCH 260/298] chore: rename the session record accessors at their call sites Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 299efee7c3..7a8cf84396 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -1642,7 +1642,7 @@ impl Storage { /// /// Policy rather than a rule, which is why it shapes the write instead of living in /// the write path. Storage refuses a state over the cap; which live sessions give way - /// to make room is this function's opinion, and [`SessionRecord::reclaim_order`] is + /// to make room is this function's opinion, and [`SessionRecord::reclaim_sort_key`] is /// where that opinion is written down. Clearing to the watermark rather than to the /// cap is what keeps the next few sign-ins from each sweeping again. /// @@ -1665,8 +1665,9 @@ impl Storage { // Selected from what is stored, so the session the write is creating is never a // candidate for the pass that made room for it. - let (over, live): (Vec, Vec) = - stored.into_iter().partition(|session| session.is_over(now)); + let (over, live): (Vec, Vec) = stored + .into_iter() + .partition(|session| session.is_expired_or_idle(now)); let mut giving_up: BTreeSet = over.into_iter().map(|session| session.session_id).collect(); @@ -1677,7 +1678,7 @@ impl Storage { // Ascending, so the least demonstrated use comes first and is given up first. let mut ordered: Vec<((bool, Timestamp, SessionId), SessionId)> = live .iter() - .map(|session| (session.reclaim_order(now), session.session_id)) + .map(|session| (session.reclaim_sort_key(now), session.session_id)) .collect(); ordered.sort(); let over_watermark = ordered.len() - SESSIONS_WATERMARK_PER_ANCHOR as usize; From cff441bef6336ea5dfce505d29e673ea058ecd04 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 13:15:06 +0200 Subject: [PATCH 261/298] chore: rename the session record accessors at their call sites Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index a380d86dfe..1681b686a9 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -408,7 +408,7 @@ fn authorize_session(now: Timestamp) -> Result<(Account, SessionRecord), AppSess }) .ok_or(AppSessionError::NoMatchingSession)?; - if session.is_over(now) { + if session.is_expired_or_idle(now) { return Err(AppSessionError::NoMatchingSession); } Ok((account, session)) From 917486c14a1e6639df842c59c0ceda4b4e1847cc Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 13:15:12 +0200 Subject: [PATCH 262/298] chore: rename the session record accessors at their call sites Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 1ad96f8a62..3e913a8800 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3403,7 +3403,7 @@ impl Storage { write .account_reference .sessions - .retain(|session| !session.is_over(now)); + .retain(|session| !session.is_expired_or_idle(now)); } // Stamped before the write rather than after, because the write is what stores the From 709ce2134f7bb072851cba6a91a1d86c1888952d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 16:59:10 +0200 Subject: [PATCH 263/298] refactor(session): name the session type Session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SessionRecord` is a session, not a record of one, and `Record` reads the way `Object` would in JavaScript. The storable follows the sibling convention — `StorableSession` in `storable/session.rs`, next to the other storables named after their domain type. `#[cbor(map)]` stores integer indices, so no field name reaches disk and there is nothing to migrate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/account.rs | 6 ++-- src/internet_identity/src/storage/storable.rs | 2 +- .../src/storage/storable/account_reference.rs | 4 +-- .../{session_record.rs => session.rs} | 22 ++++++------- src/internet_identity/src/storage/tests.rs | 32 +++++++++---------- 5 files changed, 33 insertions(+), 33 deletions(-) rename src/internet_identity/src/storage/storable/{session_record.rs => session.rs} (79%) diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index fa86b2764d..b6ca9b123c 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -40,7 +40,7 @@ pub struct AccountsCounter { pub struct AccountReference { pub account_number: Option, // None is the unreserved synthetic account pub last_used: Option, - pub sessions: Vec, + pub sessions: Vec, } impl AccountReference { @@ -56,7 +56,7 @@ impl AccountReference { /// A revocable session at one account. Only `last_refreshed` is mutable, which is why /// it is the one field absent from the seed. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] -pub struct SessionRecord { +pub struct Session { pub created_at_ns: Timestamp, pub valid_till_ns: Timestamp, pub max_idle_ns: u64, @@ -65,7 +65,7 @@ pub struct SessionRecord { pub read_only: bool, } -impl SessionRecord { +impl Session { /// One question rather than two, because a caller has no use for the halves /// apart: a session past its lifetime and one nobody has used for longer than /// it was allowed are equally over. Asking separately is how a caller ends up diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index 61a1d465c8..e06e95ef26 100644 --- a/src/internet_identity/src/storage/storable.rs +++ b/src/internet_identity/src/storage/storable.rs @@ -25,7 +25,7 @@ pub mod openid_credential_key; pub mod openid_jwks; pub mod passkey_credential; pub mod recovery_key; -pub mod session_record; +pub mod session; pub mod special_device_migration; pub mod sso_stable_id_key; pub mod storable_persistent_state; diff --git a/src/internet_identity/src/storage/storable/account_reference.rs b/src/internet_identity/src/storage/storable/account_reference.rs index c74aa5e199..1e9bd6a9da 100644 --- a/src/internet_identity/src/storage/storable/account_reference.rs +++ b/src/internet_identity/src/storage/storable/account_reference.rs @@ -1,6 +1,6 @@ use crate::storage::account::AccountReference; use crate::storage::storable::account_number::StorableAccountNumber; -use crate::storage::storable::session_record::StorableSessionRecord; +use crate::storage::storable::session::StorableSession; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use internet_identity_interface::internet_identity::types::Timestamp; @@ -17,7 +17,7 @@ pub struct StorableAccountReference { #[n(1)] pub last_used: Option, #[n(2)] - pub sessions: Option>, + pub sessions: Option>, } impl Storable for StorableAccountReference { diff --git a/src/internet_identity/src/storage/storable/session_record.rs b/src/internet_identity/src/storage/storable/session.rs similarity index 79% rename from src/internet_identity/src/storage/storable/session_record.rs rename to src/internet_identity/src/storage/storable/session.rs index aa6c647947..4322c57c11 100644 --- a/src/internet_identity/src/storage/storable/session_record.rs +++ b/src/internet_identity/src/storage/storable/session.rs @@ -1,4 +1,4 @@ -use crate::storage::account::SessionRecord; +use crate::storage::account::Session; use crate::storage::storable::browser_id::StorableBrowserId; use crate::storage::storable::duration::StorableDuration; use crate::storage::storable::timestamp::StorableTimestamp; @@ -9,7 +9,7 @@ use std::borrow::Cow; #[derive(Encode, Decode, Clone, Debug, Ord, Eq, PartialEq, PartialOrd)] #[cbor(map)] -pub struct StorableSessionRecord { +pub struct StorableSession { #[n(0)] pub created_at_ns: StorableTimestamp, #[n(1)] @@ -24,23 +24,23 @@ pub struct StorableSessionRecord { pub read_only: bool, } -impl Storable for StorableSessionRecord { +impl Storable for StorableSession { fn to_bytes(&self) -> Cow<'_, [u8]> { let mut buffer = Vec::new(); - minicbor::encode(self, &mut buffer).expect("failed to encode StorableSessionRecord"); + minicbor::encode(self, &mut buffer).expect("failed to encode StorableSession"); Cow::Owned(buffer) } fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { - minicbor::decode(&bytes).expect("failed to decode StorableSessionRecord") + minicbor::decode(&bytes).expect("failed to decode StorableSession") } const BOUND: Bound = Bound::Unbounded; } -impl From for SessionRecord { - fn from(value: StorableSessionRecord) -> Self { - SessionRecord { +impl From for Session { + fn from(value: StorableSession) -> Self { + Session { created_at_ns: value.created_at_ns, valid_till_ns: value.valid_till_ns, last_refreshed_ns: value.last_refreshed_ns, @@ -51,9 +51,9 @@ impl From for SessionRecord { } } -impl From for StorableSessionRecord { - fn from(value: SessionRecord) -> Self { - StorableSessionRecord { +impl From for StorableSession { + fn from(value: Session) -> Self { + StorableSession { created_at_ns: value.created_at_ns, valid_till_ns: value.valid_till_ns, last_refreshed_ns: value.last_refreshed_ns, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 0951bcd215..f667f4a140 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4818,9 +4818,9 @@ mod account_principal_index_tests { } } -mod session_record_tests { +mod session_tests { use super::{application_number_for, write_at}; - use crate::storage::account::{AccountReference, SessionRecord}; + use crate::storage::account::{AccountReference, Session}; use crate::storage::storable::account_reference::StorableAccountReference; use crate::{Storage, DAY_NS, MINUTE_NS}; use ic_stable_structures::{Storable, VectorMemory}; @@ -4831,8 +4831,8 @@ mod session_record_tests { /// what the tests about the absolute bound want. const NEVER_IDLE: u64 = u64::MAX; - fn session(created_at_ns: u64, valid_till_ns: u64) -> SessionRecord { - SessionRecord { + fn session(created_at_ns: u64, valid_till_ns: u64) -> Session { + Session { created_at_ns, valid_till_ns, max_idle_ns: NEVER_IDLE, @@ -4860,7 +4860,7 @@ mod session_record_tests { account_number: Some(3), last_used: Some(9), sessions: vec![ - SessionRecord { + Session { created_at_ns: 11, valid_till_ns: 22, max_idle_ns: 33, @@ -4868,7 +4868,7 @@ mod session_record_tests { browser_id: 55, read_only: false, }, - SessionRecord { + Session { created_at_ns: 66, valid_till_ns: 77, max_idle_ns: 88, @@ -4898,7 +4898,7 @@ mod session_record_tests { #[test] fn a_session_is_idle_once_nothing_has_minted_for_its_bound() { - let record = SessionRecord { + let record = Session { max_idle_ns: 30 * MINUTE_NS, last_refreshed_ns: Some(10 * MINUTE_NS), ..session(0, DAY_NS) @@ -4911,7 +4911,7 @@ mod session_record_tests { #[test] fn a_session_that_never_minted_is_measured_from_its_creation() { - let record = SessionRecord { + let record = Session { max_idle_ns: 30 * MINUTE_NS, last_refreshed_ns: None, ..session(5 * MINUTE_NS, DAY_NS) @@ -4975,12 +4975,12 @@ mod session_record_tests { #[test] fn a_session_over_by_idleness_reclaims_like_a_dead_one() { let now = 100 * DAY_NS; - let idle = SessionRecord { + let idle = Session { max_idle_ns: DAY_NS, last_refreshed_ns: Some(now - 10 * DAY_NS), ..session(now - 20 * DAY_NS, now + DAY_NS) }; - let live = SessionRecord { + let live = Session { last_refreshed_ns: Some(now - 1), ..session(now - 20 * DAY_NS, now + DAY_NS) }; @@ -4994,7 +4994,7 @@ mod session_record_tests { fn reclaim_order_ranks_dead_sessions_first() { let now = 1_000; let expired = session(1, 500); - let live = SessionRecord { + let live = Session { max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(900), ..session(400, 10_000) @@ -5008,15 +5008,15 @@ mod session_record_tests { #[test] fn a_flood_of_unused_sessions_cannot_displace_a_used_one() { let now = 100 * DAY_NS; - let held = SessionRecord { + let held = Session { max_idle_ns: NEVER_IDLE, last_refreshed_ns: 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 { + let flood: Vec = (0..500) + .map(|index| Session { browser_id: index, ..session(now - 1, now + DAY_NS) }) @@ -5031,13 +5031,13 @@ mod session_record_tests { fn an_app_in_weekly_use_outranks_one_opened_once_yesterday() { let now = 100 * DAY_NS; // Signed in three months ago, still being opened every few days. - let weekly = SessionRecord { + let weekly = Session { max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - 3 * DAY_NS), ..session(now - 90 * DAY_NS, now + DAY_NS) }; // Signed in yesterday, used for five minutes, never opened again. - let one_sitting = SessionRecord { + let one_sitting = Session { max_idle_ns: NEVER_IDLE, last_refreshed_ns: Some(now - DAY_NS + 5 * MINUTE_NS), ..session(now - DAY_NS, now + DAY_NS) From a727d291bb7bbfcc2c859334df95b69f002ed7c8 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:00:46 +0200 Subject: [PATCH 264/298] refactor(browsers): the anchor decides what a browser reveals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main.rs` was hand-mapping `Browser` into `BrowserInfo`, so the decision about which fields stay inside storage sat next to the endpoint rather than next to the type that knows the keys are secret. `Anchor::browsers_info()` makes that choice once, and returns the `Option` the interface carries so the emptiness check goes with it. `StorableBrowser` loses its `Storable` impl: it is only ever encoded as a field of `StorableAnchor`, never as a stable-structure value, so nothing consulted its `BOUND` — `StorableBrowserDescription` beside it has no impl either. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/main.rs | 16 +------------ src/internet_identity/src/storage/anchor.rs | 24 +++++++++++++++++++ .../src/storage/storable/browser.rs | 17 ------------- 3 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 1e41d1a891..94997e1373 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -1098,21 +1098,7 @@ mod v2_api { Some(stored_verified_emails) }; - let stored_browsers: Vec = state::anchor(identity_number) - .browsers() - .iter() - .map(|browser| BrowserInfo { - id: browser.id, - description: browser.description.clone(), - created_at: browser.created_at, - last_used: browser.last_used, - }) - .collect(); - let browsers = if stored_browsers.is_empty() { - None - } else { - Some(stored_browsers) - }; + let browsers = state::anchor(identity_number).browsers_info(); let identity_info = IdentityInfo { authn_methods: anchor_info diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 5c5a49cf15..7286498ccf 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -735,6 +735,30 @@ impl Anchor { &self.browsers } + /// What a caller outside storage may know about this anchor's browsers: an + /// identifier, what the browser said it was, and when. The keys stay here — they + /// are how a sign-in proves which entry it is, so handing them out would let + /// anyone who can read an identity's browsers claim one. + /// + /// `None` rather than an empty list, because that is the shape the interface + /// carries and no caller wants the difference. + pub fn browsers_info(&self) -> Option> { + if self.browsers.is_empty() { + return None; + } + Some( + self.browsers + .iter() + .map(|browser| BrowserInfo { + id: browser.id, + description: browser.description.clone(), + created_at: browser.created_at, + last_used: browser.last_used, + }) + .collect(), + ) + } + /// Resolves the browser a sign-in came from by the public key it proved possession of. /// /// An entry is reached only by the successor it announced. Presenting it promotes that diff --git a/src/internet_identity/src/storage/storable/browser.rs b/src/internet_identity/src/storage/storable/browser.rs index c6d2d2d8fd..3f703b427d 100644 --- a/src/internet_identity/src/storage/storable/browser.rs +++ b/src/internet_identity/src/storage/storable/browser.rs @@ -1,10 +1,7 @@ use crate::storage::storable::browser_description::StorableBrowserDescription; use crate::storage::storable::browser_id::StorableBrowserId; -use ic_stable_structures::storable::Bound; -use ic_stable_structures::Storable; use internet_identity_interface::internet_identity::types::Timestamp; use minicbor::{Decode, Encode}; -use std::borrow::Cow; #[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[cbor(map)] @@ -25,17 +22,3 @@ pub struct StorableBrowser { #[cbor(n(5), with = "minicbor::bytes")] pub next_browser_key: Vec, } - -impl Storable for StorableBrowser { - fn to_bytes(&self) -> Cow<'_, [u8]> { - let mut buffer = Vec::new(); - minicbor::encode(self, &mut buffer).expect("failed to encode StorableBrowser"); - Cow::Owned(buffer) - } - - fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { - minicbor::decode(&bytes).expect("failed to decode StorableBrowser") - } - - const BOUND: Bound = Bound::Unbounded; -} From 6ef83233521d97054fea9b5cbd0eff65aca55295 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:05:44 +0200 Subject: [PATCH 265/298] feat(browser-key): verifying yields evidence, and one encoding per key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification returned a `bool`, so `create_session` registering a browser from the keys it was handed depended on its caller having checked them first. `verify_browser_keys` now returns a `VerifiedBrowserKeys` with private fields and no other constructor, so the type is the proof. That makes storage depend on the verifier, which is why the module moves to the crate root: a P-256 verifier that knows nothing of sessions or storage, which either layer may use. `from_public_key_der` accepts a compressed SEC1 point as readily as an uncompressed one, and both are valid DER — so one private key had two spellings, and `resolve_browser` compares the bytes it is given. Only the uncompressed form is accepted now, which is the only one WebCrypto can emit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- Cargo.toml | 1 - .../src/{sessions => }/browser_key.rs | 168 ++++++++++++++---- src/internet_identity/src/main.rs | 2 +- src/internet_identity/src/sessions.rs | 5 - 4 files changed, 133 insertions(+), 43 deletions(-) rename src/internet_identity/src/{sessions => }/browser_key.rs (61%) delete mode 100644 src/internet_identity/src/sessions.rs diff --git a/Cargo.toml b/Cargo.toml index b2a2e4021b..09a984fd27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,6 @@ sha2 = "0.10" rsa = "0.9.10" minicbor = "1.0.0" -# DNSSEC verifier deps (PR 1b on docs/ongoing/email-recovery.md §7) p256 = { version = "0.13", default-features = false, features = ["ecdsa", "sha256", "pkcs8"] } ed25519-dalek = { version = "2.2", default-features = false } diff --git a/src/internet_identity/src/sessions/browser_key.rs b/src/internet_identity/src/browser_key.rs similarity index 61% rename from src/internet_identity/src/sessions/browser_key.rs rename to src/internet_identity/src/browser_key.rs index afab5f675a..efd22607be 100644 --- a/src/internet_identity/src/sessions/browser_key.rs +++ b/src/internet_identity/src/browser_key.rs @@ -1,4 +1,10 @@ //! Verifies that a sign-in request comes from a browser holding the key it names. +//! +//! Knows nothing of sessions or storage, so both layers may depend on it: the endpoint +//! verifies, and storage requires the [`VerifiedBrowserKeys`] that verifying produces. +//! +//! Nothing calls it yet: the sign-in ceremony that does is added on top of this. +#![allow(dead_code)] use internet_identity_interface::internet_identity::types::{PublicKey, SessionKey}; use p256::ecdsa::signature::Verifier; @@ -16,6 +22,30 @@ const SUCCESSOR_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-browser-successor"; /// A browser key is P-256, and the signature the raw `r || s` pair WebCrypto produces. const BROWSER_KEY_SIGNATURE_BYTES: usize = 64; +/// A pair of browser keys that have proved possession of each other, which is the only +/// way this type can be obtained: its fields are private and [`verify_browser_keys`] is +/// the only constructor, so holding one is the evidence rather than a reminder to check. +/// +/// [`crate::storage::Storage::create_session`] registers a browser from these keys, so +/// what it takes is this rather than two byte strings a caller assembled. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerifiedBrowserKeys { + current: PublicKey, + next: PublicKey, +} + +impl VerifiedBrowserKeys { + /// The key this sign-in was reached by. + pub fn current(&self) -> &PublicKey { + &self.current + } + + /// The successor this sign-in announced, accepted at the next one. + pub fn next(&self) -> &PublicKey { + &self.next + } +} + /// Both keys sign: the current one over the session key and its successor, and the successor /// over the session key and the key it replaces. /// @@ -28,8 +58,8 @@ pub fn verify_browser_keys( next_browser_key: &PublicKey, next_browser_key_signature: &[u8], session_key: &SessionKey, -) -> bool { - verify( +) -> Option { + let verified = verify( current_browser_key, current_browser_key_signature, &signed_message(BROWSER_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key), @@ -41,14 +71,18 @@ pub fn verify_browser_keys( session_key, current_browser_key, ), - ) + ); + verified.then(|| VerifiedBrowserKeys { + current: current_browser_key.clone(), + next: next_browser_key.clone(), + }) } fn verify(key: &PublicKey, signature: &[u8], message: &[u8]) -> bool { if signature.len() != BROWSER_KEY_SIGNATURE_BYTES { return false; } - let Ok(key) = VerifyingKey::from_public_key_der(key) else { + let Some(key) = verifying_key(key) else { return false; }; let Ok(signature) = Signature::from_slice(signature) else { @@ -57,6 +91,22 @@ fn verify(key: &PublicKey, signature: &[u8], message: &[u8]) -> bool { key.verify(message, &signature).is_ok() } +/// One encoding of a key, so one key is one browser. +/// +/// `from_public_key_der` accepts the compressed SEC1 point as readily as the uncompressed +/// one, and both are valid DER — so a single private key has two spellings, and +/// [`crate::storage::anchor::Anchor::resolve_browser`], which compares the bytes it was +/// given, would see two browsers. Only the uncompressed form is accepted, which is the +/// only one WebCrypto can emit: `exportKey("spki")` on a P-256 key writes `0x04 || X || Y` +/// and the API offers no alternative, so nothing legitimate is turned away and nothing +/// stored today needs migrating. +fn verifying_key(key: &PublicKey) -> Option { + let verifying = VerifyingKey::from_public_key_der(key).ok()?; + let uncompressed = verifying.to_encoded_point(false); + let point = key.len().checked_sub(uncompressed.as_bytes().len())?; + (&key[point..] == uncompressed.as_bytes()).then_some(verifying) +} + /// Covers the other key as well as the session key: keys are visible on the wire, so a /// signature that bound only the session key could be paired with one a caller chose. fn signed_message(domain: &[u8], session_key: &SessionKey, other_key: &PublicKey) -> Vec { @@ -74,13 +124,23 @@ mod tests { use p256::ecdsa::SigningKey; use serde_bytes::ByteBuf; - /// 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, + /// The algorithm identifier RFC 5480 fixes for a `secp256r1` public key, which is + /// what a WebCrypto `exportKey("spki")` writes ahead of the point. + const P256_ALGORITHM: [u8; 21] = [ + 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, + 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, ]; + /// The SPKI a key travels in, built around whichever point encoding is handed in, so + /// a test can offer the compressed one the same way a caller could. + fn spki(point: &[u8]) -> PublicKey { + let mut der = vec![0x30, (P256_ALGORITHM.len() + point.len() + 3) as u8]; + der.extend_from_slice(&P256_ALGORITHM); + der.extend_from_slice(&[0x03, (point.len() + 1) as u8, 0x00]); + der.extend_from_slice(point); + ByteBuf::from(der) + } + struct Key { signing: SigningKey, public: PublicKey, @@ -88,13 +148,12 @@ mod tests { fn key(seed: u8) -> Key { let signing = SigningKey::from_bytes(&[seed; 32].into()).unwrap(); - let point = VerifyingKey::from(&signing).to_encoded_point(false); - let mut der = P256_SPKI_HEADER.to_vec(); - der.extend_from_slice(point.as_bytes()); - Key { - signing, - public: ByteBuf::from(der), - } + let public = spki( + VerifyingKey::from(&signing) + .to_encoded_point(false) + .as_bytes(), + ); + Key { signing, public } } impl Key { @@ -127,6 +186,7 @@ mod tests { &next.successor(session, ¤t.public), session, ) + .is_some() } #[test] @@ -141,13 +201,14 @@ mod tests { let session = session_key(7); // Everything the wire carries, but signed only by the key the caller holds. - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, ¤t.current(&session, &announced.public), &announced.public, ¤t.current(&session, &announced.public), &session - )); + ) + .is_none()); } #[test] @@ -156,13 +217,14 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, ¤t.successor(&session, &next.public), &next.public, &next.successor(&session, ¤t.public), &session - )); + ) + .is_none()); } #[test] @@ -170,13 +232,14 @@ mod tests { let current = key(1); let next = key(2); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, ¤t.current(&session_key(7), &next.public), &next.public, &next.successor(&session_key(7), ¤t.public), &session_key(8) - )); + ) + .is_none()); } #[test] @@ -186,13 +249,14 @@ mod tests { let substituted = key(3); let session = session_key(7); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, ¤t.current(&session, &announced.public), &substituted.public, &substituted.successor(&session, ¤t.public), &session - )); + ) + .is_none()); } #[test] @@ -202,13 +266,14 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, &other.current(&session, &next.public), &next.public, &next.successor(&session, ¤t.public), &session - )); + ) + .is_none()); } #[test] @@ -218,13 +283,14 @@ mod tests { let session = session_key(7); let bare: Signature = current.signing.sign(&session); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, &bare.to_bytes(), &next.public, &next.successor(&session, ¤t.public), &session - )); + ) + .is_none()); } #[test] @@ -233,13 +299,40 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( &ByteBuf::from(vec![0u8; 91]), ¤t.current(&session, &next.public), &next.public, &next.successor(&session, ¤t.public), &session - )); + ) + .is_none()); + } + + /// One private key must not be two browsers. `resolve_browser` looks an entry up by + /// the bytes it was handed, so accepting a second encoding of the same point would let + /// one browser register twice and pass the rotation check as either. + #[test] + fn a_compressed_key_is_refused_although_it_is_valid_der() { + let current = key(1); + let next = key(2); + let session = session_key(7); + let compressed = spki( + VerifyingKey::from(¤t.signing) + .to_encoded_point(true) + .as_bytes(), + ); + + // Valid DER for that key, and both signatures made over exactly what is sent. + assert!(VerifyingKey::from_public_key_der(&compressed).is_ok()); + assert!(verify_browser_keys( + &compressed, + ¤t.current(&session, &next.public), + &next.public, + &next.successor(&session, &compressed), + &session + ) + .is_none()); } #[test] @@ -250,13 +343,14 @@ mod tests { let mut signature = current.current(&session, &next.public); signature.push(0); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, &signature, &next.public, &next.successor(&session, ¤t.public), &session - )); + ) + .is_none()); } #[test] @@ -265,19 +359,21 @@ mod tests { let next = key(2); let session = session_key(7); - assert!(!verify_browser_keys( + assert!(verify_browser_keys( ¤t.public, &[], &next.public, &next.successor(&session, ¤t.public), &session - )); - assert!(!verify_browser_keys( + ) + .is_none()); + assert!(verify_browser_keys( ¤t.public, ¤t.current(&session, &next.public), &next.public, &[], &session - )); + ) + .is_none()); } } diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 80cf569f30..2f6c7c506a 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -49,6 +49,7 @@ mod anchor_management; mod archive; mod assets; mod authz_utils; +mod browser_key; mod attributes; /// Type conversions between internal and external types. @@ -67,7 +68,6 @@ mod mcp_registration; mod openid; mod session_delegation; -mod sessions; mod single_flight_cache; mod state; mod stats; diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs deleted file mode 100644 index 1eac31f2ff..0000000000 --- a/src/internet_identity/src/sessions.rs +++ /dev/null @@ -1,5 +0,0 @@ -// 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 browser_key; From dbe4a13e97c26573c3dedc93d22e7cd7effe4b13 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:13:39 +0200 Subject: [PATCH 266/298] refactor(session): a locator, verified keys, and the illegal state said out loud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SessionRecordKey` is neither a record nor a stable-structure key — it is where one session lives, so `SessionLocator`, beside `StorableAccountLocator`. Its `account()` becomes `account_key()`, because the sibling on the session handle returns a principal from a method of the same name. `CreateSessionParams` takes the `VerifiedBrowserKeys` the verifier produces instead of two bare public keys: `create_session` registers a registry entry from them, and a caller that assembled them could have skipped the proof. `account_state` skipped a reference list whose application number resolves to nothing, silently. That state is illegal and skipping is still the right answer — refusing would block every sweep for the identity, including the origins that do resolve — but it is logged now. The storable's `session_id` becomes `id` (the type already says session), the `session_count` doc drops the consumer it named, the two `crate::delegation` imports become one, and the note above the id allocation stops claiming that nothing downstream of it can refuse. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/browser_key.rs | 9 ++- src/internet_identity/src/storage.rs | 60 ++++++++++++------- src/internet_identity/src/storage/account.rs | 2 +- .../src/storage/storable/anchor.rs | 5 +- .../src/storage/storable/session.rs | 6 +- src/internet_identity/src/storage/tests.rs | 7 ++- 6 files changed, 57 insertions(+), 32 deletions(-) diff --git a/src/internet_identity/src/browser_key.rs b/src/internet_identity/src/browser_key.rs index efd22607be..e142390ba0 100644 --- a/src/internet_identity/src/browser_key.rs +++ b/src/internet_identity/src/browser_key.rs @@ -3,7 +3,7 @@ //! Knows nothing of sessions or storage, so both layers may depend on it: the endpoint //! verifies, and storage requires the [`VerifiedBrowserKeys`] that verifying produces. //! -//! Nothing calls it yet: the sign-in ceremony that does is added on top of this. +//! `prepare_account_session`, the one caller that verifies, lands two PRs up. #![allow(dead_code)] use internet_identity_interface::internet_identity::types::{PublicKey, SessionKey}; @@ -35,6 +35,13 @@ pub struct VerifiedBrowserKeys { } impl VerifiedBrowserKeys { + /// Keys that were never verified, for tests about what happens *after* verification. + /// `#[cfg(test)]`, so no canister build can reach it and the type stays evidence. + #[cfg(test)] + pub fn unverified_for_test(current: PublicKey, next: PublicKey) -> Self { + Self { current, next } + } + /// The key this sign-in was reached by. pub fn current(&self) -> &PublicKey { &self.current diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 785aae18c2..1f65d74973 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -102,8 +102,10 @@ use ic_stable_structures::{ 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::browser_key::VerifiedBrowserKeys; +use crate::delegation::{ + self, calculate_session_seed_with_salt, canister_sig_principal, check_frontend_length, +}; use crate::openid::OpenIdCredentialKey; use crate::state::PersistentState; use crate::stats::event_stats::AggregationKey; @@ -1621,10 +1623,21 @@ impl Storage { (anchor_number, ApplicationNumber::MIN)..=(anchor_number, ApplicationNumber::MAX), ) .filter_map(|((_, application_number), list)| { - // An origin index that no longer resolves leaves a list naming nothing. - // It is not something a caller can write, so it is not something this - // hands back. - let application = self.stable_application_memory.get(&application_number)?; + // A reference list for an application that does not exist is illegal, and + // this is where it surfaces: the write gate is keyed by origin, so a list + // whose application number resolves to nothing cannot be handed to a + // caller. Skipping it is still the right answer — refusing here would + // block every sweep for the identity, including the origins that do + // resolve — but it is not something to pass over quietly. + let Some(application) = self.stable_application_memory.get(&application_number) + else { + ic_cdk::println!( + "ERROR: account reference list invariant violated: identity \ + {anchor_number} holds a list for application {application_number}, \ + which is not stored. Its sessions are unreachable and unrevocable." + ); + return None; + }; let account_references = Vec::::from(list) .into_iter() .map(AccountReferenceWrite::from) @@ -2776,7 +2789,7 @@ impl Storage { Ok(session_id) } - // Called by the sign-in ceremony, which lands two PRs up. + // Called by `prepare_account_session`, which lands two PRs up. #[allow(dead_code)] /// Creates the session `prepare_account_session` mints an identity from, replacing /// whatever this browser already held at this account. @@ -2788,8 +2801,7 @@ impl Storage { anchor_number, origin, account_number, - current_browser_key, - next_browser_key, + browser_keys, browser_description, valid_till_ns, max_idle_ns, @@ -2847,8 +2859,8 @@ impl Storage { // the record reaches storage only through the write at the end. let (browser_id, _) = anchor .resolve_browser( - current_browser_key, - next_browser_key, + browser_keys.current().clone(), + browser_keys.next().clone(), browser_description, now_ns, ) @@ -2894,9 +2906,10 @@ impl Storage { true }); - // After the checks that can refuse this ceremony, so a refused one does not burn - // an id. Ids need not be contiguous, so a later failure leaving a gap is fine; - // what must never happen is one being handed out twice. + // A gap costs nothing — ids need not be contiguous, and the write below can still + // refuse this ceremony. What must never happen is an id being handed out twice: + // it is an input to the session seed, so a reissued one would let a revoked + // session's identity be reached a second time. let session_id = self.allocate_session_id()?; let session = Session { session_id, @@ -2943,7 +2956,7 @@ impl Storage { /// /// A key whose session was replaced reads as `None` rather than as its successor: /// the successor was allocated an id of its own. - #[allow(dead_code)] // Used by the sign-in ceremony, which lands two PRs up. + #[allow(dead_code)] // Read by `get_account_session`, which lands two PRs up. pub fn read_session(&self, key: &SessionLocator) -> Option { let application_number = self.lookup_application_number_with_origin(&key.origin)?; @@ -2955,7 +2968,7 @@ impl Storage { .find(|session| session.session_id == key.session_id) } - // Called by the sign-in ceremony, which lands two PRs up. + // Called by the `revoke_browser_sessions` endpoint, which lands six PRs up. #[allow(dead_code)] /// Signs one browser out of everything, in a single message. pub fn revoke_browser_sessions( @@ -3703,17 +3716,20 @@ impl Storage { } } -// Constructed by the sign-in ceremony, which lands two PRs up. +// Constructed by `prepare_account_session`, which lands two PRs up. #[allow(dead_code)] pub struct CreateSessionParams { pub anchor_number: AnchorNumber, pub origin: FrontendHostname, pub account_number: Option, - /// What the browser proves it holds, and the successor it announces. Its registry - /// entry, its id, and whatever the cap gives up to make room for it are all worked out - /// inside the write, so no caller states any of them. - pub current_browser_key: PublicKey, - pub next_browser_key: PublicKey, + /// The keys the browser proved it holds, and the successor it announced. Verified + /// rather than reported: registering an entry from keys nobody proved would let one + /// browser be claimed by whoever read its keys off the wire, so what this takes is + /// the evidence and not two byte strings. + /// + /// Its registry entry, its id, and whatever the cap gives up to make room for it are + /// all worked out inside the write, so no caller states any of them. + pub browser_keys: VerifiedBrowserKeys, /// Taken only where this sign-in registers a browser. An entry that is advanced /// keeps the description it was registered with. pub browser_description: BrowserDescription, diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index dff56f4ddd..b6abd34d38 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -84,7 +84,7 @@ pub struct SessionLocator { } impl SessionLocator { - // Used by the app delegation path, which lands four PRs up. + // Used by `app_prepare_delegation`, which lands three PRs up. #[allow(dead_code)] /// The account this session is at. pub fn account_key(&self) -> AccountKey { diff --git a/src/internet_identity/src/storage/storable/anchor.rs b/src/internet_identity/src/storage/storable/anchor.rs index ebdb080336..657c6abf03 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -40,9 +40,8 @@ pub struct StorableAnchor { /// Monotonic per-anchor allocator for `browsers`. Ids are never reused. #[n(8)] pub next_browser_id: Option, - /// Stored 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. + /// Stored sessions this anchor holds. Expiry removes a session with no write to + /// observe, so this can read higher than what is live until a write prunes them. #[n(9)] pub session_count: Option, } diff --git a/src/internet_identity/src/storage/storable/session.rs b/src/internet_identity/src/storage/storable/session.rs index cbfe03cfa8..ac59530007 100644 --- a/src/internet_identity/src/storage/storable/session.rs +++ b/src/internet_identity/src/storage/storable/session.rs @@ -24,7 +24,7 @@ pub struct StorableSession { #[n(5)] pub read_only: bool, #[n(6)] - pub session_id: StorableSessionId, + pub id: StorableSessionId, } impl Storable for StorableSession { @@ -50,7 +50,7 @@ impl From for Session { last_refreshed_ns: value.last_refreshed_ns, browser_id: value.browser_id, read_only: value.read_only, - session_id: value.session_id, + session_id: value.id, } } } @@ -64,7 +64,7 @@ impl From for StorableSession { last_refreshed_ns: value.last_refreshed_ns, browser_id: value.browser_id, read_only: value.read_only, - session_id: value.session_id, + id: value.session_id, } } } diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 0dbd4a1fb6..c1cf2f6411 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1,4 +1,5 @@ use crate::archive::{ArchiveData, ArchiveState}; +use crate::browser_key::VerifiedBrowserKeys; use crate::openid::OpenIdCredential; use crate::state::PersistentState; use crate::stats::activity_stats::activity_counter::active_anchor_counter::ActiveAnchorCounter; @@ -108,8 +109,10 @@ pub(crate) fn params_at( anchor_number, origin: SESSION_TEST_ORIGIN.to_string(), account_number: None, - current_browser_key: browser_key(seed, generation), - next_browser_key: browser_key(seed, generation + 1), + browser_keys: VerifiedBrowserKeys::unverified_for_test( + browser_key(seed, generation), + browser_key(seed, generation + 1), + ), browser_description: description(seed), valid_till_ns: now + 10_000, max_idle_ns: None, From baa46c02af4a896cdbdda1c1a0d78aa635ad7617 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:32:34 +0200 Subject: [PATCH 267/298] refactor(session): passing the checks is a type, not a tuple `authorize_session` handed back a pair its callers unpacked positionally, so what the values had been through lived in the function's doc rather than in what it returned. `AuthorizedSession` can only be built inside it, which makes holding one the evidence that the principal lookup and the liveness check both happened. The two `account()` methods returned two different types. The session handle's returns a principal and says so; the locator's answers with a key. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 37 +++++++++++++++---- src/internet_identity/src/storage.rs | 2 +- .../src/storage/storable/session_handle.rs | 5 ++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 9596d65979..c829469d1e 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -354,7 +354,9 @@ pub fn app_prepare_delegation( request: AppPrepareDelegationRequest, ) -> Result { let now = time(); - let (account, session) = authorize_session(now)?; + let AuthorizedSession { + account, session, .. + } = authorize_session(now)?; let expiration = u64::min( now.saturating_add(APP_DELEGATION_TTL_NS), @@ -369,6 +371,8 @@ pub fn app_prepare_delegation( request.session_key, seed.as_ref(), expiration, + // Unscoped on purpose: an app calls whatever canisters it likes. The session + // credential this was minted from is the scoped one. None, access.permissions(), ); @@ -385,7 +389,9 @@ pub fn app_get_delegation( request: AppGetDelegationRequest, ) -> Result { let now = time(); - let (account, session) = authorize_session(now)?; + let AuthorizedSession { + account, session, .. + } = authorize_session(now)?; if request.expiration > now.saturating_add(APP_DELEGATION_TTL_NS) || request.expiration > session.valid_till_ns @@ -422,19 +428,32 @@ pub fn app_get_delegation( .map_err(|_| AppSessionError::NoMatchingSession) } +/// A live session the caller has been proved to be, and where it lives. +/// +/// Only [`authorize_session`] constructs one, so holding it is the evidence rather than +/// three values a caller gathered: the principal lookup and the liveness check have both +/// happened, and no field can be here without them. +struct AuthorizedSession { + // Read by the refresh stamp, which lands one PR up. + #[allow(dead_code)] + locator: SessionLocator, + account: Account, + session: Session, +} + /// 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, Session), AppSessionError> { - let key = storage_borrow(|storage| storage.lookup_session_with_principal(caller())) +fn authorize_session(now: Timestamp) -> Result { + let locator = storage_borrow(|storage| storage.lookup_session_with_principal(caller())) .ok_or(AppSessionError::NoMatchingSession)?; let (account, session) = storage_borrow(|storage| { Some(( - storage.read_account(&key.account_key())?, - storage.read_session(&key)?, + storage.read_account(&locator.account_key())?, + storage.read_session(&locator)?, )) }) .ok_or(AppSessionError::NoMatchingSession)?; @@ -442,7 +461,11 @@ fn authorize_session(now: Timestamp) -> Result<(Account, Session), AppSessionErr if session.is_expired_or_idle(now) { return Err(AppSessionError::NoMatchingSession); } - Ok((account, session)) + Ok(AuthorizedSession { + 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 62ff32c61f..f57438c56b 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3325,7 +3325,7 @@ impl Storage { /// session is ever allocated that id. pub fn lookup_session_with_principal(&self, principal: Principal) -> Option { let handle = self.lookup_session_with_principal_memory.get(&principal)?; - let account = self.lookup_account_with_principal(handle.account())?; + let account = self.lookup_account_with_principal(handle.account_principal())?; Some(SessionLocator { anchor_number: account.anchor_number, diff --git a/src/internet_identity/src/storage/storable/session_handle.rs b/src/internet_identity/src/storage/storable/session_handle.rs index 221c314fd0..1be87b9dcd 100644 --- a/src/internet_identity/src/storage/storable/session_handle.rs +++ b/src/internet_identity/src/storage/storable/session_handle.rs @@ -27,7 +27,10 @@ pub struct StorableSessionHandle { } impl StorableSessionHandle { - pub fn account(&self) -> Principal { + /// The account this session is at, as the principal the field holds. Named for what + /// it returns, because the sibling on [`crate::storage::account::SessionLocator`] + /// answers with an account key. + pub fn account_principal(&self) -> Principal { Principal::from_slice(&self.account_principal) } } From 4af979e15fb3281d62912039d18f9d2e62f6650f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:37:35 +0200 Subject: [PATCH 268/298] fix(session): a stamp that matched nothing is a refusal, not an Ok MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record_session_use` returned `Ok(false)` for three different kinds of "what you named is not there", and `?` at the call site caught only the `Err` — so an app delegation was minted for a session no list holds, which is the one outcome the design exists to prevent. It returns `Result<(), StorageError>` now, with `SessionNotFound`, and the case stops being ignorable because `?` handles it. Nobody made a decision from the bool, so it should not have been one. The refusals also move above the stamp. Returning `Err` on the IC commits whatever was written before it — only a trap rolls back — so a seed that will not derive left the session recorded as used and the caller told the call failed. `stamp_browser_use`'s doc still claimed a return value it does not have, and a saving no caller could make from a value it never receives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 11 +++-- src/internet_identity/src/storage.rs | 41 ++++++++++++------ src/internet_identity/src/storage/anchor.rs | 5 ++- src/internet_identity/src/storage/tests.rs | 48 ++++++++++++--------- 4 files changed, 67 insertions(+), 38 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 2e8006cf41..a7ccf1bd46 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -360,9 +360,11 @@ pub fn app_prepare_delegation( session, } = authorize_session(now)?; - storage_borrow_mut(|storage| storage.record_session_use(&locator, now)) - .map_err(|err| AppSessionError::InternalCanisterError(err.to_string()))?; - + // Everything that can refuse, before the stamp. Returning `Err` on the IC commits + // whatever was written before it — only a trap rolls back — so a stamp above this + // would leave the session recorded as used while the caller is told the call failed. + // All three depend on values already in hand, so there is nothing to gain by + // computing them later. let expiration = u64::min( now.saturating_add(APP_DELEGATION_TTL_NS), session.valid_till_ns, @@ -370,6 +372,9 @@ pub fn app_prepare_delegation( let seed = account_seed(&account)?; let access = DelegationAccess::from_read_only(session.read_only); + storage_borrow_mut(|storage| storage.record_session_use(&locator, now)) + .map_err(|err| AppSessionError::InternalCanisterError(err.to_string()))?; + state::signature_map_mut(|sigs| { add_delegation_signature( sigs, diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 0c2cb51bc4..7a7f113f5d 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3362,13 +3362,14 @@ impl Storage { /// Records that a session was used: its own stamp, its account reference's, and the /// browser's in the device registry. /// - /// `false` where the identity holds no such session, which is not a failure — a - /// session can be revoked between one call and the next. + /// A locator naming nothing is [`StorageError::SessionNotFound`] rather than a + /// quiet non-event. Nobody makes a decision from it, and the one caller that could + /// have ignored it went on to mint a delegation for a session no list holds. pub fn record_session_use( &mut self, key: &SessionLocator, now: Timestamp, - ) -> Result { + ) -> Result<(), StorageError> { let SessionLocator { anchor_number, origin, @@ -3377,27 +3378,27 @@ impl Storage { } = key; let (anchor_number, account_number, session_id) = (*anchor_number, *account_number, *session_id); + let not_found = || StorageError::SessionNotFound { + anchor_number, + session_id, + }; if self.lookup_application_number_with_origin(origin).is_none() { - return Ok(false); + return Err(not_found()); } let mut anchor = self.read(anchor_number)?; let (mut account_references, config) = self.account_state_for_origin(anchor_number, origin); - let Some(write) = account_references + let write = account_references .iter_mut() .find(|write| write.account_reference.account_number == account_number) - else { - return Ok(false); - }; - let Some(session) = write + .ok_or_else(not_found)?; + let session = write .account_reference .sessions .iter_mut() .find(|session| session.session_id == session_id) - else { - return Ok(false); - }; + .ok_or_else(not_found)?; session.last_refreshed_ns = Some(now); let browser_id = session.browser_id; @@ -3422,7 +3423,7 @@ impl Storage { now, BTreeMap::from([(origin.clone(), Some((account_references, config)))]), )?; - Ok(true) + Ok(()) } /// Retires an application no anchor references any more. The number is never @@ -4370,6 +4371,13 @@ pub enum StorageError { SessionAlreadyOver { anchor_number: AnchorNumber, }, + /// The session a caller named is not among the identity's — whether it never was, or + /// has since been revoked, replaced or pruned. Those are one observation rather than + /// three: a session that is not there cannot be told apart from one that never was. + SessionNotFound { + anchor_number: AnchorNumber, + session_id: SessionId, + }, AnchorNumberOutOfRange { anchor_number: AnchorNumber, range: (AnchorNumber, AnchorNumber), @@ -4462,6 +4470,13 @@ impl fmt::Display for StorageError { f, "a session for Identity Anchor {anchor_number} would be over before it started" ), + Self::SessionNotFound { + anchor_number, + session_id, + } => write!( + f, + "Identity Anchor {anchor_number} holds no session {session_id}" + ), Self::DeserializationError(err) => { write!(f, "failed to deserialize a Candid value: {err}") } diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index b1f2b07616..99042cae10 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -766,8 +766,9 @@ impl Anchor { } } - /// Advances a device's `last_used`. Reports whether anything changed, so an unknown - /// device or a repeat inside one message costs no anchor write. + /// Advances a browser's `last_used`, where the anchor holds that browser and the + /// stamp moves it forward. A browser no entry names, or a repeat inside one message, + /// leaves the registry as it is. pub fn stamp_browser_use(&mut self, browser_id: BrowserId, now: Timestamp) { if let Some(browser) = self .browsers diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index ae8ab88ac9..0871eb1c51 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6384,7 +6384,7 @@ mod session_refresh_stamp_tests { use super::held_references; use super::params; use crate::storage::account::{AccountReference, Session, SessionLocator}; - use crate::storage::CreateSessionParams; + use crate::storage::{CreateSessionParams, StorageError}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -6425,9 +6425,8 @@ mod session_refresh_stamp_tests { fn a_refresh_stamps_the_session_and_the_reference() { let (mut storage, anchor_number, key) = storage_with_session(); - let stamped = storage.record_session_use(&key, 2_000).unwrap(); + storage.record_session_use(&key, 2_000).unwrap(); - assert!(stamped); assert_eq!( session_of(&storage, anchor_number).last_refreshed_ns, Some(2_000) @@ -6440,7 +6439,7 @@ mod session_refresh_stamp_tests { let (mut storage, anchor_number, key) = storage_with_session(); for now in [1_001, 1_002, 1_003] { - assert!(storage.record_session_use(&key, now).unwrap()); + storage.record_session_use(&key, now).unwrap(); assert_eq!( session_of(&storage, anchor_number).last_refreshed_ns, Some(now) @@ -6471,7 +6470,7 @@ mod session_refresh_stamp_tests { .is_some()); assert_eq!(storage.read(anchor_number).unwrap().session_count, 2); - assert!(storage.record_session_use(&key, 2_000).unwrap()); + storage.record_session_use(&key, 2_000).unwrap(); let sessions = reference(&storage, anchor_number).sessions; assert_eq!(sessions.len(), 1, "the expired sibling was left behind"); @@ -6486,23 +6485,33 @@ mod session_refresh_stamp_tests { assert_eq!(storage.read(anchor_number).unwrap().session_count, 1); } + /// A locator naming nothing is refused rather than reported as a quiet non-event. + /// The caller that would have ignored a `false` here goes on to mint a delegation, + /// which is the one thing a session no list holds must not get. #[test] - fn a_stamp_for_a_session_that_is_gone_writes_nothing() { + fn a_stamp_for_a_session_that_is_gone_is_refused() { let (mut storage, anchor_number, _key) = storage_with_session(); - let wrote = storage - .record_session_use( - &SessionLocator { - anchor_number, - origin: ORIGIN.to_string(), - account_number: None, - session_id: 9_999, - }, - 5_000, - ) - .unwrap(); + let refused = storage.record_session_use( + &SessionLocator { + anchor_number, + origin: ORIGIN.to_string(), + account_number: None, + session_id: 9_999, + }, + 5_000, + ); - assert!(!wrote); + assert!( + matches!( + refused, + Err(StorageError::SessionNotFound { + anchor_number: refused_anchor, + session_id: 9_999 + }) if refused_anchor == anchor_number + ), + "a locator naming no session should be refused, got {refused:?}" + ); } #[test] @@ -6573,9 +6582,8 @@ mod session_refresh_stamp_tests { fn a_refresh_for_a_device_the_anchor_never_registered_still_stamps_the_session() { let (mut storage, anchor_number, key) = storage_with_session(); - let stamped = storage.record_session_use(&key, 9_000).unwrap(); + storage.record_session_use(&key, 9_000).unwrap(); - assert!(stamped); assert_eq!( session_of(&storage, anchor_number).last_refreshed_ns, Some(9_000) From 8f703bb925b9335e037085662d1a3368b092c52a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:46:02 +0200 Subject: [PATCH 269/298] feat(session): sign-out answers, and one pass removes the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app_revoke_session` was the odd one of its three siblings: no error channel, so `.expect` caught only the `Err` and `Ok(false)` reported a successful sign-out for a session left active. It returns `variant { Ok; Err : AppSessionError }` now, like `app_prepare_delegation` and `app_get_delegation`, and the trap has no job left. A session that is not there is still `Ok` — the caller wanted it gone and it is gone, and they cannot tell a pruned session from one that never existed. `revoke_session` walked the list twice, once to check the session was there and once to remove it, only so it could answer `Ok(false)`. With the not-found error one `retain` does both. `match_session` becomes `find_caller_session`: one finds the caller's session, the other authorizes it, and the return types say so. The comment above the sign-out now gives the narrower reason the two differ — not that an absent session needs revoking, but that a present-and-expired one must not be refused. Which nothing tested, so it does now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/api/internet_identity/api_v2.rs | 10 ++-- .../lib/generated/internet_identity_idl.js | 6 ++- .../generated/internet_identity_types.d.ts | 6 ++- src/internet_identity/internet_identity.did | 8 ++-- src/internet_identity/src/main.rs | 2 +- src/internet_identity/src/sessions.rs | 27 ++++++----- src/internet_identity/src/storage.rs | 39 ++++++++------- src/internet_identity/src/storage/tests.rs | 14 +++--- .../tests/integration/sessions.rs | 47 +++++++++++++++++-- 9 files changed, 108 insertions(+), 51 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 adf6d9095e..2b4d53e253 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -803,12 +803,14 @@ pub fn app_revoke_session( env: &PocketIc, canister_id: CanisterId, sender: Principal, -) -> Result<(), RejectResponse> { - env.update_call( +) -> Result, RejectResponse> { + call_candid_as( + env, canister_id, + RawEffectivePrincipal::None, sender, "app_revoke_session", - candid::encode_args(()).expect("encode app_revoke_session args"), + (), ) - .map(|_| ()) + .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 890085563f..429ed88d6c 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -953,7 +953,11 @@ export const idlFactory = ({ IDL }) => { ], [], ), - 'app_revoke_session' : IDL.Func([], [], []), + 'app_revoke_session' : IDL.Func( + [], + [IDL.Variant({ 'Ok' : IDL.Null, '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 9e17074a22..20d3968833 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -2004,7 +2004,11 @@ export interface _SERVICE { * 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>, + 'app_revoke_session' : ActorMethod< + [], + { 'Ok' : null } | + { '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 08b60a38c7..921ca13b17 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -2019,10 +2019,10 @@ 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 : () -> (); + // Signs the calling session out. A session that is already gone is success, so a + // client that retries, or that signs out twice, does not have to reason about whether + // its session was still there. An app can revoke only its own session. + app_revoke_session : () -> (variant { Ok; Err : AppSessionError }); prepare_account_delegation : ( anchor_number : UserNumber, diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 61b4f78016..250dd43105 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -520,7 +520,7 @@ fn app_prepare_delegation( } #[update] -fn app_revoke_session() { +fn app_revoke_session() -> Result<(), AppSessionError> { sessions::app_revoke_session(ic_cdk::api::time()) } diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 4be8a41359..ca822edc4f 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -469,18 +469,23 @@ fn authorize_session(now: Timestamp) -> Result Result<(), AppSessionError> { + // Found rather than authorized, so a session that is present and past its bounds is + // not refused: it is still the caller's to sign out, and refusing would leave its + // record and index entry behind. + let Ok((locator, _, _)) = find_caller_session() else { + return Ok(()); }; - // 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.revoke_session(&key, now)) - .expect("failed to revoke a session that was just matched"); + match storage_borrow_mut(|storage| storage.revoke_session(&locator, now)) { + Ok(()) | Err(StorageError::SessionNotFound { .. }) => Ok(()), + Err(err) => Err(AppSessionError::InternalCanisterError(err.to_string())), + } } /// The caller's session as stored, without asking whether it is still live. diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 2ade9d4ef2..e6d91b2151 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3426,50 +3426,49 @@ impl Storage { Ok(()) } - /// Ends one session, index entry and count included, and reports whether it was there. + /// Ends one session, index entry and count included. /// /// The key names one session by its id, so a key for a session that was replaced since - /// finds nothing rather than taking its successor down with it. + /// is [`StorageError::SessionNotFound`] rather than taking its successor down with it. + /// One pass removes it and says whether there was anything to remove, so the walk that + /// checked and the walk that removed are the same walk. pub fn revoke_session( &mut self, key: &SessionLocator, now: Timestamp, - ) -> Result { + ) -> Result<(), StorageError> { + let not_found = || StorageError::SessionNotFound { + anchor_number: key.anchor_number, + session_id: key.session_id, + }; + if self .lookup_application_number_with_origin(&key.origin) .is_none() { - return Ok(false); + return Err(not_found()); } let anchor = self.read(key.anchor_number)?; let (mut account_references, config) = self.account_state_for_origin(key.anchor_number, &key.origin); - let Some(write) = account_references + let write = account_references .iter_mut() .find(|write| write.account_reference.account_number == key.account_number) - else { - return Ok(false); - }; - if !write - .account_reference - .sessions - .iter() - .any(|session| session.session_id == key.session_id) - { - return Ok(false); + .ok_or_else(not_found)?; + let sessions = &mut write.account_reference.sessions; + let held = sessions.len(); + sessions.retain(|session| session.session_id != key.session_id); + if sessions.len() == held { + return Err(not_found()); } - write - .account_reference - .sessions - .retain(|session| session.session_id != key.session_id); self.write_account_state( anchor, now, BTreeMap::from([(key.origin.clone(), Some((account_references, config)))]), )?; - Ok(true) + Ok(()) } /// Retires an application no anchor references any more. The number is never diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 404fb1d462..51e82dd5ef 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6596,7 +6596,7 @@ mod session_removal_tests { use super::params; use super::TEST_NOW; use crate::storage::account::SessionLocator; - use crate::storage::CreateSessionParams; + use crate::storage::{CreateSessionParams, StorageError}; use crate::Storage; use ic_stable_structures::VectorMemory; use internet_identity_interface::internet_identity::types::AnchorNumber; @@ -6655,21 +6655,23 @@ mod session_removal_tests { fn removing_a_session_leaves_the_others() { let (mut storage, anchor_number, _, keys) = storage_with_sessions(&[1, 2, 3]); - let removed = storage.revoke_session(&keys[1], TEST_NOW).unwrap(); + storage.revoke_session(&keys[1], TEST_NOW).unwrap(); - assert!(removed); // Ids in registration order, so the seeds 1, 2, 3 became 0, 1, 2. assert_eq!(sessions(&storage, anchor_number), vec![0, 2]); } #[test] - fn removing_a_session_twice_reports_nothing_removed() { + fn removing_a_session_twice_is_refused_the_second_time() { let (mut storage, anchor_number, _, keys) = storage_with_sessions(&[1]); storage.revoke_session(&keys[0], TEST_NOW).unwrap(); - let removed = storage.revoke_session(&keys[0], TEST_NOW).unwrap(); + let refused = storage.revoke_session(&keys[0], TEST_NOW); - assert!(!removed); + assert!( + matches!(refused, Err(StorageError::SessionNotFound { .. })), + "a session already gone should be refused, got {refused:?}" + ); assert_eq!(sessions(&storage, anchor_number), Vec::::new()); } diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 64693b15cc..7305087ed2 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -596,13 +596,52 @@ fn should_end_access_when_the_app_signs_out() -> Result<(), RejectResponse> { }; assert!(refresh(&env).is_ok()); - app_revoke_session(&env, canister_id, session_principal)?; + app_revoke_session(&env, canister_id, session_principal)? + .expect("signing a session out succeeds, present or not"); assert_eq!(refresh(&env), Err(AppSessionError::NoMatchingSession)); Ok(()) } +/// The one reason sign-out finds the session rather than authorizing it: a session past +/// its bounds is still the caller's to sign out, and refusing would leave its record and +/// its index entry behind. Nothing else holds that in place — swap the lookup for the +/// authorizing one and every other test here still passes. +#[test] +fn should_sign_out_a_session_that_has_already_expired() -> 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.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); + + let session_count = |env: &PocketIc| -> Result { + Ok( + identity_info(env, canister_id, principal_1(), identity_number)? + .unwrap() + .browsers + .unwrap()[0] + .session_count, + ) + }; + assert_eq!(session_count(&env)?, 1); + + env.advance_time(Duration::from_secs(11 * 60)); + + app_revoke_session(&env, canister_id, session_principal)? + .expect("an expired session is still the caller's to sign out"); + + // The record and its index entry are gone, not merely unusable. + assert_eq!(session_count(&env)?, 0); + + Ok(()) +} + #[test] fn should_treat_a_repeated_sign_out_as_success() -> Result<(), RejectResponse> { let env = env(); @@ -611,7 +650,8 @@ fn should_treat_a_repeated_sign_out_as_success() -> Result<(), RejectResponse> { let (_, session_principal) = create_session(&env, canister_id, identity_number); for _ in 0..3 { - app_revoke_session(&env, canister_id, session_principal)?; + app_revoke_session(&env, canister_id, session_principal)? + .expect("signing a session out succeeds, present or not"); } Ok(()) @@ -631,7 +671,8 @@ fn should_leave_another_browsers_session_alone() -> Result<(), RejectResponse> { 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)?; + app_revoke_session(&env, canister_id, first_principal)? + .expect("signing a session out succeeds, present or not"); let still_works = app_prepare_delegation( &env, From 0d19439caa1a81d06daf3f42468fb74586ca5f3e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:51:17 +0200 Subject: [PATCH 270/298] chore(session): drop the scaffolding allowances the callers arrive with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#[allow(dead_code)]` on a function whose caller has not landed yet is a promise, and it stops being one the moment the caller does land — from then on it silences a function that is genuinely dead. The sign-in ceremony calls `create_session`, `read_session` and the browser-key verifier, so all three lose theirs here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/browser_key.rs | 3 --- src/internet_identity/src/storage.rs | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/internet_identity/src/browser_key.rs b/src/internet_identity/src/browser_key.rs index e142390ba0..30068b15ae 100644 --- a/src/internet_identity/src/browser_key.rs +++ b/src/internet_identity/src/browser_key.rs @@ -2,9 +2,6 @@ //! //! Knows nothing of sessions or storage, so both layers may depend on it: the endpoint //! verifies, and storage requires the [`VerifiedBrowserKeys`] that verifying produces. -//! -//! `prepare_account_session`, the one caller that verifies, lands two PRs up. -#![allow(dead_code)] use internet_identity_interface::internet_identity::types::{PublicKey, SessionKey}; use p256::ecdsa::signature::Verifier; diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index ae44423fa9..82f8d3581b 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -2987,8 +2987,6 @@ impl Storage { Ok(session_id) } - // Called by `prepare_account_session`, which lands two PRs up. - #[allow(dead_code)] /// Creates the session `prepare_account_session` mints an identity from, replacing /// whatever this browser already held at this account. pub fn create_session( @@ -3154,7 +3152,6 @@ impl Storage { /// /// A key whose session was replaced reads as `None` rather than as its successor: /// the successor was allocated an id of its own. - #[allow(dead_code)] // Read by `get_account_session`, which lands two PRs up. pub fn read_session(&self, key: &SessionLocator) -> Option { let application_number = self.lookup_application_number_with_origin(&key.origin)?; From af7287cd1da98d5fb9808e1e7f53637406686b10 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:57:01 +0200 Subject: [PATCH 271/298] feat(devices): group browsers by the device they run on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One card, headed per platform, instead of a "this device" section and an "other devices" one. The browser being read from is marked in place by a pill, so it keeps its position among the machine's other browsers rather than being lifted out of it. The heading counts browsers and hedges the machines — "2 browsers on Mac device(s)" — because two identical laptops report the same thing and nothing sent can tell them apart. The row carries the brand alone; the platform is on the heading above it. Signed out has a second cause. `MAX_SESSION_TTL_NS` is 30 days and `last_used` advances on every refresh, so a browser idle longer than that holds nothing that can still be minted from, whatever its session count says — the count includes expired records until a write prunes them. Past 90 days the browser is not rendered: nothing can be signed out there, and keeping it buries the rows that can. That also retires the inactive badge, which said what a signed-out row now says by itself, and with it `Badge`'s `warning` colour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/components/ui/Badge.svelte | 4 +- .../(authenticated)/devices/+page.svelte | 169 ++++++++------- .../(authenticated)/devices/browsers.test.ts | 198 +++++++++++++----- .../(authenticated)/devices/browsers.ts | 104 +++++++-- .../devices/components/DeviceRow.svelte | 53 +---- .../devices/components/GroupHeading.svelte | 44 ++++ 6 files changed, 374 insertions(+), 198 deletions(-) create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte diff --git a/src/frontend/src/lib/components/ui/Badge.svelte b/src/frontend/src/lib/components/ui/Badge.svelte index b71d7d29a6..e665645539 100644 --- a/src/frontend/src/lib/components/ui/Badge.svelte +++ b/src/frontend/src/lib/components/ui/Badge.svelte @@ -1,7 +1,7 @@ @@ -43,28 +34,9 @@
    - - {#if inactiveDays !== undefined} - - - - {$t`Inactive for ${inactiveDays} days`} - - + {#if isCurrent} + {$t`This browser`} {/if} diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte new file mode 100644 index 0000000000..2989a361c9 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte @@ -0,0 +1,44 @@ + + +
    + + +

    + {count === 1 + ? $t`1 browser on ${platform}` + : $t`${count} browsers on ${platform} device(s)`} +

    +
    From b66d4de26d2f9fa7263363974ee9dfba34a8b7b0 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 17:57:01 +0200 Subject: [PATCH 272/298] feat(devices): group browsers by the device they run on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One card, headed per platform, instead of a "this device" section and an "other devices" one. The browser being read from is marked in place by a pill, so it keeps its position among the machine's other browsers rather than being lifted out of it. The heading counts browsers and hedges the machines — "2 browsers on Mac device(s)" — because two identical laptops report the same thing and nothing sent can tell them apart. The row carries the brand alone; the platform is on the heading above it. There is no empty state, because the page can never be empty: the browser being read from is always one of the rows. Where the canister holds no record for it — it has signed in to Internet Identity, which is how the page is on screen, but not yet to an app — it renders as signed out, last used never, first seen now. Signed out has a second cause. `MAX_SESSION_TTL_NS` is 30 days and `last_used` advances on every refresh, so a browser idle longer than that holds nothing that can still be minted from, whatever its session count says — the count includes expired records until a write prunes them. Past 90 days the browser is not rendered: nothing can be signed out there, and keeping it buries the rows that can. That also retires the inactive badge, which said what a signed-out row now says by itself, and with it `Badge`'s `warning` colour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/components/ui/Badge.svelte | 4 +- .../(authenticated)/devices/+page.svelte | 159 +++++++------- .../(authenticated)/devices/browsers.test.ts | 198 +++++++++++++----- .../(authenticated)/devices/browsers.ts | 111 ++++++++-- .../devices/components/DeviceRow.svelte | 53 +---- .../devices/components/GroupHeading.svelte | 44 ++++ 6 files changed, 375 insertions(+), 194 deletions(-) create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte diff --git a/src/frontend/src/lib/components/ui/Badge.svelte b/src/frontend/src/lib/components/ui/Badge.svelte index b71d7d29a6..e665645539 100644 --- a/src/frontend/src/lib/components/ui/Badge.svelte +++ b/src/frontend/src/lib/components/ui/Badge.svelte @@ -1,7 +1,7 @@ @@ -43,28 +34,9 @@
    - - {#if inactiveDays !== undefined} - - - - {$t`Inactive for ${inactiveDays} days`} - - + {#if isCurrent} + {$t`This browser`} {/if} diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte new file mode 100644 index 0000000000..2989a361c9 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/GroupHeading.svelte @@ -0,0 +1,44 @@ + + +
    + + +

    + {count === 1 + ? $t`1 browser on ${platform}` + : $t`${count} browsers on ${platform} device(s)`} +

    +
    From 6318708131d0f712c2c5fc6888ff2d6fcb9b624e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 19:43:24 +0200 Subject: [PATCH 273/298] fix(devices): do not draw the synthetic row before the id read finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `thisBrowserId` is `undefined` both before the key record has been read and when there is no record, and the synthetic row treated it as the second. The description resolves first — parsing a user-agent string beats an IndexedDB read — so a browser that does have a record rendered twice for as long as the read took: once as a synthetic "This browser", once as its own row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../manage/(authenticated)/devices/+page.svelte | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte index 43a2d1bbb9..15101441e9 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte @@ -26,11 +26,17 @@ // Read from this browser's own key record rather than from the canister, which has no // way to tell which browser is asking: `identity_info` is signed by an access method. + // `thisBrowserId` stays `undefined` both before the read finishes and when there is no + // record, so the two are tracked apart: without that, a browser that does have a record + // renders twice for as long as the read takes — once as the synthetic row below, once + // as its own. let thisBrowserId = $state(undefined); + let browserIdRead = $state(false); $effect(() => { - void currentBrowserId($authenticatedStore.identityNumber).then( - (id) => (thisBrowserId = id), - ); + void currentBrowserId($authenticatedStore.identityNumber).then((id) => { + thisBrowserId = id; + browserIdRead = true; + }); }); // What this browser is, resolved locally. Needed even when the canister holds no record @@ -52,7 +58,9 @@ // is on screen, and it just has not signed in to an app yet. It reads as signed out, // because it is, and describing it takes no canister data. const unrecorded = $derived( - stored.some((browser) => browser.isCurrent) || thisDescription === undefined + !browserIdRead || + stored.some((browser) => browser.isCurrent) || + thisDescription === undefined ? undefined : { id: NO_RECORD_ID, From f626fdda423830276289930b64392d26a5275af4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 20:41:34 +0200 Subject: [PATCH 274/298] chore(storage): drop the account-principal helper that grew a twin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `account_principal_of` never got the caller its note promised. The sign-in ceremony derives that principal itself, in `sessions.rs`, because storage's version is keyed by an application number while the ceremony has an origin — so the signature was the wrong shape for the one caller it was written for, and the logic was rewritten a layer up instead. Storage keeps `account_principals`, the batch form the index path actually uses. The test that reached for the singular one now reads the principal off the session handle `create_session` wrote, which is the value it was crossing against the index anyway. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 29 +--------------------- src/internet_identity/src/storage/tests.rs | 11 +++++--- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 1f65d74973..6740ebaa91 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -103,9 +103,7 @@ use identity_jose::jwk::Jwk; use internet_identity_interface::archive::types::BufferedEntry; use crate::browser_key::VerifiedBrowserKeys; -use crate::delegation::{ - self, calculate_session_seed_with_salt, canister_sig_principal, check_frontend_length, -}; +use crate::delegation::{self, calculate_session_seed_with_salt, check_frontend_length}; use crate::openid::OpenIdCredentialKey; use crate::state::PersistentState; use crate::stats::event_stats::AggregationKey; @@ -2747,31 +2745,6 @@ impl Storage { } } - // Called by the sign-in ceremony, which lands two PRs up. - #[allow(dead_code)] - /// 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(&AccountKey { - anchor_number, - origin: self - .stable_application_memory - .get(&application_number)? - .origin - .clone(), - account_number, - })?; - Some(canister_sig_principal( - canister_id(), - account.calculate_seed_with_salt(&salt).to_vec(), - )) - } - /// Hands out the next session id, which no session has held before. /// /// Refuses at the ceiling rather than saturating. The id is an input to the session diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index c1cf2f6411..964972bdc1 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5704,9 +5704,14 @@ mod session_creation_tests { .lookup_application_number_with_origin(&ORIGIN.to_string()) .unwrap(); - let account_principal = storage - .account_principal_of(anchor_number, application_number, None) - .expect("the account it was just created for"); + // The principal the handle names, read back off the handle `create_session` + // wrote, which is the value the crossing is about. + let (_, handle) = storage + .lookup_session_with_principal_memory + .iter() + .next() + .expect("the session index holds the session just created"); + let account_principal = Principal::from_slice(&handle.account_principal); let locator = storage .lookup_account_with_principal_memory .get(&account_principal) From a105ec04e81d102faece42ceb9bcb7542a9ee2db Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 20:45:19 +0200 Subject: [PATCH 275/298] refactor(session): one name for one fact across both error types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AccountSessionError::NoSuchSession` and `AppSessionError::NoMatchingSession` said the same thing in two words, beside `NoSuchAccount` and `NoSuchDelegation`. The only difference is how the session was named — by id on one side, by the caller's own principal on the other — which is not worth a second vocabulary. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/internet_identity/types.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index cecce58c04..b2124729c2 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -939,6 +939,10 @@ 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, + /// + /// The same fact its counterpart on [`AccountSessionError`] names, and named the + /// same: the only difference is that this side matches the caller rather than being + /// handed a session id. + NoSuchSession, InternalCanisterError(String), } From dcc6ae603743a350605b25c7714a544df2e3ad2b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 20:51:35 +0200 Subject: [PATCH 276/298] feat(session): a wrong expiration is a missing delegation, not a missing session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app_get_delegation` answered `NoSuchSession` for two failures that are not the session's fault. Both mean the caller asked with an expiration `app_prepare_delegation` never returned: the ceiling guard can only fire on a made-up value — prepare hands back `min(now + 5min, session.valid_till)`, and `now` has only advanced since — and a signature that is absent was never added for those parameters, the session having just been proved live. `NoSuchDelegation` says so, and says what to do: prepare again, rather than sign in afresh. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../lib/generated/internet_identity_idl.js | 3 +- .../generated/internet_identity_types.d.ts | 14 +++- src/internet_identity/internet_identity.did | 6 +- src/internet_identity/src/sessions.rs | 16 +++-- .../tests/integration/sessions.rs | 69 ++++++++++++++++--- .../src/internet_identity/types.rs | 5 ++ 6 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index dd6cd31e82..23a3c33cac 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -154,8 +154,9 @@ export const idlFactory = ({ IDL }) => { 'delegation' : Delegation, }); const AppSessionError = IDL.Variant({ - 'NoMatchingSession' : IDL.Null, + 'NoSuchDelegation' : IDL.Null, 'InternalCanisterError' : IDL.Text, + 'NoSuchSession' : IDL.Null, }); const AppPrepareDelegationRequest = IDL.Record({ 'session_key' : SessionKey, 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 e87c54c73c..d2d2fc497b 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -108,14 +108,22 @@ export interface AppPrepareDelegationResponse { 'expiration' : Timestamp, } export type AppSessionError = { + /** + * The session is live, but nothing was signed for the session_key and expiration + * asked for — so the expiration is one app_prepare_delegation never returned. Prepare + * again and use what comes back; signing in afresh is not the remedy. + */ + 'NoSuchDelegation' : null + } | + { 'InternalCanisterError' : string } | + { /** * 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 }; + 'NoSuchSession' : null + }; /** * Configuration parameters related to the archive. */ diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 16d2fd84f2..3d35c5859e 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1148,7 +1148,11 @@ 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; + NoSuchSession; + // The session is live, but nothing was signed for the session_key and expiration + // asked for — so the expiration is one app_prepare_delegation never returned. Prepare + // again and use what comes back; signing in afresh is not the remedy. + NoSuchDelegation; InternalCanisterError : text; }; diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index c829469d1e..7518126508 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -393,10 +393,14 @@ pub fn app_get_delegation( account, session, .. } = authorize_session(now)?; + // An expiration this canister would never have signed, which means one the caller + // did not get from `app_prepare_delegation`: that returns + // `min(now + APP_DELEGATION_TTL_NS, session.valid_till_ns)`, and `now` has only + // advanced since, so the value it handed out cannot exceed either bound here. if request.expiration > now.saturating_add(APP_DELEGATION_TTL_NS) || request.expiration > session.valid_till_ns { - return Err(AppSessionError::NoMatchingSession); + return Err(AppSessionError::NoSuchDelegation); } let seed = account_seed(&account)?; @@ -425,7 +429,9 @@ pub fn app_get_delegation( }, signature: ByteBuf::from(signature), }) - .map_err(|_| AppSessionError::NoMatchingSession) + // The session is live — `authorize_session` above said so — so a signature that is + // not there was never added for these parameters. + .map_err(|_| AppSessionError::NoSuchDelegation) } /// A live session the caller has been proved to be, and where it lives. @@ -448,7 +454,7 @@ struct AuthorizedSession { /// nothing is attached to it. fn authorize_session(now: Timestamp) -> Result { let locator = storage_borrow(|storage| storage.lookup_session_with_principal(caller())) - .ok_or(AppSessionError::NoMatchingSession)?; + .ok_or(AppSessionError::NoSuchSession)?; let (account, session) = storage_borrow(|storage| { Some(( @@ -456,10 +462,10 @@ fn authorize_session(now: Timestamp) -> Result Result<(), Reje session_key: ByteBuf::from(vec![7; 32]), }, )?, - Err(AppSessionError::NoMatchingSession) + Err(AppSessionError::NoSuchSession) ); Ok(()) @@ -281,7 +281,7 @@ fn should_refuse_a_caller_that_is_not_the_session() -> Result<(), RejectResponse }, )?; - assert_eq!(result, Err(AppSessionError::NoMatchingSession)); + assert_eq!(result, Err(AppSessionError::NoSuchSession)); Ok(()) } @@ -307,7 +307,7 @@ fn should_refuse_a_refresh_once_the_session_has_expired() -> Result<(), RejectRe }, )?; - assert_eq!(result, Err(AppSessionError::NoMatchingSession)); + assert_eq!(result, Err(AppSessionError::NoSuchSession)); Ok(()) } @@ -353,7 +353,60 @@ fn should_refuse_an_app_delegation_longer_than_the_ttl() -> Result<(), RejectRes }, )?; - assert!(matches!(result, Err(AppSessionError::NoMatchingSession))); + // `NoSuchDelegation`, not `NoSuchSession`: the session is live and it is the + // expiration that is wrong. + assert!(matches!(result, Err(AppSessionError::NoSuchDelegation))); + + Ok(()) +} + +/// The other way to arrive at an expiration nothing was signed for: one inside the +/// ceiling, so the guard above lets it through, and simply never prepared. The session is +/// live throughout, which is what makes this a missing delegation rather than a missing +/// session — the app prepares again instead of signing in afresh. +#[test] +fn should_refuse_an_app_delegation_that_was_never_prepared() -> 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 app_key = ByteBuf::from(vec![7; 32]); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + let prepared = app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: app_key.clone(), + }, + )? + .unwrap(); + + // A minute earlier than what `prepare` returned: within the ceiling, never signed. + let never_prepared = prepared.expiration - 60 * 1_000_000_000; + let result = app_get_delegation( + &env, + canister_id, + session_principal, + AppGetDelegationRequest { + session_key: app_key.clone(), + expiration: never_prepared, + }, + )?; + + assert!(matches!(result, Err(AppSessionError::NoSuchDelegation))); + + // The one that was prepared still works, so the session itself is untouched. + assert!(app_get_delegation( + &env, + canister_id, + session_principal, + AppGetDelegationRequest { + session_key: app_key, + expiration: prepared.expiration, + }, + )? + .is_ok()); Ok(()) } @@ -416,7 +469,7 @@ fn should_not_reuse_a_session_across_a_consent_change() -> Result<(), RejectResp session_key: ByteBuf::from(vec![7; 32]), }, )?; - assert_eq!(refreshed_old, Err(AppSessionError::NoMatchingSession)); + assert_eq!(refreshed_old, Err(AppSessionError::NoSuchSession)); Ok(()) } @@ -448,7 +501,7 @@ fn should_end_the_sessions_of_a_browser_the_registry_dropped() -> Result<(), Rej session_key: ByteBuf::from(vec![7; 32]), }, )?; - assert_eq!(refreshed, Err(AppSessionError::NoMatchingSession)); + assert_eq!(refreshed, Err(AppSessionError::NoSuchSession)); Ok(()) } @@ -482,7 +535,7 @@ fn should_refuse_an_app_delegation_renewing_itself() -> Result<(), RejectRespons }, )?; - assert_eq!(result, Err(AppSessionError::NoMatchingSession)); + assert_eq!(result, Err(AppSessionError::NoSuchSession)); Ok(()) } @@ -1032,7 +1085,7 @@ fn should_mint_for_the_calling_session_and_nobody_else() -> Result<(), RejectRes session_key: ByteBuf::from(vec![7; 32]), }, )?, - Err(AppSessionError::NoMatchingSession) + Err(AppSessionError::NoSuchSession) ); Ok(()) diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index b2124729c2..543de4afe5 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -944,5 +944,10 @@ pub enum AppSessionError { /// same: the only difference is that this side matches the caller rather than being /// handed a session id. NoSuchSession, + /// The session is live, but nothing was signed for the `(session_key, expiration)` + /// asked for — so the expiration is one `app_prepare_delegation` never returned. + /// Told apart from `NoSuchSession` because the remedy differs: prepare again and use + /// what comes back, rather than sign in again. + NoSuchDelegation, InternalCanisterError(String), } From 2453bfc7246bf28184874d91c3c741d4c2e2fbb0 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 20:53:35 +0200 Subject: [PATCH 277/298] fix(session): a session lost to a race is not an internal fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stamp in `app_prepare_delegation` flattened every `StorageError` into `InternalCanisterError`, including the not-found one it gained when `record_session_use` stopped returning a bool. That case is a session revoked between `authorize_session` and the stamp — a race, and the same answer the caller would have got a moment earlier. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index bc8b153feb..6e14cdc9d8 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -372,8 +372,15 @@ pub fn app_prepare_delegation( let seed = account_seed(&account)?; let access = DelegationAccess::from_read_only(session.read_only); - storage_borrow_mut(|storage| storage.record_session_use(&locator, now)) - .map_err(|err| AppSessionError::InternalCanisterError(err.to_string()))?; + // A session revoked between `authorize_session` above and this stamp is a race, not + // an internal fault: the answer is the one the caller would have got a moment + // earlier. + storage_borrow_mut(|storage| storage.record_session_use(&locator, now)).map_err( + |err| match err { + StorageError::SessionNotFound { .. } => AppSessionError::NoSuchSession, + other => AppSessionError::InternalCanisterError(other.to_string()), + }, + )?; state::signature_map_mut(|sigs| { add_delegation_signature( From 3e485084890c97f4b97ec6162d3e8aa51c556924 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 9 Sep 2026 21:00:09 +0200 Subject: [PATCH 278/298] feat(session): stamp the access method that signs the browsers out, and pin the guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `revoke_browser_sessions` used the non-recording authorization check, so the passkey that signed every browser out left no trace on the access page. What `check_authz_and_record_activity` writes is that the access method authenticated, which it did, and twelve other authenticated updates already use it; this path writes anyway. Authorization is also the whole protection here — unlike `app_revoke_session`, this endpoint takes the identity number as an argument rather than resting on a caller being unable to produce another session's principal — and nothing pinned it. Now a second principal is refused and the session it aimed at still mints. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/sessions.rs | 12 +++- .../tests/integration/sessions.rs | 58 +++++++++++++++++-- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 431b3206b0..2d831c83e9 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -529,8 +529,16 @@ pub fn revoke_browser_sessions( request: RevokeBrowserSessionsRequest, now: Timestamp, ) -> Result<(), SessionRevokeError> { - check_authorization(request.identity_number) - .map_err(|err| SessionRevokeError::Unauthorized(err.principal))?; + // The recording form, as the twelve other authenticated updates in `main.rs` use: + // what it stamps is that the access method authenticated, which it did, and signing + // every browser out is exactly the kind of thing the access page's `last_used` should + // reflect. This path writes anyway, so the stamp costs nothing extra. + check_authz_and_record_activity(request.identity_number).map_err(|err| match err { + IdentityUpdateError::Unauthorized(principal) => SessionRevokeError::Unauthorized(principal), + IdentityUpdateError::StorageError(_, storage_error) => { + SessionRevokeError::InternalCanisterError(storage_error.to_string()) + } + })?; storage_borrow_mut(|storage| { storage.revoke_browser_sessions(request.identity_number, request.browser_id, now) diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 97a9880dd2..dedccaefd1 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -7,13 +7,13 @@ use canister_tests::api::internet_identity::api_v2::{ }; use canister_tests::flows; use canister_tests::framework::{ - env, install_ii_with_archive, principal_1, time, verify_delegation, BrowserKey, + env, install_ii_with_archive, principal_1, principal_2, time, verify_delegation, BrowserKey, }; use internet_identity_interface::internet_identity::types::{ AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, BrowserBrand, BrowserDescription, BrowserInfo, FormFactor, GetAccountSessionRequest, OperatingSystem, Permissions, PrepareAccountSessionRequest, PrepareAccountSessionResponse, - RevokeBrowserSessionsRequest, + RevokeBrowserSessionsRequest, SessionRevokeError, }; use pocket_ic::{PocketIc, RejectResponse}; use pretty_assertions::assert_eq; @@ -741,6 +741,54 @@ fn should_leave_another_browsers_session_alone() -> Result<(), RejectResponse> { Ok(()) } +/// Authorization is the whole protection on this endpoint. Unlike `app_revoke_session`, +/// which rests on a caller being unable to produce another session's principal, this one +/// takes the identity number as an argument — so nothing but the auth check stands +/// between a stranger and signing every browser of any identity out. +#[test] +fn should_refuse_to_sign_a_browser_out_for_another_principal() -> 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 browser_id = identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .browsers + .unwrap()[0] + .id; + + let refused = revoke_browser_sessions( + &env, + canister_id, + principal_2(), + RevokeBrowserSessionsRequest { + identity_number, + browser_id, + }, + )?; + + assert!( + matches!(refused, Err(SessionRevokeError::Unauthorized(principal)) if principal == principal_2()), + "another principal must not sign this identity's browsers out, got {refused:?}" + ); + + // And the session it tried to end still mints. + assert!(app_prepare_delegation( + &env, + canister_id, + session_principal, + AppPrepareDelegationRequest { + session_key: ByteBuf::from(vec![7; 32]), + }, + )? + .is_ok()); + + Ok(()) +} + #[test] fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { use canister_tests::api::internet_identity::api_v2::identity_info; @@ -806,11 +854,11 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { assert_eq!( refresh(first_principal), - Err(AppSessionError::NoMatchingSession) + Err(AppSessionError::NoSuchSession) ); assert_eq!( refresh(second_principal), - Err(AppSessionError::NoMatchingSession) + Err(AppSessionError::NoSuchSession) ); assert!(refresh(untouched_principal).is_ok()); @@ -834,7 +882,7 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { assert_eq!( refresh(first_principal), - Err(AppSessionError::NoMatchingSession), + Err(AppSessionError::NoSuchSession), "a revoked session came back when its browser signed in again" ); From 2699f79fa2a00953f37a3378fa3b82ce7fa81641 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 12:21:40 +0200 Subject: [PATCH 279/298] fix(storage): an origin too long to store has nothing to read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the origin bound to the write path left the reads behind. `read_account` and `list_accounts` never reach the choke point that mints an application, and the endpoints above them — `get_default_account`, `get_accounts`, `mcp_get_accounts` — check nothing themselves, so an over-long origin flowed through, absence normalised to the derived default, and the canister handed out a principal for an origin it could never persist. They answer nothing now instead of trapping: an origin that cannot be stored has no accounts under it, which is true rather than defensive and holds for callers not yet written. Two integration tests also stopped short of what they name. The delegation one leaned on `verify_delegation`, which builds the message it checks from whatever the reply carries — drop the targets on both sides and the signature still verifies — so the scope and permissions are now asserted outright. The successor-collision one re-signed only one of the two signatures, so the key proof failed first and `SuccessorAlreadyInUse` was never reached. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage.rs | 14 ++++++- src/internet_identity/src/storage/tests.rs | 39 +++++++++++++++++++ .../tests/integration/sessions.rs | 27 ++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index bd81d83b01..fd0a0b2c17 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3372,7 +3372,14 @@ impl Storage { /// The `Account` returned carries the seed the account signs with, so this is also /// the only place that capability is handed out — a caller that holds one has been /// through the check above. + /// + /// An origin too long to store has nothing stored under it, so it answers `None` + /// rather than deriving a default. Absence normally means "derive the default", and + /// without this a caller could be handed the seed of an account at an origin the + /// write path would refuse. pub fn read_account(&self, key: &AccountKey) -> Option { + frontend_length_within_limit(&key.origin).ok()?; + let reference = self .account_references_for_origin(key.anchor_number, &key.origin) .into_iter() @@ -3381,12 +3388,17 @@ impl Storage { self.account_for_reference(key.anchor_number, &key.origin, &reference) } - /// Every account this identity holds at `origin`. + /// Every account this identity holds at `origin`, which is none where the origin is + /// too long to have been stored — see [`Self::read_account`]. pub fn list_accounts( &self, anchor_number: AnchorNumber, origin: &FrontendHostname, ) -> Vec { + if frontend_length_within_limit(origin).is_err() { + return vec![]; + } + self.account_references_for_origin(anchor_number, origin) .iter() .filter_map(|reference| self.account_for_reference(anchor_number, origin, reference)) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 86770cf1c2..0a725d8567 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -976,6 +976,45 @@ mod application_lookup_tests { /// Storage refuses an origin it cannot store rather than trusting the endpoint that /// handed it one. Nothing reachable sends one this long — every endpoint bounds it /// first — so this is about where the guarantee lives, not about a live hazard. + /// Reads answer for the same bound the writes enforce. Without this, absence + /// normalises to the derived default and a caller is handed the seed of an account at + /// an origin nothing could ever store — the read endpoints do no length check of + /// their own. + #[test] + fn an_origin_past_the_limit_holds_nothing_to_read() { + let mut storage = Storage::new((10, 20), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let anchor = storage.allocate_anchor(0).expect("an anchor to read as"); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).expect("writing the identity"); + let too_long = format!("https://{}.com", "a".repeat(FRONTEND_HOSTNAME_LIMIT)); + + assert_eq!( + storage.read_account(&AccountKey { + anchor_number, + origin: too_long.clone(), + account_number: None, + }), + None, + "an unstorable origin must not derive a default account" + ); + assert_eq!(storage.list_accounts(anchor_number, &too_long), vec![]); + + // An origin at the limit still reads the derived default, so the bound is what + // separates them rather than the read path having stopped working. + let at_limit = format!( + "https://{}.com", + "a".repeat(FRONTEND_HOSTNAME_LIMIT - "https://".len() - ".com".len()) + ); + assert!(storage + .read_account(&AccountKey { + anchor_number, + origin: at_limit, + account_number: None, + }) + .is_some()); + } + #[test] fn an_origin_past_the_limit_is_refused_rather_than_stored() { let mut storage = Storage::new((10, 20), VectorMemory::default()); diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 9e9cc7fcb4..6e8340dcd1 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -114,6 +114,21 @@ fn should_create_a_session_and_witness_its_delegation() -> Result<(), RejectResp &fetched.signed_delegation, &env.root_key().unwrap(), ); + + // Asserted rather than left to `verify_delegation`, which builds the message it + // checks from whatever the reply carries: drop the targets on both the signing and + // the witnessing side and the signature still verifies. These two fields are what + // separate a session credential from an unrestricted delegation. + assert_eq!( + fetched.signed_delegation.delegation.targets, + Some(vec![canister_id]), + "the session credential must be usable only against Internet Identity" + ); + assert!( + fetched.signed_delegation.delegation.permissions.is_none(), + "it mints app delegations, which is an update call" + ); + Ok(()) } @@ -465,8 +480,10 @@ fn should_refuse_a_retired_key_and_accept_the_successor() -> Result<(), RejectRe 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. +/// A key another entry already holds cannot be announced as a successor even by a caller +/// who can prove it — two browsers would then race for one entry. The caller who *cannot* +/// prove it is refused a step earlier, which is +/// `should_refuse_a_successor_the_caller_cannot_prove`. #[test] fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectResponse> { let env = env(); @@ -487,6 +504,12 @@ fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectRespons request.next_browser_key = victim.successor().public_key(); request.current_browser_key_signature = attacker.sign(&request.session_key, &request.next_browser_key); + // Signed by the successor being announced, which a test can do and an attacker + // cannot. Without it the key proof fails first and the registry check below is never + // reached. + request.next_browser_key_signature = victim + .successor() + .sign_as_successor(&request.session_key, &request.current_browser_key); let result = prepare_account_session(&env, canister_id, principal_1(), request)?; assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); From 5363e325d0f6a29c3e578e96356bac32fe9ba18f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 12:48:39 +0200 Subject: [PATCH 280/298] test(session): drop a refresh test for a state that cannot occur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a_refresh_for_a_device_the_anchor_never_registered_still_stamps_the_session` built its session with `storage_with_session`, which calls `create_session` and so registers a browser — leaving it a duplicate of the registered-browser refresh above it, under a name claiming the opposite. The state it named is unreachable. A browser is given up only at `MAX_BROWSERS` in `resolve_browser`; the write gate sweeps that browser's sessions in the same write, and `sync_session_index` takes their index entries with them. The session is gone too, so a mint stops at `NoSuchSession` in `find_caller_session` long before the stamp is reached. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/internet_identity/src/storage/tests.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index c0cddb95a1..7325f0ceb5 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -6616,18 +6616,6 @@ mod session_refresh_stamp_tests { 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, key) = storage_with_session(); - - storage.record_session_use(&key, 9_000).unwrap(); - - assert_eq!( - session_of(&storage, anchor_number).last_refreshed_ns, - Some(9_000) - ); - } } mod browser_session_count_tests { From 0ffa02870d085e55f21b9410b5b4eb958b65493d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 13:57:42 +0200 Subject: [PATCH 281/298] refactor(browser): name a browser by the token it gives, wherever it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `describeBrowser` parses a user agent and returns a `BrowserDescription`. Its neighbours in `channelHandlers/` all import `Channel` and handle a JSON-RPC method; it does neither, and one of its two callers is the devices page. It belongs in `lib/utils`. `Vivaldi` and `DuckDuckGo` had hardcoded rows because both carry `Chrome/` and would otherwise be labelled Chrome — two arbitrary picks from a long list. Worse, an unrecognised browser fell back to the whole user agent as its *name*, capped at 64 bytes. Both go: a browser is named by the product token it appends, which covers Vivaldi, Yandex and anything else that self-identifies under one rule. Brave and Arc ship Chrome's agent unchanged and still read as Chrome, which is what they are asking for. Versions are dropped from the name. The canister fixes a description at registration, so one captured here would sit frozen at whichever build first signed in. The operating system had the same fallback, which is how a whole agent could end up in the field. It takes the platform out of the parenthesised block instead. The tests are rebuilt around what is under test — brand, system, form factor, client hints, limits — instead of one six-wide positional tuple, and now cover a fork that names itself, one that hides, and a platform none of the seven names. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/describeBrowser.test.ts | 294 ---------------- .../src/lib/utils/describeBrowser.test.ts | 331 ++++++++++++++++++ .../describeBrowser.ts | 93 ++++- 3 files changed, 418 insertions(+), 300 deletions(-) delete mode 100644 src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts create mode 100644 src/frontend/src/lib/utils/describeBrowser.test.ts rename src/frontend/src/lib/{stores/channelHandlers => utils}/describeBrowser.ts (58%) diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts b/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts deleted file mode 100644 index 8d295e8753..0000000000 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { describeBrowser } from "./describeBrowser"; -import type { - BrowserBrand, - FormFactor, - OperatingSystem, -} from "$lib/generated/internet_identity_types"; - -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"; - -/** - * Every agent this frontend is expected to recognise, with what it resolves to. The - * agents are real strings, because the ordering of the brand table is what makes them - * come out right: a Chromium agent carries `Safari/` and `Chrome/` too. - */ -const AGENTS: [ - string, - string, - number, - BrowserBrand, - OperatingSystem, - FormFactor, -][] = [ - [ - "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, - { Chrome: null }, - { Ios: null }, - { Mobile: null }, - ], - [ - "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, - { Firefox: null }, - { Ios: null }, - { Mobile: null }, - ], - [ - "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, - { Edge: null }, - { Ios: null }, - { Mobile: null }, - ], - [ - "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, - { Opera: null }, - { Ios: null }, - { Mobile: null }, - ], - [ - "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: null }, - { Ios: null }, - { Mobile: null }, - ], - [ - "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: null }, - { Ipados: null }, - { Tablet: null }, - ], - [ - "Safari on iPad", - IPAD_DESKTOP_MODE, - 5, - { Safari: null }, - { Ipados: null }, - { Tablet: null }, - ], - [ - "Safari on Mac", - IPAD_DESKTOP_MODE, - 0, - { Safari: null }, - { Macos: null }, - { Desktop: null }, - ], - [ - "Firefox on Mac", - FIREFOX_MAC, - 0, - { Firefox: null }, - { Macos: null }, - { Desktop: null }, - ], - [ - "Chrome on Android", - CHROME_ANDROID, - 5, - { Chrome: null }, - { Android: null }, - { Mobile: null }, - ], - [ - "Firefox on Android", - "Mozilla/5.0 (Android 14; Mobile; rv:126.0) Gecko/126.0 Firefox/126.0", - 5, - { Firefox: null }, - { Android: null }, - { Mobile: null }, - ], - [ - "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, - { SamsungInternet: null }, - { Android: null }, - { Mobile: null }, - ], - [ - "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, - { Edge: null }, - { Android: null }, - { Mobile: null }, - ], - [ - "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, - { Other: "DuckDuckGo" }, - { Android: null }, - { Mobile: null }, - ], - [ - "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, - { Edge: null }, - { Windows: null }, - { Desktop: null }, - ], - [ - "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, - { Opera: null }, - { Windows: null }, - { Desktop: null }, - ], - [ - "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, - { Chrome: null }, - { Windows: null }, - { Desktop: null }, - ], - [ - "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, - { Other: "Vivaldi" }, - { Linux: null }, - { Desktop: null }, - ], - [ - "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, - { Chrome: null }, - { ChromeOs: null }, - { Desktop: null }, - ], - [ - "Browser on an unknown device", - "curl/8.4.0", - 0, - { Other: "curl/8.4.0" }, - { Other: "curl/8.4.0" }, - { Unknown: null }, - ], -]; - -const stub = (props: Record): void => { - for (const [name, value] of Object.entries(props)) { - Object.defineProperty(navigator, name, { value, configurable: true }); - } -}; - -describe("describeBrowser", () => { - afterEach(() => { - stub({ userAgentData: undefined }); - }); - - it.each(AGENTS)( - "reads %s", - async (_label, agent, touchPoints, brand, os, form_factor) => { - stub({ userAgent: agent, maxTouchPoints: touchPoints }); - - await expect(describeBrowser()).resolves.toEqual({ - brand, - os, - form_factor, - model: [], - }); - }, - ); - - it("takes the model the platform reports", async () => { - stub({ - userAgent: CHROME_ANDROID, - maxTouchPoints: 5, - userAgentData: { - mobile: true, - getHighEntropyValues: () => Promise.resolve({ model: "Pixel 5" }), - }, - }); - - await expect(describeBrowser()).resolves.toMatchObject({ - model: ["Pixel 5"], - }); - }); - - /// Android reports the field with nothing in it off a phone, and an absent model has - /// to stay absent rather than becoming an empty string in the record. - it("treats an empty model as no model", async () => { - stub({ - userAgent: CHROME_ANDROID, - maxTouchPoints: 5, - userAgentData: { - mobile: true, - getHighEntropyValues: () => Promise.resolve({ model: "" }), - }, - }); - - await expect(describeBrowser()).resolves.toMatchObject({ model: [] }); - }); - - it("still describes the browser when the platform refuses the question", async () => { - stub({ - userAgent: CHROME_ANDROID, - maxTouchPoints: 5, - userAgentData: { - mobile: true, - getHighEntropyValues: () => Promise.reject(new Error("not allowed")), - }, - }); - - await expect(describeBrowser()).resolves.toEqual({ - brand: { Chrome: null }, - os: { Android: null }, - form_factor: { Mobile: null }, - model: [], - }); - }); - - /// The current spec says `formFactors`; the versions that shipped it first said - /// `formFactor`. A tablet has to read as one on both. - it("takes a stated form factor in either shape", async () => { - for (const high of [ - { formFactors: ["Tablet"] }, - { formFactor: "Tablet" }, - ]) { - stub({ - userAgent: CHROME_ANDROID, - maxTouchPoints: 5, - userAgentData: { - mobile: true, - getHighEntropyValues: () => Promise.resolve(high), - }, - }); - - await expect(describeBrowser()).resolves.toMatchObject({ - form_factor: { Tablet: null }, - }); - } - }); - - /// The canister refuses a token over its cap, so a resolver must never offer one. - it("caps a token it did not recognise", async () => { - stub({ userAgent: "x".repeat(500), maxTouchPoints: 0 }); - - const description = await describeBrowser(); - const token = "Other" in description.brand ? description.brand.Other : ""; - expect(new TextEncoder().encode(token).length).toBeLessThanOrEqual(64); - }); -}); diff --git a/src/frontend/src/lib/utils/describeBrowser.test.ts b/src/frontend/src/lib/utils/describeBrowser.test.ts new file mode 100644 index 0000000000..2efe81f3ba --- /dev/null +++ b/src/frontend/src/lib/utils/describeBrowser.test.ts @@ -0,0 +1,331 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { describeBrowser } from "./describeBrowser"; +import type { + BrowserBrand, + BrowserDescription, + FormFactor, + OperatingSystem, +} from "$lib/generated/internet_identity_types"; + +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 stub = (props: Record): void => { + for (const [name, value] of Object.entries(props)) { + Object.defineProperty(navigator, name, { value, configurable: true }); + } +}; + +const describing = ( + agent: string, + maxTouchPoints = 0, +): Promise => { + stub({ userAgent: agent, maxTouchPoints }); + return describeBrowser(); +}; + +afterEach(() => { + stub({ userAgentData: undefined }); +}); + +/** + * Real agent strings throughout: what makes these come out right is how the tokens sit + * relative to one another, which a hand-made string would not reproduce. + */ +describe("brand", () => { + /// The six this interface names. Each is read from a token more specific than the + /// `Chrome/` and `Safari/` every one of them also carries. + it.each([ + { + name: "Chrome on iOS, which says CriOS", + agent: + "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", + brand: { Chrome: null } satisfies BrowserBrand, + }, + { + name: "Firefox on iOS, which says FxiOS", + agent: + "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", + brand: { Firefox: null } satisfies BrowserBrand, + }, + { + name: "Edge on iOS, which says EdgiOS", + agent: + "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", + brand: { Edge: null } satisfies BrowserBrand, + }, + { + name: "Opera on iOS, which says OPT", + agent: + "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", + brand: { Opera: null } satisfies BrowserBrand, + }, + { + name: "Safari, whose own token is the WebKit build", + agent: + "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", + brand: { Safari: null } satisfies BrowserBrand, + }, + { + name: "Firefox on a Mac", + agent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", + brand: { Firefox: null } satisfies BrowserBrand, + }, + { + name: "Edge on Windows", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", + brand: { Edge: null } satisfies BrowserBrand, + }, + { + name: "Opera on Windows", + agent: + "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", + brand: { Opera: null } satisfies BrowserBrand, + }, + { + name: "Samsung Internet", + agent: + "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36", + brand: { SamsungInternet: null } satisfies BrowserBrand, + }, + { + name: "plain Chrome, whose last token is Safari", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + brand: { Chrome: null } satisfies BrowserBrand, + }, + ])("names $name", async ({ agent, brand }) => { + await expect(describing(agent)).resolves.toMatchObject({ brand }); + }); + + /// A browser outside the six is named by the token it appends, not by the agent it + /// borrowed — otherwise every Chromium fork reads as Chrome. Read from the agent + /// rather than listed, so one this frontend has never heard of still arrives named. + it.each([ + { + name: "Vivaldi", + agent: + "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", + other: "Vivaldi", + }, + { + name: "Yandex, which this frontend has never been taught", + agent: + "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 YaBrowser/24.4.1 Safari/537.36", + other: "YaBrowser", + }, + ])("names $name by its own token", async ({ agent, other }) => { + await expect(describing(agent)).resolves.toMatchObject({ + brand: { Other: other }, + }); + }); + + /// Brave ships Chrome's agent byte for byte, deliberately. There is no token to find + /// and nothing to report but Chrome — which is the honest answer, not a gap. + it("reads a browser that hides itself as the one it imitates", async () => { + await expect( + describing( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + ), + ).resolves.toMatchObject({ brand: { Chrome: null } }); + }); + + /// The canister fixes a description at registration, so a version captured here would + /// sit frozen at whichever build first signed in. + it("keeps the version out of the name", async () => { + const { brand } = await describing( + "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", + ); + + expect(brand).toEqual({ Other: "Vivaldi" }); + }); +}); + +describe("operating system", () => { + it.each([ + { + name: "Chromebook", + agent: + "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + os: { ChromeOs: null } satisfies OperatingSystem, + }, + { + name: "Android", + agent: CHROME_ANDROID, + os: { Android: null } satisfies OperatingSystem, + }, + { + name: "iPhone", + agent: + "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", + os: { Ios: null } satisfies OperatingSystem, + }, + { + name: "iPad", + agent: + "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", + os: { Ipados: null } satisfies OperatingSystem, + }, + { + name: "Windows", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + os: { Windows: null } satisfies OperatingSystem, + }, + { + name: "Linux", + agent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + os: { Linux: null } satisfies OperatingSystem, + }, + ])("reads $name", async ({ agent, os }) => { + await expect(describing(agent)).resolves.toMatchObject({ os }); + }); + + /// An iPad in desktop mode sends a Mac agent and exposes no hints, so the touch points + /// are the only thing telling it from a Mac. A Mac reports none. + it.each([ + { name: "a Mac", touchPoints: 0, os: { Macos: null } }, + { + name: "an iPad pretending to be one", + touchPoints: 5, + os: { Ipados: null }, + }, + ])("tells $name apart by its touch points", async ({ touchPoints, os }) => { + await expect( + describing( + "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", + touchPoints, + ), + ).resolves.toMatchObject({ os }); + }); + + /// A platform none of the seven names is still named by the agent, in the first + /// segment of its parenthesised block — the whole agent is not an operating system. + it("takes an unknown platform from where the agent states it", async () => { + await expect( + describing( + "Mozilla/5.0 (Haiku; U; Haiku BePC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + ), + ).resolves.toMatchObject({ os: { Other: "Haiku" } }); + }); +}); + +describe("form factor", () => { + it.each([ + { + name: "a phone from its agent", + agent: CHROME_ANDROID, + touchPoints: 5, + form_factor: { Mobile: null } satisfies FormFactor, + }, + { + name: "an Android tablet, which says Android without saying Mobile", + agent: + "Mozilla/5.0 (Linux; Android 13; SM-X710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + touchPoints: 5, + form_factor: { Tablet: null } satisfies FormFactor, + }, + { + name: "a desktop", + agent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + touchPoints: 0, + form_factor: { Desktop: null } satisfies FormFactor, + }, + ])("reads $name", async ({ agent, touchPoints, form_factor }) => { + await expect(describing(agent, touchPoints)).resolves.toMatchObject({ + form_factor, + }); + }); +}); + +describe("client hints", () => { + const withHints = (high: unknown, mobile = true) => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + mobile, + getHighEntropyValues: () => Promise.resolve(high), + }, + }); + return describeBrowser(); + }; + + it("takes the model the platform reports", async () => { + await expect(withHints({ model: "Pixel 5" })).resolves.toMatchObject({ + model: ["Pixel 5"], + }); + }); + + /// Android reports the field with nothing in it off a phone, and an absent model has + /// to stay absent rather than becoming an empty string in the record. + it("treats an empty model as no model", async () => { + await expect(withHints({ model: "" })).resolves.toMatchObject({ + model: [], + }); + }); + + /// The current spec says `formFactors`; the versions that shipped it first said + /// `formFactor`. A tablet has to read as one on both. + it.each([ + { + name: "the plural the spec settled on", + high: { formFactors: ["Tablet"] }, + }, + { name: "the singular that shipped first", high: { formFactor: "Tablet" } }, + ])("takes a stated form factor in $name", async ({ high }) => { + await expect(withHints(high)).resolves.toMatchObject({ + form_factor: { Tablet: null }, + }); + }); + + it("still describes the browser when the platform refuses the question", async () => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + mobile: true, + getHighEntropyValues: () => Promise.reject(new Error("not allowed")), + }, + }); + + await expect(describeBrowser()).resolves.toEqual({ + brand: { Chrome: null }, + os: { Android: null }, + form_factor: { Mobile: null }, + model: [], + }); + }); +}); + +/// The canister refuses a token over its cap, so a resolver never offers one. +describe("token limits", () => { + it("caps a brand it read off the agent", async () => { + const { brand } = await describing( + `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ${"B".repeat(200)}/1.0`, + ); + + const token = "Other" in brand ? brand.Other : ""; + expect(new TextEncoder().encode(token).length).toBeLessThanOrEqual(64); + }); + + it("caps a model the platform reported", async () => { + stub({ + userAgent: CHROME_ANDROID, + maxTouchPoints: 5, + userAgentData: { + mobile: true, + getHighEntropyValues: () => Promise.resolve({ model: "M".repeat(200) }), + }, + }); + + const { model } = await describeBrowser(); + expect(new TextEncoder().encode(model[0] ?? "").length).toBeLessThanOrEqual( + 64, + ); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts b/src/frontend/src/lib/utils/describeBrowser.ts similarity index 58% rename from src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts rename to src/frontend/src/lib/utils/describeBrowser.ts index 2077060440..0b7bec150a 100644 --- a/src/frontend/src/lib/stores/channelHandlers/describeBrowser.ts +++ b/src/frontend/src/lib/utils/describeBrowser.ts @@ -31,13 +31,57 @@ const BRANDS: [RegExp, BrowserBrand][] = [ [/EdgA\/|Edg\//, { Edge: null }], [/OPR\//, { Opera: null }], [/SamsungBrowser\//, { SamsungInternet: null }], - // Names itself but has no variant of its own, so it travels as the token it gave. - [/Vivaldi\//, { Other: "Vivaldi" }], - [/DuckDuckGo\//, { Other: "DuckDuckGo" }], +]; + +/** + * The two every Chromium and WebKit browser carries, whoever built it. Consulted last, + * because matching one says only which engine is underneath — a fork that names itself + * is named by its own token instead. + */ +const ENGINE_BRANDS: [RegExp, BrowserBrand][] = [ [/Chrome\//, { Chrome: null }], [/Safari\//, { Safari: null }], ]; +/// Tokens every agent carries whoever built the browser: the engine chain, the platform +/// marker, and the two Chromium ships. A browser that names itself does so with a token +/// that is none of these. +const SHARED_TOKENS = new Set([ + "Mozilla", + "AppleWebKit", + "KHTML", + "Gecko", + "Chrome", + "Chromium", + "Safari", + "Version", + "Mobile", +]); + +/** + * The product a browser names itself by, where it names one at all. + * + * An agent is a chain of `product/version` tokens and whether the browser's own is among + * them is the vendor's choice: Vivaldi, Opera, Yandex and DuckDuckGo append theirs, while + * Brave and Arc ship Chrome's agent unchanged and cannot be told from it. Read here + * rather than listed, so a browser this frontend has never heard of still arrives under + * its own name instead of under the one it borrowed. + * + * The version is dropped: the canister fixes a description at registration, so a version + * captured here would sit frozen at whichever build first signed in. + */ +const productToken = (agent: string): string | undefined => { + // `product/version` pairs only. Bare words are not products — an agent carries several + // in its parenthesised block, and `(KHTML, like Gecko)` alone would otherwise offer + // "like" as a browser name. + const products = [...agent.matchAll(/([A-Za-z][\w.-]*)\/[\w.]+/g)].map( + ([, name]) => name, + ); + // The last one, because a browser that names itself appends its token after the + // engine's and Chromium's. + return products.filter((name) => !SHARED_TOKENS.has(name)).pop(); +}; + /** Truncated on a character boundary, because the cap the canister enforces is in bytes. */ const capped = (token: string): string => { const encoder = new TextEncoder(); @@ -48,8 +92,29 @@ const capped = (token: string): string => { return capped; }; -const brandOf = (agent: string): BrowserBrand => - BRANDS.find(([token]) => token.test(agent))?.[1] ?? { Other: capped(agent) }; +/** + * A named variant where this frontend has one, otherwise the browser's own token. + * + * Three passes, in this order. A specific token wins outright — `CriOS/` is Chrome on + * iOS, and no fork borrows it. Failing that, a token the browser named itself by, so a + * Chromium fork reads as itself rather than as Chrome. Only then the engine tokens every + * one of them carries. + */ +const brandOf = (agent: string): BrowserBrand => { + const named = BRANDS.find(([token]) => token.test(agent))?.[1]; + if (named !== undefined) { + return named; + } + const own = productToken(agent); + if (own !== undefined) { + return { Other: capped(own) }; + } + return ( + ENGINE_BRANDS.find(([token]) => token.test(agent))?.[1] ?? { + Other: capped(agent), + } + ); +}; const systemOf = (agent: string, touchPoints: number): OperatingSystem => { if (/CrOS/.test(agent)) return { ChromeOs: null }; @@ -62,7 +127,23 @@ const systemOf = (agent: string, touchPoints: number): OperatingSystem => { return touchPoints > 0 ? { Ipados: null } : { Macos: null }; if (/Windows/.test(agent)) return { Windows: null }; if (/Linux|X11/.test(agent)) return { Linux: null }; - return { Other: capped(agent) }; + return { Other: capped(platformToken(agent) ?? agent) }; +}; + +/** + * The platform a user agent names, for the systems above that none of the known ones + * matched: it is the first segment of the first parenthesised block. + * + * Barely reachable — `X11` and `Linux` sweep up almost everything the seven above miss, + * and this code only ever runs inside a browser — but where it is reached the block + * still names the system, and the whole agent is not a system. + */ +const platformToken = (agent: string): string | undefined => { + const named = agent + .match(/\(([^)]*)\)/)?.[1] + .split(";")[0] + .trim(); + return named === undefined || named.length === 0 ? undefined : named; }; const formFactorOf = ( From ee0a03ffac430834a760b25fe5a39ca3f78d6eb3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 14:00:08 +0200 Subject: [PATCH 282/298] refactor(browser-key): the store rotates, rather than asking to be told to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `accept` was a callback the caller had to remember to fire, and forgetting it compiles. The record then keeps the key the canister has just retired, so every later sign-in pays a stale-key recovery round-trip; `currentBrowserId` stays absent, which costs the devices page its marker and `forgetIdentity` its canister call; and a browser that changed what it reports goes unnoticed, because there is no stored description to compare. `withBrowserProof` takes `browserIdOf` instead. The caller still supplies the id — it only exists in the canister's reply — but a required argument is something `tsc` refuses to let it omit, and the rotation happens here, once, beside the id it is written with. The store's own tests split along the line that now matters: `signIn` is a call the canister answered, `attempt` one it did not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/browser-key.store.test.ts | 50 +++++++++++++------ .../src/lib/stores/browser-key.store.ts | 28 ++++++++--- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/src/frontend/src/lib/stores/browser-key.store.test.ts b/src/frontend/src/lib/stores/browser-key.store.test.ts index b2dbf7e42e..1906434871 100644 --- a/src/frontend/src/lib/stores/browser-key.store.test.ts +++ b/src/frontend/src/lib/stores/browser-key.store.test.ts @@ -17,6 +17,7 @@ vi.mock("idb-keyval", async (importOriginal) => { }; }); import { + type BrowserProof, currentBrowserId, StaleBrowserKeyError, withBrowserProof, @@ -92,21 +93,34 @@ const signIn = ( identityNumber, sessionKey(seed), description, - async (proof) => { - await proof.accept(browserId); - return proof; - }, + (proof) => Promise.resolve(proof), + () => browserId, ); -/** Signs in without accepting, the way a call that fails or never returns leaves it. */ -const attempt = ( +/** Signs in without the canister answering, the way a call that fails leaves it: the + * rotation belongs to a sign-in that came back, so nothing advances here. The proof is + * captured on the way past, since there is no value to return. */ +const attempt = async ( identityNumber: bigint, seed: number, description: BrowserDescription = CHROME_ON_A_MAC, -) => - withBrowserProof(identityNumber, sessionKey(seed), description, (proof) => - Promise.resolve(proof), - ); +): Promise => { + let attempted: BrowserProof | undefined; + await withBrowserProof( + identityNumber, + sessionKey(seed), + description, + (proof) => { + attempted = proof; + return Promise.reject(new Error("no answer")); + }, + () => 1, + ).catch(() => undefined); + if (attempted === undefined) { + throw new Error("the proof was never built"); + } + return attempted; +}; /** jsdom has no Web Locks, so this is what serialisation is tested against. */ const stubLockApi = (): void => { @@ -210,6 +224,7 @@ describe("browser key", () => { } return Promise.resolve(attempted); }, + () => 1, ); expect(seen).toBe(2); @@ -242,6 +257,7 @@ describe("browser key", () => { ? Promise.reject(new StaleBrowserKeyError()) : Promise.resolve(attempted); }, + () => 1, ); expect(seen).toBe(2); @@ -252,10 +268,16 @@ describe("browser key", () => { let seen = 0; await expect( - withBrowserProof(IDENTITY, sessionKey(1), CHROME_ON_A_MAC, () => { - seen += 1; - return Promise.reject(new Error("network")); - }), + withBrowserProof( + IDENTITY, + sessionKey(1), + CHROME_ON_A_MAC, + () => { + seen += 1; + return Promise.reject(new Error("network")); + }, + () => 1, + ), ).rejects.toThrow("network"); expect(seen).toBe(1); }); diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts index 6743057b2e..7cac800d33 100644 --- a/src/frontend/src/lib/stores/browser-key.store.ts +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -111,8 +111,6 @@ export interface BrowserProof { 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: (browserId: number) => Promise; } /** Serialises sign-ins for one identity: two at once would leave us holding a key the @@ -195,6 +193,7 @@ const attempt = async ( sessionKey: Uint8Array, description: BrowserDescription, signIn: (proof: BrowserProof) => Promise, + browserIdOf: (value: T) => number, from?: BrowserKeyRecord, ): Promise => { const { keyPair, announced: successor } = await prepared( @@ -217,21 +216,32 @@ const attempt = async ( ), ]); - return signIn({ + const value = await signIn({ publicKey, nextPublicKey, signature, nextSignature, - accept: (browserId) => - write(identityNumber, { keyPair: successor, browserId, description }), }); + + // The canister accepted, so this browser is now the successor it announced. Done here + // rather than handed back as something to call: a caller that forgot would keep + // proving with the key the canister has just retired, and pay a recovery round-trip at + // every later sign-in with nothing to say why. + await write(identityNumber, { + keyPair: successor, + browserId: browserIdOf(value), + description, + }); + return value; }; /** * 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. + * exactly one sign-in. Advancing to the successor is this function's own job, done once + * the canister has answered — which is why the id is asked for as `browserIdOf` rather + * than left to the caller to hand back. * * The canister accepts only the successor an entry is waiting for, so a sign-in whose * response was lost leaves this browser proving with a key that has since been retired. @@ -244,6 +254,10 @@ export const withBrowserProof = ( sessionKey: Uint8Array, description: BrowserDescription, signIn: (proof: BrowserProof) => Promise, + /** Which browser the canister said this is, read off whatever `signIn` returned. A + * required argument rather than a callback to remember: the id and the rotation are + * written together, and `tsc` refuses a caller that offers neither. */ + browserIdOf: (value: T) => number, ): Promise => exclusively(identityNumber, async () => { const from = await forDescription(identityNumber, description); @@ -253,6 +267,7 @@ export const withBrowserProof = ( sessionKey, description, signIn, + browserIdOf, from, ); } catch (error) { @@ -275,6 +290,7 @@ export const withBrowserProof = ( sessionKey, description, signIn, + browserIdOf, promoted, ); } From f7b6eb56cac41c8175186f305ebd93e4e76a2d57 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 14:02:47 +0200 Subject: [PATCH 283/298] fix(app-sessions): finish removing an identity before leaving the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Sign out and remove" fired `forgetIdentity` and replaced the page on the next line, so the update call ending this browser's sessions was still in flight when the page went. The apps keep chains rooted at session records the canister still holds and go on refreshing against them — the one thing removal exists to stop. It is awaited now, and since an update call is seconds the dialog shows it: a progress ring on the button and both buttons locked, because leaving by the other one navigates out from under the revoke just the same. Reading sessions now also clears what it finds dead. The whole store is already in memory, so putting expiry ahead of the origin filter costs nothing and reaches records for origins the user never returns to, which is where they accumulated. A key the current format could not have produced goes with them: no reader can reach it and no purge matches it. `normalize` copied every record read, to stop a caller writing back through it. IndexedDB hands out a fresh structured clone on each read, so there was nothing to write back through — and the shallow spread would not have isolated the nested key pair anyway. `purgeAppSessions` becomes one function per store, matching on a parsed identity rather than a string prefix, so re-keying one store cannot quietly re-key the other and the nested `Promise.all`s go. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../components/ui/SignOutConfirmation.svelte | 32 ++++++++-- .../src/lib/stores/app-session.store.ts | 59 ++++++++++++------- .../manage/(authenticated)/+layout.svelte | 9 ++- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte b/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte index 699bfc7df8..176510d83f 100644 --- a/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte +++ b/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte @@ -5,14 +5,29 @@ import { LogOutIcon } from "@lucide/svelte"; import FeaturedIcon from "./FeaturedIcon.svelte"; import IdentityListItem from "./IdentityListItem.svelte"; + import ProgressRing from "./ProgressRing.svelte"; type Props = { identity: LastUsedIdentity; onSignOut: () => void; - onSignOutAndRemove: () => void; + /** Ends this browser's sessions canister-side before the page goes, so it is an + * update call away — hence the pending state, and hence both buttons locked while + * it runs: leaving by the other one would navigate out from under the revoke. */ + onSignOutAndRemove: () => Promise; }; let { identity, onSignOut, onSignOutAndRemove }: Props = $props(); + + let isRemoving = $state(false); + + const handleSignOutAndRemove = async () => { + isRemoving = true; + try { + await onSignOutAndRemove(); + } finally { + isRemoving = false; + } + };
    @@ -38,11 +53,20 @@
    - -
    diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index c60f00ffd8..a987cb56d3 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -53,10 +53,6 @@ interface SessionKey { origin: string; } -/** Returns a copy, so a caller mutating the record cannot write back into the store - * through the object IndexedDB handed us. */ -const normalize = (record: T): T => ({ ...record }); - const sessionKey = ({ identityNumber, accountNumber, @@ -141,10 +137,22 @@ export const appSessionsForOrigin = async ( return (await readAll(APP_SESSION_STORE)).flatMap( ([key, record]) => { const parsed = parseKey(key); - if (parsed?.origin !== origin) { + // A key `sessionKey` could not have produced — an older format, most likely. No + // reader reaches it and `purgeSessionsOf` matches on a parsed identity, so it + // would sit here forever. + if (parsed === undefined) { + void idbDel(key, APP_SESSION_STORE).catch(() => {}); return []; } + // Before the origin filter, and so for every key rather than this origin's: the + // whole store is already in memory, and an origin the user never returns to would + // otherwise keep its dead record for the life of the profile. Not awaited, the way + // every other delete here is, so a read costs no write. if (record.expiresAtMillis - EXPIRY_MARGIN_MS <= now) { + void idbDel(key, APP_SESSION_STORE).catch(() => {}); + return []; + } + if (parsed.origin !== origin) { return []; } return [ @@ -152,7 +160,7 @@ export const appSessionsForOrigin = async ( identityNumber: parsed.identityNumber, accountNumber: parsed.accountNumber, accountPrincipal: accounts.get(key), - record: normalize(record), + record, }, ]; }, @@ -174,28 +182,37 @@ export const appAccountsForOrigin = async ( { identityNumber: parsed.identityNumber, accountNumber: parsed.accountNumber, - record: normalize(record), + record, }, ] : []; }, ); +/** Every session this identity holds here, whatever origin it is at. */ +const purgeSessionsOf = async (identityNumber: bigint): Promise => { + const keys = (await readAll(APP_SESSION_STORE)) + .map(([key]) => key) + .filter((key) => parseKey(key)?.identityNumber === identityNumber); + await Promise.all( + keys.map((key) => idbDel(key, APP_SESSION_STORE).catch(() => {})), + ); +}; + +/** The account mappings for the same identity, which are keyed the same way today but + * are not the same data — so re-keying one store cannot quietly re-key the other. */ +const purgeAccountsOf = async (identityNumber: bigint): Promise => { + const keys = (await readAll(APP_ACCOUNT_STORE)) + .map(([key]) => key) + .filter((key) => parseKey(key)?.identityNumber === identityNumber); + await Promise.all( + keys.map((key) => idbDel(key, APP_ACCOUNT_STORE).catch(() => {})), + ); +}; + export const purgeAppSessions = async ( identityNumber: bigint, ): Promise => { - const prefix = `${identityNumber.toString()}:`; - await Promise.all( - [APP_SESSION_STORE, APP_ACCOUNT_STORE].map(async (store) => - Promise.all( - (await readAll(store)) - .map(([key]) => key) - .filter( - (key): key is string => - typeof key === "string" && key.startsWith(prefix), - ) - .map((key) => idbDel(key, store).catch(() => {})), - ), - ), - ); + await purgeSessionsOf(identityNumber); + await purgeAccountsOf(identityNumber); }; 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 fd5f31cadb..65bb5c087c 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte @@ -115,10 +115,15 @@ window.location.replace("/"); }; - const handleConfirmSignOutAndRemove = () => { + const handleConfirmSignOutAndRemove = async () => { const identityNumber = $authenticatedStore.identityNumber; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void forgetIdentity(identityNumber); + // Awaited: this ends the browser's sessions canister-side, and replacing the page + // while that update call is in flight leaves the apps refreshing against session + // records that still exist — which is the one thing "remove" is meant to stop. + // `forgetIdentity` swallows a canister failure and clears the local records either + // way, so waiting cannot strand the user here. + await forgetIdentity(identityNumber); sessionStore.reset(); window.location.replace("/"); }; From 1d1860d2c5df2f6ae00498db0d3d0654df3cae57 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 14:08:52 +0200 Subject: [PATCH 284/298] fix(session-delegation): a duration it cannot read is an error, not silence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StringToBigIntCodec` calls `BigInt` straight, and a throw inside a codec escapes `safeParse` rather than becoming an issue. The parse runs above the handler's `try`, so a duration like `"soon"` never reached the invalid-params branch and the app was told nothing at all. `Nat64StringCodec` reports it instead — and bounds it, since `BigInt` alone accepts `"-1"` and `""`. No regex: a second grammar beside `BigInt`'s own would be free to drift from it. `remapToLegacyDomain` moves to `urlUtils`, beside the two constants it already used, and is re-exported from `iiConnection` for its callers there. It is seven lines of string work with nothing to do with a connection, and importing it from there forced this handler's test to mock the whole legacy module — as the identity function, which quietly disabled the remap it was standing in for. The key soup gets names that say whose key each is: `recordKey` is where the session is stored, `iiSessionIdentity` and `iiSessionPublicKey` are II's own, and the request's `sessionPublicKey` stays the app's. With a comment on why that identity exists at all, which is a security property: over a redirect no verified origin identifies the requester, so the canister must not certify toward a key the request supplied. Tests reach the ceremony for the first time — the canister arguments, the stored record and the returned chain — plus the malformed duration above. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 135 +++++++++++++++++- .../channelHandlers/sessionDelegation.ts | 43 ++++-- src/frontend/src/lib/utils/iiConnection.ts | 20 +-- src/frontend/src/lib/utils/transport/utils.ts | 32 +++++ src/frontend/src/lib/utils/urlUtils.ts | 11 ++ 5 files changed, 207 insertions(+), 34 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 12831c9bce..10e719085c 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -14,16 +14,47 @@ vi.mock("$lib/globals", async () => { 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(); + +const IDENTITY = BigInt(10_000); +const prepareAccountSession = vi.fn(); +const getAccountSession = vi.fn(); + vi.mock("$lib/stores/authorization.store", () => ({ authorizationStore: { setRequestContext: (...args: unknown[]) => setRequestContext(...args), }, - authorizedStore: { subscribe: () => () => {} }, + // A store that already holds its value, which is what `waitForStore` waits for. + // Inlined rather than shared, because `vi.mock` is hoisted above anything declared + // here. + authorizedStore: { + subscribe: (run: (value: unknown) => void) => { + run({ + accessLevel: "full-access", + maxTimeToLive: undefined, + accountNumberPromise: Promise.resolve(undefined), + }); + return () => {}; + }, + }, +})); +vi.mock("$lib/stores/authentication.store", () => ({ + authenticationStore: { + subscribe: (run: (value: unknown) => void) => { + run({ + identityNumber: BigInt(10_000), + authMethod: { passkey: {} }, + actor: { + prepare_account_session: (...args: unknown[]) => + prepareAccountSession(...args), + get_account_session: (...args: unknown[]) => + getAccountSession(...args), + }, + }); + return () => {}; + }, + }, })); import { @@ -32,7 +63,13 @@ import { } from "./sessionDelegation"; import { StaleBrowserKeyError } from "$lib/stores/browser-key.store"; import { CanisterError } from "$lib/utils/utils"; -import { purgeAppSessions } from "$lib/stores/app-session.store"; +import { + appSessionsForOrigin, + purgeAppSessions, +} from "$lib/stores/app-session.store"; +import { ECDSAKeyIdentity } from "@icp-sdk/core/identity"; +import { Principal } from "@icp-sdk/core/principal"; +import { Base64ToBytesCodec } from "$lib/utils/transport/utils"; const channelWith = () => { const sent: unknown[] = []; @@ -94,6 +131,94 @@ describe("ii_session_delegation", () => { expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); expect(onError).toHaveBeenCalledWith("invalid-request"); }); + + /// A duration `BigInt` cannot read used to throw out of `safeParse`, which sits above + /// the handler's `try`, so the app was told nothing at all. + it("rejects a duration that is not a number", async () => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { + sessionPublicKey: btoa("an app key"), + maxTimeToLive: "not a number", + }, + }); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); + expect(onError).toHaveBeenCalledWith("invalid-request"); + }); + + /// The whole ceremony, which nothing else here reaches: what the canister is asked + /// for, what is kept, and what the app is handed back. + it("mints a session and answers with a chain the app can use", async () => { + const { channel, sent } = channelWith(); + const appKey = await ECDSAKeyIdentity.generate({ extractable: false }); + const appPublicKey = new Uint8Array(appKey.getPublicKey().toDer()); + const expiration = BigInt(Date.now() + 60 * 60 * 1000) * BigInt(1_000_000); + + prepareAccountSession.mockImplementation(({ session_key }) => + Promise.resolve({ + Ok: { + user_key: session_key, + expiration, + session_id: BigInt(77), + browser_id: 3, + account_principal: Principal.anonymous(), + }, + }), + ); + getAccountSession.mockImplementation(({ session_key }) => + Promise.resolve({ + Ok: { + signed_delegation: { + delegation: { pubkey: session_key, expiration, targets: [] }, + // At least 32 bytes: the chain's own parser refuses anything shorter. + signature: new Uint8Array(64).fill(7), + }, + }, + }), + ); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: Base64ToBytesCodec.encode(appPublicKey) }, + }); + + // Asked for what the request and the consent said, at this origin. + expect(prepareAccountSession).toHaveBeenCalledWith( + expect.objectContaining({ + identity_number: IDENTITY, + origin: ORIGIN, + account_number: [], + }), + ); + + // Kept, so a later silent re-auth resumes rather than signing in again — and kept + // against II's own key, never the app's. + const [stored] = await appSessionsForOrigin(ORIGIN); + expect(stored.record.sessionId).toBe(BigInt(77)); + expect(stored.identityNumber).toBe(IDENTITY); + + // Answered, and the chain ends at the app's key rather than at what the canister + // signed: the hop only II can make is what makes the on-chain half unusable alone. + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1 }); + const result = (sent[0] as { result: { publicKey: string } }).result; + expect(result.publicKey).toEqual(expect.any(String)); + }); }); describe("asBrowserKeyError", () => { diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 5ac13ee020..685e29c63e 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -4,6 +4,7 @@ import { Base64ToPublicKeyCodec, INVALID_PARAMS_ERROR_CODE, OriginSchema, + Nat64StringCodec, StringToBigIntCodec, } from "$lib/utils/transport/utils"; import { @@ -17,7 +18,7 @@ import { type AppSessionRecord, } from "$lib/stores/app-session.store"; import { validateDerivationOrigin } from "$lib/utils/validateDerivationOrigin"; -import { remapToLegacyDomain } from "$lib/utils/iiConnection"; +import { remapToLegacyDomain } from "$lib/utils/urlUtils"; import { toPermissionsArg } from "$lib/utils/accessLevel"; import { isCanisterError, @@ -39,7 +40,7 @@ import { StaleBrowserKeyError, withBrowserProof, } from "$lib/stores/browser-key.store"; -import { describeBrowser } from "$lib/stores/channelHandlers/describeBrowser"; +import { describeBrowser } from "$lib/utils/describeBrowser"; import { z } from "zod"; import type { ChannelError } from "$lib/stores/channelStore"; @@ -50,11 +51,11 @@ const SessionParamsCodec = z.object({ // How long the app is willing for the session to last. A ceiling rather than a // request: what the user picks at consent wins, an SSO organization's cap // narrows it further, and the canister clamps the result. - maxTimeToLive: z.optional(StringToBigIntCodec), + maxTimeToLive: z.optional(Nat64StringCodec), // How long the session may go unminted before the canister ends it. A ceiling // like `maxTimeToLive`: the canister clamps it to between 10 minutes and the // session's own granted length, and applies its own default where absent. - maxTimeToIdle: z.optional(StringToBigIntCodec), + maxTimeToIdle: z.optional(Nat64StringCodec), icrc95DerivationOrigin: z.optional(OriginSchema), }); @@ -238,14 +239,28 @@ const createSession = async ( ? ssoSessionMaxAgeNs : requested; - const key = { identityNumber, accountNumber, origin: effectiveOrigin }; - const iiKey = await ECDSAKeyIdentity.generate({ extractable: false }); - const iiPublicKey = new Uint8Array(iiKey.getPublicKey().toDer()); + const recordKey = { identityNumber, accountNumber, origin: effectiveOrigin }; + + // The canister never certifies a delegation toward a key the request supplied. Over a + // redirect no browser-verified origin identifies the requester, so a malicious page + // could otherwise have II authenticate for another domain's derivation origin toward + // the attacker's own key and then read the finished delegation out of certified chain + // state. Signing to a key held only here makes what is on chain inert: the usable + // chain is completed below by a second hop this key makes. Same reason `url.ts` gives + // for its intermediate-key middleware, which covers `icrc34_delegation` and passes + // this method through. Keeping the pair in the session record is also what lets a + // later silent re-auth resume the session — a consequence, not the reason. + const iiSessionIdentity = await ECDSAKeyIdentity.generate({ + extractable: false, + }); + const iiSessionPublicKey = new Uint8Array( + iiSessionIdentity.getPublicKey().toDer(), + ); const browserDescription = await describeBrowser(); const prepared = await withBrowserProof( identityNumber, - iiPublicKey, + iiSessionPublicKey, browserDescription, async (browser) => { const prepared = await actor @@ -253,7 +268,7 @@ const createSession = async ( identity_number: identityNumber, origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], - session_key: iiPublicKey, + session_key: iiSessionPublicKey, browser_description: browserDescription, current_browser_key: browser.publicKey, next_browser_key: browser.nextPublicKey, @@ -274,9 +289,9 @@ const createSession = async ( .catch((error: unknown) => { throw asBrowserKeyError(error); }); - await browser.accept(prepared.browser_id); return prepared; }, + (prepared) => prepared.browser_id, ); const fetched = await retryFor(5, () => @@ -285,7 +300,7 @@ const createSession = async ( identity_number: identityNumber, origin: effectiveOrigin, account_number: accountNumber !== undefined ? [accountNumber] : [], - session_key: iiPublicKey, + session_key: iiSessionPublicKey, session_id: prepared.session_id, expiration: prepared.expiration, }) @@ -308,15 +323,15 @@ const createSession = async ( ); const record: AppSessionRecord = { - keyPair: iiKey.getKeyPair(), + keyPair: iiSessionIdentity.getKeyPair(), chainJson: JSON.stringify(canisterChain.toJSON()), expiresAtMillis: Number(prepared.expiration / BigInt(1_000_000)), sessionId: prepared.session_id, accessLevel: authorized.accessLevel, }; - await rememberAppAccount(key, { + await rememberAppAccount(recordKey, { accountPrincipal: prepared.account_principal.toText(), }); - await storeAppSession(key, record); + await storeAppSession(recordKey, record); return { record }; }; diff --git a/src/frontend/src/lib/utils/iiConnection.ts b/src/frontend/src/lib/utils/iiConnection.ts index f20719247b..1ece6d2db3 100644 --- a/src/frontend/src/lib/utils/iiConnection.ts +++ b/src/frontend/src/lib/utils/iiConnection.ts @@ -63,10 +63,11 @@ import { } from "./analytics/webauthnAuthenticationFunnel"; import { HARDWARE_KEY_TEST } from "$lib/state/featureFlags"; import { frontendCanisterConfig } from "$lib/globals"; -import { - GATEWAY_ORIGIN_REGEX, - LEGACY_GATEWAY_DOMAIN, -} from "$lib/utils/urlUtils"; +// Re-exported so the callers in this file, and anything importing it from here, are +// untouched by the move. It lives in `urlUtils` because that is where its two constants +// already were, and because it is string work with nothing to do with a connection. +export { remapToLegacyDomain } from "$lib/utils/urlUtils"; +import { remapToLegacyDomain } from "$lib/utils/urlUtils"; /* * A (dummy) identity that always uses the same keypair. The secret key is @@ -995,17 +996,6 @@ export const creationOptions = ( }; }; -// In order to give dapps a stable principal regardless whether they use the legacy (ic0.app) or -// any of the newer canister gateway domains (icp0.io, icp.net) we map back the derivation origin -// to the ic0.app domain. -export const remapToLegacyDomain = (origin: string): string => { - const groups = origin.match(GATEWAY_ORIGIN_REGEX)?.groups; - if (groups === undefined || groups.domain === LEGACY_GATEWAY_DOMAIN) { - return origin; - } - return `https://${groups.subdomain}.${LEGACY_GATEWAY_DOMAIN}`; -}; - export const bufferEqual = (buf1: ArrayBuffer, buf2: ArrayBuffer): boolean => { if (buf1.byteLength != buf2.byteLength) return false; const dv1 = new Int8Array(buf1); diff --git a/src/frontend/src/lib/utils/transport/utils.ts b/src/frontend/src/lib/utils/transport/utils.ts index 0f232a930c..56605efdad 100644 --- a/src/frontend/src/lib/utils/transport/utils.ts +++ b/src/frontend/src/lib/utils/transport/utils.ts @@ -135,6 +135,38 @@ export const StringToBigIntCodec = z.codec(z.string(), z.bigint(), { encode: (bigint) => bigint.toString(), }); +/** + * A `nat64` as the decimal string JSON-RPC carries it. + * + * `BigInt` decides what is numeric — a second grammar beside it would be free to drift — + * but it throws on anything else, and a throw inside a codec escapes `safeParse` rather + * than becoming a validation issue, so a caller sending nonsense would get no error at + * all. Reported instead, and bounded: `BigInt` alone accepts `"-1"` and `""`. + */ +export const Nat64StringCodec = z.codec( + z.string(), + z + .bigint() + .min(BigInt(0)) + .max(BigInt(2) ** BigInt(64) - BigInt(1)), + { + decode: (value, ctx) => { + try { + return BigInt(value); + } catch { + ctx.issues.push({ + code: "invalid_format", + format: "nat64", + input: value, + message: "expected a nat64 as a decimal string", + }); + return z.NEVER; + } + }, + encode: (value) => value.toString(), + }, +); + export const StringOrNumberToBigIntCodec = z.codec( z.union([z.string(), z.number(), z.bigint()]), z.bigint(), diff --git a/src/frontend/src/lib/utils/urlUtils.ts b/src/frontend/src/lib/utils/urlUtils.ts index a792695961..a79830f8a6 100644 --- a/src/frontend/src/lib/utils/urlUtils.ts +++ b/src/frontend/src/lib/utils/urlUtils.ts @@ -86,3 +86,14 @@ export const gatewayOriginTwins = (origin: string): string[] => { (candidate) => `https://${subdomain}.${candidate}`, ); }; + +// In order to give dapps a stable principal regardless whether they use the legacy (ic0.app) or +// any of the newer canister gateway domains (icp0.io, icp.net) we map back the derivation origin +// to the ic0.app domain. +export const remapToLegacyDomain = (origin: string): string => { + const groups = origin.match(GATEWAY_ORIGIN_REGEX)?.groups; + if (groups === undefined || groups.domain === LEGACY_GATEWAY_DOMAIN) { + return origin; + } + return `https://${groups.subdomain}.${LEGACY_GATEWAY_DOMAIN}`; +}; From 262b8126ab000dfd5dd076af37b1b6d2de54ee33 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 14:12:15 +0200 Subject: [PATCH 285/298] fix(devices): follow describeBrowser to where it moved Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(new-styling)/manage/(authenticated)/devices/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte index 15101441e9..a48d5831e8 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte @@ -3,7 +3,7 @@ import { Trans } from "$lib/components/locale"; import { formatDate, formatRelative, t } from "$lib/stores/locale.store"; import { currentBrowserId } from "$lib/stores/browser-key.store"; - import { describeBrowser } from "$lib/stores/channelHandlers/describeBrowser"; + import { describeBrowser } from "$lib/utils/describeBrowser"; import type { BrowserDescription } from "$lib/generated/internet_identity_types"; import Dialog from "$lib/components/ui/Dialog.svelte"; import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte"; From 7dfa92c8a060752e56ee4d6525c44d84d5887d9e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 15:49:22 +0200 Subject: [PATCH 286/298] fix(session-delegation): keep the targets the canister signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session credential is scoped to the II canister, and `targets` is part of the message the canister signs. Rebuilding the delegation from the reply left them out, so the chain handed to the app carried a first hop that hashes to nothing in the signature tree — every call made with it came back "Invalid canister signature: the signature tree doesn't contain sig/…/… path". The wire format was never at fault: the result codec carries `targets` both ways. They were gone before anything was encoded, and went into the stored `chainJson` with the rest. The test that should have caught this mocked `targets: []` — Candid's `None`, the shape from before the credential was scoped — so a rebuild that dropped them matched. It now answers as the canister does and asserts the hop keeps them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 25 +++++++++++++++++-- .../channelHandlers/sessionDelegation.ts | 4 +++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 10e719085c..46b19bb9db 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -179,7 +179,14 @@ describe("ii_session_delegation", () => { Promise.resolve({ Ok: { signed_delegation: { - delegation: { pubkey: session_key, expiration, targets: [] }, + // As the canister answers since the session credential was scoped: the + // targets are part of what it signed, so a chain rebuilt without them is + // refused by the replica. + delegation: { + pubkey: session_key, + expiration, + targets: [[Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai")]], + }, // At least 32 bytes: the chain's own parser refuses anything shorter. signature: new Uint8Array(64).fill(7), }, @@ -216,8 +223,22 @@ describe("ii_session_delegation", () => { // signed: the hop only II can make is what makes the on-chain half unusable alone. expect(sent).toHaveLength(1); expect(sent[0]).toMatchObject({ id: 1 }); - const result = (sent[0] as { result: { publicKey: string } }).result; + const result = ( + sent[0] as { + result: { + publicKey: string; + signerDelegation: { delegation: { targets?: string[] } }[]; + }; + } + ).result; expect(result.publicKey).toEqual(expect.any(String)); + + // The hop the canister signed keeps its targets. Dropping them leaves a delegation + // that hashes to nothing in the signature tree, and every call the app makes with + // this chain comes back "Invalid canister signature". + expect(result.signerDelegation[0].delegation.targets).toEqual([ + "rwlgt-iiaaa-aaaaa-aaaaa-cai", + ]); }); }); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 685e29c63e..67e93756c2 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -313,6 +313,10 @@ const createSession = async ( delegation: new Delegation( new Uint8Array(fetched.signed_delegation.delegation.pubkey), fetched.signed_delegation.delegation.expiration, + // Carried, not dropped: `targets` is part of the message the canister signed, + // so a delegation rebuilt without them hashes to something no signature in the + // tree covers, and every call the app makes with this chain is refused. + fetched.signed_delegation.delegation.targets[0], ), signature: new Uint8Array( fetched.signed_delegation.signature, From 3999dc3a667254c3a8cd156441eb68f421388c3a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 15:57:00 +0200 Subject: [PATCH 287/298] feat(devices): this browser is a row about the present, and the page fits a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser reading the page carries no action and no "Signed out" label. Both would be claims this page cannot make about the one browser it can see in use: signing it out here ends the sessions of the page you are looking at, and calling it signed out contradicts the fact that you are using it. `Last used` reads Now for the same reason. `First seen` for it comes from the identity's local record, written when this browser first saw the identity and left alone afterwards — the canister cannot supply it, because its own record only starts at the first app sign-in. Where it does hold one, that wins, so the date jumps forward once on a new device. That is the compromise, taken over showing nothing until then. The row also had one layout doing two jobs. Above `sm` it is a line: identity, two fixed meta columns, action. Below, those columns had nowhere to go — the name and its badge need two lines and grew straight into them, which is the overlap on a phone. It is a block there instead: identity, the meta as a grid, the action across the width. The action column stays reserved above `sm` even when empty, so the meta keeps its alignment with the rows that do carry a button. The sign-out dialog asks what it is really asking: "Remember this browser?", Remember or Forget. Forget is the path that ends this browser's app sessions canister-side, so it keeps the pending state. It no longer describes the identity, so it no longer waits for one to be in the picker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../components/ui/SignOutConfirmation.svelte | 51 ++++---- .../stores/last-used-identities.store.test.ts | 11 ++ .../lib/stores/last-used-identities.store.ts | 10 ++ .../manage/(authenticated)/+layout.svelte | 22 ++-- .../(authenticated)/devices/+page.svelte | 46 ++++--- .../devices/components/DeviceRow.svelte | 121 +++++++++--------- 6 files changed, 145 insertions(+), 116 deletions(-) diff --git a/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte b/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte index 176510d83f..baa8d7d97b 100644 --- a/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte +++ b/src/frontend/src/lib/components/ui/SignOutConfirmation.svelte @@ -1,31 +1,30 @@ @@ -37,35 +36,31 @@

    - {$t`Sign out from this device`} + {$t`Remember this browser?`}

    - You can either sign out and keep your identity saved for a faster - login next time, or remove it entirely from this device. + Your apps stay signed in and your identity is ready the next time you + come back.

    -
    - -
    -
    -
    diff --git a/src/frontend/src/lib/stores/last-used-identities.store.test.ts b/src/frontend/src/lib/stores/last-used-identities.store.test.ts index 1a90f493d0..938596d0b4 100644 --- a/src/frontend/src/lib/stores/last-used-identities.store.test.ts +++ b/src/frontend/src/lib/stores/last-used-identities.store.test.ts @@ -62,6 +62,7 @@ describe("lastUsedIdentitiesStore", () => { name: name1, authMethod: { passkey: { credentialId: credId1 } }, lastUsedTimestampMillis: mockTimestamp1, + firstSeenTimestampMillis: mockTimestamp1, createdAtMillis, }, }; @@ -90,12 +91,14 @@ describe("lastUsedIdentitiesStore", () => { name: name1, authMethod: { passkey: { credentialId: credId1 } }, lastUsedTimestampMillis: mockTimestamp1, + firstSeenTimestampMillis: mockTimestamp1, }, [identity2.toString()]: { identityNumber: identity2, name: name2, authMethod: { passkey: { credentialId: credId2 } }, lastUsedTimestampMillis: mockTimestamp2, + firstSeenTimestampMillis: mockTimestamp2, }, }; expect(get(lastUsedIdentitiesStore).identities).toEqual(expected); @@ -127,6 +130,9 @@ describe("lastUsedIdentitiesStore", () => { name: name1, // Name should remain the same from the *last* call authMethod: { passkey: { credentialId: credId1 } }, lastUsedTimestampMillis: mockTimestamp3, + // Not advanced: it records when this browser first saw the identity, which the + // first call set and no later one moves. + firstSeenTimestampMillis: mockTimestamp1, }, }; expect(get(lastUsedIdentitiesStore).identities).toEqual(expected); @@ -208,6 +214,7 @@ describe("lastUsedIdentitiesStore", () => { name: name1, authMethod: { passkey: { credentialId: credId1 } }, lastUsedTimestampMillis: mockTimestamp1, + firstSeenTimestampMillis: mockTimestamp1, accounts: undefined, }); }); @@ -285,6 +292,7 @@ describe("lastUsedIdentityStore (derived store)", () => { name: name1, authMethod: { passkey: { credentialId: credId1 } }, lastUsedTimestampMillis: mockTimestamp1, + firstSeenTimestampMillis: mockTimestamp1, createdAtMillis, }; expect(get(lastUsedIdentityStore)).toEqual(expected); @@ -310,6 +318,7 @@ describe("lastUsedIdentityStore (derived store)", () => { name: name1, authMethod: { passkey: { credentialId: credId1 } }, lastUsedTimestampMillis: mockTimestamp2, + firstSeenTimestampMillis: mockTimestamp2, }; expect(get(lastUsedIdentityStore)).toEqual(expectedLatest); @@ -325,6 +334,7 @@ describe("lastUsedIdentityStore (derived store)", () => { name: name3, authMethod: { passkey: { credentialId: credId3 } }, lastUsedTimestampMillis: mockTimestamp3, + firstSeenTimestampMillis: mockTimestamp3, }; expect(get(lastUsedIdentityStore)).toEqual(expectedNewest); }); @@ -359,6 +369,7 @@ describe("lastUsedIdentityStore (derived store)", () => { name: name1, authMethod: { passkey: { credentialId: credId1 } }, lastUsedTimestampMillis: mockTimestamp3, + firstSeenTimestampMillis: mockTimestamp1, }; expect(get(lastUsedIdentityStore)).toEqual(expected); }); diff --git a/src/frontend/src/lib/stores/last-used-identities.store.ts b/src/frontend/src/lib/stores/last-used-identities.store.ts index 9acfe8be41..f5205f8260 100644 --- a/src/frontend/src/lib/stores/last-used-identities.store.ts +++ b/src/frontend/src/lib/stores/last-used-identities.store.ts @@ -52,6 +52,11 @@ export type LastUsedIdentity = { accounts?: LastUsedAccounts; lastUsedTimestampMillis: number; createdAtMillis?: number; + /** When this browser first saw this identity, which is not when the identity was + * created and not when the canister first registered a browser for it. The devices + * page shows it for a browser the canister holds no record of yet — one signed in to + * Internet Identity but to no app. Written once and left alone afterwards. */ + firstSeenTimestampMillis?: number; }; export type LastUsedIdentities = { [identityNumber: string]: LastUsedIdentity; @@ -121,6 +126,10 @@ export const initLastUsedIdentitiesStore = (): LastUsedIdentitiesStore => { accounts: identity?.accounts, ...params, lastUsedTimestampMillis: Date.now(), + // Kept from the first time this browser saw the identity, so it does not + // advance every time the identity is used. + firstSeenTimestampMillis: + identity?.firstSeenTimestampMillis ?? Date.now(), }; return lastUsedIdentities; }); @@ -135,6 +144,7 @@ export const initLastUsedIdentitiesStore = (): LastUsedIdentitiesStore => { accounts: undefined, ...params, lastUsedTimestampMillis: Date.now(), + firstSeenTimestampMillis: Date.now(), }; return lastUsedIdentities; }); 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 4037d584ba..879af18198 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte @@ -544,19 +544,15 @@ {/if} {#if isSignOutDialogOpen} - {@const currentIdentity = - $lastUsedIdentitiesStore.identities[ - $authenticatedStore.identityNumber.toString() - ]} - {#if currentIdentity !== undefined} - (isSignOutDialogOpen = false)}> - - - {/if} + + (isSignOutDialogOpen = false)}> + + {/if} {#if isReauthDialogOpen} diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte index a48d5831e8..be50dc3529 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte @@ -1,5 +1,6 @@ -
    - - {#if brandIcon !== undefined} - - {/if} - - - -
    -
    - - - {brandNameOf(description)} - - {#if isCurrent} - {$t`This browser`} - {/if} - - - - - {$t`Last used`} - {lastUsed} - - - {$t`First seen`} - {firstSeen} - - -
    + +
    +
    + + {#if brandIcon !== undefined} + + {/if} + -
    + + +
    + + {$t`Last used`} + {lastUsed} + + + {$t`First seen`} + {firstSeen}
    - + + {#if action === "sign-out"} - {:else if action === "signing-out"} From c46153fef61e5578fbd97d6942279bae135a3b24 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 17:25:48 +0200 Subject: [PATCH 288/298] fix(describe-browser): name only the form factors the canister has A device stating Watch, XR, Automotive or EInk is none of desktop, mobile or tablet. Falling through to `mobile` called a watch a phone and an e-reader a desktop, so those four resolve to Unknown instead, after the Tablet check so a device stating both keeps the variant that exists. The singular `formFactor` fallback goes with it: only `formFactors` is ever requested, so the singular could never arrive. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/utils/describeBrowser.test.ts | 36 ++++++++++++++----- src/frontend/src/lib/utils/describeBrowser.ts | 20 ++++++++--- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/frontend/src/lib/utils/describeBrowser.test.ts b/src/frontend/src/lib/utils/describeBrowser.test.ts index 2efe81f3ba..1a3e9bbbe2 100644 --- a/src/frontend/src/lib/utils/describeBrowser.test.ts +++ b/src/frontend/src/lib/utils/describeBrowser.test.ts @@ -269,16 +269,34 @@ describe("client hints", () => { }); }); - /// The current spec says `formFactors`; the versions that shipped it first said - /// `formFactor`. A tablet has to read as one on both. - it.each([ - { - name: "the plural the spec settled on", - high: { formFactors: ["Tablet"] }, + it("takes a stated form factor", async () => { + await expect(withHints({ formFactors: ["Tablet"] })).resolves.toMatchObject( + { + form_factor: { Tablet: null }, + }, + ); + }); + + /// The canister names desktops, mobiles and tablets. A device stating anything else is + /// none of the three, and saying so beats what `mobile` alone would have guessed: a + /// watch reports `mobile: true` and would read as a phone, an e-reader reports + /// `mobile: false` and would read as a desktop. + it.each(["Watch", "XR", "Automotive", "EInk"])( + "leaves a %s unnamed rather than guessing from `mobile`", + async (factor) => { + await expect( + withHints({ formFactors: [factor] }), + ).resolves.toMatchObject({ + form_factor: { Unknown: null }, + }); }, - { name: "the singular that shipped first", high: { formFactor: "Tablet" } }, - ])("takes a stated form factor in $name", async ({ high }) => { - await expect(withHints(high)).resolves.toMatchObject({ + ); + + /// Both stated, so the one the canister can name wins over the one it cannot. + it("prefers a tablet to an unnameable factor stated beside it", async () => { + await expect( + withHints({ formFactors: ["EInk", "Tablet"] }), + ).resolves.toMatchObject({ form_factor: { Tablet: null }, }); }); diff --git a/src/frontend/src/lib/utils/describeBrowser.ts b/src/frontend/src/lib/utils/describeBrowser.ts index 0b7bec150a..b68ae4818b 100644 --- a/src/frontend/src/lib/utils/describeBrowser.ts +++ b/src/frontend/src/lib/utils/describeBrowser.ts @@ -146,12 +146,26 @@ const platformToken = (agent: string): string | undefined => { return named === undefined || named.length === 0 ? undefined : named; }; +/** + * Stated form factors the canister has no variant for. A device reporting one of these is + * neither a desktop, a mobile nor a tablet, so it is named as none of them: left to fall + * through, `mobile` alone would call a watch a phone and an e-reader a desktop. + */ +const UNNAMEABLE_FORM_FACTORS = ["Watch", "XR", "Automotive", "EInk"]; + const formFactorOf = ( agent: string, system: OperatingSystem, hints: { mobile?: boolean; formFactors?: string[] }, ): FormFactor => { + // Before the unnameable check, so a device stating both keeps the variant that exists. if (hints.formFactors?.includes("Tablet") === true) return { Tablet: null }; + if ( + hints.formFactors?.some((factor) => + UNNAMEABLE_FORM_FACTORS.includes(factor), + ) === true + ) + return { Unknown: null }; if ("Ipados" in system) return { Tablet: null }; if (hints.mobile === true) return { Mobile: null }; if ("Ios" in system) return { Mobile: null }; @@ -184,7 +198,6 @@ const highEntropyHints = async (): Promise<{ getHighEntropyValues?: (hints: string[]) => Promise<{ model?: string; formFactors?: string[]; - formFactor?: string; }>; }; } @@ -196,10 +209,7 @@ const highEntropyHints = async (): Promise<{ const high = await data.getHighEntropyValues?.(["model", "formFactors"]); return { mobile: data.mobile, - // Plural in the current spec, singular in the versions that shipped it first. - formFactors: - high?.formFactors ?? - (high?.formFactor === undefined ? undefined : [high.formFactor]), + formFactors: high?.formFactors, // Empty off Android, which reports the field but has no model to put in it. model: high?.model === "" ? undefined : high?.model, }; From 777195192fb40fefb5959f9bbcef1f2d7c1834d9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 17:27:02 +0200 Subject: [PATCH 289/298] refactor(browser-key): name the successor the record holds `announced` is an adjective with no noun, leaving the doc comment to supply the word. With the noun in the name the comment says why the key is retained instead of what it is, and the rotation function's summary states what it returns. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/browser-key.store.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/frontend/src/lib/stores/browser-key.store.ts b/src/frontend/src/lib/stores/browser-key.store.ts index 7cac800d33..53bd5ea8a1 100644 --- a/src/frontend/src/lib/stores/browser-key.store.ts +++ b/src/frontend/src/lib/stores/browser-key.store.ts @@ -16,7 +16,7 @@ interface BrowserKeyRecord { * sign-in is known to have been accepted. The canister reaches this browser's entry * only through the successor it announced, so losing this key while the canister kept * it would leave the browser unable to prove it is itself ever again. */ - announced?: CryptoKeyPair; + announcedSuccessor?: CryptoKeyPair; /** Absent until a sign-in has told us which browser we are. */ browserId?: number; /** What was reported when this browser registered, so a change can be noticed. @@ -141,8 +141,8 @@ const sameDescription = ( (one.model[0] ?? "") === (other.model[0] ?? ""); /** - * The record to sign in with, which is a fresh one where this browser no longer matches - * what it registered as. + * Returns the stored record unless the browser description changed; in that case, rotates + * to a fresh record so this sign-in registers as a new browser. * * A registered entry keeps the description it was created with, so a browser reporting * something else is one the canister has not seen. Rather than ask for an entry to be @@ -163,7 +163,7 @@ const forDescription = async ( } const fresh: BrowserKeyRecord = { keyPair: await generate(), - announced: await generate(), + announcedSuccessor: await generate(), }; await write(identityNumber, fresh); return fresh; @@ -173,21 +173,26 @@ const forDescription = async ( const prepared = async ( identityNumber: bigint, from?: BrowserKeyRecord, -): Promise>> => { +): Promise< + Required> +> => { const stored = from ?? (await read(identityNumber)); const keyPair = stored?.keyPair ?? (await generate()); // Both halves go on disk before the call. The canister may accept this sign-in and // never tell us, and from that moment the only key that reaches our entry is the // successor we announced — a successor generated and discarded per attempt would be // gone with the response that carried it. - const announced = stored?.announced ?? (await generate()); - if (stored?.keyPair !== keyPair || stored?.announced !== announced) { - await write(identityNumber, { ...stored, keyPair, announced }); + const announcedSuccessor = stored?.announcedSuccessor ?? (await generate()); + if ( + stored?.keyPair !== keyPair || + stored?.announcedSuccessor !== announcedSuccessor + ) { + await write(identityNumber, { ...stored, keyPair, announcedSuccessor }); } - return { keyPair, announced }; + return { keyPair, announcedSuccessor }; }; -/** One attempt, proving `keyPair` and announcing `announced`. */ +/** One attempt, proving `keyPair` and announcing `announcedSuccessor`. */ const attempt = async ( identityNumber: bigint, sessionKey: Uint8Array, @@ -196,7 +201,7 @@ const attempt = async ( browserIdOf: (value: T) => number, from?: BrowserKeyRecord, ): Promise => { - const { keyPair, announced: successor } = await prepared( + const { keyPair, announcedSuccessor: successor } = await prepared( identityNumber, from, ); @@ -280,7 +285,7 @@ export const withBrowserProof = ( // it is announced. Starting over costs a second row in the user's list, which beats // a browser that can never sign in again. const promoted: BrowserKeyRecord = { - keyPair: stored?.announced ?? (await generate()), + keyPair: stored?.announcedSuccessor ?? (await generate()), }; // Carried into the retry rather than read back, so a storage failure costs the // rotation and not the sign-in. From 0baa7d69aa81c2df9788cc8cd35539a418c0ea74 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 17:28:46 +0200 Subject: [PATCH 290/298] fix(sessions): answer undefined for a key the parser cannot read `parseKey` is documented to return undefined for a key it cannot read and honoured that for the shape checks, but let `BigInt` throw on a non-numeric segment. Every purge and the expiry sweep run it over every key in the store, so one unreadable key stopped them all. Removing an identity from the list, and an Unauthorized from a stored II session, both drop the records they can rather than calling forgetIdentity: revoking a browser's sessions needs full authorization, which a stored session delegation does not carry, so the call was a round trip that could only fail. The toast no longer claims apps were signed out, and Undo restores what removal took. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../src/lib/stores/app-session.store.test.ts | 22 +++++++++++++ .../src/lib/stores/app-session.store.ts | 20 ++++++++--- .../src/routes/(new-styling)/+page.svelte | 33 ++++++------------- .../authorize/views/ContinueView.svelte | 8 ++--- 4 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/frontend/src/lib/stores/app-session.store.test.ts b/src/frontend/src/lib/stores/app-session.store.test.ts index e56fe5d062..3e36fe82cd 100644 --- a/src/frontend/src/lib/stores/app-session.store.test.ts +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; +import { createStore, set as idbSet } from "idb-keyval"; import { appAccountsForOrigin, appSessionsForOrigin, @@ -169,6 +170,27 @@ describe("app session store", () => { ).resolves.toEqual([]); }); + /// `BigInt` throws on a segment that is not a number. Every purge and the expiry sweep + /// run the key parser over every key in the store, so one key it cannot read must cost + /// that key and nothing else. + it("reads and purges around a key it cannot parse", async () => { + const sessions = createStore("ii-app-sessions", "sessions"); + await idbSet( + "not-a-number:default:" + ORIGIN, + record(anHourFromNow()), + sessions, + ); + await storeAppSession( + { identityNumber: BigInt(10_000), origin: ORIGIN }, + record(anHourFromNow()), + ); + + await expect(appSessionsForOrigin(ORIGIN)).resolves.toMatchObject([ + { identityNumber: BigInt(10_000) }, + ]); + await expect(purgeAppSessions(BigInt(10_000))).resolves.toBeUndefined(); + }); + it("purges the account mappings of one identity too", async () => { await rememberAppAccount( { identityNumber: BigInt(10_000), origin: ORIGIN }, diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index a987cb56d3..abfe065aa0 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -70,11 +70,21 @@ const parseKey = (key: IDBValidKey): SessionKey | undefined => { return undefined; } const accountPart = key.slice(separator + 1, accountSeparator); - return { - identityNumber: BigInt(key.slice(0, separator)), - accountNumber: accountPart === "default" ? undefined : BigInt(accountPart), - origin: key.slice(accountSeparator + 1), - }; + try { + return { + identityNumber: BigInt(key.slice(0, separator)), + accountNumber: + accountPart === "default" ? undefined : BigInt(accountPart), + origin: key.slice(accountSeparator + 1), + }; + } catch { + // `BigInt` throws on a segment that is not a number, and this is documented to + // answer `undefined` for a key it cannot read. Letting it throw would take out the + // caller instead: every purge and the expiry sweep run this over every key in the + // store, so one unreadable key would stop them all — including the sweep that + // deletes it. + return undefined; + } }; const readAll = async ( diff --git a/src/frontend/src/routes/(new-styling)/+page.svelte b/src/frontend/src/routes/(new-styling)/+page.svelte index 9ee271b794..c6a8e03454 100644 --- a/src/frontend/src/routes/(new-styling)/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/+page.svelte @@ -13,7 +13,8 @@ import type { AuthMode } from "$lib/flows/authFlow.svelte"; import { beforeNavigate, preloadData } from "$app/navigation"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; - import { forgetIdentity } from "$lib/stores/session-delegation.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 { @@ -91,26 +92,17 @@ duration: 2000, }); }; + // Local only. `ManageIdentities` disables the remove button on the selected identity, + // so the identity removed here is never the one signed in — and revoking a browser's + // sessions needs full authorization, which a stored session delegation does not carry. + // Dropping the records is therefore the whole of it, which is what makes Undo worth + // offering: the identity comes back, and nothing was signed out to restore. const handleRemoveIdentity = (identityNumber: bigint) => { - // If the removed identity is currently selected, - // switch to the next available one in the list. - // - // The last identity cannot be removed, so there will - // always be at least one next identity available. - const isCurrent = selectedIdentity?.identityNumber === identityNumber; - if (isCurrent) { - const nextIdentity = lastUsedIdentities.find( - (identity) => identity.identityNumber !== identityNumber, - ); - if (nextIdentity !== undefined) { - lastUsedIdentitiesStore.selectIdentity(nextIdentity.identityNumber); - } - } - const removedIdentity = $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void forgetIdentity(identityNumber); + void purgeSession(identityNumber); + void purgeAppSessions(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { @@ -118,18 +110,13 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, + description: $t`${identityName} has been removed from this device.`, closable: true, duration: 5000, action: { label: $t`Undo`, onClick: () => { lastUsedIdentitiesStore.restoreIdentity(removedIdentity); - if (isCurrent) { - lastUsedIdentitiesStore.selectIdentity( - removedIdentity.identityNumber, - ); - } }, }, }); 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 738e65de82..dd2d152f64 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte @@ -20,7 +20,7 @@ } from "$lib/stores/authentication.store"; import { actorForIdentity, - forgetIdentity, + purgeSession, } from "$lib/stores/session-delegation.store"; import { throwCanisterError, isCanisterError } from "$lib/utils/utils"; import type { ActorSubclass } from "@icp-sdk/core/agent"; @@ -262,7 +262,7 @@ isCanisterError(err) && err.type === "Unauthorized" ) { - void forgetIdentity(selectedIdentityNumber); + void purgeSession(selectedIdentityNumber); } else { throw err; } @@ -350,7 +350,7 @@ isCanisterError(err) && err.type === "Unauthorized" ) { - void forgetIdentity(selectedIdentityNumber); + void purgeSession(selectedIdentityNumber); } else { throw err; } @@ -481,7 +481,7 @@ isCanisterError(err) && err.type === "Unauthorized" ) { - void forgetIdentity(selectedIdentityNumber); + void purgeSession(selectedIdentityNumber); } else { throw err; } From e371f37ba2f8f7caceb62571e69233124a42b56e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 17:30:01 +0200 Subject: [PATCH 291/298] fix(transport): accept only decimal digits as a nat64 BigInt reads more than the decimal strings JSON-RPC carries: "" and " " are 0n, "+1" is 1n, "0x10" is 16n, and the nat64 bounds reject none of them. A malformed duration therefore reached the canister as a number it silently clamped, instead of the invalid-params error the app could act on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 48 +++++++++++-------- src/frontend/src/lib/utils/transport/utils.ts | 17 ++++--- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 46b19bb9db..929510d0f9 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -132,29 +132,35 @@ describe("ii_session_delegation", () => { expect(onError).toHaveBeenCalledWith("invalid-request"); }); - /// A duration `BigInt` cannot read used to throw out of `safeParse`, which sits above - /// the handler's `try`, so the app was told nothing at all. - it("rejects a duration that is not a number", async () => { - const { channel, sent } = channelWith(); - const onError = vi.fn(); + /// A duration `BigInt` cannot read throws out of `safeParse`, which sits above the + /// handler's `try`, so the app would be told nothing at all. The rest `BigInt` reads + /// happily as something else: `""` and `" "` are `0n`, `"+1"` is `1n`, `"0x10"` is + /// `16n`, and the nat64 bounds reject none of them — so the canister would clamp a + /// number the app never meant to send. + it.each(["not a number", "", " ", "+1", "0x10", "-1"])( + "rejects %o as a duration", + async (maxTimeToLive) => { + const { channel, sent } = channelWith(); + const onError = vi.fn(); - await handleSessionDelegationRequest( - channel, - onError, - )({ - jsonrpc: "2.0", - id: 1, - method: "ii_session_delegation", - params: { - sessionPublicKey: btoa("an app key"), - maxTimeToLive: "not a number", - }, - }); + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { + sessionPublicKey: btoa("an app key"), + maxTimeToLive, + }, + }); - expect(sent).toHaveLength(1); - expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); - expect(onError).toHaveBeenCalledWith("invalid-request"); - }); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); + expect(onError).toHaveBeenCalledWith("invalid-request"); + }, + ); /// The whole ceremony, which nothing else here reaches: what the canister is asked /// for, what is kept, and what the app is handed back. diff --git a/src/frontend/src/lib/utils/transport/utils.ts b/src/frontend/src/lib/utils/transport/utils.ts index 56605efdad..2fa61c02c5 100644 --- a/src/frontend/src/lib/utils/transport/utils.ts +++ b/src/frontend/src/lib/utils/transport/utils.ts @@ -138,11 +138,15 @@ export const StringToBigIntCodec = z.codec(z.string(), z.bigint(), { /** * A `nat64` as the decimal string JSON-RPC carries it. * - * `BigInt` decides what is numeric — a second grammar beside it would be free to drift — - * but it throws on anything else, and a throw inside a codec escapes `safeParse` rather - * than becoming a validation issue, so a caller sending nonsense would get no error at - * all. Reported instead, and bounded: `BigInt` alone accepts `"-1"` and `""`. + * `BigInt` reads more than that — `""` and `" "` are `0n`, `"+1"` is `1n`, `"0x10"` is + * `16n` — and the bounds below reject none of them, so a malformed duration would arrive + * as a number the canister silently clamps rather than as an error the app can act on. + * Digits only, therefore, checked before converting. What is left is reported rather than + * thrown: a throw inside a codec escapes `safeParse` instead of becoming a validation + * issue, and a caller sending nonsense would get no answer at all. */ +const DECIMAL_DIGITS = /^\d+$/; + export const Nat64StringCodec = z.codec( z.string(), z @@ -151,9 +155,7 @@ export const Nat64StringCodec = z.codec( .max(BigInt(2) ** BigInt(64) - BigInt(1)), { decode: (value, ctx) => { - try { - return BigInt(value); - } catch { + if (!DECIMAL_DIGITS.test(value)) { ctx.issues.push({ code: "invalid_format", format: "nat64", @@ -162,6 +164,7 @@ export const Nat64StringCodec = z.codec( }); return z.NEVER; } + return BigInt(value); }, encode: (value) => value.toString(), }, From 8cbc044c87c6dbbe5596ca5d0cc641bf2cb833b4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 17:32:20 +0200 Subject: [PATCH 292/298] fix(devices): lay the list out for the pane it sits in The row switched layouts on the page's width while living in a settings column that is narrow at any viewport, so the wide layout stayed in a space too small for it: the name truncated and the meta printed over the badge. It switches on the card's own width now. Narrow, the action moves up beside the name instead of taking a full-width row below the meta, which is what made a row five lines tall. The inset rule between rows is its own element: indenting the row to inset the rule moved the row with it, so every browser after the first sat 16px right of the first. The browser being read from leads its group, and the sign-out confirmation names it in the title while the button carries the scope, so the two no longer say the same words and Cancel exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../(authenticated)/devices/+page.svelte | 51 ++++++------ .../(authenticated)/devices/browsers.test.ts | 14 ++++ .../(authenticated)/devices/browsers.ts | 8 ++ .../devices/components/DeviceRow.svelte | 83 ++++++++++++------- 4 files changed, 100 insertions(+), 56 deletions(-) diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte index be50dc3529..2fd0d9f2b5 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/+page.svelte @@ -7,8 +7,6 @@ import { describeBrowser } from "$lib/utils/describeBrowser"; import type { BrowserDescription } from "$lib/generated/internet_identity_types"; import Dialog from "$lib/components/ui/Dialog.svelte"; - import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte"; - import { TriangleAlertIcon } from "@lucide/svelte"; import { toaster } from "$lib/components/utils/toaster"; import DeviceRow from "./components/DeviceRow.svelte"; import GroupHeading from "./components/GroupHeading.svelte"; @@ -133,9 +131,7 @@ signedOut = [...signedOut, browser.id]; toaster.success({ title: $t`Signed out of all apps`, - description: browser.isCurrent - ? $t`This browser no longer has access to your apps.` - : $t`${browser.name} no longer has access to your apps.`, + description: $t`${browser.name} no longer has access to your apps.`, }); } catch (error) { toaster.error({ @@ -174,10 +170,15 @@
      {#each group.browsers as browser, index (browser.id)} -
    • 0 ? "border-border-tertiary ml-4 border-t" : ""} - > + rather than as the boundary between two. Drawn as its own element: + indenting the row to inset the rule moved the row with it. --> + {#if index > 0} +
    • + {/if} +
    • (confirming = undefined)} width="wider"> +
      - - - -

      - {$t`Sign out of all apps?`} + {$t`Sign out ${target.name}?`}

      - {#if target.isCurrent} - - Every app you opened from this browser will ask you to sign in - again. You'll stay signed in to Internet Identity here. - - {:else} - {$t`${target.name} will lose access to all apps signed in with this identity.`} - {/if} + {$t`You can sign in again from ${target.name} at any time.`}

      - +
      + + +
      {/if} diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts index 689bae55f1..e10355227a 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.test.ts @@ -173,6 +173,20 @@ describe("groupBrowsers", () => { expect(groups.map((group) => group.platform)).toEqual(["iPhone", "Mac"]); }); + /// The one row the user can place without reading it. The canister returns records in + /// registration order, which says nothing about which browser is asking. + it("leads the group with the browser being read from", () => { + const [group] = groupBrowsers( + fromCanisterBrowsers( + [[at(1, 5, CHROME_ON_A_MAC), at(2, 1, CHROME_ON_A_MAC)]], + 1, + ), + now, + ); + + expect(group.browsers.map((entry) => entry.id)).toEqual([1, 2]); + }); + it("heads the group with the glyph of its platform", () => { const [group] = groupBrowsers( fromCanisterBrowsers([ diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.ts index 0a5f2fb3a9..99cdfa4d52 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.ts +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/browsers.ts @@ -201,6 +201,14 @@ export const groupBrowsers = ( group.browsers.push(browser); } } + // The browser reading the page leads its group: it is the one row the user can place + // without reading it, and the canister returns records in registration order, which + // says nothing about that. + for (const group of groups.values()) { + group.browsers.sort( + (one, other) => Number(other.isCurrent) - Number(one.isCurrent), + ); + } return [...groups.values()]; }; diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte index ae4e7e748b..5ad3127bd6 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/devices/components/DeviceRow.svelte @@ -31,15 +31,34 @@ const dimmed = $derived(action === "signed-out"); + +{#snippet actionControl()} + {#if action === "sign-out"} + + {:else if action === "signing-out"} + {$t`Signing out…`} + {:else if action === "signed-out"} + {$t`Signed out`} + {/if} +{/snippet} + -
      -
      +
      +
      {#if brandIcon !== undefined} {/if} + + + + {@render actionControl()} +
      - +
      - + {$t`Last used`} {lastUsed} - + {$t`First seen`} @@ -86,25 +121,11 @@
      - + - {#if action === "sign-out"} - - {:else if action === "signing-out"} - {$t`Signing out…`} - {:else if action === "signed-out"} - {$t`Signed out`} - {/if} + {@render actionControl()}
      From d66c325fe92a1e0d67d3ca36a8dca1c9e2aa9c4d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 17:34:32 +0200 Subject: [PATCH 293/298] fix(silent-reauth): choose among the sessions that are still live Counting the stored records rather than the live sessions made one live record beside one revoked elsewhere look like two candidates, so a request with exactly one answer was refused as an ambiguity. Liveness is decided before the choice now, which also leaves the chosen session already checked. The credential pre-fetch and the page view both wait for `isReady`. Neither can be decided at mount, and a silently answered request sets no authorization context, so it was paying a canister query per remembered identity and counting a view of a page nobody saw. chooseSilentSession moves beside the handler that uses it: its only non-test caller is in $lib and reached into a route directory for it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 26 +++++++++++++++++ .../channelHandlers/sessionDelegation.ts | 23 +++++++++------ .../(new-styling)/authorize/+layout.svelte | 28 +++++++++++++------ 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 26dc6891b6..6dc93b0569 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -393,7 +393,9 @@ describe("a session the canister no longer holds", () => { beforeEach(async () => { checkSession.mockClear(); + checkSession.mockResolvedValue(true); await purgeAppSessions(BigInt(10_000)); + await purgeAppSessions(BigInt(10_001)); (await promptStore()).set({}); }); @@ -442,6 +444,30 @@ describe("a session the canister no longer holds", () => { expect(await appSessionsForOrigin(ORIGIN)).toHaveLength(1); }); + /// Two records, one of them ended from another browser. Counting the records rather + /// than the live sessions made that an ambiguity and refused a request there was only + /// one answer to. + it("serves the one live session beside a record that was revoked elsewhere", async () => { + await storedSession(BigInt(10_000)); + await storedSession(BigInt(10_001)); + // `appSessionsForOrigin` lists them in key order, so the first is 10_000's. + checkSession.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + (await promptStore()).set({ prompt: "none" }); + + const { channel, sent } = channelWith(); + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(sent[0]).toMatchObject({ result: {} }); + }); + /// The denial is a skip, not a verdict: the very next request finds the record still /// there and can succeed on it. it("serves the same record once the canister answers again", async () => { diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index af8c869885..6350eb39d6 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -45,7 +45,7 @@ import { get } from "svelte/store"; import { chooseSilentSession, type SilentDenial, -} from "../../../routes/(new-styling)/authorize/silentReauth"; +} from "$lib/stores/channelHandlers/silentReauth"; import type { AccountSessionError } from "$lib/generated/internet_identity_types"; import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; import { @@ -248,17 +248,22 @@ export const handleSessionDelegationRequest = // Silence is something an app asks for. Anything else, an absent `prompt` included, // runs the ceremony, so a held session is never handed over without the user // seeing a screen they did not request. - const held = + const stored = prompt === "none" ? await appSessionsForOrigin(effectiveOrigin) : []; + // Liveness before the choice, not after it: choosing among the stored records + // counts a session the user ended elsewhere, so one live record beside one + // revoked record read as two candidates and were refused as an ambiguity. + // + // The records stay either way. A session that is really gone leaves a record + // that is filtered out on read once it expires, and removing it would need a + // certified answer — an update call, made by the app, not by this. + const alive = await Promise.all( + stored.map((entry) => sessionIsLive(entry.record)), + ); + const held = stored.filter((_, index) => alive[index]); const chosen = chooseSilentSession({ held, hint }); - let usable = "session" in chosen ? chosen.session : undefined; - if (usable && !(await sessionIsLive(usable.record))) { - // The record stays. A session that is really gone leaves a record that is - // filtered out on read once it expires, and removing it would need a certified - // answer — which is an update call, made by the app, not by this. - usable = undefined; - } + const usable = "session" in chosen ? chosen.session : undefined; if (usable) { const chain = await extendToApp( diff --git a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte index cbb4c321b4..241e316e06 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte @@ -20,7 +20,6 @@ import { handleError } from "$lib/components/utils/error"; import { sessionStore } from "$lib/stores/session.store"; import { t } from "$lib/stores/locale.store"; - import { onMount } from "svelte"; import { analytics } from "$lib/utils/analytics/analytics"; import { throwCanisterError } from "$lib/utils/utils"; import { AuthLastUsedFlow } from "$lib/flows/authLastUsedFlow.svelte"; @@ -255,16 +254,27 @@ } }; - // Pre-fetch passkey credential ids - $effect(() => + // Both of these belong to a request that puts something on screen, and neither can be + // decided at mount: `prompt: "none"` is known only once the request is parsed. That is + // what `isReady` reports — a silently answered request never sets the authorization + // context, so it never turns true. Gating on it skips the credential pre-fetch's one + // canister query per remembered identity, none of which a silent request uses, and + // stops counting a page view for a page nobody was shown. + // + // The interactive path loses only the gap between mount and request parse, which is + // over well before the user could act on either. + let viewCounted = false; + $effect(() => { + if (!isReady) { + return; + } authLastUsedFlow.init( lastUsedIdentities.map(({ identityNumber }) => identityNumber), - ), - ); - - // Track page view for authorization flow - onMount(() => { - analytics.pageView(); + ); + if (!viewCounted) { + viewCounted = true; + analytics.pageView(); + } }); From c83b3e042c852455de4e23fc028f678aa72bb080 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 19:01:31 +0200 Subject: [PATCH 294/298] style(describe-browser): format the test file Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- src/frontend/src/lib/utils/describeBrowser.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/lib/utils/describeBrowser.test.ts b/src/frontend/src/lib/utils/describeBrowser.test.ts index 1a3e9bbbe2..aa2be3e40f 100644 --- a/src/frontend/src/lib/utils/describeBrowser.test.ts +++ b/src/frontend/src/lib/utils/describeBrowser.test.ts @@ -284,11 +284,11 @@ describe("client hints", () => { it.each(["Watch", "XR", "Automotive", "EInk"])( "leaves a %s unnamed rather than guessing from `mobile`", async (factor) => { - await expect( - withHints({ formFactors: [factor] }), - ).resolves.toMatchObject({ - form_factor: { Unknown: null }, - }); + await expect(withHints({ formFactors: [factor] })).resolves.toMatchObject( + { + form_factor: { Unknown: null }, + }, + ); }, ); From c9a3465c45c83e36a4a03eb423a112691506b65f Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 19:05:28 +0200 Subject: [PATCH 295/298] refactor(sessions): separate forgetting an identity from revoking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One function served both flows and what it did depended on whether authenticationStore happened to hold the identity, which no caller passed in. So removing an identity from a list fired revoke_browser_sessions through a stored session delegation, which full authorization refuses: a round trip that always answered Unauthorized, swallowed, while four toasts told the user their apps had been signed out. forgetIdentity now drops the local records and nothing else. revokeIdentity ends this browser's sessions and then forgets, and inspects the result variant, so a refusal no longer resolves like a success. Its one caller is the sign-out-and-remove on the manage page, where the user is authenticated as the identity. Removing the access method in use is refused rather than handled. Both item components disable Remove while it is the current method unless it is also the last, and removing the last requires isSignedInWithRecovery, which reads the same authMethod and is true only for recovery — so it cannot hold at once with isCurrentAccessMethod. Removing the selected identity moves the selection on first: the landing page and the other list pages pass no `selected`, so every identity there is removable, and the selection would otherwise name an entry that is gone and render as signed out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../stores/session-delegation.store.test.ts | 55 +++++++++++++++---- .../lib/stores/session-delegation.store.ts | 53 ++++++++++++------ .../src/routes/(new-styling)/+page.svelte | 32 ++++++++--- .../(new-styling)/authorize/+layout.svelte | 2 +- .../routes/(new-styling)/cli/+layout.svelte | 2 +- .../manage/(authenticated)/+layout.svelte | 11 ++-- .../(authenticated)/access/+page.svelte | 20 +++---- .../routes/(new-styling)/mcp/+layout.svelte | 2 +- 8 files changed, 125 insertions(+), 52 deletions(-) diff --git a/src/frontend/src/lib/stores/session-delegation.store.test.ts b/src/frontend/src/lib/stores/session-delegation.store.test.ts index c907efd93a..8f4a46c1bd 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.test.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.test.ts @@ -349,7 +349,7 @@ describe("mintSession — failure is swallowed", () => { }); }); -describe("forgetIdentity", () => { +describe("forgetIdentity and revokeIdentity", () => { const BROWSER_KEY_STORE = idbCreateStore("ii-browser-keys", "keys"); const storedSessionDelegation = async () => { @@ -392,10 +392,10 @@ describe("forgetIdentity", () => { await purgeAppSessions(IDENTITY_NUMBER); }); - /// The gap this exists to close: dropping the local records alone leaves the apps - /// holding chains rooted at session records the canister still has, so they stay - /// signed in and keep refreshing. - it("ends this browser's sessions for the identity it forgets", async () => { + /// The gap `revokeIdentity` exists to close: dropping the local records alone leaves + /// the apps holding chains rooted at session records the canister still has, so they + /// stay signed in and keep refreshing. + it("revoking ends this browser's sessions, then forgets it locally", async () => { const revoke = vi.fn(() => Promise.resolve({ Ok: null })); const actor = { revoke_browser_sessions: revoke, @@ -412,9 +412,9 @@ describe("forgetIdentity", () => { await storedSessionDelegation(); await storedAppSession(); - const { forgetIdentity } = + const { revokeIdentity } = await import("$lib/stores/session-delegation.store"); - await forgetIdentity(IDENTITY_NUMBER); + await revokeIdentity(IDENTITY_NUMBER); expect(revoke).toHaveBeenCalledWith({ identity_number: IDENTITY_NUMBER, @@ -430,10 +430,45 @@ describe("forgetIdentity", () => { ).resolves.toBeUndefined(); }); + /// Removing an identity from a list reaches this, and it holds no full authorization + /// for the identity — `revoke_browser_sessions` would answer `Unauthorized`. So it does + /// not ask, rather than paying for a request that cannot succeed. + it("forgetting makes no canister call", async () => { + const revoke = vi.fn(() => Promise.resolve({ Ok: null })); + const actor = { + revoke_browser_sessions: revoke, + } as unknown as ActorSubclass<_SERVICE>; + const { authenticationStore } = + await import("$lib/stores/authentication.store"); + vi.spyOn(authenticationStore, "subscribe").mockImplementation((cb) => { + cb({ identityNumber: IDENTITY_NUMBER, actor } as Parameters< + typeof cb + >[0]); + return () => {}; + }); + await knownBrowser(7); + await storedSessionDelegation(); + await storedAppSession(); + + const { forgetIdentity } = + await import("$lib/stores/session-delegation.store"); + await forgetIdentity(IDENTITY_NUMBER); + + expect(revoke).not.toHaveBeenCalled(); + const { appSessionsForOrigin } = + await import("$lib/stores/app-session.store"); + await expect( + appSessionsForOrigin("https://app.example.com"), + ).resolves.toEqual([]); + await expect( + idbGet(IDENTITY_NUMBER.toString(), TEST_STORE), + ).resolves.toBeUndefined(); + }); + /// The local half must not depend on the canister being reachable: keeping the records /// because a call failed would leave II able to sign the user back in silently, which /// is the thing they asked it to stop doing. - it("forgets locally even when the canister call fails", async () => { + it("revoking forgets locally even when the canister call fails", async () => { const actor = { revoke_browser_sessions: vi.fn(() => Promise.reject(new Error("offline")), @@ -451,9 +486,9 @@ describe("forgetIdentity", () => { await storedSessionDelegation(); await storedAppSession(); - const { forgetIdentity } = + const { revokeIdentity } = await import("$lib/stores/session-delegation.store"); - await expect(forgetIdentity(IDENTITY_NUMBER)).resolves.toBeUndefined(); + await expect(revokeIdentity(IDENTITY_NUMBER)).resolves.toBeUndefined(); const { appSessionsForOrigin } = await import("$lib/stores/app-session.store"); diff --git a/src/frontend/src/lib/stores/session-delegation.store.ts b/src/frontend/src/lib/stores/session-delegation.store.ts index 9864b95d8f..a03d0bb1d5 100644 --- a/src/frontend/src/lib/stores/session-delegation.store.ts +++ b/src/frontend/src/lib/stores/session-delegation.store.ts @@ -18,6 +18,7 @@ import { sessionDelegationIdentity, type SessionDelegationRecord, } from "$lib/utils/authentication/sessionDelegation"; +import { throwCanisterError } from "$lib/utils/utils"; const SESSION_DELEGATION_STORE = createStore("ii-session-delegations", "keys"); @@ -120,18 +121,35 @@ export const actorForIdentity = async ( }; /** - * Forgets an identity on this device, and signs it out of every app it is signed into - * from here. + * Drops what this device remembers of an identity: its II session, and the app sessions + * held under it. * - * Dropping the local records alone would only stop II from silently signing the user - * back in: the apps hold delegation chains rooted at session records the canister still - * has, and go on refreshing against them until they expire. Ending this browser's - * sessions for this identity is what makes "forget" mean signed out. + * Local only, and named for what it does. Apps keep the delegation chains they already + * hold and go on refreshing against the canister's session records until those expire — + * ending those is {@link revokeIdentity}, which requires being authenticated as this + * identity and so is not something every caller can do. + * + * Records are per identity, so this leaves the user's other identities on this browser + * alone. + */ +export const forgetIdentity = async (identityNumber: bigint): Promise => { + await purgeSession(identityNumber); + await purgeAppSessions(identityNumber); +}; + +/** + * Signs this browser out of every app it reached with an identity, then forgets it here. + * + * Only callable while authenticated as this identity: `revoke_browser_sessions` is + * gated on full authorization, which a stored session delegation does not carry, so a + * caller that merely holds records for the identity gets `Unauthorized` and revokes + * nothing. Removing an identity from a list is that caller — it uses + * {@link forgetIdentity} instead, rather than paying for a request that cannot succeed. * * Sessions are per browser and per identity, so this leaves other identities on this * browser, and this identity on the user's other browsers, alone. */ -export const forgetIdentity = async (identityNumber: bigint): Promise => { +export const revokeIdentity = async (identityNumber: bigint): Promise => { const browserId = await currentBrowserId(identityNumber); const actor = browserId === undefined @@ -139,16 +157,19 @@ export const forgetIdentity = async (identityNumber: bigint): Promise => { : await actorForIdentity(identityNumber); if (browserId !== undefined && actor !== undefined) { try { - await actor.revoke_browser_sessions({ - identity_number: identityNumber, - browser_id: browserId, - }); + // `throwCanisterError`, because the method answers a result variant: an `Err` would + // otherwise resolve like a success and a refusal would read as a sign-out. + await actor + .revoke_browser_sessions({ + identity_number: identityNumber, + browser_id: browserId, + }) + .then(throwCanisterError); } catch { - // The local records go either way. Keeping them because the canister could not be - // reached would leave II able to sign the user back in silently, which is the - // thing the user asked it to stop doing. + // The local records go either way. Keeping them because the canister refused or + // could not be reached would leave II able to sign the user back in silently, + // which is the thing the user asked it to stop doing. } } - await purgeSession(identityNumber); - await purgeAppSessions(identityNumber); + await forgetIdentity(identityNumber); }; diff --git a/src/frontend/src/routes/(new-styling)/+page.svelte b/src/frontend/src/routes/(new-styling)/+page.svelte index c6a8e03454..12193f971c 100644 --- a/src/frontend/src/routes/(new-styling)/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/+page.svelte @@ -13,8 +13,7 @@ import type { AuthMode } from "$lib/flows/authFlow.svelte"; 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 { forgetIdentity } from "$lib/stores/session-delegation.store"; import { goto } from "$app/navigation"; import { toaster } from "$lib/components/utils/toaster"; import { @@ -92,17 +91,27 @@ duration: 2000, }); }; - // Local only. `ManageIdentities` disables the remove button on the selected identity, - // so the identity removed here is never the one signed in — and revoking a browser's - // sessions needs full authorization, which a stored session delegation does not carry. - // Dropping the records is therefore the whole of it, which is what makes Undo worth - // offering: the identity comes back, and nothing was signed out to restore. + // Every identity here is removable, the selected one included: this page passes no + // `selected` to `ManageIdentities`, because nobody is signed in on it. Only + // `manage/(authenticated)` passes one, where the identity in use cannot be removed. const handleRemoveIdentity = (identityNumber: bigint) => { + // Removing the selected identity would leave the selection naming an entry that is + // gone, which renders as signed out with other identities still present. Moved on + // before the delete, and moved back by Undo. + const isCurrent = selectedIdentity?.identityNumber === identityNumber; + if (isCurrent) { + const nextIdentity = lastUsedIdentities.find( + (identity) => identity.identityNumber !== identityNumber, + ); + if (nextIdentity !== undefined) { + lastUsedIdentitiesStore.selectIdentity(nextIdentity.identityNumber); + } + } + const removedIdentity = $lastUsedIdentitiesStore.identities[`${identityNumber}`]; lastUsedIdentitiesStore.removeIdentity(identityNumber); - void purgeSession(identityNumber); - void purgeAppSessions(identityNumber); + void forgetIdentity(identityNumber); isManageIdentitiesDialogOpen = false; if (removedIdentity !== undefined) { @@ -117,6 +126,11 @@ label: $t`Undo`, onClick: () => { lastUsedIdentitiesStore.restoreIdentity(removedIdentity); + if (isCurrent) { + lastUsedIdentitiesStore.selectIdentity( + removedIdentity.identityNumber, + ); + } }, }, }); diff --git a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte index 4eb6e31e1b..e5a82ab905 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte @@ -197,7 +197,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, + description: $t`${identityName} has been removed from this device.`, closable: true, duration: 5000, action: { diff --git a/src/frontend/src/routes/(new-styling)/cli/+layout.svelte b/src/frontend/src/routes/(new-styling)/cli/+layout.svelte index 6eac7d1916..9ff0a8a7ff 100644 --- a/src/frontend/src/routes/(new-styling)/cli/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/cli/+layout.svelte @@ -71,7 +71,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, + description: $t`${identityName} has been removed from this device.`, closable: true, duration: 5000, action: { 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 65bb5c087c..c217eecbf7 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte @@ -25,7 +25,10 @@ } from "$lib/stores/authentication.store"; import { DelegationIdentity } from "@icp-sdk/core/identity"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; - import { forgetIdentity } from "$lib/stores/session-delegation.store"; + import { + forgetIdentity, + revokeIdentity, + } from "$lib/stores/session-delegation.store"; import { sessionStore } from "$lib/stores/session.store"; import { locales, localeStore, t } from "$lib/stores/locale.store"; import { AuthLastUsedFlow } from "$lib/flows/authLastUsedFlow.svelte"; @@ -121,9 +124,9 @@ // Awaited: this ends the browser's sessions canister-side, and replacing the page // while that update call is in flight leaves the apps refreshing against session // records that still exist — which is the one thing "remove" is meant to stop. - // `forgetIdentity` swallows a canister failure and clears the local records either + // `revokeIdentity` swallows a canister failure and clears the local records either // way, so waiting cannot strand the user here. - await forgetIdentity(identityNumber); + await revokeIdentity(identityNumber); sessionStore.reset(); window.location.replace("/"); }; @@ -141,7 +144,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, + description: $t`${identityName} has been removed from this device.`, closable: true, duration: 5000, action: { diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte index ae22f0d60b..41f9a66d40 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/access/+page.svelte @@ -23,7 +23,6 @@ } from "$app/navigation"; import { canisterId } from "$lib/globals"; import { authenticationStore } from "$lib/stores/authentication.store"; - import { forgetIdentity } from "$lib/stores/session-delegation.store"; import { authenticateWithPasskey } from "$lib/utils/authentication/passkey"; import { authenticateWithJWT } from "$lib/utils/authentication/jwt"; import { @@ -391,16 +390,17 @@ ]) .then(throwCanisterError); } - // Logout and forget identity if it's the current access method + // The method in use cannot be removed, so nothing here signs the user out. Both + // item components disable Remove while `isCurrentAccessMethod` holds unless it is + // also the last method, and removing the last one requires `isSignedInWithRecovery` + // — which reads the same `authMethod` and is true only for a recovery phrase or + // recovery email, so it cannot hold at the same time as `isCurrentAccessMethod`. + // Refused rather than handled, so a change to either guard fails loudly instead of + // quietly ending the session the user is using. if (isCurrentAccessMethod($authenticatedStore, removingAccessMethod)) { - const identityNumber = $authenticatedStore.identityNumber; - lastUsedIdentitiesStore.removeIdentity(identityNumber); - // Awaited, unlike the other forget sites: the navigation below would cut a - // fire-and-forget call off before it reached the canister. - await forgetIdentity(identityNumber); - sessionStore.reset(); - location.replace("/login"); - return; + throw new Error( + "the access method in use cannot be removed; switch to another first", + ); } // Optimistic update accessMethods = accessMethods diff --git a/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte b/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte index 3bf7a65455..b1ba91db05 100644 --- a/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte @@ -100,7 +100,7 @@ removedIdentity.name ?? `${removedIdentity.identityNumber}`; toaster.create({ title: $t`Identity removed`, - description: $t`${identityName} has been removed from this device. Apps you were signed into here have been signed out.`, + description: $t`${identityName} has been removed from this device.`, closable: true, duration: 5000, action: { From 65bad0936f3698bf26b450dbc2adb54f64fd2831 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 19:33:47 +0200 Subject: [PATCH 296/298] test(e2e): drive the sign-out dialog by its current labels The dialog asks "Remember this browser?" with Remember and Forget. The fixture still waited for the old heading and clicked the old buttons, so every test that signed out timed out. Forget is matched exactly, so it does not also match the "Forgetting..." label the button takes while the sessions are being revoked. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../tests/e2e-playwright/fixtures/managePage.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/frontend/tests/e2e-playwright/fixtures/managePage.ts b/src/frontend/tests/e2e-playwright/fixtures/managePage.ts index c9e6679aca..40398f1e60 100644 --- a/src/frontend/tests/e2e-playwright/fixtures/managePage.ts +++ b/src/frontend/tests/e2e-playwright/fixtures/managePage.ts @@ -24,17 +24,21 @@ class SignOutConfirmation { this.#page = page; } + /** Signs out and keeps the identity on this device: the dialog's "Remember". */ async keepIdentity(): Promise { - await this.#page - .getByRole("button", { name: "Sign out and keep identity" }) - .click(); + await this.#page.getByRole("button", { name: "Remember" }).click(); await this.#page.waitForURL(II_URL); } + /** Signs out and drops the identity: the dialog's "Forget". */ async removeFromDevice(): Promise { + // Exact, so it does not also match the in-flight "Forgetting..." label this button + // takes on while the sessions are being revoked. await this.#page - .getByRole("button", { name: "Sign out and remove from device" }) + .getByRole("button", { name: "Forget", exact: true }) .click(); + // Forgetting revokes this browser's sessions before navigating, so the wait covers a + // canister round trip rather than a redirect. await this.#page.waitForURL(II_URL); } } @@ -96,7 +100,7 @@ class IdentitySwitcherPopover { .click(); await expect( this.#page.getByRole("heading", { - name: "Sign out from this device", + name: "Remember this browser?", }), ).toBeVisible(); const confirmation = new SignOutConfirmation(this.#page); From 9ec4fcd22ae7c8024d1ebd92184ba4349031e73a Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 23:01:21 +0200 Subject: [PATCH 297/298] test(e2e): read the signed-out identity from what remains The sign-out dialog names no identity, so asserting the active one appears in it cannot hold. Which identity was signed out is still covered, by the stored last-used entries the test already checks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../routes/manage/sign-out-active-identity.spec.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/frontend/tests/e2e-playwright/routes/manage/sign-out-active-identity.spec.ts b/src/frontend/tests/e2e-playwright/routes/manage/sign-out-active-identity.spec.ts index 93bc00c577..8ee90b52ed 100644 --- a/src/frontend/tests/e2e-playwright/routes/manage/sign-out-active-identity.spec.ts +++ b/src/frontend/tests/e2e-playwright/routes/manage/sign-out-active-identity.spec.ts @@ -70,12 +70,9 @@ test.describe("Sign-out confirmation targets the active identity", () => { .findPasskey(DEFAULT_PASSKEY_NAME) .switch((dialog) => dialog.confirm()); - await managePage.signOut(async (confirmation) => { - const dialog = page.getByRole("dialog"); - await expect(dialog.getByText(identityY.name)).toBeVisible(); - await expect(dialog.getByText(identityX.name)).toBeHidden(); - await confirmation.removeFromDevice(); - }); + // The dialog names no identity, so which one is being signed out is read from what + // the sign-out leaves behind rather than from the screen. + await managePage.signOut((confirmation) => confirmation.removeFromDevice()); const raw = await page.evaluate(() => localStorage.getItem("ii-last-used-identities"), From d31f27f9faf5cecfd09390ba3230c4fea74af8d1 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Thu, 10 Sep 2026 23:34:07 +0200 Subject: [PATCH 298/298] test(sessions): cover the ceremony's request and the id it stores The tests around this one locate the prepare call by its TTL and then read only max_idle, and they check a record exists without looking inside it. So the identity, origin and account the session is asked for went unasserted, and so did the session id. The id earns its own assertion: get_account_session names the session by it, so a record holding the wrong one cannot be resumed, and every other test here would still pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ --- .../channelHandlers/sessionDelegation.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 6dc93b0569..7a9118475b 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -671,6 +671,31 @@ describe("keeping a session for later", () => { ); }); + /// What the ceremony sends and what it keeps, neither of which the tests around this + /// one reach: they locate the request by its TTL and then read only `max_idle`, and + /// they check that a record exists without looking at what is in it. + /// + /// `session_id` earns its own assertion because nothing downstream would catch a wrong + /// one here: `get_account_session` names the session by that id, so a record holding + /// the wrong one is a session that cannot be resumed, and every other test in this + /// file would still pass. + it("asks for this identity at this origin, and keeps the session it is given", async () => { + const { sent, prepared } = await runCeremony(true, { + maxTimeToLive: "5400000000000", + }); + + expect(sent).toHaveLength(1); + expect(ourRequest(prepared, BigInt(5_400_000_000_000))).toMatchObject({ + identity_number: BigInt(10_000), + origin: ORIGIN, + // The default account, which the consent resolved to no account number. + account_number: [], + }); + await expect(appSessionsForOrigin(ORIGIN)).resolves.toMatchObject([ + { identityNumber: BigInt(10_000), record: { sessionId: BigInt(1_000) } }, + ]); + }); + it("keeps a session the app asked to be resumable", async () => { const { sent } = await runCeremony(true);