From 6eb8b206a71cfb16a30bcbefbffa5b9f4f34dec1 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 6 Oct 2026 13:59:10 +0200 Subject: [PATCH] feat(account,session): resume from derived key passphrases and report second-factor methods `AccountClient::key_passphrases` is now public, and `AccountClient::with_key_passphrases` / `ProtonDriveClient::with_key_passphrases` build a client from the per-key passphrases alone. A client that persists a session can store those instead of the mailbox password, which on a single-password account is the login password itself. `ProtonApiSession::second_factor_methods` exposes the login's 2FA bitmask (TOTP, FIDO2), so a client without WebAuthn can tell an account that only has a security key apart from a wrong code. --- crates/proton-drive-rs/src/client.rs | 27 ++++++++++- crates/proton-sdk/src/account.rs | 68 ++++++++++++++++++++++++-- crates/proton-sdk/src/session.rs | 71 +++++++++++++++++++++++++--- 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 5a89845..b18ad26 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -508,9 +508,34 @@ impl ProtonDriveClient { mailbox_password: impl Into>, key_salts: Vec, ) -> Self { + Self::with_account( + session, + AccountClient::with_key_salts(session, mailbox_password, key_salts), + ) + } + + /// Build a Drive client whose key chain unlocks from per-key passphrases + /// already derived, with no mailbox password at all. + /// + /// For a client that persists a session: storing the derived passphrases + /// ([`AccountClient::key_passphrases`], captured right after login) in + /// place of the mailbox password means the store never holds the + /// password itself — which, on a single-password account, is the login + /// password. They unlock the keys all the same, so keep them as secret. + pub fn with_key_passphrases( + session: &ProtonApiSession, + key_passphrases: HashMap>, + ) -> Self { + Self::with_account( + session, + AccountClient::with_key_passphrases(session, key_passphrases), + ) + } + + fn with_account(session: &ProtonApiSession, account: AccountClient) -> Self { Self { http: session.http().with_base_route("drive/"), - account: AccountClient::with_key_salts(session, mailbox_password, key_salts), + account, cache: Arc::new(Mutex::new(DriveCache::default())), entities: DriveEntityCache::new(InMemoryCacheRepository::shared()), telemetry: NoopTelemetry::shared(), diff --git a/crates/proton-sdk/src/account.rs b/crates/proton-sdk/src/account.rs index c426013..60d06ca 100644 --- a/crates/proton-sdk/src/account.rs +++ b/crates/proton-sdk/src/account.rs @@ -134,6 +134,28 @@ impl AccountClient { } } + /// Build a client from per-key passphrases already derived — the output of + /// [`key_passphrases`](Self::key_passphrases) — with no mailbox password. + /// + /// Neither the mailbox password nor the key salts are needed after this: + /// the passphrases are what both exist to produce. A key whose id is + /// missing from the map does not unlock, exactly as a key without a salt. + pub fn with_key_passphrases( + session: &ProtonApiSession, + key_passphrases: HashMap>, + ) -> Self { + Self { + inner: Arc::new(Inner { + http: session.http().clone(), + mailbox_password: Vec::new(), + cache: Mutex::new(Cache { + key_passphrases: Some(key_passphrases), + ..Cache::default() + }), + }), + } + } + /// The account's key salts, fetched (and cached) unless already seeded. /// /// Requires the `locked` scope — call it on a freshly password-authenticated @@ -314,9 +336,16 @@ impl AccountClient { Ok(keys) } - /// Derive (and cache) the per-key passphrases from the mailbox password and - /// the account's key salts. - async fn key_passphrases(&self) -> Result>> { + /// The per-key passphrases, key id → passphrase, derived (and cached) from + /// the mailbox password and the account's key salts. + /// + /// Each one unlocks a key, so they are as secret as the password they come + /// from. Persisting these instead of the password, and resuming with + /// [`with_key_passphrases`](Self::with_key_passphrases), keeps the password + /// itself out of storage. Deriving needs the salts, so on a session + /// resumed without them call this right after login, as for + /// [`key_salts`](Self::key_salts). + pub async fn key_passphrases(&self) -> Result>> { { let cache = self.inner.cache.lock().await; if let Some(passphrases) = &cache.key_passphrases { @@ -434,3 +463,36 @@ fn encode_query_component(value: &str) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ProtonClientConfiguration; + use crate::session::{PasswordMode, ResumeParameters}; + + fn session() -> ProtonApiSession { + ProtonApiSession::resume( + ProtonClientConfiguration::new("test@1.0.0"), + ResumeParameters { + session_id: "session".to_owned().into(), + username: "user".to_owned(), + user_id: "user-id".to_owned().into(), + access_token: "access".to_owned(), + refresh_token: "refresh".to_owned(), + scopes: Vec::new(), + is_waiting_for_second_factor_code: false, + password_mode: PasswordMode::Single, + }, + ) + .expect("resume") + } + + #[tokio::test] + async fn seeded_key_passphrases_are_used_without_deriving_or_a_request() { + let passphrases = HashMap::from([("key-1".to_owned(), b"derived".to_vec())]); + let client = AccountClient::with_key_passphrases(&session(), passphrases.clone()); + // No salts were given and the base URL is unreachable from a test, so + // anything but the seeded map would fail here. + assert_eq!(client.key_passphrases().await.unwrap(), passphrases); + } +} diff --git a/crates/proton-sdk/src/session.rs b/crates/proton-sdk/src/session.rs index 0106b05..bca96c9 100644 --- a/crates/proton-sdk/src/session.rs +++ b/crates/proton-sdk/src/session.rs @@ -57,6 +57,28 @@ pub struct ProtonApiSession { scopes: Vec, password_mode: PasswordMode, is_waiting_for_second_factor: bool, + second_factor_methods: SecondFactorMethods, +} + +/// Which second factors an account has enabled, as its login reported them. +/// +/// Proton sends a bitmask: `1` an authenticator app (TOTP), `2` a security +/// key (FIDO2). [`ProtonApiSession::apply_second_factor_code`] answers only +/// the first, so a client without WebAuthn uses this to tell an account it +/// cannot finish signing in apart from a code that was simply wrong. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SecondFactorMethods { + pub totp: bool, + pub fido2: bool, +} + +impl SecondFactorMethods { + fn from_wire(enabled: i32) -> Self { + Self { + totp: enabled & 1 != 0, + fido2: enabled & 2 != 0, + } + } } impl ProtonApiSession { @@ -76,6 +98,8 @@ impl ProtonApiSession { scopes: params.scopes, password_mode: params.password_mode, is_waiting_for_second_factor: params.is_waiting_for_second_factor_code, + // Only a login reports them, and a resumed session is past that. + second_factor_methods: SecondFactorMethods::default(), }) } @@ -166,6 +190,7 @@ impl ProtonApiSession { refresh_token: auth.refresh_token, }; let http = ApiHttpClient::new(config, auth.session_id.clone(), tokens)?; + let enabled = auth.second_factor.map_or(0, |f| f.enabled); Ok(Self { http, @@ -174,10 +199,8 @@ impl ProtonApiSession { user_id: auth.user_id, scopes: auth.scopes, password_mode: PasswordMode::from_wire(auth.password_mode), - is_waiting_for_second_factor: auth - .second_factor - .map(|f| f.is_enabled()) - .unwrap_or(false), + is_waiting_for_second_factor: enabled != 0, + second_factor_methods: SecondFactorMethods::from_wire(enabled), }) } @@ -243,6 +266,12 @@ impl ProtonApiSession { self.is_waiting_for_second_factor } + /// The second factors the account has enabled, as its login reported + /// them. All `false` on a resumed session, which no longer needs one. + pub fn second_factor_methods(&self) -> SecondFactorMethods { + self.second_factor_methods + } + /// Snapshot current tokens for persistence (they may have rotated on refresh). pub async fn current_tokens(&self) -> Tokens { self.http.current_tokens().await @@ -325,8 +354,36 @@ struct SecondFactorInfo { enabled: i32, } -impl SecondFactorInfo { - fn is_enabled(&self) -> bool { - self.enabled != 0 +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn second_factor_methods_read_from_the_login_bitmask() { + assert_eq!( + SecondFactorMethods::from_wire(0), + SecondFactorMethods::default() + ); + assert_eq!( + SecondFactorMethods::from_wire(1), + SecondFactorMethods { + totp: true, + fido2: false + } + ); + assert_eq!( + SecondFactorMethods::from_wire(2), + SecondFactorMethods { + totp: false, + fido2: true + } + ); + assert_eq!( + SecondFactorMethods::from_wire(3), + SecondFactorMethods { + totp: true, + fido2: true + } + ); } }