From 4a3a7291b8617dc90af556bb6001341d07d8499a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 15 Oct 2026 19:56:56 +0200 Subject: [PATCH] chore(deps): proton-sdk and proton-drive-rs 0.9.0 Adapt sign-in to the 0.8 account API and use what it adds: - Key passphrases are KeyPassphrases now, wiped on drop. The stored session keeps its JSON shape and converts on unlock. - The mailbox password is checked with AccountClient::unlock(), one core/v4/users request instead of loading every address, and a wrong one is recognised as ProtonError::KeysLocked instead of any InvalidOperation. - KeysLocked from a resumed session counts as signed out: the mailbox password changed, and only signing in again derives passphrases that unlock the keys. - A sign-in Proton answers with code 2028 (Blocked) says so, instead of showing the server text that claims the app version is outdated. Also picked up without code changes: a token refresh that outlives its caller and is never replayed (0.8.2). --- Cargo.lock | 13 ++++---- Cargo.toml | 4 +-- crates/pstr-core/src/account.rs | 56 ++++++++++++++++++++++++++------- crates/pstr-core/src/error.rs | 13 ++++++-- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e96ea93..62c7e05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4844,9 +4844,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pgp" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" +checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a" dependencies = [ "aead 0.5.2", "aes 0.8.4", @@ -5218,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proton-drive-rs" -version = "0.7.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f" +checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54" dependencies = [ "async-trait", "base64 0.23.0", @@ -5245,9 +5245,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.7.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0" +checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875" dependencies = [ "aes-gcm 0.11.0", "async-trait", @@ -5274,6 +5274,7 @@ dependencies = [ "thiserror 2.0.19", "tokio", "tracing", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a84568d..9b97a01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,8 +28,8 @@ authors = ["Nils Pukropp "] opt-level = 2 [workspace.dependencies] -proton-sdk = { version = "0.7.3" } -proton-drive-rs = { version = "0.7.3" } +proton-sdk = { version = "0.9.0" } +proton-drive-rs = { version = "0.9.0" } # Internal crates. pstr-core = { path = "crates/pstr-core" } diff --git a/crates/pstr-core/src/account.rs b/crates/pstr-core/src/account.rs index 6df9977..251d14b 100644 --- a/crates/pstr-core/src/account.rs +++ b/crates/pstr-core/src/account.rs @@ -33,7 +33,8 @@ use std::collections::HashMap; use std::sync::Arc; use proton_drive_rs::{NodeKind, ProtonDriveClient}; -use proton_sdk::api::HumanVerificationCredential; +use proton_sdk::account::KeyPassphrases; +use proton_sdk::api::{HumanVerificationCredential, ResponseCode}; use proton_sdk::error::ProtonError; use proton_sdk::ids::NodeUid; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; @@ -51,6 +52,12 @@ const SECURITY_KEY_ONLY: &str = "This account's only second factor is a security app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \ with its code."; +/// Why a sign-in Proton answered with [`ResponseCode::Blocked`] failed. The +/// server's own text says the app version is no longer supported, which is +/// not true and sends the viewer looking for an update that does not exist. +const SIGN_IN_BLOCKED: &str = "Proton has blocked sign-ins from this app for now. Wait a while \ + and try again, or sign in once on proton.me first."; + /// Blocks in flight for streaming from the account, as for a public link — /// see `ShareStore::open`. const MAX_INFLIGHT_BLOCKS: usize = 48; @@ -91,10 +98,10 @@ impl StoredSession { } } - fn unlock(&self) -> HashMap> { + fn unlock(&self) -> KeyPassphrases { self.key_passphrases .iter() - .map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec())) + .map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes())) .collect() } @@ -424,9 +431,9 @@ impl AccountStore { /// back the account. /// /// The key passphrases are derived here, while the token still has the scope -/// the key salts need. Loading the addresses is what proves the mailbox -/// password: it unlocks the user keys, and a wrong password unlocks none of -/// them. Only the passphrases are stored; the password goes no further. +/// the key salts need. Unlocking the user keys is what proves the mailbox +/// password: a wrong one unlocks none of them. Only the passphrases are +/// stored; the password goes no further. async fn finish( secrets: Arc, session: ProtonApiSession, @@ -435,10 +442,10 @@ async fn finish( let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec()); probe .account() - .addresses() + .unlock() .await .map_err(|error| match error { - ProtonError::InvalidOperation(_) => { + ProtonError::KeysLocked => { Error::SignIn("that mailbox password did not unlock the account".to_owned()) } other => other.into(), @@ -447,10 +454,10 @@ async fn finish( .account() .key_passphrases() .await? - .into_iter() + .iter() .map(|(id, passphrase)| { - String::from_utf8(passphrase) - .map(|passphrase| (id, passphrase)) + std::str::from_utf8(passphrase) + .map(|passphrase| (id.to_owned(), passphrase.to_owned())) .map_err(|_| Error::Config("a derived key passphrase is not text".to_owned())) }) .collect::>>()?; @@ -500,7 +507,8 @@ fn persist_refreshed_tokens( }); } -/// Lift a CAPTCHA gate and a wrong password out of the generic API error. +/// Lift a CAPTCHA gate, a blocked sign-in and a wrong password out of the +/// generic API error. /// /// Only a gate that can be solved in an app becomes /// [`Error::HumanVerification`]; one that wants an e-mail or SMS code is a plain @@ -512,6 +520,9 @@ fn classify_sign_in_failure(error: ProtonError) -> Error { { return Error::HumanVerification(Box::new(challenge)); } + if api.code == ResponseCode::Blocked { + return Error::SignIn(SIGN_IN_BLOCKED.to_owned()); + } if api.http_status == 422 && !api.is_human_verification_required() { return Error::SignIn(if api.message.is_empty() { "Proton did not accept that address and password".to_owned() @@ -642,6 +653,27 @@ mod tests { assert!(store.resume().expect("resume").is_none()); } + #[test] + fn a_stored_session_unlocks_with_its_passphrases() { + let unlock = stored().unlock(); + assert_eq!(unlock.len(), 1); + assert_eq!(unlock.get("key-1"), Some(b"derived".as_slice())); + } + + #[test] + fn a_blocked_sign_in_does_not_claim_the_app_is_outdated() { + let error = classify_sign_in_failure(ProtonError::Api(proton_sdk::error::ProtonApiError { + code: ResponseCode::Blocked, + http_status: 422, + message: "This version of the app is no longer supported".to_owned(), + details: None, + })); + assert!( + matches!(&error, Error::SignIn(text) if text == SIGN_IN_BLOCKED), + "{error}" + ); + } + #[test] fn a_stored_session_keeps_its_password_mode() { let params = stored().resume_parameters(); diff --git a/crates/pstr-core/src/error.rs b/crates/pstr-core/src/error.rs index 7939046..4155370 100644 --- a/crates/pstr-core/src/error.rs +++ b/crates/pstr-core/src/error.rs @@ -85,11 +85,14 @@ impl Error { } /// Whether this is the account's session having ended: the refresh token - /// was revoked or expired, or the token lacks a scope only a password - /// sign-in grants. Nothing but signing in again fixes it. + /// was revoked or expired, the token lacks a scope only a password + /// sign-in grants, or the stored key passphrases no longer unlock the + /// account because its mailbox password changed. Nothing but signing in + /// again fixes it. pub fn is_signed_out(&self) -> bool { match self { Self::SignedOut(_) => true, + Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true, Self::Drive(proton_sdk::error::ProtonError::Api(api)) => { api.is_invalid_refresh_token() || api.is_insufficient_scope() } @@ -127,4 +130,10 @@ mod tests { "io: client error (SendRequest): connection closed before message completed" ); } + + #[test] + fn stored_passphrases_that_no_longer_unlock_mean_signed_out() { + let error = Error::Drive(proton_sdk::error::ProtonError::KeysLocked); + assert!(error.is_signed_out()); + } }