feat: merge OpenPGP certificates and distribute relays in them

We put all relay addresses as a notation subpacket
in the direct key signature to distribute the relay addresses.
This commit is contained in:
link2xt
2026-03-02 03:56:16 +00:00
committed by l
parent 54858361a9
commit 11b6a108f5
12 changed files with 571 additions and 113 deletions

View File

@@ -3,23 +3,25 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{BufRead, Cursor};
use anyhow::{Context as _, Result};
use deltachat_contact_tools::EmailAddress;
use anyhow::{Context as _, Result, ensure};
use deltachat_contact_tools::{EmailAddress, may_be_valid_addr};
use pgp::armor::BlockType;
use pgp::composed::{
ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType as PgpKeyType,
Message, MessageBuilder, SecretKeyParamsBuilder, SignedPublicKey, SignedPublicSubKey,
SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
Message, MessageBuilder, SecretKeyParamsBuilder, SignedKeyDetails, SignedPublicKey,
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
};
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::{SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::packet::{Signature, SignatureConfig, SignatureType, Subpacket, SubpacketData};
use pgp::types::{
CompressionAlgorithm, KeyDetails, KeyVersion, Password, SigningKey as _, StringToKey,
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
StringToKey,
};
use rand_old::{Rng as _, thread_rng};
use sha2::Sha256;
use tokio::runtime::Handle;
use crate::key::{DcKey, Fingerprint};
@@ -420,6 +422,166 @@ pub async fn symm_decrypt<T: BufRead + std::fmt::Debug + 'static + Send>(
.await?
}
/// Merges and minimizes OpenPGP certificates.
///
/// Keeps at most one direct key signature and
/// at most one User ID with exactly one signature.
///
/// See <https://openpgp.dev/book/adv/certificates.html#merging>
/// and <https://openpgp.dev/book/adv/certificates.html#certificate-minimization>.
///
/// `new_certificate` does not necessarily contain newer data.
/// It may come not directly from the key owner,
/// e.g. via protected Autocrypt header or protected attachment
/// in a signed message, but from Autocrypt-Gossip header or a vCard.
/// Gossiped key may be older than the one we have
/// or even have some packets maliciously dropped
/// (for example, all encryption subkeys dropped)
/// or restored from some older version of the certificate.
pub fn merge_openpgp_certificates(
old_certificate: SignedPublicKey,
new_certificate: SignedPublicKey,
) -> Result<SignedPublicKey> {
old_certificate
.verify_bindings()
.context("First key cannot be verified")?;
new_certificate
.verify_bindings()
.context("Second key cannot be verified")?;
// Decompose certificates.
let SignedPublicKey {
primary_key: old_primary_key,
details: old_details,
public_subkeys: old_public_subkeys,
} = old_certificate;
let SignedPublicKey {
primary_key: new_primary_key,
details: new_details,
public_subkeys: _new_public_subkeys,
} = new_certificate;
// Public keys may be serialized differently, e.g. using old and new packet type,
// so we compare imprints instead of comparing the keys
// directly with `old_primary_key == new_primary_key`.
// Imprints, like fingerprints, are calculated over normalized packets.
// On error we print fingerprints as this is what is used in the database
// and what most tools show.
let old_imprint = old_primary_key.imprint::<Sha256>()?;
let new_imprint = new_primary_key.imprint::<Sha256>()?;
ensure!(
old_imprint == new_imprint,
"Cannot merge certificates with different primary keys {} and {}",
old_primary_key.fingerprint(),
new_primary_key.fingerprint()
);
// Decompose old and the new key details.
//
// Revocation signatures are currently ignored so we do not store them.
//
// User attributes are thrown away on purpose,
// the only defined in RFC 9580 attribute is the Image Attribute
// (<https://www.rfc-editor.org/rfc/rfc9580.html#section-5.12.1>
// which we do not use and do not want to gossip.
let SignedKeyDetails {
revocation_signatures: _old_revocation_signatures,
direct_signatures: old_direct_signatures,
users: old_users,
user_attributes: _old_user_attributes,
} = old_details;
let SignedKeyDetails {
revocation_signatures: _new_revocation_signatures,
direct_signatures: new_direct_signatures,
users: new_users,
user_attributes: _new_user_attributes,
} = new_details;
// Select at most one direct key signature, the newest one.
let best_direct_key_signature: Option<Signature> = old_direct_signatures
.into_iter()
.chain(new_direct_signatures)
.filter(|x: &Signature| x.verify_key(&old_primary_key).is_ok())
.max_by_key(|x: &Signature|
// Converting to seconds because `Ord` is not derived for `Timestamp`:
// <https://github.com/rpgp/rpgp/issues/737>
x.created().map_or(0, |ts| ts.as_secs()));
let direct_signatures: Vec<Signature> = best_direct_key_signature.into_iter().collect();
// Select at most one User ID.
//
// We prefer User IDs marked as primary,
// but will select non-primary otherwise
// because sometimes keys have no primary User ID,
// such as Alice's key in `test-data/key/alice-secret.asc`.
let best_user: Option<SignedUser> = old_users
.into_iter()
.chain(new_users.clone())
.filter_map(|SignedUser { id, signatures }| {
// Select the best signature for each User ID.
// If User ID has no valid signatures, it is filtered out.
let best_user_signature: Option<Signature> = signatures
.into_iter()
.filter(|signature: &Signature| {
signature
.verify_certification(&old_primary_key, pgp::types::Tag::UserId, &id)
.is_ok()
})
.max_by_key(|signature: &Signature| {
signature.created().map_or(0, |ts| ts.as_secs())
});
best_user_signature.map(|signature| (id, signature))
})
.max_by_key(|(_id, signature)| signature.created().map_or(0, |ts| ts.as_secs()))
.map(|(id, signature)| SignedUser {
id,
signatures: vec![signature],
});
let users: Vec<SignedUser> = best_user.into_iter().collect();
let public_subkeys = old_public_subkeys;
Ok(SignedPublicKey {
primary_key: old_primary_key,
details: SignedKeyDetails {
revocation_signatures: vec![],
direct_signatures,
users,
user_attributes: vec![],
},
public_subkeys,
})
}
/// Returns relays addresses from the public key signature.
///
/// Not more than 3 relays are returned for each key.
pub(crate) fn addresses_from_public_key(public_key: &SignedPublicKey) -> Option<Vec<String>> {
for signature in &public_key.details.direct_signatures {
// The signature should be verified already when importing the key,
// but we double-check here.
let signature_is_valid = signature.verify_key(&public_key.primary_key).is_ok();
debug_assert!(signature_is_valid);
if signature_is_valid {
for notation in signature.notations() {
if notation.name == "relays@chatmail.at"
&& let Ok(value) = str::from_utf8(&notation.value)
{
return Some(
value
.split(",")
.map(|s| s.to_string())
.filter(|s| may_be_valid_addr(s))
.take(3)
.collect(),
);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use std::sync::LazyLock;
@@ -818,4 +980,24 @@ mod tests {
}
Ok(())
}
#[test]
fn test_merge_openpgp_certificates() {
let alice = alice_keypair().to_public_key();
let bob = bob_keypair().to_public_key();
// Merging certificate with itself does not change it.
assert_eq!(
merge_openpgp_certificates(alice.clone(), alice.clone()).unwrap(),
alice
);
assert_eq!(
merge_openpgp_certificates(bob.clone(), bob.clone()).unwrap(),
bob
);
// Cannot merge certificates with different primary key.
assert!(merge_openpgp_certificates(alice.clone(), bob.clone()).is_err());
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
}
}