Files
proton-sdk-rs/crates/proton-sdk/src/account.rs
T
nvrl fe42f6c972 fix(account): open address tokens with inactive account keys, skip a broken address
An address key's token was decrypted with the active account keys only.
A token encrypted to an account key that is now inactive never opened,
although that key still opens with the password, so the address failed
with "has no primary key". proton-drive-linux issue #40 shows this as a
mount that retries "missing key" forever. Tokens are now tried with
every account key that opens, active ones first; signing still uses
active keys only. The key report walks the same chain.

addresses() also failed as a whole when one address did not open. That
address is now left out with a warning and the rest work; the call fails
only when none opens. address_private_keys and the new address(id) name
the reason for a left-out address, and default_address fails with it
when the left-out address is the default rather than signing with
another one.
2026-10-31 13:07:09 +01:00

948 lines
37 KiB
Rust

//! 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<Inner>,
}
struct Inner {
http: ApiHttpClient,
mailbox_password: Zeroizing<Vec<u8>>,
cache: Mutex<Cache>,
}
#[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<Vec<KeySalt>>,
/// key id → passphrase derived from the mailbox password and key salts.
key_passphrases: Option<KeyPassphrases>,
user_keys: Option<Vec<PrivateKey>>,
/// 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<Vec<PrivateKey>>,
addresses: Option<Vec<Address>>,
address_keys: HashMap<AddressId, Vec<PrivateKey>>,
/// Addresses whose keys did not open, with why. They are left out of
/// `addresses` so the others still work.
failed_addresses: HashMap<AddressId, String>,
/// Why the lowest-ordered address did not open, when it did not.
default_failed: Option<String>,
/// email → active (non-compromised) public keys, for authorship verification.
public_keys: HashMap<String, Vec<PublicKey>>,
}
impl AccountClient {
pub fn new(session: &ProtonApiSession, mailbox_password: impl Into<Vec<u8>>) -> 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<Vec<u8>>,
key_salts: Vec<KeySalt>,
) -> 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<Vec<KeySalt>> {
{
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<Vec<Address>> {
{
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<Address> {
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<Address> {
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<Vec<PrivateKey>> {
{
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<PublicKey> {
{
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::<AddressPublicKeyListResponse>(&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::<Vec<_>>(),
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<Quota> {
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<Vec<PrivateKey>> {
{
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<Vec<PrivateKey>> {
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<KeyPassphrases> {
{
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<PrivateKey>)> {
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<Vec<u8>> {
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<std::sync::atomic::AtomicUsize>,
) {
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:?}"
);
}
}