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.
This commit is contained in:
nvrl committed 2026-10-06 13:59:10 +02:00
1 parent 1bd5fe8a07
commit 6eb8b206a7
3 files changed
+155 -11

No files matched your search

+26 -1
View File
@@ -508,9 +508,34 @@ impl ProtonDriveClient {
mailbox_password: impl Into<Vec<u8>>, mailbox_password: impl Into<Vec<u8>>,
key_salts: Vec<KeySalt>, key_salts: Vec<KeySalt>,
) -> Self { ) -> 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<String, Vec<u8>>,
) -> Self {
Self::with_account(
session,
AccountClient::with_key_passphrases(session, key_passphrases),
)
}
fn with_account(session: &ProtonApiSession, account: AccountClient) -> Self {
Self { Self {
http: session.http().with_base_route("drive/"), 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())), cache: Arc::new(Mutex::new(DriveCache::default())),
entities: DriveEntityCache::new(InMemoryCacheRepository::shared()), entities: DriveEntityCache::new(InMemoryCacheRepository::shared()),
telemetry: NoopTelemetry::shared(), telemetry: NoopTelemetry::shared(),
+65 -3
View File
@@ -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<String, Vec<u8>>,
) -> 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. /// The account's key salts, fetched (and cached) unless already seeded.
/// ///
/// Requires the `locked` scope — call it on a freshly password-authenticated /// Requires the `locked` scope — call it on a freshly password-authenticated
@@ -314,9 +336,16 @@ impl AccountClient {
Ok(keys) Ok(keys)
} }
/// Derive (and cache) the per-key passphrases from the mailbox password and /// The per-key passphrases, key id → passphrase, derived (and cached) from
/// the account's key salts. /// the mailbox password and the account's key salts.
async fn key_passphrases(&self) -> Result<HashMap<String, Vec<u8>>> { ///
/// 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<HashMap<String, Vec<u8>>> {
{ {
let cache = self.inner.cache.lock().await; let cache = self.inner.cache.lock().await;
if let Some(passphrases) = &cache.key_passphrases { if let Some(passphrases) = &cache.key_passphrases {
@@ -434,3 +463,36 @@ fn encode_query_component(value: &str) -> String {
} }
out 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);
}
}
+64 -7
View File
@@ -57,6 +57,28 @@ pub struct ProtonApiSession {
scopes: Vec<String>, scopes: Vec<String>,
password_mode: PasswordMode, password_mode: PasswordMode,
is_waiting_for_second_factor: bool, 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 { impl ProtonApiSession {
@@ -76,6 +98,8 @@ impl ProtonApiSession {
scopes: params.scopes, scopes: params.scopes,
password_mode: params.password_mode, password_mode: params.password_mode,
is_waiting_for_second_factor: params.is_waiting_for_second_factor_code, 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, refresh_token: auth.refresh_token,
}; };
let http = ApiHttpClient::new(config, auth.session_id.clone(), tokens)?; let http = ApiHttpClient::new(config, auth.session_id.clone(), tokens)?;
let enabled = auth.second_factor.map_or(0, |f| f.enabled);
Ok(Self { Ok(Self {
http, http,
@@ -174,10 +199,8 @@ impl ProtonApiSession {
user_id: auth.user_id, user_id: auth.user_id,
scopes: auth.scopes, scopes: auth.scopes,
password_mode: PasswordMode::from_wire(auth.password_mode), password_mode: PasswordMode::from_wire(auth.password_mode),
is_waiting_for_second_factor: auth is_waiting_for_second_factor: enabled != 0,
.second_factor second_factor_methods: SecondFactorMethods::from_wire(enabled),
.map(|f| f.is_enabled())
.unwrap_or(false),
}) })
} }
@@ -243,6 +266,12 @@ impl ProtonApiSession {
self.is_waiting_for_second_factor 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). /// Snapshot current tokens for persistence (they may have rotated on refresh).
pub async fn current_tokens(&self) -> Tokens { pub async fn current_tokens(&self) -> Tokens {
self.http.current_tokens().await self.http.current_tokens().await
@@ -325,8 +354,36 @@ struct SecondFactorInfo {
enabled: i32, enabled: i32,
} }
impl SecondFactorInfo { #[cfg(test)]
fn is_enabled(&self) -> bool { mod tests {
self.enabled != 0 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
}
);
} }
} }