feat(sdk/account): support resolving public keys for authorship verification

This commit is contained in:
nvrl committed 2026-06-25 15:54:03 +02:00
1 parent edab8ff8e9
commit fdfab3df3f
2 files changed
+108 -2

No files matched your search

+77 -2
View File
@@ -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<Vec<PrivateKey>>,
addresses: Option<Vec<Address>>,
address_keys: HashMap<AddressId, Vec<PrivateKey>>,
/// email → active (non-compromised) public keys, for authorship verification.
public_keys: HashMap<String, Vec<PublicKey>>,
}
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<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
}
/// Decrypted user (account) keys.
async fn user_keys(&self) -> Result<Vec<PrivateKey>> {
{
@@ -265,3 +325,18 @@ fn decode_base64(value: &str) -> Result<Vec<u8>> {
.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
}
+31
View File
@@ -102,3 +102,34 @@ pub(super) struct KeySalt {
#[serde(rename = "KeySalt")]
pub value: Option<String>,
}
/// 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<PublicKeyEntry>,
}
#[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
}
}