mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
ab7f057afe
commit
4a3a7291b8
4 files changed
+64
-22
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user