mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
1bd5fe8a07
commit
6eb8b206a7
3 files changed
+155
-11
No files matched your search
@@ -508,9 +508,34 @@ impl ProtonDriveClient {
|
||||
mailbox_password: impl Into<Vec<u8>>,
|
||||
key_salts: Vec<KeySalt>,
|
||||
) -> 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 {
|
||||
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(),
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
/// 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<HashMap<String, Vec<u8>>> {
|
||||
/// 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<HashMap<String, Vec<u8>>> {
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,28 @@ pub struct ProtonApiSession {
|
||||
scopes: Vec<String>,
|
||||
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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user