diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index a129756ce3..bf914b7ebd 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -559,6 +559,43 @@ 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({ + 'Edge' : IDL.Null, + 'Firefox' : 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, + 'description' : BrowserDescription, + 'created_at' : Timestamp, + 'last_used' : Timestamp, + }); const McpConfig = IDL.Record({ 'url' : IDL.Opt(IDL.Text), 'enabled' : IDL.Bool, @@ -575,6 +612,7 @@ export const idlFactory = ({ IDL }) => { 'name' : IDL.Opt(IDL.Text), 'email_recovery' : IDL.Opt(IDL.Vec(EmailRecoveryCredential)), 'created_at' : IDL.Opt(Timestamp), + '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 418784ba4a..9b922ade20 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 } @@ -942,6 +950,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. + */ + 'browsers' : [] | [Array], /** * The anchor's synced trusted-MCP-server config (absent when the * anchor never wrote one). Carried here rather than read from the @@ -1297,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 @@ -1528,6 +1550,54 @@ export type Salt = Uint8Array | number[]; export type SessionDelegationError = { 'NoSuchDelegation' : null } | { 'InternalCanisterError' : string } | { 'Unauthorized' : Principal }; +/** + * 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 = { 'Edge' : null } | + { 'Firefox' : 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. Named variants are the six that hold + * 97% of the web between them, because a variant is what earns an icon. + */ + '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, + /** + * 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, +} 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..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,6 +40,7 @@ const baseIdentityInfo: IdentityInfo = { created_at: [], authn_method_registration: [], openid_credentials: [], + browsers: [], mcp_config: [], }; diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index df31eff779..2809b7a9cf 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1006,6 +1006,48 @@ type IdentityAuthnInfo = record { recovery_authn_methods : vec AuthnMethod; }; +// 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; + // A browser this list does not name, shown as the client resolved it. Worth seeing + // 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; +}; + +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; + // 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; +}; + type IdentityInfo = record { authn_methods : vec AuthnMethodData; authn_method_registration : opt AuthnMethodRegistrationInfo; @@ -1026,6 +1068,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. + 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 2bcf28a491..50d7551bd5 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 { + 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 0e1d458742..94997e1373 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -1098,6 +1098,8 @@ mod v2_api { Some(stored_verified_emails) }; + let browsers = state::anchor(identity_number).browsers_info(); + let identity_info = IdentityInfo { authn_methods: anchor_info .devices @@ -1113,6 +1115,7 @@ mod v2_api { created_at: anchor_info.created_at, email_recovery, verified_emails, + 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 dcb1f19f93..af6038f0f7 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -871,6 +871,8 @@ impl Storage { created_at_ns: _, name: _, verified_emails: _, + browsers: _, + next_browser_id: _, }) = previous_anchor_maybe { ( diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs index 95d57cc5dc..7286498ccf 100644 --- a/src/internet_identity/src/storage/anchor.rs +++ b/src/internet_identity/src/storage/anchor.rs @@ -2,6 +2,8 @@ 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::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; @@ -38,11 +40,87 @@ pub struct Anchor { pub(crate) email_recovery: Vec, /// Capped by `MAX_VERIFIED_EMAILS_PER_ANCHOR`. pub(crate) verified_emails: Vec, + /// 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_BROWSERS: usize = 20; + +/// Why a browser's presented keys cannot be resolved. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +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 + /// 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, + /// 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 list for one browser, and accepting it would leave a leaked + /// key useful for longer than the one sign-in rotation allows it. + StaleBrowserKey, +} + +/// 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, + /// The browser's own public key, DER-encoded. What the entry is looked up by. + 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, + /// What this browser reported about itself when it registered. + pub description: BrowserDescription, + pub created_at: Timestamp, + pub last_used: Timestamp, +} + +impl From for Browser { + fn from(value: StorableBrowser) -> Self { + Browser { + id: value.id, + current_browser_key: ByteBuf::from(value.current_browser_key), + next_browser_key: ByteBuf::from(value.next_browser_key), + description: BrowserDescription::from(value.description), + created_at: value.created_at, + last_used: value.last_used, + } + } +} + +impl From for StorableBrowser { + fn from(value: Browser) -> Self { + StorableBrowser { + id: value.id, + current_browser_key: value.current_browser_key.into_vec(), + next_browser_key: value.next_browser_key.into_vec(), + description: StorableBrowserDescription::from(value.description), + 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 +253,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { openid_credentials, email_recovery, verified_emails, + browsers, + next_browser_id, metadata, name, created_at, @@ -194,6 +274,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { .map(StorableVerifiedEmail::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![]); @@ -433,6 +515,8 @@ impl From for (StorableFixedAnchor, StorableAnchor) { recovery_keys, email_recovery, verified_emails, + browsers, + next_browser_id, }, ) } @@ -448,6 +532,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { recovery_keys, email_recovery, verified_emails, + browsers, + next_browser_id, } = storable_anchor; let name = name.clone(); @@ -466,6 +552,12 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { .into_iter() .map(VerifiedEmail::from) .collect(); + let browsers = browsers + .unwrap_or_default() + .into_iter() + .map(Browser::from) + .collect(); + let next_browser_id = next_browser_id.unwrap_or_default(); let mut devices = passkey_credentials .unwrap_or_default() @@ -560,6 +652,8 @@ impl From<(AnchorNumber, StorableAnchor)> for Anchor { openid_credentials, email_recovery, verified_emails, + browsers, + next_browser_id, devices, metadata, } @@ -586,6 +680,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], + browsers: vec![], + next_browser_id: 0, anchor_number, devices, metadata, @@ -612,6 +708,12 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho .into_iter() .map(VerifiedEmail::from) .collect(); + let browsers = storable_anchor + .browsers + .unwrap_or_default() + .into_iter() + .map(Browser::from) + .collect(); Anchor { anchor_number, @@ -619,6 +721,8 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho openid_credentials, email_recovery, verified_emails, + browsers, + next_browser_id: storable_anchor.next_browser_id.unwrap_or_default(), metadata, name, created_at, @@ -627,6 +731,127 @@ impl From<(AnchorNumber, StorableFixedAnchor, Option)> for Ancho } impl Anchor { + pub fn browsers(&self) -> &[Browser] { + &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 + /// 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::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. + /// + /// `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, + description: BrowserDescription, + now: Timestamp, + ) -> 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.browsers + .iter() + .position(|browser| browser.next_browser_key == *candidate) + }; + let entry_holding = |candidate: &PublicKey| { + self.browsers.iter().position(|browser| { + browser.current_browser_key == *candidate || browser.next_browser_key == *candidate + }) + }; + + 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_browser_key); + let successor_holder = entry_holding(&next_browser_key); + if successor_holder.is_some() && successor_holder != owner { + return Err(BrowserError::SuccessorAlreadyInUse); + } + + if let Some(index) = advances { + 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::StaleBrowserKey); + } + + let id = self.next_browser_id; + self.next_browser_id = self.next_browser_id.saturating_add(1); + self.browsers.push(Browser { + id, + current_browser_key, + next_browser_key, + description, + created_at: now, + last_used: now, + }); + + let mut dropped = vec![]; + while self.browsers.len() > MAX_BROWSERS { + let least_recently_used = self + .browsers + .iter() + .enumerate() + .min_by_key(|(_, browser)| (browser.last_used, browser.id)) + .map(|(index, _)| index); + match least_recently_used { + Some(index) => { + dropped.push(self.browsers.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 +862,8 @@ impl Anchor { openid_credentials: vec![], email_recovery: vec![], verified_emails: vec![], + 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 85d1dcf626..6dedacd86a 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 { + browsers: vec![], + next_browser_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 { + browsers: vec![], + next_browser_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 { + browsers: vec![], + next_browser_id: 0, anchor_number: ANCHOR_NUMBER, devices: vec![ device1.clone(), @@ -1260,3 +1266,561 @@ mod mirror_verified_email_tests { .all(|e| e.address != "new@example.com")); } } + +mod browser_tests { + use super::*; + use crate::storage::anchor::{BrowserError, MAX_BROWSERS}; + 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]) + } + + /// What a browser presenting `browser_key(seed)` announces it will rotate to. + fn successor_key(seed: u8) -> PublicKey { + 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(); + + let (id, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome on MacBook"), + 1_000, + ) + .unwrap(); + + assert_eq!(id, 0); + assert_eq!(anchor.browsers().len(), 1); + 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_its_entry_and_keeps_its_description() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome on MacBook"), + 1_000, + ) + .unwrap(); + + let (again, _) = anchor + .resolve_browser( + successor_key(1), + browser_key(2), + description("Something else"), + 2_000, + ) + .unwrap(); + + assert_eq!(again, id); + assert_eq!(anchor.browsers().len(), 1); + assert_eq!( + anchor.browsers()[0].description, + description("Chrome on MacBook") + ); + } + + #[test] + fn registration_stamps_both_timestamps() { + let mut anchor = anchor(); + + anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + 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_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + anchor + .resolve_browser( + successor_key(1), + browser_key(2), + description("Chrome"), + 5_000, + ) + .unwrap(); + + 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_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + let (id, _) = anchor + .resolve_browser( + browser_key(2), + successor_key(2), + description("Firefox"), + 2_000, + ) + .unwrap(); + + assert_eq!(id, 1); + assert_eq!(anchor.browsers().len(), 2); + } + + #[test] + fn ids_are_never_reused() { + let mut anchor = anchor(); + let (first, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + anchor.browsers.clear(); + + let (second, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 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_BROWSERS { + anchor + .resolve_browser( + browser_key(index as u8), + successor_key(index as u8), + description(&format!("device-{index}")), + index as u64 + 1, + ) + .unwrap(); + } + + let (newest, dropped) = anchor + .resolve_browser( + browser_key(200), + successor_key(200), + 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(|b| b.description == description("device-0"))); + assert!(anchor + .browsers() + .iter() + .any(|b| b.description == description("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_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), + description(&format!("device-{index}")), + index as u64 + 1, + ) + .unwrap(); + } + anchor + .resolve_browser( + successor_key(0), + rotating_key(0, 200), + description("oldest"), + 9_000, + ) + .unwrap(); + + let (_, dropped) = anchor + .resolve_browser( + browser_key(200), + successor_key(200), + description("newest"), + 10_000, + ) + .unwrap(); + + assert_eq!(dropped, vec![1]); + assert!(anchor.browsers().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(); + // 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), 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), + description("phone"), + 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_browser( + browser_key(wipe as u8 + 1), + successor_key(wipe as u8 + 1), + description(&format!("wiped-{wipe}")), + 1_001 + wipe * 10, + ) + .unwrap(); + } + + 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_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + let (after, _) = anchor + .resolve_browser( + browser_key(2), + successor_key(2), + description("Chrome"), + 2_000, + ) + .unwrap(); + + assert_ne!(after, before); + 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_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + let (again, _) = anchor + .resolve_browser( + successor_key(1), + browser_key(2), + description("Chrome"), + 2_000, + ) + .unwrap(); + + 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, browser_key(2)); + } + + #[test] + fn the_key_a_successor_replaced_is_retired() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + anchor + .resolve_browser( + successor_key(1), + browser_key(2), + description("Chrome"), + 2_000, + ) + .unwrap(); + + let (after, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(3), + description("Chrome"), + 3_000, + ) + .unwrap(); + + assert_ne!(after, id); + assert_eq!(anchor.browsers().len(), 2); + } + + /// 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 list 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_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .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_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 2_000, + ); + + assert_eq!(retried, Err(BrowserError::StaleBrowserKey)); + 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 + /// it announced is what gets it back to its own entry. + #[test] + fn promoting_the_announced_successor_resolves_the_same_browser() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + let (again, _) = anchor + .resolve_browser( + successor_key(1), + successor_key(2), + description("Chrome"), + 2_000, + ) + .unwrap(); + + 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)); + } + + #[test] + fn rotating_repeatedly_keeps_the_same_browser() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_browser(browser_key(0), browser_key(1), description("Chrome"), 1) + .unwrap(); + + for step in 1..10u8 { + let (again, _) = anchor + .resolve_browser( + browser_key(step), + browser_key(step + 1), + description("Chrome"), + step as u64 * 100, + ) + .unwrap(); + assert_eq!(again, id); + } + + assert_eq!(anchor.browsers().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_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + 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), + description("Firefox"), + 2_000, + ); + + assert_eq!(stealing_the_key, Err(BrowserError::SuccessorAlreadyInUse)); + assert_eq!( + stealing_the_successor, + Err(BrowserError::SuccessorAlreadyInUse) + ); + assert_eq!(anchor.browsers().len(), 1); + } + + /// The browser that already holds it is re-announcing, which a retry does. + #[test] + fn a_second_sign_in_from_a_key_a_browser_never_announced_is_a_new_browser() { + let mut anchor = anchor(); + let (id, _) = anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + // Neither slot holds it, so there is nothing to say this is the same browser. + let (other, _) = anchor + .resolve_browser( + browser_key(7), + successor_key(7), + description("Chrome"), + 2_000, + ) + .unwrap(); + + assert_ne!(other, id); + assert_eq!(anchor.browsers().len(), 2); + } + + #[test] + fn a_browser_cannot_name_itself_its_own_successor() { + let mut anchor = anchor(); + + // 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_browser(browser_key(1), browser_key(1), description("Chrome"), 1_000), + Err(BrowserError::SuccessorMatchesCurrent) + ); + assert!(anchor.browsers().is_empty()); + } + + #[test] + fn a_registered_browser_cannot_stop_rotating_either() { + let mut anchor = anchor(); + anchor + .resolve_browser( + browser_key(1), + successor_key(1), + description("Chrome"), + 1_000, + ) + .unwrap(); + + assert_eq!( + anchor.resolve_browser( + successor_key(1), + successor_key(1), + description("Chrome"), + 2_000 + ), + 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)); + } +} diff --git a/src/internet_identity/src/storage/storable.rs b/src/internet_identity/src/storage/storable.rs index e06e95ef26..afae1b51c2 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_description; pub mod browser_id; pub mod credential_id; pub mod duration; diff --git a/src/internet_identity/src/storage/storable/anchor.rs b/src/internet_identity/src/storage/storable/anchor.rs index 60226068df..cff73754b3 100644 --- a/src/internet_identity/src/storage/storable/anchor.rs +++ b/src/internet_identity/src/storage/storable/anchor.rs @@ -1,3 +1,5 @@ +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; @@ -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_BROWSERS`. + #[n(7)] + pub browsers: Option>, + /// Monotonic per-anchor allocator for `browsers`. Ids are never reused. + #[n(8)] + pub next_browser_id: Option, } impl Storable for StorableAnchor { diff --git a/src/internet_identity/src/storage/storable/browser.rs b/src/internet_identity/src/storage/storable/browser.rs new file mode 100644 index 0000000000..3f703b427d --- /dev/null +++ b/src/internet_identity/src/storage/storable/browser.rs @@ -0,0 +1,24 @@ +use crate::storage::storable::browser_description::StorableBrowserDescription; +use crate::storage::storable::browser_id::StorableBrowserId; +use internet_identity_interface::internet_identity::types::Timestamp; +use minicbor::{Decode, Encode}; + +#[derive(Encode, Decode, Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[cbor(map)] +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 description: StorableBrowserDescription, + #[n(2)] + pub created_at: Timestamp, + #[n(3)] + pub last_used: Timestamp, + #[cbor(n(4), with = "minicbor::bytes")] + pub current_browser_key: Vec, + #[cbor(n(5), with = "minicbor::bytes")] + pub next_browser_key: Vec, +} 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..c0e6cda663 --- /dev/null +++ b/src/internet_identity/src/storage/storable/browser_description.rs @@ -0,0 +1,258 @@ +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)] + 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::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::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::Safari, + 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::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/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f667f4a140..2ddf55d2f8 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1410,6 +1410,8 @@ fn test_anchor_storage_migration_round_trip() { "empty anchor", storage.allocate_anchor(now).unwrap(), Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 0, devices: vec![], openid_credentials: vec![], @@ -1442,6 +1444,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 1, devices: vec![Device { pubkey: ByteBuf::from("recovery_key_pubkey"), @@ -1485,6 +1489,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 2, devices: vec![Device { pubkey: ByteBuf::from("passkey_pubkey"), @@ -1528,6 +1534,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 3, devices: vec![Device { pubkey: ByteBuf::from("passkey_no_origin"), @@ -1571,6 +1579,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 4, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey"), @@ -1614,6 +1624,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 5, devices: vec![Device { pubkey: ByteBuf::from("recovery_passkey_no_origin"), @@ -1657,6 +1669,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 6, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_auth"), @@ -1700,6 +1714,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 7, devices: vec![Device { pubkey: ByteBuf::from("browser_storage_key_recovery"), @@ -1757,6 +1773,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 8, devices: vec![ Device { @@ -1801,6 +1819,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 9, devices: vec![], openid_credentials: vec![openid_credential(1)], @@ -1820,6 +1840,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 10, devices: vec![], openid_credentials: vec![], @@ -1852,6 +1874,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 11, devices: vec![Device { pubkey: ByteBuf::from("unknown_keytype_passkey"), @@ -1902,6 +1926,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 12, devices: vec![Device { pubkey: ByteBuf::from("device_with_metadata"), @@ -1939,6 +1965,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 13, devices: vec![], openid_credentials: vec![], @@ -1971,6 +1999,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 14, devices: vec![Device { pubkey: ByteBuf::from("protected_recovery_key"), @@ -2017,6 +2047,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 15, devices: vec![Device { pubkey: ByteBuf::from("protected_passkey"), @@ -2062,6 +2094,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 16, devices: vec![Device { pubkey: ByteBuf::from("unusual_device"), @@ -2105,6 +2139,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 17, devices: vec![Device { pubkey: ByteBuf::from("recovery_phrase_custom_alias"), @@ -2148,6 +2184,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_id: 0, anchor_number: 18, devices: vec![Device { pubkey: ByteBuf::from("platform_passkey"), @@ -2191,6 +2229,8 @@ fn test_anchor_storage_migration_round_trip() { anchor }, Anchor { + browsers: vec![], + next_browser_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..691aabfb04 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 { + 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 c133653af1..d734d5e859 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_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); + + 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.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 8f2f160fc2..1f69430997 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -16,6 +16,76 @@ 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. +/// +/// 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. +/// +/// 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, + /// 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 fb6766262c..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::{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,6 +79,15 @@ pub struct IdentityAuthnInfo { pub recovery_authn_methods: Vec, } +/// 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 description: BrowserDescription, + pub created_at: Timestamp, + pub last_used: Timestamp, +} + #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] pub struct IdentityInfo { pub authn_methods: Vec, @@ -99,6 +110,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 browsers: Option>, /// The anchor's synced trusted-MCP-server config (master toggle + /// trusted server URL). `None` for an anchor that never wrote one. ///