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

+44 -12
View File
@@ -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<String, Vec<u8>> {
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<dyn SecretStore>,
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::<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
/// [`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();
+11 -2
View File
@@ -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());
}
}