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 ffb6fde4ce..205ec72e9f 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -772,3 +772,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 5762e2db05..23a3c33cac 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -138,6 +138,33 @@ 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({ + 'NoSuchDelegation' : IDL.Null, + 'InternalCanisterError' : IDL.Text, + 'NoSuchSession' : IDL.Null, + }); + 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 +385,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), @@ -923,6 +939,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 8dcad36f67..d2d2fc497b 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -89,6 +89,41 @@ 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 = { + /** + * 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. + */ + 'NoSuchSession' : null + }; /** * Configuration parameters related to the archive. */ @@ -1957,6 +1992,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 fe44bc2e76..3d35c5859e 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1127,6 +1127,35 @@ 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. + 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; +}; + type IdentityInfo = record { authn_methods : vec AuthnMethodData; authn_method_registration : opt AuthnMethodRegistrationInfo; @@ -1988,6 +2017,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 c9461ea443..75cbcafa68 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -512,6 +512,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] fn prepare_account_delegation( anchor_number: AnchorNumber, diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 965615b35c..7518126508 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -7,7 +7,7 @@ use crate::delegation::{ der_encode_canister_sig_key, frontend_length_within_limit, DelegationAccess, }; use crate::state::{self, storage_borrow, storage_borrow_mut}; -use crate::storage::account::{AccountKey, Session, SessionLocator}; +use crate::storage::account::{Account, AccountKey, Session, SessionLocator}; use crate::storage::anchor::BrowserError; use crate::storage::{CreateSessionParams, StorageError}; use crate::{update_root_hash, DAY_NS, MINUTE_NS}; @@ -15,11 +15,14 @@ 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::internet_identity::types::{ - AccountNumber, AccountSessionError, AnchorNumber, BrowserBrand, BrowserDescription, Delegation, - FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, OperatingSystem, - PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp, + AccountNumber, AccountSessionError, AnchorNumber, AppGetDelegationRequest, + AppPrepareDelegationRequest, AppPrepareDelegationResponse, AppSessionError, BrowserBrand, + BrowserDescription, Delegation, FrontendHostname, GetAccountSessionRequest, + GetAccountSessionResponse, OperatingSystem, PrepareAccountSessionRequest, + PrepareAccountSessionResponse, SignedDelegation, Timestamp, }; use serde_bytes::ByteBuf; @@ -343,6 +346,141 @@ fn get_account_principal_for_origin( )) } +/// 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 AuthorizedSession { + account, session, .. + } = authorize_session(now)?; + + let expiration = u64::min( + now.saturating_add(APP_DELEGATION_TTL_NS), + session.valid_till_ns, + ); + 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, + // Unscoped on purpose: an app calls whatever canisters it likes. The session + // credential this was minted from is the scoped one. + None, + 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 AuthorizedSession { + 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::NoSuchDelegation); + } + + 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), + }) + // 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. +/// +/// 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 { + let locator = storage_borrow(|storage| storage.lookup_session_with_principal(caller())) + .ok_or(AppSessionError::NoSuchSession)?; + + let (account, session) = storage_borrow(|storage| { + Some(( + storage.read_account(&locator.account_key())?, + storage.read_session(&locator)?, + )) + }) + .ok_or(AppSessionError::NoSuchSession)?; + + if session.is_expired_or_idle(now) { + return Err(AppSessionError::NoSuchSession); + } + Ok(AuthorizedSession { + locator, + 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)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index fd0a0b2c17..c898f8a192 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -3288,6 +3288,49 @@ impl Storage { } } + /// The session a caller's principal names, or `None` where the index no longer + /// leads to one. + /// + /// A resolution, not an authorisation: the session it names may be expired or + /// read-only, which is the caller's to check. What it does rule out is a stale + /// entry, since the key it builds carries the id the entry recorded and no later + /// 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_principal())?; + + Some(SessionLocator { + anchor_number: account.anchor_number, + origin: account.origin, + account_number: account.account_number, + session_id: handle.session_id, + }) + } + + /// 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 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 { + Some(AccountKey { + anchor_number: stored.anchor_number, + origin: self + .stable_application_memory + .get(&stored.application_number)? + .origin, + account_number: stored.account_number, + }) + } + /// Retires an application no anchor references any more. The number is never /// reissued. fn remove_unreferenced_application( diff --git a/src/internet_identity/src/storage/account.rs b/src/internet_identity/src/storage/account.rs index b6abd34d38..85ea605756 100644 --- a/src/internet_identity/src/storage/account.rs +++ b/src/internet_identity/src/storage/account.rs @@ -84,8 +84,6 @@ pub struct SessionLocator { } impl SessionLocator { - // 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 { AccountKey { diff --git a/src/internet_identity/src/storage/storable/session_handle.rs b/src/internet_identity/src/storage/storable/session_handle.rs index fb61be06d3..1be87b9dcd 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_id::StorableSessionId; +use candid::Principal; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use minicbor::{Decode, Encode}; @@ -25,6 +26,15 @@ pub struct StorableSessionHandle { pub session_id: StorableSessionId, } +impl StorableSessionHandle { + /// 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) + } +} + 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 0a725d8567..6b5f551bcf 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -5151,6 +5151,30 @@ 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(); + record_use(&mut storage, anchor_number, origin.clone(), None, 1_000).unwrap(); + let principal = default_account_principal(anchor_number, &origin); + + let key = storage.lookup_account_with_principal(principal).unwrap(); + + assert_eq!(key.anchor_number, anchor_number); + assert_eq!(key.origin, origin); + assert_eq!(key.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 session_tests { diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 6e8340dcd1..9a6659f510 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -2,21 +2,24 @@ 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, BrowserBrand, BrowserDescription, FormFactor, GetAccountSessionRequest, - OperatingSystem, PrepareAccountSessionRequest, PrepareAccountSessionResponse, + AccountSessionError, AppGetDelegationRequest, AppPrepareDelegationRequest, AppSessionError, + BrowserBrand, BrowserDescription, FormFactor, GetAccountSessionRequest, OperatingSystem, + Permissions, 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"; +const APP_DELEGATION_TTL_NS: u64 = 5 * 60 * 1_000_000_000; fn chrome_on_a_mac() -> BrowserDescription { BrowserDescription { @@ -132,6 +135,52 @@ 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 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. + let second = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + assert_ne!(second.session_id, first.session_id); + 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::NoSuchSession) + ); + + Ok(()) +} + #[test] fn should_refuse_a_session_for_another_anchor() -> Result<(), RejectResponse> { let env = env(); @@ -150,6 +199,362 @@ 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::NoSuchSession)); + + 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::NoSuchSession)); + + 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, + }, + )?; + + // `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(()) +} + +/// 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 browser = BrowserKey::new(1); + let full_access = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + 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(); + + assert_ne!(read_only.session_id, full_access.session_id); + 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::NoSuchSession)); + + 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_BROWSERS: 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_BROWSERS { + let mut request = session_request_from(identity_number, &BrowserKey::new(index as u8 + 2)); + request.browser_description.model = Some(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::NoSuchSession)); + + 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::NoSuchSession)); + + 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. @@ -517,6 +922,49 @@ fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectRespons Ok(()) } +/// Rotation changes what the browser proves with, not which browser it is, and sessions +/// record the browser. So a rotation must not cost the user their session. +#[test] +fn should_keep_the_browser_entry_across_a_rotation() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + 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.browser_id, first.browser_id); + assert_ne!(rotated.session_id, first.session_id); + + 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] @@ -624,3 +1072,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::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), }