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).
This commit is contained in:
nvrl committed 2026-10-15 19:56:56 +02:00
1 parent ab7f057afe
commit 4a3a7291b8
4 files changed
+64 -22

No files matched your search

Generated
+7 -6
View File
@@ -4844,9 +4844,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pgp" name = "pgp"
version = "0.20.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
dependencies = [ dependencies = [
"aead 0.5.2", "aead 0.5.2",
"aes 0.8.4", "aes 0.8.4",
@@ -5218,9 +5218,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.7.3" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1868294071187f6198aad10e01bd1ccaa02e70cd6b92f729f9cf7215671aa20f" checksum = "eb9638bbcc807188ce43e6012361d0f5ce4ab70011f6160240df05e474eb3b54"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.0", "base64 0.23.0",
@@ -5245,9 +5245,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.7.3" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "118e33440fcf26a05bd6cc19d544d1270ab183ece0fe535904b4767b16d5b3e0" checksum = "79557071826330cebd39cf4081d6b75a42721134e5b442682d8e82e709584875"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
@@ -5274,6 +5274,7 @@ dependencies = [
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"zeroize",
] ]
[[package]] [[package]]
+2 -2
View File
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
opt-level = 2 opt-level = 2
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.7.3" } proton-sdk = { version = "0.9.0" }
proton-drive-rs = { version = "0.7.3" } proton-drive-rs = { version = "0.9.0" }
# Internal crates. # Internal crates.
pstr-core = { path = "crates/pstr-core" } pstr-core = { path = "crates/pstr-core" }
+44 -12
View File
@@ -33,7 +33,8 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use proton_drive_rs::{NodeKind, ProtonDriveClient}; 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::error::ProtonError;
use proton_sdk::ids::NodeUid; use proton_sdk::ids::NodeUid;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; 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 \ app cannot use yet. Add an authenticator app in your Proton account settings, then sign in \
with its code."; 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 — /// Blocks in flight for streaming from the account, as for a public link —
/// see `ShareStore::open`. /// see `ShareStore::open`.
const MAX_INFLIGHT_BLOCKS: usize = 48; const MAX_INFLIGHT_BLOCKS: usize = 48;
@@ -91,10 +98,10 @@ impl StoredSession {
} }
} }
fn unlock(&self) -> HashMap<String, Vec<u8>> { fn unlock(&self) -> KeyPassphrases {
self.key_passphrases self.key_passphrases
.iter() .iter()
.map(|(id, passphrase)| (id.clone(), passphrase.as_bytes().to_vec())) .map(|(id, passphrase)| (id.as_str(), passphrase.as_bytes()))
.collect() .collect()
} }
@@ -424,9 +431,9 @@ impl AccountStore {
/// back the account. /// back the account.
/// ///
/// The key passphrases are derived here, while the token still has the scope /// 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 /// the key salts need. Unlocking the user keys is what proves the mailbox
/// password: it unlocks the user keys, and a wrong password unlocks none of /// password: a wrong one unlocks none of them. Only the passphrases are
/// them. Only the passphrases are stored; the password goes no further. /// stored; the password goes no further.
async fn finish( async fn finish(
secrets: Arc<dyn SecretStore>, secrets: Arc<dyn SecretStore>,
session: ProtonApiSession, session: ProtonApiSession,
@@ -435,10 +442,10 @@ async fn finish(
let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec()); let probe = ProtonDriveClient::new(&session, mailbox_password.as_bytes().to_vec());
probe probe
.account() .account()
.addresses() .unlock()
.await .await
.map_err(|error| match error { .map_err(|error| match error {
ProtonError::InvalidOperation(_) => { ProtonError::KeysLocked => {
Error::SignIn("that mailbox password did not unlock the account".to_owned()) Error::SignIn("that mailbox password did not unlock the account".to_owned())
} }
other => other.into(), other => other.into(),
@@ -447,10 +454,10 @@ async fn finish(
.account() .account()
.key_passphrases() .key_passphrases()
.await? .await?
.into_iter() .iter()
.map(|(id, passphrase)| { .map(|(id, passphrase)| {
String::from_utf8(passphrase) std::str::from_utf8(passphrase)
.map(|passphrase| (id, passphrase)) .map(|passphrase| (id.to_owned(), passphrase.to_owned()))
.map_err(|_| Error::Config("a derived key passphrase is not text".to_owned())) .map_err(|_| Error::Config("a derived key passphrase is not text".to_owned()))
}) })
.collect::<Result<HashMap<_, _>>>()?; .collect::<Result<HashMap<_, _>>>()?;
@@ -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 /// 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 /// [`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)); 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() { if api.http_status == 422 && !api.is_human_verification_required() {
return Error::SignIn(if api.message.is_empty() { return Error::SignIn(if api.message.is_empty() {
"Proton did not accept that address and password".to_owned() "Proton did not accept that address and password".to_owned()
@@ -642,6 +653,27 @@ mod tests {
assert!(store.resume().expect("resume").is_none()); 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] #[test]
fn a_stored_session_keeps_its_password_mode() { fn a_stored_session_keeps_its_password_mode() {
let params = stored().resume_parameters(); let params = stored().resume_parameters();
+11 -2
View File
@@ -85,11 +85,14 @@ impl Error {
} }
/// Whether this is the account's session having ended: the refresh token /// 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 /// was revoked or expired, the token lacks a scope only a password
/// sign-in grants. Nothing but signing in again fixes it. /// 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 { pub fn is_signed_out(&self) -> bool {
match self { match self {
Self::SignedOut(_) => true, Self::SignedOut(_) => true,
Self::Drive(proton_sdk::error::ProtonError::KeysLocked) => true,
Self::Drive(proton_sdk::error::ProtonError::Api(api)) => { Self::Drive(proton_sdk::error::ProtonError::Api(api)) => {
api.is_invalid_refresh_token() || api.is_insufficient_scope() api.is_invalid_refresh_token() || api.is_insufficient_scope()
} }
@@ -127,4 +130,10 @@ mod tests {
"io: client error (SendRequest): connection closed before message completed" "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());
}
} }