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
Generated
+7
-6
@@ -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]]
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
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" }
|
||||
|
||||
@@ -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