From bd27f7862bdb574c8fccb9332f34e38737399ca2 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 17 Oct 2026 12:13:27 +0200 Subject: [PATCH] feat(account,drive): report which keys open and which key each token is for AccountClient::key_report walks the user and address keys like the unlock path, but records each step instead of skipping what fails: whether each user key opens (inactive ones included), and the recipients of each address key token. ProtonDriveClient:: my_files_share_recipients names the keys the My Files share passphrase is encrypted to without decrypting anything. Together they trace a "missing key" to the key it was meant for (proton-drive-linux#40). crypto::recipients and crypto::key_ids read OpenPGP identifiers from messages and keys, so a recipient can be matched to a key. --- crates/proton-drive-rs/src/client.rs | 12 ++ crates/proton-sdk/src/account.rs | 61 ++++++++ crates/proton-sdk/src/account/report.rs | 172 +++++++++++++++++++++++ crates/proton-sdk/src/crypto.rs | 4 +- crates/proton-sdk/src/crypto/keys.rs | 44 +++++- crates/proton-sdk/src/crypto/messages.rs | 37 ++++- 6 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 crates/proton-sdk/src/account/report.rs diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 34a63cd..ef58fb1 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -555,6 +555,18 @@ impl ProtonDriveClient { &self.account } + /// The address the My Files share belongs to, and the keys its passphrase + /// is encrypted to (see [`proton_sdk::crypto::recipients`]). Decrypts + /// nothing and creates nothing, so it answers even when the share key does + /// not open: matched against + /// [`AccountClient::key_report`](proton_sdk::account::AccountClient::key_report), + /// it names the address key My Files needs. + pub async fn my_files_share_recipients(&self) -> Result<(AddressId, Vec>)> { + let response: MyFilesShareResponse = self.http.get("v2/shares/my-files").await?; + let recipients = proton_sdk::crypto::recipients(&response.share.passphrase)?; + Ok((response.share.address_id, recipients)) + } + /// Total account storage usage (`MaxSpace`/`UsedSpace`), account-wide across /// all Proton products. Convenience passthrough to /// [`AccountClient::quota`](proton_sdk::account::AccountClient::quota). diff --git a/crates/proton-sdk/src/account.rs b/crates/proton-sdk/src/account.rs index 969809d..5dca44a 100644 --- a/crates/proton-sdk/src/account.rs +++ b/crates/proton-sdk/src/account.rs @@ -12,8 +12,10 @@ 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; @@ -621,6 +623,65 @@ mod tests { 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()); + assert!(!keys[1].unlocks); + assert!(keys[1].error.as_deref().unwrap().contains("missing key")); + 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); + } + #[tokio::test] async fn an_account_without_an_active_key_is_not_a_password_problem() { let key = crate::crypto::generate_node_key().unwrap(); diff --git a/crates/proton-sdk/src/account/report.rs b/crates/proton-sdk/src/account/report.rs new file mode 100644 index 0000000..5fe52d2 --- /dev/null +++ b/crates/proton-sdk/src/account/report.rs @@ -0,0 +1,172 @@ +//! A report on the account's key chain, for diagnosing keys that do not open. +//! +//! [`AccountClient::key_report`] walks the same chain as the unlock path in the +//! parent module, but records every step instead of skipping what fails: each +//! user key and whether the client's passphrases open it, and for each address +//! key the keys its token is encrypted to. A token that fails with "missing +//! key" can then be traced to the user key it was meant for. + +use crate::crypto::{self, PrivateKey}; +use crate::error::Result; +use crate::ids::{AddressId, AddressKeyId, UserKeyId}; + +use super::AccountClient; +use super::dtos::{AddressListResponse, UserResponse}; + +/// The account's keys as this client sees them. Holds identifiers and flags +/// only, never key material, so it is safe to show and to share. +#[derive(Debug, Clone)] +pub struct KeyReport { + pub user_keys: Vec, + pub addresses: Vec, +} + +/// One user (account) key. +#[derive(Debug, Clone)] +pub struct UserKeyReport { + pub id: UserKeyId, + pub primary: bool, + /// `Active` from the server. The unlock path uses active keys only. + pub active: bool, + /// Whether the client's passphrase for this key opens it. Tried for + /// inactive keys too, which the unlock path never does. + pub unlocks: bool, + /// Why the key does not open, when it does not. + pub error: Option, + /// The key's OpenPGP identifiers, see [`crypto::key_ids`]. + pub key_ids: Vec, +} + +/// One address and its keys. +#[derive(Debug, Clone)] +pub struct AddressReport { + pub id: AddressId, + pub email: String, + pub keys: Vec, +} + +/// One address key. +#[derive(Debug, Clone)] +pub struct AddressKeyReport { + pub id: AddressKeyId, + pub primary: bool, + pub active: bool, + /// The keys the token is encrypted to, see [`crypto::recipients`]. `None` + /// for a key without a token, whose passphrase comes from the password. + pub token_recipients: Option>>, + /// Whether the key opens the way the unlock path opens it: through its + /// token with the active user keys that open, or through the password. + pub unlocks: bool, + /// Why the key does not open, when it does not. + pub error: Option, + /// The key's OpenPGP identifiers, see [`crypto::key_ids`]. + pub key_ids: Vec, +} + +impl KeyReport { + /// The user key a message recipient (from [`crypto::recipients`]) names. + pub fn user_key_named(&self, recipient: &str) -> Option<&UserKeyReport> { + self.user_keys + .iter() + .find(|key| key.key_ids.iter().any(|id| id == recipient)) + } + + /// The address key a message recipient names, with its address. + pub fn address_key_named( + &self, + recipient: &str, + ) -> Option<(&AddressReport, &AddressKeyReport)> { + self.addresses.iter().find_map(|address| { + address + .keys + .iter() + .find(|key| key.key_ids.iter().any(|id| id == recipient)) + .map(|key| (address, key)) + }) + } +} + +impl AccountClient { + /// Report on every user and address key, see [`KeyReport`]. + /// + /// Always asks the server (`core/v4/users`, `core/v4/addresses`) and caches + /// nothing, so it reflects the account as it is now. Fails only when the + /// server cannot be reached or the passphrases cannot be derived; a key + /// that does not open is part of the report, not an error. + pub async fn key_report(&self) -> Result { + let passphrases = self.key_passphrases().await?; + let user: UserResponse = self.inner.http.get("core/v4/users").await?; + + let mut user_keys = Vec::with_capacity(user.user.keys.len()); + let mut active_unlocked = Vec::new(); + for dto in &user.user.keys { + let unlocked = match passphrases.get(dto.id.as_str()) { + Some(passphrase) => PrivateKey::from_armored(&dto.private_key, passphrase) + .map_err(|e| e.to_string()), + None => Err("no passphrase for this key".to_string()), + }; + let (unlocks, error) = match unlocked { + Ok(key) => { + if dto.is_active() { + active_unlocked.push(key); + } + (true, None) + } + Err(e) => (false, Some(e)), + }; + user_keys.push(UserKeyReport { + id: dto.id.clone(), + primary: dto.primary != 0, + active: dto.is_active(), + unlocks, + error, + key_ids: crypto::key_ids(&dto.private_key).unwrap_or_default(), + }); + } + + let response: AddressListResponse = self.inner.http.get("core/v4/addresses").await?; + let mut addresses = Vec::with_capacity(response.addresses.len()); + for dto in &response.addresses { + let mut keys = Vec::with_capacity(dto.keys.len()); + for key_dto in &dto.keys { + let passphrase = match (&key_dto.token, &key_dto.signature) { + (Some(token), Some(_signature)) => { + crypto::decrypt_armored_with_keys(token, &active_unlocked) + .map_err(|e| format!("token: {e}")) + } + _ => passphrases + .get(key_dto.id.as_str()) + .map(<[u8]>::to_vec) + .ok_or_else(|| "no passphrase for this key".to_string()), + }; + let unlocked = passphrase.and_then(|passphrase| { + PrivateKey::from_armored(&key_dto.private_key, &passphrase) + .map(drop) + .map_err(|e| e.to_string()) + }); + keys.push(AddressKeyReport { + id: key_dto.id.clone(), + primary: key_dto.is_primary(), + active: key_dto.is_active(), + token_recipients: key_dto + .token + .as_deref() + .map(|token| crypto::recipients(token).unwrap_or_default()), + unlocks: unlocked.is_ok(), + error: unlocked.err(), + key_ids: crypto::key_ids(&key_dto.private_key).unwrap_or_default(), + }); + } + addresses.push(AddressReport { + id: dto.id.clone(), + email: dto.email.clone(), + keys, + }); + } + + Ok(KeyReport { + user_keys, + addresses, + }) + } +} diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs index 1322b3f..7df5aca 100644 --- a/crates/proton-sdk/src/crypto.rs +++ b/crates/proton-sdk/src/crypto.rs @@ -23,8 +23,8 @@ pub use encrypt::{ encrypt_invitation, generate_node_hash_key, generate_node_key, generate_node_key_aead, }; pub use errors::CryptoError; -pub use keys::{PrivateKey, decrypt_armored_with_keys}; -pub use messages::decrypt_armored_with_password; +pub use keys::{PrivateKey, decrypt_armored_with_keys, key_ids}; +pub use messages::{decrypt_armored_with_password, recipients}; pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier}; pub use verify::{ PublicKey, VerificationKeyRing, VerificationStatus, verify_detached, diff --git a/crates/proton-sdk/src/crypto/keys.rs b/crates/proton-sdk/src/crypto/keys.rs index 6d5da4f..008d62b 100644 --- a/crates/proton-sdk/src/crypto/keys.rs +++ b/crates/proton-sdk/src/crypto/keys.rs @@ -7,7 +7,7 @@ //! re-run the key's S2K derivation. use pgp::composed::{Deserializable, DetachedSignature, SignedSecretKey}; -use pgp::types::Password; +use pgp::types::{KeyDetails, Password}; use super::errors::CryptoError; use super::messages; @@ -138,6 +138,23 @@ impl PrivateKey { } } +/// Every identifier a message's recipient can name this armored key by, as +/// lowercase hex: the key ID and the fingerprint of the primary key and of +/// each subkey. Reads only the public parts, so no passphrase is needed. +pub fn key_ids(armored: &str) -> Result, CryptoError> { + let (key, _headers) = SignedSecretKey::from_string(armored) + .map_err(|e| CryptoError::Parse(format!("secret key: {e}")))?; + let mut ids = vec![ + key.primary_key.legacy_key_id().to_string(), + format!("{:x}", key.primary_key.fingerprint()), + ]; + for subkey in &key.secret_subkeys { + ids.push(subkey.key.legacy_key_id().to_string()); + ids.push(format!("{:x}", subkey.key.fingerprint())); + } + Ok(ids) +} + /// Decrypt an armored message, trying each key in `keys` until one succeeds. pub fn decrypt_armored_with_keys( armored: &str, @@ -158,6 +175,31 @@ mod tests { use super::*; use crate::crypto::encrypt::generate_node_key; + #[test] + fn a_message_names_the_key_it_is_encrypted_to() { + let node = generate_node_key().expect("generate node key"); + let other = generate_node_key().expect("generate node key"); + let key = PrivateKey::from_armored(&node.locked_armored, &node.passphrase).unwrap(); + let message = key.encrypt(b"token").unwrap(); + + let recipients = super::super::recipients(&message).unwrap(); + assert_eq!(recipients.len(), 1); + let recipient = recipients[0].as_deref().expect("a named recipient"); + // Read from the locked key: no passphrase is needed to name it. + assert!( + key_ids(&node.locked_armored) + .unwrap() + .iter() + .any(|id| id == recipient) + ); + assert!( + !key_ids(&other.locked_armored) + .unwrap() + .iter() + .any(|id| id == recipient) + ); + } + #[test] fn from_armored_rejects_a_wrong_passphrase() { // `from_armored` doubles as the passphrase check, so a key that does not diff --git a/crates/proton-sdk/src/crypto/messages.rs b/crates/proton-sdk/src/crypto/messages.rs index 50e07e0..765461b 100644 --- a/crates/proton-sdk/src/crypto/messages.rs +++ b/crates/proton-sdk/src/crypto/messages.rs @@ -1,6 +1,6 @@ //! PGP message decryption helpers. -use pgp::composed::{Message, SignedSecretKey}; +use pgp::composed::{Esk, Message, SignedSecretKey}; use pgp::types::Password; use super::errors::CryptoError; @@ -81,6 +81,35 @@ pub fn decrypt_armored_any( Err(last_err) } +/// The keys an armored message's session key is encrypted to, as lowercase +/// hex: the key ID of a v3 PKESK, the fingerprint of a v6 one. A hidden +/// recipient (a wildcard key ID, or a v6 PKESK without a fingerprint) is +/// `None`. Only the packet headers are read, so no key is needed. +/// +/// Matched against [`key_ids`](super::key_ids), this tells which key a +/// message that fails with "missing key" was meant for. +pub fn recipients(armored: &str) -> Result>, CryptoError> { + let (message, _headers) = + Message::from_string(armored).map_err(|e| CryptoError::Parse(format!("message: {e}")))?; + let Message::Encrypted { esk, .. } = &message else { + return Ok(Vec::new()); + }; + Ok(esk + .iter() + .filter_map(|esk| match esk { + Esk::PublicKeyEncryptedSessionKey(pkesk) => Some(match pkesk.fingerprint() { + Ok(fingerprint) => fingerprint.map(|f| format!("{f:x}")), + Err(_) => pkesk + .id() + .ok() + .filter(|id| !id.is_wildcard()) + .map(|id| id.to_string()), + }), + Esk::SymKeyEncryptedSessionKey(_) => None, + }) + .collect()) +} + /// Drain a decrypted message to bytes, transparently decompressing if needed. pub(crate) fn read_plaintext(mut message: Message<'_>) -> Result, CryptoError> { while message.is_compressed() { @@ -102,6 +131,12 @@ mod tests { use pgp::crypto::sym::SymmetricKeyAlgorithm; use pgp::types::{Password, StringToKey}; + #[test] + fn a_password_encrypted_message_has_no_key_recipients() { + let message = encrypt_armored_with_password(b"secret", b"password"); + assert!(super::recipients(&message).unwrap().is_empty()); + } + /// Build the shape Proton's `SharePassphrase` has: an armored, symmetrically /// encrypted message whose passphrase is the bcrypt-derived key password. fn encrypt_armored_with_password(plaintext: &[u8], password: &[u8]) -> String {