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.
This commit is contained in:
nvrl committed 2026-10-17 12:13:27 +02:00
1 parent 155bf23615
commit bd27f7862b
6 files changed
+326 -4

No files matched your search

+61
View File
@@ -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();
+172
View File
@@ -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<UserKeyReport>,
pub addresses: Vec<AddressReport>,
}
/// 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<String>,
/// The key's OpenPGP identifiers, see [`crypto::key_ids`].
pub key_ids: Vec<String>,
}
/// One address and its keys.
#[derive(Debug, Clone)]
pub struct AddressReport {
pub id: AddressId,
pub email: String,
pub keys: Vec<AddressKeyReport>,
}
/// 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<Vec<Option<String>>>,
/// 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<String>,
/// The key's OpenPGP identifiers, see [`crypto::key_ids`].
pub key_ids: Vec<String>,
}
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<KeyReport> {
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,
})
}
}
+2 -2
View File
@@ -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,
+43 -1
View File
@@ -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<Vec<String>, 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
+36 -1
View File
@@ -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<Vec<Option<String>>, 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<Vec<u8>, 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 {