mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
155bf23615
commit
bd27f7862b
6 files changed
+326
-4
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user