diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index 166229bd56..ffb6fde4ce 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -746,3 +746,29 @@ pub fn get_account_delegation_with_read_only( ) .map(|(x,)| x) } + +pub fn prepare_account_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: PrepareAccountSessionRequest, +) -> Result, RejectResponse> { + call_candid_as( + env, + canister_id, + RawEffectivePrincipal::None, + sender, + "prepare_account_session", + (request,), + ) + .map(|(x,)| x) +} + +pub fn get_account_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, + request: GetAccountSessionRequest, +) -> Result, RejectResponse> { + query_candid_as(env, canister_id, sender, "get_account_session", (request,)).map(|(x,)| x) +} diff --git a/src/canister_tests/src/framework.rs b/src/canister_tests/src/framework.rs index d4b47ff430..a9300adafd 100644 --- a/src/canister_tests/src/framework.rs +++ b/src/canister_tests/src/framework.rs @@ -375,6 +375,70 @@ pub fn restore_compressed_stable_memory(env: &PocketIc, canister_id: CanisterId, env.set_stable_memory(canister_id, buffer, BlobCompression::Gzip); } +/// A browser key of the kind `prepare_account_session` demands a proof from. +/// +/// The DER encoding and the domain prefix have to match what the canister verifies, +/// so both are spelled out here rather than derived. +pub struct BrowserKey { + signing_key: p256::ecdsa::SigningKey, +} + +/// The SPKI header WebCrypto emits for an `ECDSA` P-256 public key, ahead of the 65-byte +/// uncompressed point. +const P256_SPKI_HEADER: [u8; 26] = [ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, + 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, +]; + +const BROWSER_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-browser-key"; +const SUCCESSOR_KEY_SIGNATURE_DOMAIN: &[u8] = b"ii-session-browser-successor"; + +impl BrowserKey { + pub fn new(seed: u8) -> Self { + Self { + signing_key: p256::ecdsa::SigningKey::from_bytes(&[seed; 32].into()) + .expect("failed to build a browser key"), + } + } + + /// The key a browser rotates to after `self`, so a test can walk the chain. + pub fn successor(&self) -> Self { + let mut seed = [0u8; 32]; + seed.copy_from_slice(&self.signing_key.to_bytes()); + seed[0] = seed[0].wrapping_add(1); + Self { + signing_key: p256::ecdsa::SigningKey::from_bytes(&seed.into()) + .expect("failed to build a browser key"), + } + } + + pub fn public_key(&self) -> PublicKey { + let point = p256::ecdsa::VerifyingKey::from(&self.signing_key).to_encoded_point(false); + let mut der = P256_SPKI_HEADER.to_vec(); + der.extend_from_slice(point.as_bytes()); + ByteBuf::from(der) + } + + pub fn sign(&self, session_key: &SessionKey, next_browser_key: &PublicKey) -> ByteBuf { + self.sign_with(BROWSER_KEY_SIGNATURE_DOMAIN, session_key, next_browser_key) + } + + /// The successor's own signature, proving the browser holds the key it announces. + pub fn sign_as_successor(&self, session_key: &SessionKey, device_key: &PublicKey) -> ByteBuf { + self.sign_with(SUCCESSOR_KEY_SIGNATURE_DOMAIN, session_key, device_key) + } + + fn sign_with(&self, domain: &[u8], session_key: &SessionKey, other: &PublicKey) -> ByteBuf { + use p256::ecdsa::signature::Signer; + + let mut message = domain.to_vec(); + message.extend_from_slice(session_key); + message.extend_from_slice(other); + let signature: p256::ecdsa::Signature = self.signing_key.sign(&message); + ByteBuf::from(signature.to_bytes().to_vec()) + } +} + pub const PUBKEY_1: &str = "test"; pub const PUBKEY_2: &str = "some other key"; pub const RECOVERY_PUBKEY_1: &str = "recovery 1"; diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 670499547e..5762e2db05 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -386,6 +386,26 @@ export const idlFactory = ({ IDL }) => { 'InternalCanisterError' : IDL.Text, 'Unauthorized' : IDL.Principal, }); + const GetAccountSessionRequest = IDL.Record({ + 'session_id' : IDL.Nat64, + 'session_key' : SessionKey, + 'origin' : FrontendHostname, + 'account_number' : IDL.Opt(AccountNumber), + 'expiration' : Timestamp, + 'identity_number' : UserNumber, + }); + const GetAccountSessionResponse = IDL.Record({ + 'signed_delegation' : SignedDelegation, + }); + const AccountSessionError = IDL.Variant({ + 'InternalCanisterError' : IDL.Text, + 'Unauthorized' : IDL.Principal, + 'NoSuchSession' : IDL.Null, + 'NoSuchDelegation' : IDL.Null, + 'NoSuchAccount' : IDL.Null, + 'InvalidBrowserKey' : IDL.Null, + 'StaleBrowserKey' : IDL.Null, + }); const GetAccountsError = IDL.Variant({ 'InternalCanisterError' : IDL.Text, 'Unauthorized' : IDL.Principal, @@ -718,6 +738,27 @@ export const idlFactory = ({ IDL }) => { 'user_key' : UserKey, 'expiration' : Timestamp, }); + const PrepareAccountSessionRequest = IDL.Record({ + 'permissions' : IDL.Opt(Permissions), + 'max_idle' : IDL.Opt(IDL.Nat64), + 'current_browser_key' : PublicKey, + 'session_key' : SessionKey, + 'valid_for' : IDL.Opt(IDL.Nat64), + 'origin' : FrontendHostname, + 'current_browser_key_signature' : IDL.Vec(IDL.Nat8), + 'browser_description' : BrowserDescription, + 'account_number' : IDL.Opt(AccountNumber), + 'identity_number' : UserNumber, + 'next_browser_key' : PublicKey, + 'next_browser_key_signature' : IDL.Vec(IDL.Nat8), + }); + const PrepareAccountSessionResponse = IDL.Record({ + 'user_key' : PublicKey, + 'session_id' : IDL.Nat64, + 'browser_id' : IDL.Nat32, + 'expiration' : Timestamp, + 'account_principal' : IDL.Principal, + }); const PrepareAttributeRequest = IDL.Record({ 'origin' : FrontendHostname, 'attribute_keys' : IDL.Vec(IDL.Text), @@ -1071,6 +1112,16 @@ export const idlFactory = ({ IDL }) => { ], ['query'], ), + 'get_account_session' : IDL.Func( + [GetAccountSessionRequest], + [ + IDL.Variant({ + 'Ok' : GetAccountSessionResponse, + 'Err' : AccountSessionError, + }), + ], + ['query'], + ), 'get_accounts' : IDL.Func( [UserNumber, FrontendHostname], [ @@ -1327,6 +1378,16 @@ export const idlFactory = ({ IDL }) => { ], [], ), + 'prepare_account_session' : IDL.Func( + [PrepareAccountSessionRequest], + [ + IDL.Variant({ + 'Ok' : PrepareAccountSessionResponse, + 'Err' : AccountSessionError, + }), + ], + [], + ), 'prepare_attributes' : IDL.Func( [PrepareAttributeRequest], [ diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index 683f3dc93c..8dcad36f67 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -19,6 +19,32 @@ export interface AccountInfo { 'last_used' : [] | [Timestamp], } export type AccountNumber = bigint; +export type AccountSessionError = { 'InternalCanisterError' : string } | + { 'Unauthorized' : Principal } | + { 'NoSuchSession' : null } | + { + /** + * The session is there, but no delegation was signed for the session_key and + * expiration asked for. Ask again with the ones prepare_account_session returned; + * signing in afresh is not the remedy. + */ + 'NoSuchDelegation' : null + } | + { 'NoSuchAccount' : null } | + { + /** + * The browser's key is unusable, or its signature does not verify against it. + */ + 'InvalidBrowserKey' : null + } | + { + /** + * The browser presented a key it has already rotated away from, which happens when it + * never learned that its last sign-in succeeded. It holds the successor that does + * resolve, so the answer is to promote that one and present it. + */ + 'StaleBrowserKey' : null + }; export interface AccountUpdate { 'name' : [] | [string] } export type AddTentativeDeviceResponse = { /** @@ -696,6 +722,20 @@ export type GetAccountError = { 'anchor_number' : UserNumber, } }; +export interface GetAccountSessionRequest { + /** + * The session prepare_account_session created. + */ + 'session_id' : bigint, + 'session_key' : SessionKey, + 'origin' : FrontendHostname, + 'account_number' : [] | [AccountNumber], + 'expiration' : Timestamp, + 'identity_number' : UserNumber, +} +export interface GetAccountSessionResponse { + 'signed_delegation' : SignedDelegation, +} export type GetAccountsError = { 'InternalCanisterError' : string } | { 'Unauthorized' : Principal }; export type GetAttributesError = { 'AuthorizationError' : Principal } | @@ -1334,6 +1374,76 @@ export interface PrepareAccountDelegation { 'user_key' : UserKey, 'expiration' : Timestamp, } +export interface PrepareAccountSessionRequest { + /** + * The consented access level, fixed for the session's life. + */ + 'permissions' : [] | [Permissions], + /** + * How long the session may go unminted before it is over, clamped to between + * 10 minutes and the session's own granted length. Absent leaves the + * canister's own default. + */ + 'max_idle' : [] | [bigint], + /** + * The browser's own public key, DER-encoded, as the registry currently holds it. A + * key this anchor has not seen registers a browser under it. + */ + 'current_browser_key' : PublicKey, + /** + * The II frontend's own public key. + */ + 'session_key' : SessionKey, + /** + * Clamped to the session maximum. + */ + 'valid_for' : [] | [bigint], + 'origin' : FrontendHostname, + /** + * Signature over session_key and next_browser_key, verified with current_browser_key. + */ + 'current_browser_key_signature' : Uint8Array | number[], + /** + * What this browser is, for the user's session list. + */ + 'browser_description' : BrowserDescription, + 'account_number' : [] | [AccountNumber], + 'identity_number' : UserNumber, + /** + * What the browser rotates to once this sign-in succeeds. Must differ from + * current_browser_key: a browser that never rotates keeps a leaked key useful. + */ + 'next_browser_key' : PublicKey, + /** + * Signature by next_browser_key over session_key and current_browser_key, proving the + * browser holds the key it is announcing. + */ + 'next_browser_key_signature' : Uint8Array | number[], +} +export interface PrepareAccountSessionResponse { + 'user_key' : PublicKey, + /** + * Names the session this ceremony created, and is what get_account_session is given + * to collect the delegation signed for it. Not a credential: it names a session, it + * does not authorise one. + */ + 'session_id' : bigint, + /** + * Which browser this sign-in was attributed to, so the settings list can mark the one + * the user is looking at, and so the browser knows which registration its key now + * belongs to. Not a credential: a caller never presents it. + */ + 'browser_id' : number, + /** + * The session's valid_till. + */ + 'expiration' : Timestamp, + /** + * The principal apps see for this account, so the frontend can tell its own + * sessions apart without minting a delegation to learn it. + */ + 'account_principal' : Principal, +} export type PrepareAttributeError = { 'AuthorizationError' : Principal } | { 'ValidationError' : { 'problems' : Array } } | { 'GetAccountError' : GetAccountError }; @@ -2103,6 +2213,11 @@ export interface _SERVICE { { 'Ok' : SignedDelegation } | { 'Err' : AccountDelegationError } >, + 'get_account_session' : ActorMethod< + [GetAccountSessionRequest], + { 'Ok' : GetAccountSessionResponse } | + { 'Err' : AccountSessionError } + >, /** * Multiple accounts */ @@ -2395,6 +2510,17 @@ export interface _SERVICE { { 'Ok' : PrepareAccountDelegation } | { 'Err' : AccountDelegationError } >, + /** + * Creates or reuses a revocable session at one account and signs its identity to + * the II frontend's own key. Called only by the II frontend, which ships with the + * canister; requires an anchor access method, so a session can neither spawn nor + * extend itself. + */ + 'prepare_account_session' : ActorMethod< + [PrepareAccountSessionRequest], + { 'Ok' : PrepareAccountSessionResponse } | + { 'Err' : AccountSessionError } + >, /** * Attribute sharing protocol * ========================== diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 9d05e94d62..fe44bc2e76 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -1050,6 +1050,83 @@ type BrowserInfo = record { session_count : nat32; }; +type PrepareAccountSessionRequest = record { + identity_number : UserNumber; + origin : FrontendHostname; + account_number : opt AccountNumber; + // The II frontend's own public key. + session_key : SessionKey; + // What this browser is, for the user's session list. + browser_description : BrowserDescription; + // The browser's own public key, DER-encoded, as the registry currently holds it. A + // key this anchor has not seen registers a browser under it. + current_browser_key : PublicKey; + // What the browser rotates to once this sign-in succeeds. Must differ from + // current_browser_key: a browser that never rotates keeps a leaked key useful. + next_browser_key : PublicKey; + // Signature over session_key and next_browser_key, verified with current_browser_key. + current_browser_key_signature : blob; + // Signature by next_browser_key over session_key and current_browser_key, proving the + // browser holds the key it is announcing. + next_browser_key_signature : blob; + // The consented access level, fixed for the session's life. + permissions : opt Permissions; + // Clamped to the session maximum. + valid_for : opt nat64; + // How long the session may go unminted before it is over, clamped to between + // 10 minutes and the session's own granted length. Absent leaves the + // canister's own default. + max_idle : opt nat64; +}; + +type PrepareAccountSessionResponse = record { + user_key : PublicKey; + // The session's valid_till. + expiration : Timestamp; + // Names the session this ceremony created, and is what get_account_session is given + // to collect the delegation signed for it. Not a credential: it names a session, it + // does not authorise one. + session_id : nat64; + // Which browser this sign-in was attributed to, so the settings list can mark the one + // the user is looking at, and so the browser knows which registration its key now + // belongs to. Not a credential: a caller never presents it. + browser_id : nat32; + // The principal apps see for this account, so the frontend can tell its own + // sessions apart without minting a delegation to learn it. + account_principal : principal; +}; + +type GetAccountSessionRequest = record { + identity_number : UserNumber; + origin : FrontendHostname; + account_number : opt AccountNumber; + session_key : SessionKey; + expiration : Timestamp; + // The session prepare_account_session created. + session_id : nat64; +}; + +type GetAccountSessionResponse = record { + signed_delegation : SignedDelegation; +}; + +type AccountSessionError = variant { + Unauthorized : principal; + NoSuchAccount; + NoSuchSession; + // The session is there, but no delegation was signed for the session_key and + // expiration asked for. Ask again with the ones prepare_account_session returned; + // signing in afresh is not the remedy. + NoSuchDelegation; + // The browser's key is unusable, or its signature does not verify against it. + InvalidBrowserKey; + // The browser presented a key it has already rotated away from, which happens when it + // never learned that its last sign-in succeeded. It holds the successor that does + // resolve, so the answer is to promote that one and present it. + StaleBrowserKey; + InternalCanisterError : text; +}; + type IdentityInfo = record { authn_methods : vec AuthnMethodData; authn_method_registration : opt AuthnMethodRegistrationInfo; @@ -1904,6 +1981,13 @@ service : (opt InternetIdentityInit) -> { update : AccountUpdate ) -> (variant { Ok : AccountInfo; Err: UpdateAccountError }); + // Creates or reuses a revocable session at one account and signs its identity to + // the II frontend's own key. Called only by the II frontend, which ships with the + // canister; requires an anchor access method, so a session can neither spawn nor + // extend itself. + prepare_account_session : (PrepareAccountSessionRequest) -> (variant { Ok : PrepareAccountSessionResponse; Err : AccountSessionError }); + get_account_session : (GetAccountSessionRequest) -> (variant { Ok : GetAccountSessionResponse; Err : AccountSessionError }) query; + prepare_account_delegation : ( anchor_number : UserNumber, origin : FrontendHostname, diff --git a/src/internet_identity/src/account_management.rs b/src/internet_identity/src/account_management.rs index f1aebc6c07..b36a935a54 100644 --- a/src/internet_identity/src/account_management.rs +++ b/src/internet_identity/src/account_management.rs @@ -2,8 +2,9 @@ use crate::anchor_management::post_operation_bookkeeping; use crate::{ delegation::{ - add_delegation_signature, check_frontend_length, delegation_bookkeeping, - delegation_signature_msg_with_permissions, der_encode_canister_sig_key, DelegationAccess, + add_delegation_signature, delegation_bookkeeping, + delegation_signature_msg_with_permissions, der_encode_canister_sig_key, + frontend_length_within_limit, DelegationAccess, }, ii_domain::IIDomain, state::{self, storage_borrow, storage_borrow_mut}, @@ -266,7 +267,7 @@ pub fn prepare_account_delegation( ii_domain: &Option, now: Timestamp, ) -> Result { - check_frontend_length(&origin); + frontend_length_within_limit(&origin).map_err(AccountDelegationError::InternalCanisterError)?; let account = storage_borrow(|storage| { storage @@ -320,6 +321,7 @@ pub fn prepare_account_delegation( session_key, seed.as_ref(), expiration, + None, access.permissions(), ); }); @@ -341,7 +343,7 @@ pub fn get_account_delegation( expiration: Timestamp, access: DelegationAccess, ) -> Result { - check_frontend_length(origin); + frontend_length_within_limit(origin).map_err(AccountDelegationError::InternalCanisterError)?; storage_borrow(|storage| { let account = storage diff --git a/src/internet_identity/src/browser_key.rs b/src/internet_identity/src/browser_key.rs index e142390ba0..30068b15ae 100644 --- a/src/internet_identity/src/browser_key.rs +++ b/src/internet_identity/src/browser_key.rs @@ -2,9 +2,6 @@ //! //! Knows nothing of sessions or storage, so both layers may depend on it: the endpoint //! verifies, and storage requires the [`VerifiedBrowserKeys`] that verifying produces. -//! -//! `prepare_account_session`, the one caller that verifies, lands two PRs up. -#![allow(dead_code)] use internet_identity_interface::internet_identity::types::{PublicKey, SessionKey}; use p256::ecdsa::signature::Verifier; diff --git a/src/internet_identity/src/delegation.rs b/src/internet_identity/src/delegation.rs index 33490b1c2b..3776ede7d5 100644 --- a/src/internet_identity/src/delegation.rs +++ b/src/internet_identity/src/delegation.rs @@ -172,25 +172,46 @@ pub(crate) fn canister_sig_principal(canister_id: Principal, seed: Vec) -> P Principal::self_authenticating(der_encode_canister_sig_key_for(canister_id, seed)) } -/// Adds a delegation signature for `pk` to the signature map. `permissions` -/// is the delegation's optional `permissions` field (folded into the signed -/// message when present) — pass `access.permissions()` of a -/// [`DelegationAccess`], or `None` for flows that never restrict. +/// Adds a delegation signature for `pk` to the signature map. +/// +/// `targets` is the delegation's optional `targets` field and `permissions` its optional +/// `permissions` field, both folded into the signed message when present — pass +/// `access.permissions()` of a [`DelegationAccess`], and `None` for flows that never +/// restrict. Whatever is passed here has to be passed again when the signature is +/// fetched: the two messages must match byte for byte or the lookup finds nothing. pub fn add_delegation_signature( sigs: &mut SignatureMap, pk: PublicKey, seed: &[u8], expiration: Timestamp, + targets: Option<&[Principal]>, permissions: Option<&str>, ) { + let targets = targets.map(target_bytes); let inputs = CanisterSigInputs { domain: DELEGATION_SIG_DOMAIN, seed, - message: &delegation_signature_msg_with_permissions(&pk, expiration, None, permissions), + message: &delegation_signature_msg_with_permissions( + &pk, + expiration, + targets.as_ref(), + permissions, + ), }; sigs.add_signature(&inputs); } +/// The `targets` field as the signed message carries it: each principal as raw bytes. +/// +/// The side that adds a signature and the side that fetches it both convert through +/// here, because the two messages have to be identical or the lookup finds nothing. +pub fn target_bytes(targets: &[Principal]) -> Vec> { + targets + .iter() + .map(|target| target.as_slice().to_vec()) + .collect() +} + /// The value of a delegation's `permissions` field that restricts the /// sender to query calls: the IC rejects update calls authenticated /// through such a delegation. @@ -275,9 +296,9 @@ pub fn delegation_signature_msg_with_permissions( representation_independent_hash(m.as_slice()).to_vec() } -pub(crate) fn check_frontend_length(frontend: &FrontendHostname) { - const FRONTEND_HOSTNAME_LIMIT: usize = 255; +pub(crate) const FRONTEND_HOSTNAME_LIMIT: usize = 255; +pub(crate) fn check_frontend_length(frontend: &FrontendHostname) { let n = frontend.len(); if frontend.len() > FRONTEND_HOSTNAME_LIMIT { trap(&format!( @@ -286,6 +307,22 @@ pub(crate) fn check_frontend_length(frontend: &FrontendHostname) { } } +/// The same bound as [`check_frontend_length`], answered rather than trapped. +/// +/// A hostname this long is not reachable through any client, so what comes back says +/// only that it was refused — for callers whose response already carries a variant for +/// the unreachable edges, which is a better answer than a rejected message the caller +/// cannot read as one. +pub(crate) fn frontend_length_within_limit(frontend: &FrontendHostname) -> Result<(), String> { + if frontend.len() > FRONTEND_HOSTNAME_LIMIT { + return Err(format!( + "frontend hostname {} exceeds the limit of {FRONTEND_HOSTNAME_LIMIT} bytes", + frontend.len() + )); + } + Ok(()) +} + #[cfg(test)] mod test { use super::*; diff --git a/src/internet_identity/src/email_inbound/smtp.rs b/src/internet_identity/src/email_inbound/smtp.rs index e8a4250593..ada69fc8df 100644 --- a/src/internet_identity/src/email_inbound/smtp.rs +++ b/src/internet_identity/src/email_inbound/smtp.rs @@ -915,6 +915,7 @@ pub(super) fn stamp_recovery_delegation( &seed, expiration, None, + None, ); }); crate::update_root_hash(); diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index be8e3cd53c..c9461ea443 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -68,6 +68,7 @@ mod mcp_registration; mod openid; mod session_delegation; +mod sessions; mod single_flight_cache; mod state; mod stats; @@ -497,6 +498,20 @@ fn set_default_account( Ok(result) } +#[update] +fn prepare_account_session( + request: PrepareAccountSessionRequest, +) -> Result { + sessions::prepare_account_session(request) +} + +#[query] +fn get_account_session( + request: GetAccountSessionRequest, +) -> Result { + sessions::get_account_session(request) +} + #[update] fn prepare_account_delegation( anchor_number: AnchorNumber, diff --git a/src/internet_identity/src/mcp_registration.rs b/src/internet_identity/src/mcp_registration.rs index e790462f8f..599efad097 100644 --- a/src/internet_identity/src/mcp_registration.rs +++ b/src/internet_identity/src/mcp_registration.rs @@ -265,7 +265,7 @@ pub async fn prepare( // No queries-only restriction on the registration delegation itself: it // authenticates one update (mcp_register_v2). The read-only choice is // recorded on the index entry below, not on this signature. - add_delegation_signature(sigs, registration_key, &seed, expiration, None); + add_delegation_signature(sigs, registration_key, &seed, expiration, None, None); }); update_root_hash(); diff --git a/src/internet_identity/src/openid.rs b/src/internet_identity/src/openid.rs index b395617fa7..3616ee367f 100644 --- a/src/internet_identity/src/openid.rs +++ b/src/internet_identity/src/openid.rs @@ -138,7 +138,7 @@ impl OpenIdCredential { state::signature_map_mut(|sigs| { // Unrestricted: the OpenID flow has no read-only option. - add_delegation_signature(sigs, session_key, seed.as_ref(), expiration, None); + add_delegation_signature(sigs, session_key, seed.as_ref(), expiration, None, None); }); update_root_hash(); diff --git a/src/internet_identity/src/session_delegation.rs b/src/internet_identity/src/session_delegation.rs index 5a4a157cbb..5503554539 100644 --- a/src/internet_identity/src/session_delegation.rs +++ b/src/internet_identity/src/session_delegation.rs @@ -61,7 +61,7 @@ pub fn prepare_session_delegation( state::signature_map_mut(|sigs| { // Unrestricted: session delegations have no read-only option. - add_delegation_signature(sigs, session_key, &seed, expiration, None); + add_delegation_signature(sigs, session_key, &seed, expiration, None, None); }); update_root_hash(); diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs new file mode 100644 index 0000000000..965615b35c --- /dev/null +++ b/src/internet_identity/src/sessions.rs @@ -0,0 +1,406 @@ +use crate::authz_utils::{ + check_authorization, check_authz_and_record_activity, AuthorizationError, IdentityUpdateError, +}; +use crate::browser_key::verify_browser_keys; +use crate::delegation::{ + add_delegation_signature, calculate_session_seed_with_salt, canister_sig_principal, + 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::anchor::BrowserError; +use crate::storage::{CreateSessionParams, StorageError}; +use crate::{update_root_hash, DAY_NS, MINUTE_NS}; +use candid::Principal; +use ic_canister_sig_creation::signature_map::CanisterSigInputs; +use ic_canister_sig_creation::DELEGATION_SIG_DOMAIN; +use ic_cdk::api::time; +use ic_certification::Hash; +use internet_identity_interface::internet_identity::types::{ + AccountNumber, AccountSessionError, AnchorNumber, BrowserBrand, BrowserDescription, Delegation, + FrontendHostname, GetAccountSessionRequest, GetAccountSessionResponse, OperatingSystem, + PrepareAccountSessionRequest, PrepareAccountSessionResponse, SignedDelegation, Timestamp, +}; +use serde_bytes::ByteBuf; + +pub const DEFAULT_SESSION_TTL_NS: u64 = 30 * DAY_NS; +pub const MAX_SESSION_TTL_NS: u64 = 30 * DAY_NS; +const MIN_SESSION_TTL_NS: u64 = 10 * MINUTE_NS; + +/// A bound on each token a browser reports about itself. Refused rather than truncated: +/// a client sending something this long is sending something wrong, and a cut-off token +/// would put a value in the record that no parser ever produced. +const MAX_BROWSER_TOKEN_BYTES: usize = 64; + +/// Whether every token in a description is short enough to store. +/// +/// Only the tokens a client writes itself can be too long. The named variants carry no +/// text, so a description of nothing but those is within the limit whatever it says. +fn browser_description_within_limits(description: &BrowserDescription) -> bool { + let within = |token: &str| token.len() <= MAX_BROWSER_TOKEN_BYTES; + let brand = match &description.brand { + BrowserBrand::Other(token) => within(token), + _ => true, + }; + let os = match &description.os { + OperatingSystem::Other(token) => within(token), + _ => true, + }; + brand && os && description.model.as_deref().is_none_or(within) +} + +impl From for AccountSessionError { + fn from(err: AuthorizationError) -> Self { + AccountSessionError::Unauthorized(err.principal) + } +} + +impl From for AccountSessionError { + fn from(err: IdentityUpdateError) -> Self { + match err { + IdentityUpdateError::Unauthorized(principal) => { + AccountSessionError::Unauthorized(principal) + } + IdentityUpdateError::StorageError(_, storage_error) => storage_error.into(), + } + } +} + +impl From for AccountSessionError { + fn from(err: StorageError) -> Self { + match err { + StorageError::MissingAccount { .. } | StorageError::ApplicationNotFound { .. } => { + AccountSessionError::NoSuchAccount + } + other => AccountSessionError::InternalCanisterError(other.to_string()), + } + } +} + +pub fn prepare_account_session( + request: PrepareAccountSessionRequest, +) -> Result { + let PrepareAccountSessionRequest { + identity_number, + origin, + account_number, + session_key, + browser_description, + current_browser_key, + next_browser_key, + current_browser_key_signature, + next_browser_key_signature, + permissions, + valid_for, + max_idle, + } = request; + + check_authz_and_record_activity(identity_number)?; + frontend_length_within_limit(&origin).map_err(AccountSessionError::InternalCanisterError)?; + if !browser_description_within_limits(&browser_description) { + return Err(AccountSessionError::InternalCanisterError( + "browser description exceeds the limit".to_string(), + )); + } + + // Everything that can refuse, before anything is stored — and among the refusals, the + // cheap one first. An account this identity does not hold is the likeliest legitimate + // failure, so a request that was never going to succeed does not pay for two P-256 + // verifications on the way to being told so. Nothing is revealed by the order: + // `check_authz_and_record_activity` above is the auth guard, so only the identity's + // own holder gets this far. + if storage_borrow(|storage| { + storage.read_account(&AccountKey { + anchor_number: identity_number, + origin: origin.clone(), + account_number, + }) + }) + .is_none() + { + return Err(AccountSessionError::NoSuchAccount); + } + + let browser_keys = verify_browser_keys( + ¤t_browser_key, + ¤t_browser_key_signature, + &next_browser_key, + &next_browser_key_signature, + &session_key, + ) + .ok_or(AccountSessionError::InvalidBrowserKey)?; + + let now = time(); + let valid_till = now.saturating_add( + valid_for + .unwrap_or(DEFAULT_SESSION_TTL_NS) + .clamp(MIN_SESSION_TTL_NS, MAX_SESSION_TTL_NS), + ); + let access = DelegationAccess::from(permissions); + let read_only = access == DelegationAccess::ReadOnly; + + // The browser is resolved by the write, not here: registering it can put the registry + // over its cap, and the browser that gives way takes its sessions with it. That is one + // change with the session created below, so it is one write, and working any of it out + // here would be working out something the write has to be told. + let (_, session) = storage_borrow_mut(|storage| { + storage.create_session(CreateSessionParams { + anchor_number: identity_number, + origin: origin.clone(), + account_number, + browser_keys, + browser_description, + valid_till_ns: valid_till, + max_idle_ns: max_idle, + read_only, + now_ns: now, + }) + }) + .map_err(|err| match err { + // Told apart from the rest because the browser can act on it: it is the only + // party holding the successor that does resolve. + StorageError::Browser(BrowserError::StaleBrowserKey) => { + AccountSessionError::StaleBrowserKey + } + StorageError::Browser(_) => AccountSessionError::InvalidBrowserKey, + // The account was checked above, so anything left is a broken storage invariant + // rather than a request this caller could have got wrong. + err => AccountSessionError::InternalCanisterError(err.to_string()), + })?; + + let seed = session_identity(identity_number, &origin, account_number, &session) + .expect("failed to derive the identity of a session that was just created"); + let account_principal = + get_account_principal_for_origin(identity_number, &origin, account_number) + .expect("failed to derive the principal of an account that was just read"); + + state::signature_map_mut(|sigs| { + add_delegation_signature( + sigs, + session_key, + seed.as_ref(), + session.valid_till_ns, + Some(&session_delegation_targets()), + None, + ); + }); + update_root_hash(); + + Ok(PrepareAccountSessionResponse { + user_key: ByteBuf::from(der_encode_canister_sig_key(seed.to_vec())), + expiration: session.valid_till_ns, + session_id: session.session_id, + browser_id: session.browser_id, + account_principal, + }) +} + +pub fn get_account_session( + request: GetAccountSessionRequest, +) -> Result { + let GetAccountSessionRequest { + identity_number, + origin, + account_number, + session_key, + expiration, + session_id, + } = request; + + check_authorization(identity_number)?; + frontend_length_within_limit(&origin).map_err(AccountSessionError::InternalCanisterError)?; + + // `prepare_account_session` handed the browser this id, so the session is named + // exactly rather than searched for. An id naming a session that was replaced since + // finds nothing, which is the honest answer: the delegation this call is collecting + // was signed for the session that is gone. + let session = storage_borrow(|storage| { + storage.read_session(&SessionLocator { + anchor_number: identity_number, + origin: origin.clone(), + account_number, + session_id, + }) + }) + .ok_or(AccountSessionError::NoSuchSession)?; + + // Not `NoSuchSession`: the session was just read. A seed that will not derive means + // the salt is unset, which is a canister that never initialised rather than anything + // this caller named. + let seed = session_identity(identity_number, &origin, account_number, &session)?; + + // The session is there and its seed derives, so what is missing is the signature for + // this `(session_key, expiration)` — a delegation, not a session. Saying + // `NoSuchSession` sent the frontend to sign in again when the answer is to ask with + // the parameters `prepare_account_session` signed. + let signed_delegation = get_session_delegation(&seed, &session_key, expiration) + .ok_or(AccountSessionError::NoSuchDelegation)?; + + Ok(GetAccountSessionResponse { signed_delegation }) +} + +/// The one canister a session credential may call. +/// +/// It exists to mint app delegations, which is an update call on this canister and +/// nothing else. Scoping it here rather than leaving it to the frontend to add is the +/// difference between a restriction and a request: the party holding the session key is +/// the party that would have to add it, and can decline to. +/// +/// `permissions` stays absent for the same reason it is set on app delegations: minting +/// is an update call, so a read-only session that could not make one could not sign in +/// to an app at all. Read-only travels on the app delegation this credential mints. +fn session_delegation_targets() -> Vec { + vec![ic_cdk::id()] +} + +/// The signature `prepare_account_session` added, or `None` where it never signed for +/// this `(session_key, expiration)`. +/// +/// The message must match the one signed byte for byte, targets included, which is why +/// both sides take them from [`session_delegation_targets`]. +fn get_session_delegation( + seed: &Hash, + session_key: &[u8], + expiration: Timestamp, +) -> Option { + let targets = session_delegation_targets(); + let signed_targets = crate::delegation::target_bytes(&targets); + state::assets_and_signatures(|certified_assets, sigs| { + let inputs = CanisterSigInputs { + domain: DELEGATION_SIG_DOMAIN, + seed, + message: &crate::delegation::delegation_signature_msg_with_permissions( + session_key, + expiration, + Some(&signed_targets), + None, + ), + }; + sigs.get_signature_as_cbor(&inputs, Some(certified_assets.root_hash())) + .ok() + }) + .map(|signature| SignedDelegation { + delegation: Delegation { + pubkey: ByteBuf::from(session_key.to_vec()), + expiration, + targets: Some(targets), + permissions: None, + }, + signature: ByteBuf::from(signature), + }) +} + +fn session_identity( + anchor_number: AnchorNumber, + origin: &FrontendHostname, + account_number: Option, + session: &Session, +) -> Result { + let salt = storage_borrow(|storage| storage.salt().copied()).ok_or_else(|| { + AccountSessionError::InternalCanisterError(StorageError::SaltNotSet.to_string()) + })?; + + let account = storage_borrow(|storage| { + storage.read_account(&AccountKey { + anchor_number, + origin: origin.clone(), + account_number, + }) + }) + .ok_or(AccountSessionError::NoSuchAccount)?; + let seed = calculate_session_seed_with_salt( + &salt, + &account.calculate_seed_with_salt(&salt), + session.session_id, + ); + Ok(seed) +} + +/// The principal an app sees for this account. A session handle names the account by this +/// and never by the numbers behind it, which are II's alone. +/// +/// The account is read rather than reconstructed: a materialized default derives from +/// `seed_from_anchor`, which only the stored list carries. +fn get_account_principal_for_origin( + anchor_number: AnchorNumber, + origin: &FrontendHostname, + account_number: Option, +) -> Result { + let salt = storage_borrow(|storage| storage.salt().copied()).ok_or_else(|| { + AccountSessionError::InternalCanisterError(StorageError::SaltNotSet.to_string()) + })?; + let account = storage_borrow(|storage| { + storage.read_account(&AccountKey { + anchor_number, + origin: origin.clone(), + account_number, + }) + }) + .ok_or(AccountSessionError::NoSuchAccount)?; + Ok(canister_sig_principal( + ic_cdk::id(), + account.calculate_seed_with_salt(&salt).to_vec(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use internet_identity_interface::internet_identity::types::FormFactor; + + fn description( + brand: BrowserBrand, + os: OperatingSystem, + model: Option<&str>, + ) -> BrowserDescription { + BrowserDescription { + brand, + os, + form_factor: FormFactor::Desktop, + model: model.map(str::to_string), + } + } + + /// The named variants carry no text of their own, so nothing about them can be too + /// long however many of them there are. + #[test] + fn a_description_of_named_tokens_is_always_within_the_limit() { + assert!(browser_description_within_limits(&description( + BrowserBrand::Safari, + OperatingSystem::Ipados, + None + ))); + } + + #[test] + fn each_token_a_client_writes_is_bounded() { + let long = "x".repeat(MAX_BROWSER_TOKEN_BYTES + 1); + let at_limit = "x".repeat(MAX_BROWSER_TOKEN_BYTES); + + assert!(browser_description_within_limits(&description( + BrowserBrand::Other(at_limit.clone()), + OperatingSystem::Other(at_limit.clone()), + Some(&at_limit) + ))); + + for over in [ + description( + BrowserBrand::Other(long.clone()), + OperatingSystem::Macos, + None, + ), + description( + BrowserBrand::Chrome, + OperatingSystem::Other(long.clone()), + None, + ), + description(BrowserBrand::Chrome, OperatingSystem::Macos, Some(&long)), + ] { + assert!( + !browser_description_within_limits(&over), + "a token of {} bytes should be refused: {over:?}", + long.len() + ); + } + } +} diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index 40223bbb6a..fd0a0b2c17 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -103,7 +103,7 @@ use identity_jose::jwk::Jwk; use internet_identity_interface::archive::types::BufferedEntry; use crate::browser_key::VerifiedBrowserKeys; -use crate::delegation::{self, calculate_session_seed_with_salt, check_frontend_length}; +use crate::delegation::{self, calculate_session_seed_with_salt, frontend_length_within_limit}; use crate::openid::OpenIdCredentialKey; use crate::state::PersistentState; use crate::stats::event_stats::AggregationKey; @@ -2325,9 +2325,18 @@ impl Storage { // write that left nothing behind would leave an application no counter will ever // retire, since retirement only ever runs off a write to its account state — // which is why this sits after the check above rather than before it. + // + // It is also the only place an origin becomes a stored one, so it is where the + // length bound belongs: storage does not trust its callers to have checked, and + // an already-stored origin passed through here when it was minted. let application_number = match stored_number { Some(application_number) => application_number, - None => minting.allocate_application_number()?, + None => { + frontend_length_within_limit(&origin).map_err(|_| StorageError::OriginTooLong { + origin: origin.clone(), + })?; + minting.allocate_application_number()? + } }; // An origin index pointing at an application that is gone is a broken invariant @@ -2953,8 +2962,6 @@ impl Storage { Ok(session_id) } - // Called by `prepare_account_session`, which lands two PRs up. - #[allow(dead_code)] /// Creates the session `prepare_account_session` mints an identity from, replacing /// whatever this browser already held at this account. pub fn create_session( @@ -3120,7 +3127,6 @@ impl Storage { /// /// A key whose session was replaced reads as `None` rather than as its successor: /// the successor was allocated an id of its own. - #[allow(dead_code)] // Read by `get_account_session`, which lands two PRs up. pub fn read_session(&self, key: &SessionLocator) -> Option { let application_number = self.lookup_application_number_with_origin(&key.origin)?; @@ -3366,8 +3372,13 @@ impl Storage { /// The `Account` returned carries the seed the account signs with, so this is also /// the only place that capability is handed out — a caller that holds one has been /// through the check above. + /// + /// An origin too long to store has nothing stored under it, so it answers `None` + /// rather than deriving a default. Absence normally means "derive the default", and + /// without this a caller could be handed the seed of an account at an origin the + /// write path would refuse. pub fn read_account(&self, key: &AccountKey) -> Option { - check_frontend_length(&key.origin); + frontend_length_within_limit(&key.origin).ok()?; let reference = self .account_references_for_origin(key.anchor_number, &key.origin) @@ -3377,13 +3388,16 @@ impl Storage { self.account_for_reference(key.anchor_number, &key.origin, &reference) } - /// Every account this identity holds at `origin`. + /// Every account this identity holds at `origin`, which is none where the origin is + /// too long to have been stored — see [`Self::read_account`]. pub fn list_accounts( &self, anchor_number: AnchorNumber, origin: &FrontendHostname, ) -> Vec { - check_frontend_length(origin); + if frontend_length_within_limit(origin).is_err() { + return vec![]; + } self.account_references_for_origin(anchor_number, origin) .iter() @@ -3433,8 +3447,6 @@ impl Storage { name: String, now: Timestamp, ) -> Result { - check_frontend_length(&origin); - // An absent list normalises to the derived default, which is how the first named // account at an origin does not cost the identity the default it had. A // tombstone normalises to nothing and stays that way. @@ -3492,8 +3504,6 @@ impl Storage { account: Account, now: Timestamp, ) -> Result { - check_frontend_length(&account.origin); - let Account { account_number, anchor_number, @@ -3608,8 +3618,6 @@ impl Storage { account_number: Option, now: Timestamp, ) -> Result<(), StorageError> { - check_frontend_length(&origin); - let anchor = self.read(anchor_number)?; let (account_references, _) = self.account_state_for_origin(anchor_number, &origin); // The stored config with one field moved, rather than a config built here: this @@ -3888,8 +3896,6 @@ impl Storage { } } -// Constructed by `prepare_account_session`, which lands two PRs up. -#[allow(dead_code)] pub struct CreateSessionParams { pub anchor_number: AnchorNumber, pub origin: FrontendHostname, @@ -4267,6 +4273,11 @@ pub enum StorageError { OriginNotFoundForApplicationNumber { application_number: ApplicationNumber, }, + /// An origin too long to store. Refused here rather than at an endpoint, so the + /// bound holds for every caller instead of for the ones that remembered to check. + OriginTooLong { + origin: FrontendHostname, + }, ErrorUpdatingAccountCounter, SaltNotSet, AccountsCounterOverflow, @@ -4370,6 +4381,9 @@ impl fmt::Display for StorageError { f, "Origin not found for application number {application_number}", ), + Self::OriginTooLong { origin } => { + write!(f, "the origin exceeds the limit at {} bytes", origin.len()) + } Self::ErrorUpdatingAccountCounter => write!(f, "Error updating account counter"), Self::SaltNotSet => write!( f, diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index 198bbf1a85..0a725d8567 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -1,5 +1,6 @@ use crate::archive::{ArchiveData, ArchiveState}; use crate::browser_key::VerifiedBrowserKeys; +use crate::delegation::FRONTEND_HOSTNAME_LIMIT; use crate::openid::OpenIdCredential; use crate::state::PersistentState; use crate::stats::activity_stats::activity_counter::active_anchor_counter::ActiveAnchorCounter; @@ -947,11 +948,19 @@ mod application_lookup_tests { assert_eq!(storage.lookup_application_with_origin_memory.len(), 0); } + /// The index is keyed by the origin's hash rather than by its bytes, so an origin at + /// the limit is looked up the same way a short one is and is stored whole. #[test] - fn should_handle_very_long_origins_with_sha256() { + fn should_handle_an_origin_at_the_limit_with_sha256() { let mut storage = Storage::new((10, 20), VectorMemory::default()); - let long_origin = format!("https://{}.com", "a".repeat(20_000)); + let prefix = "https://"; + let suffix = ".com"; + let long_origin = format!( + "{prefix}{}{suffix}", + "a".repeat(FRONTEND_HOSTNAME_LIMIT - prefix.len() - suffix.len()) + ); + assert_eq!(long_origin.len(), FRONTEND_HOSTNAME_LIMIT); let app_number = application_number_for(&mut storage, &long_origin); assert_eq!(app_number, 0); @@ -964,6 +973,80 @@ mod application_lookup_tests { assert_eq!(stored_app.origin, long_origin); } + /// Storage refuses an origin it cannot store rather than trusting the endpoint that + /// handed it one. Nothing reachable sends one this long — every endpoint bounds it + /// first — so this is about where the guarantee lives, not about a live hazard. + /// Reads answer for the same bound the writes enforce. Without this, absence + /// normalises to the derived default and a caller is handed the seed of an account at + /// an origin nothing could ever store — the read endpoints do no length check of + /// their own. + #[test] + fn an_origin_past_the_limit_holds_nothing_to_read() { + let mut storage = Storage::new((10, 20), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let anchor = storage.allocate_anchor(0).expect("an anchor to read as"); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).expect("writing the identity"); + let too_long = format!("https://{}.com", "a".repeat(FRONTEND_HOSTNAME_LIMIT)); + + assert_eq!( + storage.read_account(&AccountKey { + anchor_number, + origin: too_long.clone(), + account_number: None, + }), + None, + "an unstorable origin must not derive a default account" + ); + assert_eq!(storage.list_accounts(anchor_number, &too_long), vec![]); + + // An origin at the limit still reads the derived default, so the bound is what + // separates them rather than the read path having stopped working. + let at_limit = format!( + "https://{}.com", + "a".repeat(FRONTEND_HOSTNAME_LIMIT - "https://".len() - ".com".len()) + ); + assert!(storage + .read_account(&AccountKey { + anchor_number, + origin: at_limit, + account_number: None, + }) + .is_some()); + } + + #[test] + fn an_origin_past_the_limit_is_refused_rather_than_stored() { + let mut storage = Storage::new((10, 20), VectorMemory::default()); + storage.update_salt([17u8; 32]); + let anchor = storage + .allocate_anchor(0) + .expect("an anchor to write under"); + let anchor_number = anchor.anchor_number(); + storage.write(anchor).expect("writing the identity"); + let too_long = format!("https://{}.com", "a".repeat(FRONTEND_HOSTNAME_LIMIT)); + + // A config write, the smallest write that stores something at an origin and so + // the smallest one that has to mint an application for it. + let refused = storage.write_account_state_for_testing( + anchor_number, + BTreeMap::from([( + too_long.clone(), + Some((vec![], Some(AnchorApplicationConfig::default()))), + )]), + ); + + assert!( + matches!(refused, Err(StorageError::OriginTooLong { ref origin }) if origin == &too_long), + "an origin of {} bytes should be refused, got {refused:?}", + too_long.len() + ); + assert_eq!( + storage.lookup_application_number_with_origin(&too_long), + None + ); + } + #[test] fn should_increment_application_counter_correctly() { let mut storage = Storage::new((10, 20), VectorMemory::default()); diff --git a/src/internet_identity/tests/integration/main.rs b/src/internet_identity/tests/integration/main.rs index 88238b8ed2..3e8383145b 100644 --- a/src/internet_identity/tests/integration/main.rs +++ b/src/internet_identity/tests/integration/main.rs @@ -19,6 +19,7 @@ mod mcp; mod openid; mod rollback; mod session_delegation; +mod sessions; mod stable_memory; mod upgrade; mod v2_api; diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs new file mode 100644 index 0000000000..6e8340dcd1 --- /dev/null +++ b/src/internet_identity/tests/integration/sessions.rs @@ -0,0 +1,626 @@ +//! Tests for revocable app sessions: creating one, and minting app delegations from it. + +use candid::Principal; +use canister_tests::api::internet_identity::api_v2::{ + get_account_session, prepare_account_session, +}; +use canister_tests::flows; +use canister_tests::framework::{ + env, install_ii_with_archive, principal_1, time, verify_delegation, BrowserKey, +}; +use internet_identity_interface::internet_identity::types::{ + AccountSessionError, BrowserBrand, BrowserDescription, FormFactor, GetAccountSessionRequest, + OperatingSystem, PrepareAccountSessionRequest, PrepareAccountSessionResponse, +}; +use pocket_ic::{PocketIc, RejectResponse}; +use pretty_assertions::assert_eq; +use serde_bytes::ByteBuf; + +const ORIGIN: &str = "https://some-dapp.com"; + +fn chrome_on_a_mac() -> BrowserDescription { + BrowserDescription { + brand: BrowserBrand::Chrome, + os: OperatingSystem::Macos, + form_factor: FormFactor::Desktop, + model: None, + } +} + +fn firefox_on_linux() -> BrowserDescription { + BrowserDescription { + brand: BrowserBrand::Firefox, + os: OperatingSystem::Linux, + form_factor: FormFactor::Desktop, + model: None, + } +} + +fn session_request(identity_number: u64) -> PrepareAccountSessionRequest { + session_request_from(identity_number, &BrowserKey::new(1)) +} + +/// The same browser presenting the same key again is what makes a sign-in a reuse rather +/// than a registration, so every test that wants a second browser passes a second key. +fn session_request_from( + identity_number: u64, + browser: &BrowserKey, +) -> PrepareAccountSessionRequest { + let session_key = ByteBuf::from(vec![1; 32]); + let next_browser_key = browser.successor().public_key(); + PrepareAccountSessionRequest { + identity_number, + origin: ORIGIN.to_string(), + account_number: None, + browser_description: chrome_on_a_mac(), + current_browser_key: browser.public_key(), + current_browser_key_signature: browser.sign(&session_key, &next_browser_key), + next_browser_key_signature: browser + .successor() + .sign_as_successor(&session_key, &browser.public_key()), + next_browser_key, + session_key, + permissions: None, + valid_for: None, + max_idle: None, + } +} + +/// Creates a session and returns it together with the principal its chain roots at. +fn create_session( + env: &PocketIc, + canister_id: Principal, + identity_number: u64, +) -> (PrepareAccountSessionResponse, Principal) { + let prepared = prepare_account_session( + env, + canister_id, + principal_1(), + session_request(identity_number), + ) + .unwrap() + .unwrap(); + let session_principal = Principal::self_authenticating(&prepared.user_key); + (prepared, session_principal) +} + +#[test] +fn should_create_a_session_and_witness_its_delegation() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let (prepared, _) = create_session(&env, canister_id, identity_number); + assert!(prepared.expiration > time(&env)); + + let fetched = get_account_session( + &env, + canister_id, + principal_1(), + GetAccountSessionRequest { + identity_number, + origin: ORIGIN.to_string(), + account_number: None, + session_key: ByteBuf::from(vec![1; 32]), + expiration: prepared.expiration, + session_id: prepared.session_id, + }, + )? + .unwrap(); + + verify_delegation( + &env, + prepared.user_key.clone(), + &fetched.signed_delegation, + &env.root_key().unwrap(), + ); + + // Asserted rather than left to `verify_delegation`, which builds the message it + // checks from whatever the reply carries: drop the targets on both the signing and + // the witnessing side and the signature still verifies. These two fields are what + // separate a session credential from an unrestricted delegation. + assert_eq!( + fetched.signed_delegation.delegation.targets, + Some(vec![canister_id]), + "the session credential must be usable only against Internet Identity" + ); + assert!( + fetched.signed_delegation.delegation.permissions.is_none(), + "it mints app delegations, which is an update call" + ); + + Ok(()) +} + +#[test] +fn should_refuse_a_session_for_another_anchor() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let result = prepare_account_session( + &env, + canister_id, + Principal::anonymous(), + session_request(identity_number), + )?; + + assert!(matches!(result, Err(AccountSessionError::Unauthorized(_)))); + + Ok(()) +} + +/// A request naming an account the identity does not hold is the one failure a caller can +/// provoke here, so it must be refused before anything is written. Otherwise a rejected +/// sign-in would still leave a browser in the user's list. +#[test] +fn should_refuse_an_unknown_account_without_registering_a_browser() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.account_number = Some(9_999); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::NoSuchAccount)); + assert_eq!( + identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .browsers, + None + ); + + Ok(()) +} + +/// A browser is named by a key it proves possession of. Without the proof an attacker +/// holding an access method could attribute a session to a browser the user recognises. +#[test] +fn should_refuse_a_signature_from_another_key() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.current_browser_key_signature = + BrowserKey::new(9).sign(&request.session_key, &request.next_browser_key); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} + +/// The proof takes its freshness from the session key, so a signature captured from one +/// request cannot be replayed to attach a second session to that browser. +#[test] +fn should_refuse_a_signature_over_another_session_key() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let mut request = session_request_from(identity_number, &browser); + request.session_key = ByteBuf::from(vec![2; 32]); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} + +#[test] +fn should_refuse_a_key_that_is_not_a_public_key() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.current_browser_key = ByteBuf::from(vec![0; 91]); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} + +/// Verification runs before anything is written, so a rejected proof leaves no browser +/// in the user's list. +#[test] +fn should_register_no_browser_when_the_proof_fails() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let mut request = session_request(identity_number); + request.current_browser_key_signature = ByteBuf::from(vec![0; 64]); + prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap_err(); + + assert_eq!( + identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .browsers, + None + ); + + Ok(()) +} + +/// A key the identity has not seen registers a browser of its own, which is the signal a +/// sign-in from somewhere the user does not recognise gives them. +#[test] +fn should_register_a_second_browser_for_a_key_it_has_not_seen() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + + let mut second = session_request_from(identity_number, &BrowserKey::new(2)); + second.browser_description = firefox_on_linux(); + prepare_account_session(&env, canister_id, principal_1(), second)?.unwrap(); + + let devices = identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .browsers + .expect("the identity should hold browsers"); + + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].description, chrome_on_a_mac()); + assert_eq!(devices[1].description, firefox_on_linux()); + assert_ne!(devices[0].id, devices[1].id); + + Ok(()) +} + +/// A browser that lost its key is a new browser, which is the cost of the design and +/// what the registry cap is sized for. +#[test] +fn should_register_a_fresh_browser_after_a_storage_wipe() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request(identity_number), + )? + .unwrap(); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &BrowserKey::new(3)), + )? + .unwrap(); + + let devices = identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .browsers + .expect("the identity should hold browsers"); + + assert_eq!(devices.len(), 2); + + Ok(()) +} + +/// The browser rotates its key at every sign-in, so the successor it announced last time is +/// what it presents next. +#[test] +fn should_accept_the_successor_a_browser_announced() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::identity_info; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + let rotated = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + assert_eq!(rotated.browser_id, first.browser_id); + assert_eq!( + identity_info(&env, canister_id, principal_1(), identity_number)? + .unwrap() + .browsers + .unwrap() + .len(), + 1 + ); + + Ok(()) +} + +/// Which is what stops a copied browser profile signing in alongside the original without +/// showing up: the key it copied is retired the next time the real browser signs in, so the +/// copy can only come back as a browser of its own. +#[test] +fn should_treat_a_retired_key_as_a_new_browser() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + // A browser generates a fresh successor for every attempt, a copy of one included, and + // has to prove it holds it. + let fresh = BrowserKey::new(7); + let mut request = session_request_from(identity_number, &browser); + request.next_browser_key = fresh.public_key(); + request.current_browser_key_signature = + browser.sign(&request.session_key, &request.next_browser_key); + request.next_browser_key_signature = + fresh.sign_as_successor(&request.session_key, &browser.public_key()); + let copy = prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); + + assert_ne!(copy.browser_id, first.browser_id); + + Ok(()) +} + +/// A retired key announcing the successor that replaced it is a replay of a request the real +/// browser already made, and the successor is in use, so it is refused outright. +#[test] +fn should_refuse_a_replayed_announcement() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + let replayed = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )?; + + assert_eq!(replayed, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} + +/// A response the browser never received leaves it proving with a key the entry has +/// already retired. That is refused, and named so the browser knows what to do about it: +/// promote the successor it announced and present that, which lands on its own entry +/// rather than on a second one. +#[test] +fn should_refuse_a_retired_key_and_accept_the_successor() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let first = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )? + .unwrap(); + + assert_eq!( + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser), + )?, + Err(AccountSessionError::StaleBrowserKey) + ); + + let retried = prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &browser.successor()), + )? + .unwrap(); + + assert_eq!(retried.browser_id, first.browser_id); + + Ok(()) +} + +/// A key another entry already holds cannot be announced as a successor even by a caller +/// who can prove it — two browsers would then race for one entry. The caller who *cannot* +/// prove it is refused a step earlier, which is +/// `should_refuse_a_successor_the_caller_cannot_prove`. +#[test] +fn should_refuse_a_successor_another_browser_holds() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let victim = BrowserKey::new(1); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &victim), + )? + .unwrap(); + + let attacker = BrowserKey::new(2); + let mut request = session_request_from(identity_number, &attacker); + request.next_browser_key = victim.successor().public_key(); + request.current_browser_key_signature = + attacker.sign(&request.session_key, &request.next_browser_key); + // Signed by the successor being announced, which a test can do and an attacker + // cannot. Without it the key proof fails first and the registry check below is never + // reached. + request.next_browser_key_signature = victim + .successor() + .sign_as_successor(&request.session_key, &request.current_browser_key); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} + +/// A key nobody holds cannot be announced: without the successor's own signature, a key read +/// off the wire could be planted as another browser's successor and claimed later. +#[test] +fn should_refuse_a_successor_the_caller_cannot_prove() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let mut request = session_request_from(identity_number, &browser); + // Everything the wire carries, but the successor's signature made by the wrong key. + request.next_browser_key_signature = + browser.sign_as_successor(&request.session_key, &browser.public_key()); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} + +/// Rotation is what stops a leaked browser key from being useful for longer than one +/// sign-in, so a browser cannot decline it by announcing the key it is presenting. +#[test] +fn should_refuse_a_successor_equal_to_the_key_presented() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let mut request = session_request_from(identity_number, &browser); + // Both signatures are real: the caller holds the key it is naming as its own successor. + request.next_browser_key = browser.public_key(); + request.current_browser_key_signature = + browser.sign(&request.session_key, &request.next_browser_key); + request.next_browser_key_signature = + browser.sign_as_successor(&request.session_key, &browser.public_key()); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} + +/// The idle bound is the app's to ask for. Absent it defaults, and a value below the +/// floor is clamped up rather than refused, so a short request still yields a usable +/// session. +#[test] +fn should_store_the_requested_idle_bound() -> Result<(), RejectResponse> { + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + canister_tests::api::internet_identity::init_salt(&env, canister_id)?; + let identity_number = flows::register_anchor(&env, canister_id); + + let browser = BrowserKey::new(1); + let mut request = session_request_from(identity_number, &browser); + // Under the ten-minute floor, so the clamp is what makes this session usable. + request.max_idle = Some(60_000_000_000); + prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); + + // A session clamped up to the floor still mints, which is the point of clamping + // rather than refusing. + let devices = canister_tests::api::internet_identity::api_v2::identity_info( + &env, + canister_id, + principal_1(), + identity_number, + )? + .unwrap() + .browsers + .unwrap_or_default(); + assert_eq!(devices.len(), 1); + + Ok(()) +} + +/// Announcing a key another browser of this identity holds keeps two entries from answering +/// to one key, which is what makes resolving a presented key unambiguous. +#[test] +fn should_refuse_a_successor_another_browser_holds_even_when_proven() -> Result<(), RejectResponse> +{ + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + + let victim = BrowserKey::new(1); + prepare_account_session( + &env, + canister_id, + principal_1(), + session_request_from(identity_number, &victim), + )? + .unwrap(); + + // The attacker proves possession of the victim's key, as a profile copy could. + let attacker = BrowserKey::new(2); + let mut request = session_request_from(identity_number, &attacker); + request.next_browser_key = victim.public_key(); + request.current_browser_key_signature = + attacker.sign(&request.session_key, &request.next_browser_key); + request.next_browser_key_signature = + victim.sign_as_successor(&request.session_key, &attacker.public_key()); + let result = prepare_account_session(&env, canister_id, principal_1(), request)?; + + assert_eq!(result, Err(AccountSessionError::InvalidBrowserKey)); + + Ok(()) +} diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index b732c44746..b2124729c2 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -829,3 +829,120 @@ pub enum SetDefaultAccountError { origin: FrontendHostname, }, } + +/// Creates or reuses a revocable session at one account and signs its identity to the +/// II frontend's own key. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct PrepareAccountSessionRequest { + pub identity_number: IdentityNumber, + pub origin: FrontendHostname, + pub account_number: Option, + pub session_key: SessionKey, + /// Labels the browser in the user's session list, e.g. "Chrome on MacBook". + pub browser_description: BrowserDescription, + /// The browser's own public key, DER-encoded, as the registry currently holds it. A + /// key this anchor has not seen registers a browser under it. + pub current_browser_key: PublicKey, + /// What the browser rotates to once this sign-in succeeds. + pub next_browser_key: PublicKey, + /// Signature over `session_key` and `next_browser_key`, verified with + /// `current_browser_key`. A second signature by `next_browser_key` proves the browser + /// holds the key it is announcing. + pub current_browser_key_signature: ByteBuf, + pub next_browser_key_signature: ByteBuf, + /// The consented access level, fixed for the session's life. + pub permissions: Option, + /// Clamped to the session maximum. + pub valid_for: Option, + /// How long the session may go unminted before it is over. Clamped to between + /// 10 minutes and the session's own granted length; absent leaves the + /// canister's own default. + pub max_idle: Option, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct PrepareAccountSessionResponse { + pub user_key: UserKey, + pub expiration: Timestamp, + /// Names the session this ceremony created, and is what `get_account_session` is + /// given to collect the delegation signed for it. Not a credential: it names a + /// session, it does not authorise one, and the caller has just proved it owns this + /// one anyway. + pub session_id: SessionId, + /// Which browser this sign-in was attributed to, so the settings list can mark the one + /// the user is looking at, and so the browser knows which registration its key now + /// belongs to. Not a credential: a caller never presents it. + pub browser_id: BrowserId, + /// The principal apps see for this account. The caller is the anchor that owns it + /// and can mint a delegation for it at any time, so this reveals nothing new; it + /// saves the II frontend from having to mint one just to learn it. + pub account_principal: Principal, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct GetAccountSessionRequest { + pub identity_number: IdentityNumber, + pub origin: FrontendHostname, + pub account_number: Option, + pub session_key: SessionKey, + pub expiration: Timestamp, + /// The session `prepare_account_session` created, named exactly rather than + /// searched for. + pub session_id: SessionId, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct GetAccountSessionResponse { + pub signed_delegation: SignedDelegation, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum AccountSessionError { + Unauthorized(Principal), + NoSuchAccount, + NoSuchSession, + /// The session is there, but no delegation was signed for the session key and + /// expiration asked for. Told apart from `NoSuchSession` because the remedies differ: + /// this one is answered by asking with the parameters `prepare_account_session` + /// signed, not by signing in again. + NoSuchDelegation, + /// The browser's key is unusable, or its signature does not verify against it. + InvalidBrowserKey, + /// The browser presented a key it has already rotated away from, which happens when + /// it never learned that its last sign-in succeeded. It holds the successor that does + /// resolve, so the answer is to promote that one and present it. + StaleBrowserKey, + InternalCanisterError(String), +} + +/// Mints an app delegation from a live session. The session is proven by the caller's +/// own chain, so nothing about the account is named in the request. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct AppPrepareDelegationRequest { + pub session_key: SessionKey, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct AppPrepareDelegationResponse { + pub user_key: UserKey, + pub expiration: Timestamp, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct AppGetDelegationRequest { + pub session_key: SessionKey, + pub expiration: Timestamp, +} + +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub enum AppSessionError { + /// No usable session behind this caller: revoked, expired, pruned, or never one at + /// all. One outcome, because which of those it is depends on whether a prune has run + /// yet, and because an app can act on none of them differently. + /// + /// The same fact its counterpart on [`AccountSessionError`] names, and named the + /// same: the only difference is that this side matches the caller rather than being + /// handed a session id. + NoSuchSession, + InternalCanisterError(String), +}