mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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.
948 lines
37 KiB
Rust
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:?}"
|
|
);
|
|
}
|
|
}
|