From d8d49cc219bf55defcc038bcbf4422b81c54d1c8 Mon Sep 17 00:00:00 2001 From: link2xt Date: Mon, 8 Jun 2026 15:05:38 +0200 Subject: [PATCH] feat: merging and minimization of subkeys --- src/e2ee.rs | 3 +- src/mimefactory.rs | 4 +- src/pgp.rs | 214 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 215 insertions(+), 6 deletions(-) diff --git a/src/e2ee.rs b/src/e2ee.rs index 0779da3b8..d6c8a192e 100644 --- a/src/e2ee.rs +++ b/src/e2ee.rs @@ -25,9 +25,10 @@ impl EncryptHelper { } pub fn get_aheader(&self) -> Aheader { + let public_key = pgp::minimize_autocrypt_certificate(&self.public_key); Aheader { addr: self.addr.clone(), - public_key: self.public_key.clone(), + public_key, prefer_encrypt: EncryptPreference::Mutual, verified: false, } diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 283713cbf..15a1703c9 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -1099,9 +1099,11 @@ impl MimeFactory { continue; } + let public_key = crate::pgp::minimize_autocrypt_certificate(&key); + let header = Aheader { addr: addr.clone(), - public_key: key.clone(), + public_key, // Autocrypt 1.1.0 specification says that // `prefer-encrypt` attribute SHOULD NOT be included. prefer_encrypt: EncryptPreference::NoPreference, diff --git a/src/pgp.rs b/src/pgp.rs index 4ecf95cfe..32178a0a7 100644 --- a/src/pgp.rs +++ b/src/pgp.rs @@ -1,7 +1,8 @@ //! OpenPGP helper module using [rPGP facilities](https://github.com/rpgp/rpgp). use std::cmp::Ordering; -use std::collections::{HashMap, HashSet}; +use std::collections::btree_map::Entry as BTreeMapEntry; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::Cursor; use anyhow::{Context as _, Result, ensure}; @@ -15,7 +16,7 @@ use pgp::crypto::aead::{AeadAlgorithm, ChunkSize}; use pgp::crypto::ecc_curve::ECCCurve; use pgp::crypto::hash::HashAlgorithm; use pgp::crypto::sym::SymmetricKeyAlgorithm; -use pgp::packet::{Signature, Subpacket, SubpacketData}; +use pgp::packet::{Signature, SignatureType, Subpacket, SubpacketData}; use pgp::types::{ CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _, StringToKey, @@ -346,6 +347,163 @@ pub async fn symm_encrypt_message( .await? } +/// Minimizes the signatures of a subkey. +/// +/// Keeps at most one subkey binding signature +/// and at most one revocation signature, +/// preferring the newest signatures. +/// +/// Subkey binding signature is kept +/// even if the revocation signature exists +/// because according to +/// +/// "Every subkey MUST have at least one Subkey Binding signature." +/// Distributing subkey with only a revocation signature +/// is not allowed according to the standard, +/// so we keep a subkey binding signature next to it +/// for interoperability. +/// +/// This function does not check if the signatures are valid. +/// Such properties should be validated when importing OpenPGP certificates. +fn minimize_subpacket_signatures(signatures: Vec) -> Vec { + let mut newest_revocation_signature: Option = None; + let mut newest_binding_signature: Option = None; + for signature in signatures { + let Some(config) = signature.config() else { + // Skip unknown signatures. + continue; + }; + match config.typ { + SignatureType::SubkeyBinding => { + if newest_binding_signature.as_ref().is_none_or(|s| { + s.created().map(|ts| ts.as_secs()) < signature.created().map(|ts| ts.as_secs()) + }) { + newest_binding_signature = Some(signature) + } + } + SignatureType::SubkeyRevocation => { + if newest_revocation_signature.as_ref().is_none_or(|s| { + s.created().map(|ts| ts.as_secs()) < signature.created().map(|ts| ts.as_secs()) + }) { + newest_revocation_signature = Some(signature) + } + } + _ => continue, + } + } + newest_revocation_signature + .into_iter() + .chain(newest_binding_signature) + .collect() +} + +/// Minimizes OpenPGP certificate for Autocrypt and Autocrypt-Gossip headers. +pub fn minimize_autocrypt_certificate(certificate: &SignedPublicKey) -> SignedPublicKey { + let primary_key = certificate.primary_key.clone(); + let details = certificate.details.clone(); + + // Select the newest non-expiring subkey and the newest expiring subkey. + let fallback_subkey = certificate + .public_subkeys + .iter() + .filter(|subkey| { + subkey + .signatures + .iter() + .find(|signature| { + signature + .config() + .is_some_and(|config| config.typ == SignatureType::SubkeyBinding) + }) + .is_some_and(|signature| signature.key_expiration_time().is_none()) + }) + .max_by_key(|subkey| { + subkey + .signatures + .iter() + .find(|signature| { + signature + .config() + .is_some_and(|config| config.typ == SignatureType::SubkeyBinding) + }) + .map_or(0, |signature| { + signature.created().unwrap_or(subkey.created_at()).as_secs() + }) + }); + let rotating_subkey = certificate + .public_subkeys + .iter() + .filter(|subkey| { + subkey + .signatures + .iter() + .find(|signature| { + signature + .config() + .is_some_and(|config| config.typ == SignatureType::SubkeyBinding) + }) + .is_some_and(|signature| signature.key_expiration_time().is_some()) + }) + .max_by_key(|subkey| { + subkey + .signatures + .iter() + .find(|signature| { + signature + .config() + .is_some_and(|config| config.typ == SignatureType::SubkeyBinding) + }) + .map_or(0, |signature| { + signature.created().unwrap_or(subkey.created_at()).as_secs() + }) + }); + let public_subkeys: Vec<_> = fallback_subkey + .into_iter() + .chain(rotating_subkey) + .cloned() + .collect(); + + // We do not want to ever gossip more than two subkeys + // to save the traffic. + debug_assert!(public_subkeys.len() <= 2); + + SignedPublicKey { + primary_key, + details, + public_subkeys, + } +} + +/// Merges two OpenPGP subkeys. +fn merge_openpgp_subkey(old_subkey: &mut SignedPublicSubKey, new_subkey: SignedPublicSubKey) { + debug_assert_eq!(old_subkey.fingerprint(), new_subkey.fingerprint()); + old_subkey.signatures = minimize_subpacket_signatures( + std::mem::take(&mut old_subkey.signatures) + .into_iter() + .chain(new_subkey.signatures) + .collect(), + ); +} + +/// Merges OpenPGP subkey vectors. +pub fn merge_openpgp_subkeys( + subkeys: impl IntoIterator, +) -> Result> { + let mut merged_subkeys: BTreeMap<_, SignedPublicSubKey> = BTreeMap::new(); + for subkey in subkeys { + let imprint = subkey.imprint::()?; + match merged_subkeys.entry(imprint) { + BTreeMapEntry::Vacant(entry) => { + entry.insert(subkey); + } + BTreeMapEntry::Occupied(entry) => { + merge_openpgp_subkey(entry.into_mut(), subkey); + } + } + } + Ok(merged_subkeys.into_values().collect()) +} + /// Merges and minimizes OpenPGP certificates. /// /// Keeps at most one direct key signature and @@ -382,7 +540,7 @@ pub fn merge_openpgp_certificates( let SignedPublicKey { primary_key: new_primary_key, details: new_details, - public_subkeys: _new_public_subkeys, + public_subkeys: new_public_subkeys, } = new_certificate; // Public keys may be serialized differently, e.g. using old and new packet type, @@ -463,7 +621,55 @@ pub fn merge_openpgp_certificates( }); let users: Vec = best_user.into_iter().collect(); - let public_subkeys = old_public_subkeys; + let (fallback_subkeys, mut rotating_subkeys): (Vec<_>, Vec<_>) = + merge_openpgp_subkeys(old_public_subkeys.into_iter().chain(new_public_subkeys))? + .into_iter() + .filter_map(|subkey| { + // Select the newest subkey binding signature. + // + // There is at most one subkey binding signature at this point + // because older subkey binding signatures are removed during merging. + let signature = subkey.signatures.iter().find(|signature| { + signature + .config() + .is_some_and(|config| config.typ == SignatureType::SubkeyBinding) + })?; + + let created_at_secs = signature.created().unwrap_or(subkey.created_at()).as_secs(); + let expires_at_secs: Option = signature + .key_expiration_time() + .map(|duration| duration.as_secs()) + .filter(|duration_secs| *duration_secs != 0) + .map(|duration_secs| { + subkey.created_at().as_secs().saturating_add(duration_secs) + }); + + Some((subkey, created_at_secs, expires_at_secs)) + }) + .partition(|(_subkey, _created_at_secs, expires_at_secs)| expires_at_secs.is_none()); + let fallback_subkey: Option = fallback_subkeys + .into_iter() + .max_by_key(|(_subkey, created_at_secs, _)| *created_at_secs) + .map(|(subkey, _, _)| subkey); + + rotating_subkeys + .sort_by_key(|(_subkey, created_at_secs, _)| std::cmp::Reverse(*created_at_secs)); + + // Put the fallback subkey first so it is gossiped first. + // + // We want to always gossip non-expiring key first + // for older versions that always encrypted to the first subkey. + // + // Keep 10 newest rotating subkeys to avoid storing indefinitely growing number of subkeys locally. + let public_subkeys = fallback_subkey + .into_iter() + .chain( + rotating_subkeys + .into_iter() + .take(10) + .map(|(subkey, _, _)| subkey), + ) + .collect(); Ok(SignedPublicKey { primary_key: old_primary_key,