From 6a07a2b222e516dfb89a60756d08ee3755606435 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sat, 25 Jul 2026 21:08:52 +0200 Subject: [PATCH] feat: send Autocrypt pgp key in MDNs occassionally and when relaylist changes --- src/mimefactory.rs | 75 +++++++++++++++++++++++++--- src/mimefactory/mimefactory_tests.rs | 54 ++++++++++++++++++++ src/sql/migrations.rs | 14 ++++++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/src/mimefactory.rs b/src/mimefactory.rs index 3ee7f3592..72ecd3468 100644 --- a/src/mimefactory.rs +++ b/src/mimefactory.rs @@ -25,7 +25,7 @@ use crate::e2ee::EncryptHelper; use crate::ensure_and_debug_assert; use crate::ephemeral::Timer as EphemeralTimer; use crate::headerdef::HeaderDef; -use crate::key::{DcKey, SignedPublicKey, self_fingerprint}; +use crate::key::{DcKey, SignedPublicKey, load_self_public_key, self_fingerprint}; use crate::location; use crate::log::warn; use crate::message::{Message, MsgId, Viewtype}; @@ -619,11 +619,68 @@ impl MimeFactory { Ok(res) } - fn should_skip_autocrypt(&self) -> bool { - match &self.loaded { - Loaded::Message { .. } => false, - Loaded::Mdn { .. } => true, + /// Returns whether own Autocrypt key should be attached to this MDN + /// and if so, records the attachment. + /// + /// The key is attached to encrypted MDNs + /// once per `gossip_period` for each recipient + /// and immediately when own key gains a newer self-signature, + /// so that contacts we only read messages from + /// still learn our current key and relay list + /// and will likely re-gossip it to group chats. + async fn update_mdn_pubkey_attachment(&self, context: &Context) -> Result { + debug_assert!( + self.encryption_pubkeys + .as_deref() + .is_none_or(|keys| keys.len() <= 1), + "MDNs have at most one recipient key; own key is only added at encryption time" + ); + let Some([(_, key)]) = self.encryption_pubkeys.as_deref() else { + return Ok(false); + }; + let fingerprint = key.dc_fingerprint().hex(); + let self_key_created = load_self_public_key(context) + .await? + .details + .direct_signatures + .iter() + .filter_map(|sig| sig.created()) + .max() + .map_or(0, |created| i64::from(created.as_secs())); + let gossip_period = context.get_config_i64(Config::GossipPeriod).await?; + let now = time(); + let attached_timestamp: Option = context + .sql + .query_get_value( + "SELECT attached_timestamp FROM mdn_autocrypt_timestamp WHERE fingerprint=?", + (&fingerprint,), + ) + .await?; + + // Attach when our key gained a newer self-signature + // (e.g. relay addresses changed) or every `gossip_period`. + // If clocks are skewed, attach always. + let should_attach = attached_timestamp.is_none_or(|attached_timestamp| { + self_key_created > attached_timestamp + || now >= attached_timestamp.saturating_add(gossip_period) + || now < attached_timestamp + }); + if should_attach { + // We don't track or care if the MDN fails to be send or received + // because attaching a potentially fresh key is only best-effort + // and we want to keep the attach-key mechanism simple and localized. + context + .sql + .execute( + "INSERT INTO mdn_autocrypt_timestamp (fingerprint, attached_timestamp) + VALUES (?, ?) + ON CONFLICT (fingerprint) + DO UPDATE SET attached_timestamp=excluded.attached_timestamp", + (&fingerprint, now), + ) + .await?; } + Ok(should_attach) } fn should_attach_profile_data(msg: &Message) -> bool { @@ -945,11 +1002,13 @@ impl MimeFactory { } let grpimage = self.grpimage(); - let skip_autocrypt = self.should_skip_autocrypt(); + let should_attach_pubkey = match &self.loaded { + Loaded::Message { .. } => true, + Loaded::Mdn { .. } => self.update_mdn_pubkey_attachment(context).await?, + }; let encrypt_helper = EncryptHelper::new(context).await?; - if !skip_autocrypt { - // unless determined otherwise we add the Autocrypt header + if should_attach_pubkey { let aheader = encrypt_helper.get_aheader().to_string(); headers.push(( "Autocrypt", diff --git a/src/mimefactory/mimefactory_tests.rs b/src/mimefactory/mimefactory_tests.rs index 32ee79849..a508fa455 100644 --- a/src/mimefactory/mimefactory_tests.rs +++ b/src/mimefactory/mimefactory_tests.rs @@ -327,6 +327,60 @@ async fn test_mdn_create_encrypted() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_mdn_autocrypt_throttle() -> Result<()> { + async fn mdn_has_aheader( + bob: &TestContext, + alice: &TestContext, + rcvd: &Message, + ) -> Result { + let mf = MimeFactory::from_mdn(bob, rcvd.from_id, rcvd.rfc724_mid.clone(), vec![]).await?; + let rendered_msg = mf.render(bob).await?; + let mime = MimeMessage::from_bytes(alice, rendered_msg.message.as_bytes()).await?; + Ok(mime.autocrypt_fingerprint.is_some()) + } + + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + bob.set_config_bool(Config::MdnsEnabled, true).await?; + + let rcvd = tcm.send_recv_accept(&alice, &bob, "Heyho").await; + message::markseen_msgs(&bob, vec![rcvd.id]).await?; + + assert!(mdn_has_aheader(&bob, &alice, &rcvd).await?); + assert!(!mdn_has_aheader(&bob, &alice, &rcvd).await?); + + // Own key change forces the header: + // a relay list change bumps the transports timestamp + // which becomes the key signature timestamp, + // so drop the cached self key to re-derive it. + SystemTime::shift(Duration::from_secs(100)); + bob.sql + .execute("UPDATE transports SET add_timestamp=?", (time(),)) + .await?; + *bob.self_public_key.lock().await = None; + assert!(mdn_has_aheader(&bob, &alice, &rcvd).await?); + assert!(!mdn_has_aheader(&bob, &alice, &rcvd).await?); + + // A stored timestamp from the future is ignored + // and replaced by one from the current clock. + bob.sql + .execute( + "UPDATE mdn_autocrypt_timestamp SET attached_timestamp=?", + (time() + 1000,), + ) + .await?; + assert!(mdn_has_aheader(&bob, &alice, &rcvd).await?); + assert!(!mdn_has_aheader(&bob, &alice, &rcvd).await?); + + let gossip_period = bob.get_config_i64(Config::GossipPeriod).await?; + SystemTime::shift(Duration::from_secs(gossip_period.try_into()?)); + assert!(mdn_has_aheader(&bob, &alice, &rcvd).await?); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_subject_in_group() -> Result<()> { async fn send_msg_get_subject( diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index e0304bf70..f435cc2db 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -2520,6 +2520,20 @@ UPDATE msgs SET state=24 WHERE state=18; -- Change OutPreparing to OutFailed. .await?; } + inc_and_check(&mut migration_version, 160)?; + if dbversion < migration_version { + // Tracks when own key was last attached to an MDN + // so it is not attached to every MDN. + sql.execute_migration( + "CREATE TABLE mdn_autocrypt_timestamp ( + fingerprint TEXT PRIMARY KEY NOT NULL, -- Upper-case fingerprint of the recipient key. + attached_timestamp INTEGER NOT NULL + ) STRICT", + migration_version, + ) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await?