diff --git a/crates/proton-sdk/src/account.rs b/crates/proton-sdk/src/account.rs index 71966b5..8d701de 100644 --- a/crates/proton-sdk/src/account.rs +++ b/crates/proton-sdk/src/account.rs @@ -17,13 +17,16 @@ use std::sync::Arc; use tokio::sync::Mutex; -use crate::crypto::{self, PrivateKey}; +use crate::crypto::{self, PrivateKey, PublicKey}; use crate::error::{ProtonError, Result}; use crate::http::ApiHttpClient; use crate::ids::AddressId; use crate::session::ProtonApiSession; -use dtos::{AddressDto, AddressListResponse, KeySaltListResponse, UserResponse}; +use dtos::{ + AddressDto, AddressListResponse, AddressPublicKeyListResponse, KeySaltListResponse, + UserResponse, +}; /// Public view of an email address attached to the account. #[derive(Debug, Clone)] @@ -58,6 +61,8 @@ struct Cache { user_keys: Option>, addresses: Option>, address_keys: HashMap>, + /// email → active (non-compromised) public keys, for authorship verification. + public_keys: HashMap>, } impl AccountClient { @@ -132,6 +137,61 @@ impl AccountClient { }) } + /// 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 { + { + 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::(&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::>(), + 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 + } + /// Decrypted user (account) keys. async fn user_keys(&self) -> Result> { { @@ -265,3 +325,18 @@ fn decode_base64(value: &str) -> Result> { .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 +} diff --git a/crates/proton-sdk/src/account/dtos.rs b/crates/proton-sdk/src/account/dtos.rs index 9d783fe..0763960 100644 --- a/crates/proton-sdk/src/account/dtos.rs +++ b/crates/proton-sdk/src/account/dtos.rs @@ -102,3 +102,34 @@ pub(super) struct KeySalt { #[serde(rename = "KeySalt")] pub value: Option, } + +/// Response for `core/v4/keys/all` — the active public keys for an email +/// address. Mirrors C# `AddressPublicKeyListResponse`. +#[derive(Debug, Deserialize)] +pub(super) struct AddressPublicKeyListResponse { + #[serde(rename = "Address")] + pub address: PublicKeyListAddress, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PublicKeyListAddress { + #[serde(rename = "Keys")] + pub keys: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PublicKeyEntry { + /// Status flags; bit `1` = not compromised, bit `2` = not obsolete. + #[serde(rename = "Flags")] + pub flags: i32, + #[serde(rename = "PublicKey")] + pub public_key: String, +} + +impl PublicKeyEntry { + /// Bit `1` of `Flags` (C# `PublicKeyStatus.IsNotCompromised`): only + /// non-compromised keys are used to verify authorship. + pub fn is_not_compromised(&self) -> bool { + self.flags & 1 != 0 + } +}