//! Account client: user, addresses and their decrypted private keys. //! //! Mirrors the parts of `ProtonAccountClient` / `AddressOperations` that the //! Drive read path needs. The unlock chain is: //! //! 1. mailbox password + per-key salt → bcrypt-derived passphrase //! 2. passphrase → unlock **user keys** //! 3. user keys → decrypt an **address key token** → unlock the address key //! //! (Some address keys have no token and instead reuse an account-key passphrase //! derived in step 1.) Decrypted keys are cached for the lifetime of the client. mod dtos; mod passphrases; mod report; pub use passphrases::KeyPassphrases; pub use report::{AddressKeyReport, AddressReport, KeyReport, UserKeyReport}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use zeroize::Zeroizing; use crate::crypto::{self, PrivateKey, PublicKey}; use crate::error::{ProtonError, Result}; use crate::http::ApiHttpClient; use crate::ids::{AddressId, AddressKeyId}; use crate::session::ProtonApiSession; use dtos::{ AddressDto, AddressListResponse, AddressPublicKeyListResponse, KeySaltListResponse, UserResponse, }; pub use dtos::KeySalt; /// Public view of an email address attached to the account. #[derive(Debug, Clone)] pub struct Address { pub id: AddressId, pub email: String, pub order: i32, pub status: i32, /// Index, within the decrypted key list, of the primary key. pub primary_key_index: usize, /// Id of the address's primary key (the `AddressKeyID` for write requests). pub primary_key_id: AddressKeyId, } /// Total account storage usage, in bytes (all Proton products, not Drive-only). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Quota { /// Total storage available to the account. pub max_space: i64, /// Storage currently used. pub used_space: i64, } impl Quota { /// Bytes still free (never negative). pub fn available(&self) -> i64 { (self.max_space - self.used_space).max(0) } /// Fraction of the quota in use, `0.0..=1.0` (0 when `max_space` is 0). pub fn used_fraction(&self) -> f64 { if self.max_space <= 0 { 0.0 } else { (self.used_space as f64 / self.max_space as f64).clamp(0.0, 1.0) } } } /// Resolves account keys needed to decrypt Drive metadata. /// /// Construction takes the mailbox (data) password because, per Proton's key /// model, it is required even for read-only decryption. #[derive(Clone)] pub struct AccountClient { inner: Arc, } struct Inner { http: ApiHttpClient, mailbox_password: Zeroizing>, cache: Mutex, } #[derive(Default)] struct Cache { /// Raw key salts, either fetched from `core/v4/keys/salts` or seeded by the /// caller (see [`AccountClient::with_key_salts`]). key_salts: Option>, /// key id → passphrase derived from the mailbox password and key salts. key_passphrases: Option, user_keys: Option>, /// Every user key that opens, active ones first: what an address key's /// token may be encrypted to. See [`AccountClient::token_keys`]. token_keys: Option>, addresses: Option>, address_keys: HashMap>, /// Addresses whose keys did not open, with why. They are left out of /// `addresses` so the others still work. failed_addresses: HashMap, /// Why the lowest-ordered address did not open, when it did not. default_failed: Option, /// email → active (non-compromised) public keys, for authorship verification. public_keys: HashMap>, } impl AccountClient { pub fn new(session: &ProtonApiSession, mailbox_password: impl Into>) -> Self { Self { inner: Arc::new(Inner { http: session.http().clone(), mailbox_password: Zeroizing::new(mailbox_password.into()), cache: Mutex::new(Cache::default()), }), } } /// Build a client with the account's key salts already known, so the key /// chain unlocks without calling `core/v4/keys/salts`. /// /// That endpoint requires the `locked` scope, which only a /// password-authenticated access token carries: a session resumed from /// persisted tokens (or one whose token has since gone through /// `auth/v4/refresh`) gets a 403 there. Capture the salts with /// [`key_salts`](Self::key_salts) right after login, persist them next to /// the session tokens, and seed them here on resume. pub fn with_key_salts( session: &ProtonApiSession, mailbox_password: impl Into>, key_salts: Vec, ) -> Self { Self { inner: Arc::new(Inner { http: session.http().clone(), mailbox_password: Zeroizing::new(mailbox_password.into()), cache: Mutex::new(Cache { key_salts: Some(key_salts), ..Cache::default() }), }), } } /// Build a client from per-key passphrases already derived — the output of /// [`key_passphrases`](Self::key_passphrases) — with no mailbox password. /// /// Neither the mailbox password nor the key salts are needed after this: /// the passphrases are what both exist to produce. A key whose id is /// missing from the map does not unlock, exactly as a key without a salt. pub fn with_key_passphrases( session: &ProtonApiSession, key_passphrases: KeyPassphrases, ) -> Self { Self { inner: Arc::new(Inner { http: session.http().clone(), mailbox_password: Zeroizing::new(Vec::new()), cache: Mutex::new(Cache { key_passphrases: Some(key_passphrases), ..Cache::default() }), }), } } /// The account's key salts, fetched (and cached) unless already seeded. /// /// Requires the `locked` scope — call it on a freshly password-authenticated /// session; see [`with_key_salts`](Self::with_key_salts). pub async fn key_salts(&self) -> Result> { { let cache = self.inner.cache.lock().await; if let Some(salts) = &cache.key_salts { return Ok(salts.clone()); } } let response: KeySaltListResponse = self.inner.http.get("core/v4/keys/salts").await?; self.inner.cache.lock().await.key_salts = Some(response.key_salts.clone()); Ok(response.key_salts) } /// All addresses on the account, ordered by their `Order` field. pub async fn addresses(&self) -> Result> { { let cache = self.inner.cache.lock().await; if let Some(addresses) = &cache.addresses { return Ok(addresses.clone()); } } // Ensure user keys (and thus the key passphrases) are loaded first. self.user_keys().await?; let response: AddressListResponse = self.inner.http.get("core/v4/addresses").await?; let mut addresses = Vec::with_capacity(response.addresses.len()); let mut failed = HashMap::new(); let mut first_error = None; for dto in &response.addresses { // An address whose keys do not open is left out rather than // failing the account: the shares on every other address still // work, and one broken address must not stop them all. let (address, keys) = match self.decrypt_address(dto).await { Ok(decrypted) => decrypted, Err(ProtonError::InvalidOperation(reason)) => { tracing::warn!(address_id = %dto.id, %reason, "address keys do not open; skipping the address"); failed.insert(dto.id.clone(), reason.clone()); first_error.get_or_insert(reason); continue; } Err(e) => return Err(e), }; self.inner .cache .lock() .await .address_keys .insert(address.id.clone(), keys); addresses.push(address); } if addresses.is_empty() && let Some(reason) = first_error { return Err(ProtonError::invalid_operation(reason)); } addresses.sort_by_key(|a| a.order); let default_failed = response .addresses .iter() .min_by_key(|dto| dto.order) .and_then(|dto| failed.get(&dto.id).cloned()); let mut cache = self.inner.cache.lock().await; cache.failed_addresses = failed; cache.default_failed = default_failed; cache.addresses = Some(addresses.clone()); Ok(addresses) } /// One address by id. An address whose keys did not open fails with the /// reason, not as unknown. pub async fn address(&self, address_id: &AddressId) -> Result
{ let addresses = self.addresses().await?; if let Some(address) = addresses.into_iter().find(|a| &a.id == address_id) { return Ok(address); } let cache = self.inner.cache.lock().await; Err(match cache.failed_addresses.get(address_id) { Some(reason) => ProtonError::invalid_operation(reason.clone()), None => ProtonError::invalid_operation(format!("no address {address_id}")), }) } /// The default (lowest-ordered) address. /// /// When that address's keys did not open, this fails with the reason /// rather than handing out the next address: what it signs would then be /// signed by an address the user did not choose. pub async fn default_address(&self) -> Result
{ let addresses = self.addresses().await?; if let Some(reason) = self.inner.cache.lock().await.default_failed.clone() { return Err(ProtonError::invalid_operation(reason)); } addresses .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation("user has no address")) } /// Decrypted private keys for a given address. pub async fn address_private_keys(&self, address_id: &AddressId) -> Result> { { let cache = self.inner.cache.lock().await; if let Some(keys) = cache.address_keys.get(address_id) { return Ok(keys.clone()); } } // Loading all addresses populates the per-address key cache. self.addresses().await?; let cache = self.inner.cache.lock().await; if let Some(keys) = cache.address_keys.get(address_id) { return Ok(keys.clone()); } Err(match cache.failed_addresses.get(address_id) { Some(reason) => ProtonError::invalid_operation(reason.clone()), None => ProtonError::invalid_operation(format!("no keys for address {address_id}")), }) } /// Active public keys for an email address, used to verify authorship /// signatures. Mirrors C# `AddressOperations.GetPublicKeysAsync`: /// `core/v4/keys/all?InternalOnly=1&Email=…`, keeping only keys whose /// `Flags` mark them not-compromised. Cached per email. /// /// Because verification is non-fatal, a resolution failure (unknown /// address, external domain, transport error) is logged and yields an empty /// key set rather than propagating — the caller then sees /// [`crate::crypto::VerificationStatus::NoVerifier`]. pub async fn public_keys(&self, email: &str) -> Vec { { let cache = self.inner.cache.lock().await; if let Some(keys) = cache.public_keys.get(email) { return keys.clone(); } } let path = format!( "core/v4/keys/all?InternalOnly=1&Email={}", encode_query_component(email) ); let keys = match self .inner .http .get::(&path) .await { Ok(response) => response .address .keys .iter() .filter(|entry| entry.is_not_compromised()) .filter_map(|entry| match PublicKey::from_armored(&entry.public_key) { Ok(key) => Some(key), Err(e) => { tracing::warn!(%email, error = %e, "failed to parse public key"); None } }) .collect::>(), Err(e) => { tracing::warn!(%email, error = %e, "failed to resolve public keys; treating as unverified"); Vec::new() } }; self.inner .cache .lock() .await .public_keys .insert(email.to_string(), keys.clone()); keys } /// Total account storage usage (`core/v4/users`). /// /// Account-wide `MaxSpace`/`UsedSpace` across all Proton products, not the /// Drive-only quota. Not cached — reflects live usage on each call. pub async fn quota(&self) -> Result { let response: UserResponse = self.inner.http.get("core/v4/users").await?; Ok(Quota { max_space: response.user.max_space, used_space: response.user.used_space, }) } /// Check that this client's password (or passphrases) unlocks the account. /// /// Fetches the user's keys once (`core/v4/users`) and unlocks them, nothing /// more: no address is loaded or decrypted. Fails with /// [`ProtonError::KeysLocked`] when the secret is wrong, which is the /// answer a login prompt wants for "try again", and with a transport error /// when Proton could not be reached, which says nothing about the secret. /// The unlocked keys are cached, so a client that passes this has done the /// work its first real call would have. pub async fn unlock(&self) -> Result<()> { self.user_keys().await.map(drop) } /// Decrypted active user (account) keys. async fn user_keys(&self) -> Result> { { let cache = self.inner.cache.lock().await; if let Some(keys) = &cache.user_keys { return Ok(keys.clone()); } } let passphrases = self.key_passphrases().await?; let response: UserResponse = self.inner.http.get("core/v4/users").await?; let mut keys = Vec::new(); let mut inactive = Vec::new(); for key_dto in &response.user.keys { let Some(passphrase) = passphrases.get(key_dto.id.as_str()) else { if key_dto.is_active() { tracing::warn!(key_id = %key_dto.id, "no passphrase for user key"); } continue; }; match PrivateKey::from_armored(&key_dto.private_key, passphrase) { Ok(key) if key_dto.is_active() => keys.push(key), Ok(key) => inactive.push(key), // An inactive key is usually one whose passphrase went with an // old password, so not opening is what it is expected to do. Err(e) if !key_dto.is_active() => { tracing::debug!(key_id = %key_dto.id, error = %e, "inactive user key does not open") } Err(e) => { tracing::warn!(key_id = %key_dto.id, error = %e, "failed to unlock user key") } } } if keys.is_empty() { // Keys the account has, none of which this client's secret opens — // as opposed to an account with no active key at all, which no // password fixes. return Err(if response.user.keys.iter().any(|k| k.is_active()) { ProtonError::KeysLocked } else { ProtonError::invalid_operation("the account has no active user key") }); } let mut cache = self.inner.cache.lock().await; cache.token_keys = Some(keys.iter().cloned().chain(inactive).collect()); cache.user_keys = Some(keys.clone()); Ok(keys) } /// Every user key that opens, active ones first, for decrypting address /// key tokens. /// /// A token can be encrypted to an account key that has since been marked /// inactive, for example after a key was replaced. That key still opens /// with the password, and the address key behind the token is still the /// one the address's shares are encrypted to, so the token must be tried /// with it too. Only active keys are used for anything else. async fn token_keys(&self) -> Result> { self.user_keys().await?; Ok(self .inner .cache .lock() .await .token_keys .clone() .unwrap_or_default()) } /// The per-key passphrases, key id → passphrase, derived (and cached) from /// the mailbox password and the account's key salts. /// /// Each one unlocks a key, so they are as secret as the password they come /// from. Persisting these instead of the password, and resuming with /// [`with_key_passphrases`](Self::with_key_passphrases), keeps the password /// itself out of storage. Deriving needs the salts, so on a session /// resumed without them call this right after login, as for /// [`key_salts`](Self::key_salts). pub async fn key_passphrases(&self) -> Result { { let cache = self.inner.cache.lock().await; if let Some(passphrases) = &cache.key_passphrases { return Ok(passphrases.clone()); } } let salts = self.key_salts().await?; let mut passphrases = KeyPassphrases::new(); for salt in &salts { let Some(salt_b64) = &salt.value else { continue; }; if salt_b64.is_empty() { continue; } let salt_bytes = decode_base64(salt_b64)?; let passphrase = crypto::derive_key_passphrase(&self.inner.mailbox_password, &salt_bytes)?; passphrases.insert(salt.key_id.clone(), passphrase.to_vec()); } self.inner.cache.lock().await.key_passphrases = Some(passphrases.clone()); Ok(passphrases) } /// Build the public [`Address`] and unlock all of its active private keys. async fn decrypt_address(&self, dto: &AddressDto) -> Result<(Address, Vec)> { let user_keys = self.token_keys().await?; let passphrases = self.key_passphrases().await?; let mut keys = Vec::new(); let mut primary_key_index = None; let mut primary_key_id = None; for key_dto in &dto.keys { if !key_dto.is_active() { continue; } // Two ways to obtain the passphrase that unlocks an address key: // decrypt its token with the user keys, or fall back to an // account-key passphrase derived from the mailbox password. let passphrase = match (&key_dto.token, &key_dto.signature) { (Some(token), Some(_signature)) => { match crypto::decrypt_armored_with_keys(token, &user_keys) { Ok(passphrase) => Zeroizing::new(passphrase), Err(e) => { tracing::warn!(key_id = %key_dto.id, error = %e, "failed to decrypt address key token"); continue; } } } _ => match passphrases.get(key_dto.id.as_str()) { Some(passphrase) => Zeroizing::new(passphrase.to_vec()), None => { tracing::warn!(key_id = %key_dto.id, "no passphrase for address key"); continue; } }, }; match PrivateKey::from_armored(&key_dto.private_key, &passphrase) { Ok(key) => { if key_dto.is_primary() && primary_key_index.is_none() { primary_key_index = Some(keys.len()); primary_key_id = Some(key_dto.id.clone()); } keys.push(key); } Err(e) => { tracing::warn!(key_id = %key_dto.id, error = %e, "failed to unlock address key"); } } } let primary_key_index = primary_key_index.ok_or_else(|| { ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)) })?; let primary_key_id = primary_key_id.ok_or_else(|| { ProtonError::invalid_operation(format!("address {} has no primary key", dto.id)) })?; let address = Address { id: dto.id.clone(), email: dto.email.clone(), order: dto.order, status: dto.status, primary_key_index, primary_key_id, }; Ok((address, keys)) } } fn decode_base64(value: &str) -> Result> { use base64::Engine; base64::engine::general_purpose::STANDARD .decode(value) .map_err(|e| ProtonError::invalid_operation(format!("invalid base64 salt: {e}"))) } /// Percent-encode a value for use in a URL query component. Email addresses /// contain `@` (and may contain `+`), which must be escaped. fn encode_query_component(value: &str) -> String { let mut out = String::with_capacity(value.len()); for byte in value.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { out.push(byte as char) } _ => out.push_str(&format!("%{byte:02X}")), } } out } #[cfg(test)] mod tests { use super::*; use crate::config::ProtonClientConfiguration; use crate::session::{PasswordMode, ResumeParameters}; fn session() -> ProtonApiSession { ProtonApiSession::resume( ProtonClientConfiguration::new("test@1.0.0"), ResumeParameters { session_id: "session".to_owned().into(), username: "user".to_owned(), user_id: "user-id".to_owned().into(), access_token: "access".to_owned(), refresh_token: "refresh".to_owned(), scopes: Vec::new(), is_waiting_for_second_factor_code: false, password_mode: PasswordMode::Single, }, ) .expect("resume") } #[tokio::test] async fn seeded_key_passphrases_are_used_without_deriving_or_a_request() { let passphrases = KeyPassphrases::from_iter([("key-1", b"derived".to_vec())]); let client = AccountClient::with_key_passphrases(&session(), passphrases.clone()); // No salts were given and the base URL is unreachable from a test, so // anything but the seeded map would fail here. assert_eq!(client.key_passphrases().await.unwrap(), passphrases); } /// A session pointed at a loopback server that answers every request with /// `body`, and the number of requests it has served. async fn session_serving( body: String, ) -> ( ProtonApiSession, std::sync::Arc, ) { use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let served = std::sync::Arc::new(AtomicUsize::new(0)); let counter = served.clone(); tokio::spawn(async move { loop { let Ok((mut sock, _)) = listener.accept().await else { return; }; let mut buf = [0u8; 4096]; let _ = sock.read(&mut buf).await; counter.fetch_add(1, Ordering::SeqCst); let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); let _ = sock.write_all(head.as_bytes()).await; let _ = sock.write_all(body.as_bytes()).await; let _ = sock.flush().await; } }); let session = ProtonApiSession::resume( ProtonClientConfiguration::new("test@1.0.0") .with_base_url(format!("http://{addr}/")) .with_retry_policy(crate::config::RetryPolicy::disabled()), ResumeParameters { session_id: "session".to_owned().into(), username: "user".to_owned(), user_id: "user-id".to_owned().into(), access_token: "access".to_owned(), refresh_token: "refresh".to_owned(), scopes: Vec::new(), is_waiting_for_second_factor_code: false, password_mode: PasswordMode::Dual, }, ) .expect("resume"); (session, served) } /// The `core/v4/users` answer for one account key locked with `passphrase`. fn user_with_key(armored: &str, active: i32) -> String { serde_json::json!({ "Code": 1000, "User": { "ID": "user-id", "MaxSpace": 1, "UsedSpace": 0, "Keys": [{ "ID": "key-1", "PrivateKey": armored, "Primary": 1, "Active": active }] } }) .to_string() } #[tokio::test] async fn unlock_accepts_the_passphrase_that_locks_the_key() { let key = crate::crypto::generate_node_key().unwrap(); let (session, served) = session_serving(user_with_key(&key.locked_armored, 1)).await; let passphrases = KeyPassphrases::from_iter([("key-1", key.passphrase.clone())]); AccountClient::with_key_passphrases(&session, passphrases) .unlock() .await .expect("right passphrase"); assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 1); } #[tokio::test] async fn unlock_reports_a_wrong_passphrase_as_keys_locked() { let key = crate::crypto::generate_node_key().unwrap(); let (session, _) = session_serving(user_with_key(&key.locked_armored, 1)).await; let passphrases = KeyPassphrases::from_iter([("key-1", b"not it".to_vec())]); let error = AccountClient::with_key_passphrases(&session, passphrases) .unlock() .await .expect_err("wrong passphrase"); assert!(matches!(error, ProtonError::KeysLocked), "{error:?}"); assert!(!error.is_retriable()); } #[tokio::test] async fn unlock_treats_a_missing_passphrase_as_keys_locked() { let key = crate::crypto::generate_node_key().unwrap(); let (session, _) = session_serving(user_with_key(&key.locked_armored, 1)).await; let error = AccountClient::with_key_passphrases(&session, KeyPassphrases::new()) .unlock() .await .expect_err("no passphrase"); assert!(matches!(error, ProtonError::KeysLocked), "{error:?}"); } #[tokio::test] async fn the_key_report_traces_a_token_to_the_inactive_key_it_was_meant_for() { use crate::crypto::{PrivateKey, generate_node_key}; let active = generate_node_key().unwrap(); let inactive = generate_node_key().unwrap(); let (good, stranded) = (generate_node_key().unwrap(), generate_node_key().unwrap()); let token_for = |user_key: &crate::crypto::GeneratedNodeKey, passphrase: &[u8]| { PrivateKey::from_armored(&user_key.locked_armored, &user_key.passphrase) .unwrap() .encrypt(passphrase) .unwrap() }; // Both answers in one body: each request reads only its own field. let body = serde_json::json!({ "Code": 1000, "User": { "ID": "user-id", "MaxSpace": 1, "UsedSpace": 0, "Keys": [ { "ID": "key-1", "PrivateKey": active.locked_armored, "Primary": 1, "Active": 1 }, { "ID": "key-2", "PrivateKey": inactive.locked_armored, "Primary": 0, "Active": 0 } ] }, "Addresses": [{ "ID": "address-1", "Email": "user@proton.me", "Status": 1, "Order": 1, "Keys": [ { "ID": "address-key-1", "PrivateKey": good.locked_armored, "Token": token_for(&active, &good.passphrase), "Signature": "signature", "Primary": 1, "Active": 1 }, { "ID": "address-key-2", "PrivateKey": stranded.locked_armored, "Token": token_for(&inactive, &stranded.passphrase), "Signature": "signature", "Primary": 0, "Active": 1 } ] }] }); let (session, _) = session_serving(body.to_string()).await; let passphrases = KeyPassphrases::from_iter([ ("key-1", active.passphrase.clone()), ("key-2", inactive.passphrase.clone()), ]); let report = AccountClient::with_key_passphrases(&session, passphrases) .key_report() .await .unwrap(); assert!(report.user_keys.iter().all(|key| key.unlocks)); let keys = &report.addresses[0].keys; assert!(keys[0].unlocks && keys[0].error.is_none()); // The report tries the token with every key that opens, as the unlock // path does. assert!(keys[1].unlocks && keys[1].error.is_none()); let recipients = keys[1].token_recipients.as_ref().unwrap(); let meant_for = report .user_key_named(recipients[0].as_deref().unwrap()) .unwrap(); assert_eq!(meant_for.id.as_str(), "key-2"); assert!(!meant_for.active); } /// A body answering both `core/v4/users` and `core/v4/addresses`: one /// active account key and one inactive one, and `addresses` as given. fn account_with( active: &crate::crypto::GeneratedNodeKey, inactive: &crate::crypto::GeneratedNodeKey, addresses: serde_json::Value, ) -> String { serde_json::json!({ "Code": 1000, "User": { "ID": "user-id", "MaxSpace": 1, "UsedSpace": 0, "Keys": [ { "ID": "key-1", "PrivateKey": active.locked_armored, "Primary": 1, "Active": 1 }, { "ID": "key-2", "PrivateKey": inactive.locked_armored, "Primary": 0, "Active": 0 } ] }, "Addresses": addresses }) .to_string() } /// An address key entry whose token, for `user_key`, carries the /// passphrase of `address_key`. fn address_key( id: &str, primary: bool, user_key: &crate::crypto::GeneratedNodeKey, address_key: &crate::crypto::GeneratedNodeKey, ) -> serde_json::Value { let token = PrivateKey::from_armored(&user_key.locked_armored, &user_key.passphrase) .unwrap() .encrypt(&address_key.passphrase) .unwrap(); serde_json::json!({ "ID": id, "PrivateKey": address_key.locked_armored, "Token": token, "Signature": "signature", "Primary": i32::from(primary), "Active": 1 }) } fn both_passphrases( active: &crate::crypto::GeneratedNodeKey, inactive: &crate::crypto::GeneratedNodeKey, ) -> KeyPassphrases { KeyPassphrases::from_iter([ ("key-1", active.passphrase.clone()), ("key-2", inactive.passphrase.clone()), ]) } #[tokio::test] async fn an_address_key_whose_token_is_for_an_inactive_account_key_opens() { use crate::crypto::generate_node_key; let (active, inactive) = (generate_node_key().unwrap(), generate_node_key().unwrap()); let (first, second) = (generate_node_key().unwrap(), generate_node_key().unwrap()); let body = account_with( &active, &inactive, serde_json::json!([{ "ID": "address-1", "Email": "user@proton.me", "Status": 1, "Order": 1, "Keys": [ address_key("address-key-1", true, &inactive, &first), address_key("address-key-2", false, &active, &second) ] }]), ); let (session, _) = session_serving(body).await; let client = AccountClient::with_key_passphrases(&session, both_passphrases(&active, &inactive)); let address = client.default_address().await.expect("primary key opens"); assert_eq!(address.primary_key_id.as_str(), "address-key-1"); let keys = client.address_private_keys(&address.id).await.unwrap(); assert_eq!(keys.len(), 2); } #[tokio::test] async fn an_address_whose_keys_do_not_open_leaves_the_others_working() { use crate::crypto::generate_node_key; let (active, inactive) = (generate_node_key().unwrap(), generate_node_key().unwrap()); let (good, broken, stranger) = ( generate_node_key().unwrap(), generate_node_key().unwrap(), generate_node_key().unwrap(), ); // The broken address's only key has a token for a key not on the account. let body = account_with( &active, &inactive, serde_json::json!([ { "ID": "address-broken", "Email": "old@proton.me", "Status": 1, "Order": 1, "Keys": [address_key("address-key-b", true, &stranger, &broken)] }, { "ID": "address-good", "Email": "user@proton.me", "Status": 1, "Order": 2, "Keys": [address_key("address-key-g", true, &active, &good)] } ]), ); let (session, _) = session_serving(body).await; let client = AccountClient::with_key_passphrases(&session, both_passphrases(&active, &inactive)); let addresses = client.addresses().await.expect("one address opens"); assert_eq!(addresses.len(), 1); assert_eq!(addresses[0].id.as_str(), "address-good"); let good_id = addresses[0].id.clone(); assert_eq!( client.address_private_keys(&good_id).await.unwrap().len(), 1 ); let broken_id: AddressId = "address-broken".to_owned().into(); for error in [ client .address(&broken_id) .await .expect_err("broken") .to_string(), match client.address_private_keys(&broken_id).await { Ok(_) => panic!("broken address opened"), Err(e) => e.to_string(), }, // The broken address is the default, so nothing is signed with // another one in its place. client .default_address() .await .expect_err("default is broken") .to_string(), ] { assert!(error.contains("address-broken"), "{error}"); } } #[tokio::test] async fn an_account_whose_addresses_all_fail_to_open_is_an_error() { use crate::crypto::generate_node_key; let (active, inactive) = (generate_node_key().unwrap(), generate_node_key().unwrap()); let (broken, stranger) = (generate_node_key().unwrap(), generate_node_key().unwrap()); let body = account_with( &active, &inactive, serde_json::json!([{ "ID": "address-broken", "Email": "old@proton.me", "Status": 1, "Order": 1, "Keys": [address_key("address-key-b", true, &stranger, &broken)] }]), ); let (session, _) = session_serving(body).await; let client = AccountClient::with_key_passphrases(&session, both_passphrases(&active, &inactive)); let error = client.addresses().await.expect_err("nothing opens"); assert!( matches!(error, ProtonError::InvalidOperation(_)), "{error:?}" ); } #[tokio::test] async fn an_account_without_an_active_key_is_not_a_password_problem() { let key = crate::crypto::generate_node_key().unwrap(); let (session, _) = session_serving(user_with_key(&key.locked_armored, 0)).await; let passphrases = KeyPassphrases::from_iter([("key-1", key.passphrase.clone())]); let error = AccountClient::with_key_passphrases(&session, passphrases) .unlock() .await .expect_err("no active key"); assert!( matches!(error, ProtonError::InvalidOperation(_)), "{error:?}" ); } }