Compare commits

...
Author SHA1 Message Date
holger krekel a31f1441f8 feat: introduce keyupdate message to inform contacts about relay changes
When the published relay list changes,
a symmetrically encrypted keyupdate message carries the re-signed key,
and is broadcast to all non-blocked key-contacts from accepted chats.
The encryption secret is derived from the public primary key packet,
so every holder of a copy of that key can decrypt, forever.
For more details see src/keyupdate.rs module docs.
2026-08-16 22:56:03 +02:00
21 changed files with 723 additions and 110 deletions
@@ -430,6 +430,10 @@ pub enum SystemMessageType {
CallEnded,
MessagePinned,
MessageUnpinned,
/// Keyupdate message informing contacts about the current key and relay list.
/// Never shown in chats: receivers apply the key and then trash the message.
Keyupdate,
}
impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
@@ -461,6 +465,7 @@ impl From<deltachat::mimeparser::SystemMessage> for SystemMessageType {
SystemMessage::CallEnded => SystemMessageType::CallEnded,
SystemMessage::MessagePinned => SystemMessageType::MessagePinned,
SystemMessage::MessageUnpinned => SystemMessageType::MessageUnpinned,
SystemMessage::Keyupdate => SystemMessageType::Keyupdate,
}
}
}
+3
View File
@@ -366,6 +366,9 @@ pub enum Config {
/// Whether automatic relay management successfully added the desired number of relays
AutomaticRelayManagementFinished,
/// Sorted, comma-separated relay list for which no keyupdate is due.
KeyupdateBaseline,
/// Whether to avoid using IMAP IDLE even if the server supports it.
///
/// This is a developer option for testing "fake idle".
+12 -1
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::ops::Deref;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, AtomicI64};
use std::sync::{Arc, OnceLock, Weak};
use std::time::Duration;
@@ -332,6 +332,10 @@ pub struct InnerContext {
/// `Connectivity` values for published relays, unordered. Used to compute the aggregate connectivity,
/// see [`Context::get_connectivity()`].
pub(crate) published_connectivities: parking_lot::Mutex<Vec<ConnectivityStore>>,
/// Timestamp after which the SMTP loop checks for a keyupdate to send, or 0 if none is due.
/// Not persistent: losing it only delays the check.
pub(crate) keyupdate_check_deadline: AtomicI64,
}
/// The state of ongoing process.
@@ -509,6 +513,7 @@ impl Context {
self_fingerprint: OnceLock::new(),
self_public_key: Mutex::new(None),
published_connectivities: parking_lot::Mutex::new(Vec::new()),
keyupdate_check_deadline: AtomicI64::new(0),
};
let ctx = Context {
@@ -1067,6 +1072,12 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"keyupdate_baseline",
self.get_config(Config::KeyupdateBaseline)
.await?
.unwrap_or_default(),
);
let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
+37 -3
View File
@@ -22,8 +22,8 @@ use crate::chat::ChatId;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::key::self_fingerprint;
use crate::key::{Fingerprint, SignedPublicKey, load_self_secret_keyring};
use crate::key::{DcKey, Fingerprint, SignedPublicKey, load_self_secret_keyring, self_fingerprint};
use crate::pgp::keyupdate_secret;
use crate::token::Namespace;
/// Tries to decrypt the message,
@@ -135,7 +135,7 @@ async fn decrypt_session_key_symmetrically(
return Ok((plain_session_key, fingerprint));
}
// Finally, try decrypting using own AUTH tokens
// Then, try decrypting using own AUTH tokens
// There can be a lot of AUTH tokens,
// because a new one is generated every time a QR code is shown
let res: Option<PlainSessionKey> = try_decrypt_with_auth_token(esk, conn, self_fp)?;
@@ -143,6 +143,11 @@ async fn decrypt_session_key_symmetrically(
return Ok((plain_session_key, None));
}
// Finally, try decrypting using the keyupdate secrets of the key-contacts
if let Some((psk, fingerprint)) = try_decrypt_with_keyupdate_secret(esk, conn)? {
return Ok((psk, Some(fingerprint)));
}
bail!("Could not find symmetric secret for session key")
})
.await
@@ -167,6 +172,35 @@ fn try_decrypt_with_bobstate(
Ok(None)
}
/// Tries to decrypt the session key with the keyupdate secrets derived
/// from the keys of the unblocked key-contacts,
/// so keyupdates from blocked contacts are not processed.
///
/// Only reached for keyupdates and undecryptable garbage,
/// so any sender can trigger one certificate parse per key-contact.
fn try_decrypt_with_keyupdate_secret(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, String)>> {
let mut stmt = conn.prepare(
"SELECT c.fingerprint, k.public_key
FROM contacts c INNER JOIN public_keys k ON k.fingerprint=c.fingerprint
WHERE c.id>9 AND c.blocked=0",
)?;
let mut rows = stmt.query(())?;
while let Some(row) = rows.next()? {
let fingerprint: String = row.get(0)?;
let public_key_bytes: Vec<u8> = row.get(1)?;
if let Ok(key) = SignedPublicKey::from_slice(&public_key_bytes)
&& let Ok(secret) = keyupdate_secret(&key)
&& let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(secret))
{
return Ok(Some((psk, fingerprint)));
}
}
Ok(None)
}
fn try_decrypt_with_broadcast_secret(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
+161
View File
@@ -0,0 +1,161 @@
//! # Keyupdate messages.
//!
//! Keyupdates keep chats connected when relays are added or removed,
//! by hand today, by automatic relay management later.
//! Contacts learn our relay list only from messages carrying our key,
//! so without keyupdates, the next message of a "mutually silent" contact
//! might go to relays that we no longer read.
//! Reliable keyupdates could also speed up recovery from wider-scale network degradation.
//!
//! When the published relay list changes,
//! key-contacts are informed with a keyupdate message:
//! one broadcast for all contacts, symmetrically encrypted with a secret
//! derived from the own key (`pgp::keyupdate_secret`),
//! carrying the re-signed key with its current relay list
//! and the protected header `Chat-Content: key-update` ([`render_keyupdate_message`]).
//! Receivers trial-decrypt it (`decrypt::try_decrypt_with_keyupdate_secret`),
//! apply the key on the normal Autocrypt path
//! and only then trash the message without updating the sender's `last_seen`.
//! Replaying an old keyupdate cannot revert a relay list,
//! because certificate merging keeps the newest direct key signature.
//!
//! Recipients are the unblocked key-contacts that are members of an accepted chat
//! ([`keyupdate_recipients`]), leaving out (potentially many)
//! subscribers of our own broadcast channels unless they are in a chat too.
//!
//! Incoming keyupdates are accepted from any unblocked key-contact
//! without checking any further chat state (contact request etc.):
//! a stale relay list is worth updating in any case,
//! with certificate merging being the final cryptographic guardian.
//!
//! Sending policy:
//!
//! - A transport change only schedules a check ([`schedule_keyupdate_check`]),
//! setting a deadline that each further change pushes,
//! debouncing several changes into a single keyupdate message.
//!
//! - The SMTP loop sends once the deadline passed and its queue is drained,
//! so real messages are never delayed and a keyupdate is only attempted
//! when the loop is ready to send: never during send-failure backoff.
//!
//! - Sending is driven by a diff: a keyupdate is due only while the published
//! relay list differs from the recorded [`Config::KeyupdateBaseline`].
//! A migration seeds it, so upgrading alone pushes no keyupdate messages.
//!
//! - Only the device where the change originated sends keyupdates:
//! devices applying a multi-device sync changing the relay list
//! record the new list as baseline without sending
//! ([`set_current_relays_as_keyupdate_baseline`]),
//! which also prevents devices catching up on old sync messages
//! from sending historical states.
use std::collections::BTreeMap;
use std::sync::atomic::Ordering;
use anyhow::Result;
use deltachat_contact_tools::addr_normalize;
use crate::config::Config;
use crate::constants::{Chattype, DC_CHAT_ID_LAST_SPECIAL};
use crate::contact::ContactId;
use crate::context::Context;
use crate::key::{DcKey, SignedPublicKey};
use crate::message::insert_tombstone;
use crate::mimefactory::render_keyupdate_message;
use crate::pgp::relay_addrs;
use crate::smtp::insert_into_smtp;
use crate::tools::{create_outgoing_rfc724_mid, time};
/// Delay after a transport change, so that one automatic relay addition run yields one message.
const KEYUPDATE_DEBOUNCE_SECONDS: i64 = 30;
/// Returns deduplicated relay addresses of the key-contacts to inform,
/// see the module docs for the criteria.
async fn keyupdate_recipients(context: &Context) -> Result<Vec<String>> {
// Deliberately not narrowed further: selecting by 1:1 chats or by activity
// may disclose to the relays which contacts are close ones,
// and we want to ensure chat connectivity also between "mutually silent" contacts.
let rows = context
.sql
.query_map_vec(
"SELECT c.addr, k.public_key
FROM contacts c
LEFT JOIN public_keys k ON k.fingerprint=c.fingerprint
WHERE c.id>? AND c.fingerprint<>'' AND c.blocked=0
AND EXISTS (
SELECT 1 FROM chats_contacts cc
INNER JOIN chats ch ON ch.id=cc.chat_id
WHERE cc.contact_id=c.id AND cc.add_timestamp >= cc.remove_timestamp
AND ch.id>? AND ch.type IN (?, ?, ?) AND ch.blocked=0
)",
(
ContactId::LAST_SPECIAL,
DC_CHAT_ID_LAST_SPECIAL,
Chattype::Single,
Chattype::Group,
Chattype::InBroadcast,
),
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<Vec<u8>>>(1)?)),
)
.await?;
let mut recipients = BTreeMap::new();
for (addr, public_key) in rows {
let public_key = public_key.and_then(|bytes| SignedPublicKey::from_slice(&bytes).ok());
for relay in relay_addrs(public_key.as_ref(), &addr) {
if !relay.is_empty() {
recipients.entry(addr_normalize(&relay)).or_insert(relay);
}
}
}
Ok(recipients.into_values().collect())
}
/// Returns the published relay list in the format stored in [`Config::KeyupdateBaseline`].
async fn published_relays_joined(context: &Context) -> Result<String> {
let mut relays = context.get_published_self_addrs().await?;
relays.sort();
Ok(relays.join(","))
}
/// Schedules a check for whether a keyupdate needs sending, after the debounce period.
pub(crate) fn schedule_keyupdate_check(context: &Context) {
context.keyupdate_check_deadline.store(
time().saturating_add(KEYUPDATE_DEBOUNCE_SECONDS),
Ordering::Relaxed,
);
}
/// Records the currently published relay list as not needing a keyupdate, see the module docs.
pub(crate) async fn set_current_relays_as_keyupdate_baseline(context: &Context) -> Result<()> {
let current = published_relays_joined(context).await?;
context
.set_config_internal(Config::KeyupdateBaseline, Some(&current))
.await
}
/// Sends a keyupdate message if the published relay list differs from the recorded baseline.
pub(crate) async fn maybe_send_keyupdate_message(context: &Context) -> Result<()> {
let current = published_relays_joined(context).await?;
let last = context.get_config(Config::KeyupdateBaseline).await?;
if last.unwrap_or_default() == current {
return Ok(());
}
let recipients = keyupdate_recipients(context).await?.join(" ");
if !recipients.is_empty() {
let rfc724_mid = create_outgoing_rfc724_mid();
let rendered_message = render_keyupdate_message(context, &rfc724_mid).await?;
let msg_id = insert_tombstone(context, &rfc724_mid).await?;
insert_into_smtp(context, &rfc724_mid, &recipients, rendered_message, msg_id).await?;
context.scheduler.interrupt_smtp().await;
}
// Record only after queueing, so failed queueing is retried by a later check.
context
.set_config_internal(Config::KeyupdateBaseline, Some(&current))
.await
}
#[cfg(test)]
mod keyupdate_tests;
+134
View File
@@ -0,0 +1,134 @@
use super::*;
use crate::contact::Contact;
use crate::decrypt::get_encrypted_pgp_message_boxed;
use crate::pgp::addresses_from_public_key;
use crate::test_utils::TestContextManager;
use crate::transport::send_sync_transports;
use pgp::composed::{Esk, Message};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_keyupdate_recipients() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await;
let dom = &tcm.dom().await;
let elena = &tcm.elena().await;
let fiona = &tcm.fiona().await;
alice.create_chat(bob).await;
alice
.create_group_with_members("Group", &[charlie, elena])
.await;
// The group chat stays accepted, so only the contact-level block excludes elena.
Contact::block(alice, alice.add_or_lookup_contact_id(elena).await).await?;
alice
.create_broadcast_with_subscribers("Channel", &[dom])
.await;
// Unaccepted contact request.
tcm.send_recv(fiona, alice, "hi").await;
let mut recipients = keyupdate_recipients(alice).await?;
recipients.sort();
assert_eq!(recipients, ["bob@example.net", "charlie@example.net"]);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_and_receive_keyupdate() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
alice.create_chat(bob).await;
let alice_contact = bob.add_or_lookup_contact(alice).await;
assert_eq!(alice_contact.last_seen(), 0);
// Create Bob's chat before the transport change: creating it later
// would re-import Alice's current key and hide a failing keyupdate.
let bob_chat_id = bob.create_chat_id(alice).await;
alice.add_transport("alice@relay.example.net").await;
maybe_send_keyupdate_message(alice).await?;
let keyupdate = alice.pop_sent_msg().await;
assert_eq!(keyupdate.recipients, "bob@example.net");
assert!(keyupdate.payload.contains("Subject: [...]"));
// One SKESK and no PKESK: nothing leaks about the recipients.
let mail = mailparse::parse_mail(keyupdate.payload.as_bytes())?;
let msg = get_encrypted_pgp_message_boxed(&mail)?.unwrap();
let Message::Encrypted { esk, .. } = &*msg else {
panic!("Expected encrypted message");
};
assert!(matches!(esk[..], [Esk::SymKeyEncryptedSessionKey(_)]));
bob.recv_msg_trash(&keyupdate).await;
let alice_key_at_bob = alice_contact.public_key(bob).await?.unwrap();
let addrs = addresses_from_public_key(&alice_key_at_bob).unwrap();
assert!(addrs.contains(&"alice@relay.example.net".to_string()));
// No green "online" dot, unlike for a regular message, see `test_last_seen()`.
let alice_contact = Contact::get_by_id(bob, alice_contact.id).await?;
assert_eq!(alice_contact.last_seen(), 0);
let bob_message = bob.send_text(bob_chat_id, "hi").await;
assert!(bob_message.recipients.contains("alice@relay.example.net"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_keyupdate_trigger_dedup() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
// No contacts yet, so this only records the baseline.
maybe_send_keyupdate_message(alice).await?;
assert!(alice.pop_sent_msg_opt().await.is_none());
alice.create_chat(bob).await;
maybe_send_keyupdate_message(alice).await?;
assert!(alice.pop_sent_msg_opt().await.is_none());
alice.add_transport("alice@relay.example.net").await;
send_sync_transports(alice).await?;
let deadline = alice.keyupdate_check_deadline.load(Ordering::Relaxed);
assert!(deadline > time());
// Sending is driven by the changed relay list, not by the deadline.
maybe_send_keyupdate_message(alice).await?;
assert!(alice.pop_sent_msg_opt().await.is_some());
assert!(alice.pop_sent_msg_opt().await.is_none());
maybe_send_keyupdate_message(alice).await?;
assert!(alice.pop_sent_msg_opt().await.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_keyupdate_not_sent_by_synced_device() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let alice2 = &tcm.alice().await;
let bob = &tcm.bob().await;
for a in [alice, alice2] {
a.set_config_bool(Config::SyncMsgs, true).await?;
a.set_config_bool(Config::BccSelf, true).await?;
// Both devices need a recipient, otherwise silence proves nothing.
a.create_chat(bob).await;
}
alice.add_transport("alice@relay.example.net").await;
send_sync_transports(alice).await?;
alice.send_sync_msg().await?;
alice2.recv_msg_trash(&alice.pop_sent_msg().await).await;
maybe_send_keyupdate_message(alice2).await?;
assert!(alice2.pop_sent_msg_opt().await.is_none());
maybe_send_keyupdate_message(alice).await?;
assert_eq!(alice.pop_sent_msg().await.recipients, "bob@example.net");
Ok(())
}
+1
View File
@@ -72,6 +72,7 @@ pub mod ephemeral;
mod imap;
pub mod imex;
pub mod key;
mod keyupdate;
pub mod location;
pub mod login_param;
pub mod message;
+1
View File
@@ -1059,6 +1059,7 @@ impl Message {
| SystemMessage::CallEnded
| SystemMessage::MessagePinned // UI should scroll to pinned message on tapping
| SystemMessage::MessageUnpinned // UI should scroll to unpinned message on tapping
| SystemMessage::Keyupdate
| SystemMessage::Unknown => Ok(None),
}
}
+89 -65
View File
@@ -10,6 +10,8 @@ use deltachat_contact_tools::sanitize_bidi_characters;
use iroh_gossip::proto::TopicId;
use mail_builder::headers::HeaderType;
use mail_builder::headers::address::Address;
use mail_builder::headers::raw::Raw;
use mail_builder::headers::text::Text;
use mail_builder::mime::MimePart;
use tokio::fs;
@@ -32,7 +34,9 @@ use crate::message::{Message, MsgId, Viewtype};
use crate::mimeparser::SystemMessage;
use crate::param::Param;
use crate::peer_channels::{create_iroh_header, get_iroh_topic_for_msg};
use crate::pgp::{SeipdVersion, addresses_from_public_key, pubkey_supports_seipdv2};
use crate::pgp::{
SeipdVersion, addresses_from_public_key, keyupdate_secret, pubkey_supports_seipdv2, relay_addrs,
};
use crate::simplify::escape_message_footer_marks;
use crate::stock_str;
use crate::tools::{IsNoneOrEmpty, create_outgoing_rfc724_mid, remove_subject_prefix, time};
@@ -578,9 +582,7 @@ impl MimeFactory {
let public_key = SignedPublicKey::from_slice(&public_key_bytes)?;
let relays =
addresses_from_public_key(&public_key).unwrap_or_else(|| vec![addr.clone()]);
recipients.extend(relays);
recipients.extend(relay_addrs(Some(&public_key), &addr));
to.push((authname, addr.clone()));
Encryption::Asymmetric {
@@ -897,7 +899,7 @@ impl MimeFactory {
}
} else if contact.is_key_contact() {
let encryption_pubkeys = if let Some(key) = contact.public_key(context).await? {
recipients = addresses_from_public_key(&key).unwrap_or_else(|| vec![addr.clone()]);
recipients = relay_addrs(Some(&key), &addr);
vec![(addr.clone(), key)]
} else {
Vec::new()
@@ -1798,6 +1800,7 @@ impl MimeFactory {
SystemMessage::CallEnded => {}
SystemMessage::MessagePinned => {}
SystemMessage::MessageUnpinned => {}
SystemMessage::Keyupdate => {}
}
if command == SystemMessage::GroupDescriptionChanged
@@ -2422,6 +2425,42 @@ fn b_encode(value: &str) -> String {
)
}
/// Returns the protected headers shared by symmetrically encrypted messages
/// that are not part of a chat.
async fn symm_encrypted_headers(
context: &Context,
subject: &str,
) -> Result<Vec<(&'static str, HeaderType<'static>)>> {
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(time(), 0)
.unwrap()
.to_rfc2822();
let mut headers = vec![
("To", Address::new_list(vec![hidden_recipients()]).into()),
("Date", Raw::new(date).into()),
("Subject", Text::new(subject.to_string()).into()),
];
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
if context.get_config_bool(Config::Bot).await? {
headers.push(("Auto-Submitted", Raw::new("auto-generated").into()));
}
Ok(headers)
}
/// Renders `queued_mail` for SMTP with the own key pair and primary address.
async fn render_with_self_key(context: &Context, queued_mail: QueuedMail) -> Result<String> {
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let from_addr = context.get_primary_self_addr().await?;
let rendered_mail = render_queued_mail(
queued_mail,
&public_key,
&secret_key,
from_addr,
RenderSideEffects::default(),
)?;
Ok(rendered_mail.message)
}
pub(crate) async fn render_symm_encrypted_securejoin_message(
context: &Context,
step: &str,
@@ -2434,81 +2473,66 @@ pub(crate) async fn render_symm_encrypted_securejoin_message(
let message: MimePart<'static> = MimePart::new("text/plain", "Secure-Join");
let mut headers = Vec::<(&'static str, HeaderType<'static>)>::new();
let to: Vec<Address<'static>> = vec![hidden_recipients()];
headers.push((
"To",
mail_builder::headers::address::Address::new_list(to.clone()).into(),
));
let timestamp = time();
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0)
.unwrap()
.to_rfc2822();
headers.push(("Date", mail_builder::headers::raw::Raw::new(date).into()));
headers.push((
"Subject",
mail_builder::headers::text::Text::new("Secure-Join".to_string()).into(),
));
// Automatic Response headers <https://www.rfc-editor.org/rfc/rfc3834>
if context.get_config_bool(Config::Bot).await? {
headers.push((
"Auto-Submitted",
mail_builder::headers::raw::Raw::new("auto-generated".to_string()).into(),
));
}
headers.push((
"Secure-Join",
mail_builder::headers::raw::Raw::new(step.to_string()).into(),
));
headers.push((
"Secure-Join-Auth",
mail_builder::headers::text::Text::new(auth.to_string()).into(),
));
let mut headers = symm_encrypted_headers(context, "Secure-Join").await?;
headers.push(("Secure-Join", Raw::new(step.to_string()).into()));
headers.push(("Secure-Join-Auth", Text::new(auth.to_string()).into()));
let message = add_headers_to_encrypted_part(message, headers);
// Disable compression for SecureJoin to ensure
// there are no compression side channels
// leaking information about the tokens.
let should_compress = false;
// Only sign the message if we attach the pubkey.
let should_sign = should_attach_pubkey;
let raw_message = part_to_bytes(message);
let queued_mail = QueuedMail {
raw_message,
raw_message: part_to_bytes(message),
display_name: String::new(),
rfc724_mid: rfc724_mid.to_string(),
encryption: Encryption::Symmetric {
shared_secret: shared_secret.to_string(),
},
should_attach_pubkey,
should_sign,
should_compress,
// Only sign the message if we attach the pubkey.
should_sign: should_attach_pubkey,
// Disable compression for SecureJoin to ensure
// there are no compression side channels
// leaking information about the tokens.
should_compress: false,
};
render_with_self_key(context, queued_mail).await
}
/// Renders a keyupdate message informing contacts
/// about the current key and relay list, see [`crate::keyupdate`].
pub(crate) async fn render_keyupdate_message(
context: &Context,
rfc724_mid: &str,
) -> Result<String> {
let message: MimePart<'static> = MimePart::new(
"text/plain",
"This message updates the sender's encryption key and relay list.",
);
let mut headers = symm_encrypted_headers(context, "Keyupdate").await?;
headers.push(("Chat-Content", Raw::new("key-update").into()));
let message = add_headers_to_encrypted_part(message, headers);
let public_key = key::load_self_public_key(context).await?;
let secret_key = key::load_self_secret_key(context).await?;
let side_effects = RenderSideEffects::default();
let from_addr = context.get_primary_self_addr().await?;
let rendered_mail = render_queued_mail(
queued_mail,
&public_key,
&secret_key,
from_addr,
side_effects,
)?;
let queued_mail = QueuedMail {
raw_message: part_to_bytes(message),
display_name: String::new(),
rfc724_mid: rfc724_mid.to_string(),
encryption: Encryption::Symmetric {
shared_secret: keyupdate_secret(&public_key)?,
},
// The attached key with its relay list notation is the actual payload.
should_attach_pubkey: true,
// Receivers drop symmetrically encrypted messages
// that are not signed by the contact owning the secret.
should_sign: true,
// Compression normalizes the size: uncompressed, the length grows
// linearly with the relay list and leaks its size to relays.
should_compress: true,
};
Ok(rendered_mail.message)
render_with_self_key(context, queued_mail).await
}
/// Renders MIME part into a vector of bytes.
+6
View File
@@ -269,6 +269,10 @@ pub enum SystemMessage {
/// Message unpinned. The unpinned message is referred in `In-Reply-To:` header.
MessageUnpinned = 72,
/// Keyupdate message informing contacts about the current key and relay list.
/// Receivers apply the key, then trash the message without updating `last_seen`.
Keyupdate = 80,
}
impl MimeMessage {
@@ -751,6 +755,8 @@ impl MimeMessage {
self.is_system_message = SystemMessage::MessagePinned;
} else if value == "message-unpinned" {
self.is_system_message = SystemMessage::MessageUnpinned;
} else if value == "key-update" {
self.is_system_message = SystemMessage::Keyupdate;
}
} else if self.get_header(HeaderDef::ChatGroupMemberRemoved).is_some() {
self.is_system_message = SystemMessage::MemberRemovedFromGroup;
@@ -1,7 +1,8 @@
use super::*;
use crate::chat::{create_broadcast, load_broadcast_secret};
use crate::constants::DC_CHAT_ID_TRASH;
use crate::key::{load_self_secret_key, self_fingerprint};
use crate::contact::Contact;
use crate::key::{load_self_public_key, load_self_secret_key, self_fingerprint};
use crate::pgp;
use crate::qr::{Qr, check_qr};
use crate::receive_imf::receive_imf;
@@ -248,7 +249,82 @@ async fn test_qr_code_happy_path() -> Result<()> {
.await
}
/// Control: Test that the behavior is the same when the shared secret is unknown
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_keyupdate_unsigned_rejected() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.add_or_lookup_contact(alice).await;
let secret = pgp::keyupdate_secret(&load_self_public_key(alice).await?)?;
let alice_addr = alice.get_config(Config::Addr).await?.unwrap();
test_shared_secret_decryption_ext(
bob,
&alice_addr,
&secret,
None,
Some("Unsigned message is not allowed to be encrypted with this shared secret"),
)
.await?;
bob.assert_warn("Unsigned message is not allowed to be encrypted with this shared secret")
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_keyupdate_wrong_signer_rejected() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let charlie = &tcm.charlie().await; // Attacker who also has Alice's key
bob.add_or_lookup_contact(alice).await;
let secret = pgp::keyupdate_secret(&load_self_public_key(alice).await?)?;
let charlie_addr = charlie.get_config(Config::Addr).await?.unwrap();
test_shared_secret_decryption_ext(
bob,
&charlie_addr,
&secret,
Some(charlie),
Some("This sender is not allowed to encrypt with this secret key"),
)
.await?;
bob.assert_warn("This sender is not allowed to encrypt with this secret key")
.await;
Ok(())
}
/// A blocked contact's keyupdate secret is never tried, so the message stays undecryptable.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_keyupdate_blocked_sender_rejected() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
Contact::block(bob, bob.add_or_lookup_contact_id(alice).await).await?;
let secret = pgp::keyupdate_secret(&load_self_public_key(alice).await?)?;
let alice_addr = alice.get_config(Config::Addr).await?.unwrap();
test_shared_secret_decryption_ext(
bob,
&alice_addr,
&secret,
Some(alice),
Some("Could not find symmetric secret for session key"),
)
.await?;
bob.assert_warn("Could not find symmetric secret for session key")
.await;
bob.assert_warn("unencrypted message").await;
Ok(())
}
/// Control: Test that the behavior is the same when the shared secret is unknown.
///
/// This is also how an old client, or one that does not know the sender's key,
/// treats a keyupdate message: the secret cannot be derived, so it is unknown.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unknown_secret() -> Result<()> {
let mut tcm = TestContextManager::new();
+48 -3
View File
@@ -15,12 +15,13 @@ use pgp::crypto::ecc_curve::ECCCurve;
use pgp::crypto::hash::HashAlgorithm;
use pgp::crypto::sym::SymmetricKeyAlgorithm;
use pgp::packet::{Signature, Subpacket, SubpacketData};
use pgp::ser::Serialize;
use pgp::types::{
CompressionAlgorithm, Imprint, KeyDetails, KeyVersion, Password, SignedUser, SigningKey as _,
StringToKey,
};
use rand_old::{Rng as _, thread_rng};
use sha2::Sha256;
use sha2::{Digest as _, Sha256};
use crate::configure::MAX_RELAYS;
use crate::key::{DcKey, Fingerprint};
@@ -250,7 +251,7 @@ pub fn pk_validate(
}
/// Symmetrically encrypt the message.
/// This is used for broadcast channels and for version 2 of the Securejoin protocol.
/// This is used for broadcast channels, Securejoin v3 and `keyupdate` messages.
/// `shared secret` is the secret that will be used for symmetric encryption.
pub fn symm_encrypt_message(
plain: Vec<u8>,
@@ -452,6 +453,36 @@ pub(crate) fn addresses_from_public_key(public_key: &SignedPublicKey) -> Option<
None
}
/// Returns the addresses to reach the owner of `public_key`,
/// falling back to `addr` if there is no key
/// (as for key-contacts created from a fingerprint alone)
/// or if the key carries no relay list.
pub(crate) fn relay_addrs(public_key: Option<&SignedPublicKey>, addr: &str) -> Vec<String> {
public_key
.and_then(addresses_from_public_key)
.unwrap_or_else(|| vec![addr.to_string()])
}
/// Returns the symmetric secret for keyupdate messages sent by the owner of `public_key`.
///
/// The secret is derived from the primary key packet,
/// which is immutable and identical in every copy of the key ever handed out:
/// this is safe to hash directly because rpgp serializes the packet body without the header,
/// the only part that differs between the legacy and current packet format.
/// So everyone who ever obtained the key can derive the secret and decrypt, forever.
/// That is exactly the intended audience of keyupdates (key-contacts),
/// but messages encrypted with this secret must only carry near-public payloads,
/// i.e. the key itself and its relay list, never any traces of user content or metadata.
///
/// The `keyupdate` prefix domain-separates the digest from v6 fingerprints,
/// which are also Sha256 hashes over the same key material and travel in invite links.
pub(crate) fn keyupdate_secret(public_key: &SignedPublicKey) -> Result<String> {
let mut hasher = Sha256::new();
hasher.update(b"keyupdate");
hasher.update(Serialize::to_bytes(&public_key.primary_key)?);
Ok(format!("keyupdate/{}", hex::encode(hasher.finalize())))
}
/// Returns true if public key advertises SEIPDv2 feature.
pub(crate) fn pubkey_supports_seipdv2(public_key: &SignedPublicKey) -> bool {
// If any Direct Key Signature or any User ID signature has SEIPDv2 feature,
@@ -492,7 +523,7 @@ mod tests {
decrypt,
key::{load_self_public_key, self_fingerprint, store_self_keypair},
mimefactory::{part_to_bytes, wrap_encrypted_part},
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair},
test_utils::{TestContext, TestContextManager, alice_keypair, bob_keypair, pqc_keypair},
token,
};
use pgp::composed::{Esk, Message};
@@ -848,6 +879,20 @@ mod tests {
assert!(merge_openpgp_certificates(bob.clone(), alice.clone()).is_err());
}
#[test]
fn test_keyupdate_secret() {
// Pinned: deployed contacts derive the secret from their stored key copies.
// One v4 and one v6 key: the v6 body additionally contains the key material length.
assert_eq!(
keyupdate_secret(&alice_keypair().to_public_key()).unwrap(),
"keyupdate/51d75fab83de11ac2dfbaa4221d4bffe04773da64e3004616f7456f96ae7d6be"
);
assert_eq!(
keyupdate_secret(&pqc_keypair().to_public_key()).unwrap(),
"keyupdate/07989f6c74aa553bf1cbed500588fad969be6011ca3ac470781d343f9a2de408"
);
}
/// Test PQC support.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_pqc() -> Result<()> {
+6 -1
View File
@@ -740,7 +740,9 @@ pub(crate) async fn receive_imf_inner(
msg
};
if !from_id.is_special() {
// A keyupdate is invisible, so refreshing `last_seen` would light up the
// sender's green "online" dot with no visible user message sent.
if !from_id.is_special() && mime_parser.is_system_message != SystemMessage::Keyupdate {
contact::update_last_seen(context, from_id, mime_parser.timestamp_sent).await?;
}
@@ -1153,6 +1155,9 @@ async fn decide_chat_assignment(
} else if mime_parser.is_system_message == SystemMessage::MessageUnpinned {
info!(context, "Message unpinned (TRASH).");
true
} else if mime_parser.is_system_message == SystemMessage::Keyupdate {
info!(context, "Keyupdate message (TRASH).");
true
} else if let Some(ref decryption_error) = mime_parser.decryption_error
&& !mime_parser.incoming
{
+23 -1
View File
@@ -21,6 +21,7 @@ use crate::download::{download_known_post_messages_without_pre_message, download
use crate::ephemeral;
use crate::events::EventType;
use crate::imap::{Imap, session::Session};
use crate::keyupdate::{maybe_send_keyupdate_message, schedule_keyupdate_check};
use crate::location;
use crate::log::{LogExt, warn};
use crate::reaction::broadcast_reactions::maybe_broadcast_reactions;
@@ -573,6 +574,9 @@ async fn smtp_loop(
return;
}
// Re-arm the non-persistent deadline to catch changes lost to a restart.
schedule_keyupdate_check(&ctx);
let mut timeout = None;
loop {
if let Err(err) = send_smtp_messages(&ctx, &mut connection).await {
@@ -626,8 +630,26 @@ async fn smtp_loop(
slept.saturating_add(rand::random_range((slept / 2)..=slept)),
));
} else {
// Queue is drained: send a due keyupdate without delaying real messages.
let check_deadline = ctx.keyupdate_check_deadline.load(Ordering::Relaxed);
let wait = check_deadline.saturating_sub(time());
if check_deadline != 0 && wait <= 0 {
// Clear first so that a change arriving meanwhile sets a new deadline.
ctx.keyupdate_check_deadline.store(0, Ordering::Relaxed);
// A failed check is retried at the next loop start or transport change;
// re-arming here would retry persistent failures every 30 seconds forever.
maybe_send_keyupdate_message(&ctx).await.log_err(&ctx).ok();
continue;
}
info!(ctx, "SMTP has no messages to retry, waiting for interrupt.");
idle_interrupt_receiver.recv().await.unwrap_or_default();
let interrupt = async { idle_interrupt_receiver.recv().await.unwrap_or_default() };
if check_deadline != 0 {
let duration = std::time::Duration::from_secs(u64::try_from(wait).unwrap_or(1));
tokio::time::timeout(duration, interrupt).await.ok();
} else {
interrupt.await;
}
};
info!(ctx, "SMTP fake idle interrupted.")
+2 -19
View File
@@ -16,11 +16,12 @@ use crate::key;
use crate::key::{DcKey, Fingerprint, load_self_public_key, self_fingerprint};
use crate::log::LogExt as _;
use crate::log::warn;
use crate::message::{self, Message, MsgId, Viewtype};
use crate::message::{self, Message, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::Param;
use crate::qr::check_qr;
use crate::securejoin::bob::JoinerProgress;
use crate::smtp::insert_into_smtp;
use crate::sync::Sync::*;
use crate::tools::{create_id, create_outgoing_rfc724_mid, time};
use crate::{SecurejoinSource, mimefactory, stats};
@@ -731,24 +732,6 @@ pub(crate) async fn handle_securejoin_handshake(
}
}
async fn insert_into_smtp(
context: &Context,
rfc724_mid: &str,
recipients: &str,
rendered_message: String,
msg_id: MsgId,
) -> Result<(), Error> {
context
.sql
.execute(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
VALUES (?1, ?2, ?3, ?4)",
(&rfc724_mid, &recipients, &rendered_message, msg_id),
)
.await?;
Ok(())
}
/// Observe self-sent Securejoin message.
///
/// In a multi-device-setup, there may be other devices that "see" the handshake messages.
+2 -3
View File
@@ -16,9 +16,8 @@ use crate::message::{self, Message, MsgId, Viewtype};
use crate::mimeparser::{MimeMessage, SystemMessage};
use crate::param::{Param, Params};
use crate::pgp::addresses_from_public_key;
use crate::securejoin::{
ContactId, encrypted_and_signed, insert_into_smtp, verify_sender_by_fingerprint,
};
use crate::securejoin::{ContactId, encrypted_and_signed, verify_sender_by_fingerprint};
use crate::smtp::insert_into_smtp;
use crate::stock_str;
use crate::sync::Sync::*;
use crate::tools::{create_outgoing_rfc724_mid, time};
+19
View File
@@ -327,6 +327,25 @@ pub(crate) async fn smtp_send(
status
}
/// Inserts a rendered message into the `smtp` table for sending.
pub(crate) async fn insert_into_smtp(
context: &Context,
rfc724_mid: &str,
recipients: &str,
rendered_message: String,
msg_id: MsgId,
) -> Result<(), Error> {
context
.sql
.execute(
"INSERT INTO smtp (rfc724_mid, recipients, mime, msg_id)
VALUES (?1, ?2, ?3, ?4)",
(&rfc724_mid, &recipients, &rendered_message, msg_id),
)
.await?;
Ok(())
}
/// Sends message identified by `smtp` table rowid over SMTP connection.
///
/// Removes row if the message should not be retried, otherwise increments retry count.
+22
View File
@@ -2610,6 +2610,28 @@ UPDATE msgs SET state=24 WHERE state=18; -- Change OutPreparing to OutFailed.
.await?;
}
inc_and_check(&mut migration_version, 164)?;
if dbversion < migration_version {
// Seed the keyupdate baseline so that upgrading alone
// sends nothing, see `keyupdate.rs`.
sql.execute_migration_transaction(
|transaction| {
let relays: Vec<String> = transaction
.prepare("SELECT addr FROM transports WHERE is_published=1 ORDER BY addr")?
.query_map((), |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?;
transaction.execute(
"INSERT OR REPLACE INTO config (keyname, value)
VALUES ('keyupdate_baseline', ?)",
(relays.join(","),),
)?;
Ok(())
},
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?
+17
View File
@@ -29,6 +29,23 @@ async fn test_clear_config_cache() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_keyupdate_baseline_migration() -> Result<()> {
let configured = STOP_MIGRATIONS_AT
.scope(163, async move { TestContext::new_alice().await })
.await;
configured.sql.run_migrations(&configured).await?;
let relays = configured.get_config(Config::KeyupdateBaseline).await?;
assert_eq!(relays.as_deref(), Some("alice@example.org"));
// A fresh account is seeded with an empty baseline.
let fresh = TestContext::new().await;
let relays = fresh.get_config(Config::KeyupdateBaseline).await?;
assert_eq!(relays.as_deref(), Some(""));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_key_contacts_migration_autocrypt() -> Result<()> {
let t = STOP_MIGRATIONS_AT
+48 -12
View File
@@ -25,7 +25,8 @@ use uuid::Uuid;
use crate::aheader::{Aheader, EncryptPreference};
use crate::chat::{
self, Chat, ChatId, ChatIdBlocked, MessageListOptions, add_to_chat_contacts_table, create_group,
self, Chat, ChatId, ChatIdBlocked, MessageListOptions, add_to_chat_contacts_table,
create_broadcast, create_group,
};
use crate::chatlist::Chatlist;
use crate::config::Config;
@@ -200,17 +201,7 @@ impl TestContextManager {
test_context.name()
));
// Insert a transport for the new address.
test_context.sql
.execute(
"INSERT OR IGNORE INTO transports (addr, entered_param, configured_param) VALUES (?, ?, ?)",
(
new_addr,
serde_json::to_string(&EnteredLoginParam{addr: new_addr.to_string(), ..Default::default()}).unwrap(),
format!(r#"{{"addr":"{new_addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic"}}"#)
),
).await.unwrap();
test_context.add_transport(new_addr).await;
test_context.set_primary_self_addr(new_addr).await.unwrap();
// ensure_secret_key_exists() is called during configure
key::ensure_secret_key_exists(test_context).await.unwrap();
@@ -578,6 +569,32 @@ impl TestContext {
}
}
/// Adds a published transport for `addr` without any network activity.
pub async fn add_transport(&self, addr: &str) {
// A fresh `add_timestamp` makes the re-signed self key newer than the copies
// contacts hold, so that certificate merging prefers the new relay list.
self.sql
.execute(
"INSERT OR IGNORE INTO transports (addr, entered_param, configured_param, add_timestamp) VALUES (?, ?, ?, ?)",
(
addr,
serde_json::to_string(&EnteredLoginParam {
addr: addr.to_string(),
..Default::default()
})
.unwrap(),
format!(
r#"{{"addr":"{addr}","imap":[],"imap_user":"","imap_password":"","smtp":[],"smtp_user":"","smtp_password":"","certificate_checks":"Automatic"}}"#
),
time(),
),
)
.await
.unwrap();
// Invalidate the cached self key so that it is regenerated with the new list.
self.self_public_key.lock().await.take();
}
/// Retrieves a sent message from the jobs table.
///
/// This retrieves and removes a message which has been scheduled to send from the jobs
@@ -1154,6 +1171,25 @@ ORDER BY id"
chat_id
}
/// Creates a broadcast channel with `subscribers` added as member rows directly.
/// Joining via securejoin instead would also create accepted 1:1 chats with them.
pub async fn create_broadcast_with_subscribers(
&self,
name: &str,
subscribers: &[&TestContext],
) -> ChatId {
let chat_id = create_broadcast(self, name.to_string()).await.unwrap();
let mut to_add = vec![];
for subscriber in subscribers {
to_add.push(self.add_or_lookup_contact_id(subscriber).await);
}
add_to_chat_contacts_table(self, time(), chat_id, &to_add)
.await
.unwrap();
chat_id
}
/// Set the legacy `protected` column in the chats table to 1,
/// because for now, only these chats that were once protected can be used
/// to gossip verifications.
+9
View File
@@ -19,6 +19,7 @@ use crate::config::Config;
use crate::context::Context;
use crate::ensure_and_debug_assert;
use crate::events::EventType;
use crate::keyupdate::{schedule_keyupdate_check, set_current_relays_as_keyupdate_baseline};
use crate::login_param::EnteredLoginParam;
use crate::net::load_connection_timestamp;
use crate::provider::Socket;
@@ -614,6 +615,8 @@ pub(crate) async fn send_sync_transports(context: &Context) -> Result<()> {
removed_transports,
})
.await?;
// Set the deadline before interrupting, so the woken SMTP loop sees it.
schedule_keyupdate_check(context);
context.scheduler.interrupt_smtp().await;
Ok(())
@@ -678,6 +681,12 @@ pub(crate) async fn sync_transports(
.restart_io_after_fetch
.store(true, Ordering::Relaxed);
context.emit_event(EventType::TransportsModified);
// Only the originating device sends a keyupdate;
// a device ingesting a sync message just records the new baseline, see `keyupdate.rs`.
// Acceptable gap: with concurrent changes on two devices,
// contacts may only learn the merged list with the next relay change or regular message.
set_current_relays_as_keyupdate_baseline(context).await?;
}
Ok(())
}