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