Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions deltachat-rpc-client/tests/test_cross_core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import subprocess
import time

import pytest

Expand Down Expand Up @@ -86,3 +87,46 @@ def test_second_device(acf, alice_and_remote_bob) -> None:
remote_eval("locals()['future']()")

assert new_account.get_config("addr") == remote_eval("bob.get_config('addr')")


def test_keyupdate_against_core_2_48_march_2026(acf, alice_and_remote_bob):
"""Test 2.48 Bob learns a new relay of Alice from a keyupdate, and is shown nothing."""
alice, alice_contact_bob, remote_eval = alice_and_remote_bob("2.48.0")

def bob_sees():
return remote_eval(
"{'chats': len(bob.get_chatlist()),"
" 'fresh': len(bob._rpc.get_fresh_msgs(bob.id)),"
" 'contacts': len(bob.get_contacts()),"
" 'alice_chat': bob._rpc.get_chat_id_by_contact_id(bob.id, bob_contact_alice.id) or 0}",
)

# Keyupdates go to contacts who plausibly hold our key:
# an accepted chat alone is not enough, a message must have flowed.
alice_chat = alice_contact_bob.create_chat()
alice.set_config("keyupdate_debounce", "1")
old_addr = alice.get_config("configured_addr")
alice_chat.send_text("hi")
assert remote_eval("bob.wait_for_incoming_msg().get_snapshot().text") == "hi"
before = bob_sees()

# Certificate merging keeps the newest direct key signature,
# and signature timestamps have one-second resolution:
# without waiting, the re-signed key can tie with the copy Bob holds, keeping his.
time.sleep(2)
alice.add_transport_from_qr(acf.get_account_qr())
alice.bring_online()
(new_addr,) = [t["addr"] for t in alice.list_transports() if t["addr"] != old_addr]

# The 2.48 core has no encryption enforcement, but the keyupdate MDN without
# referenced message keeps it invisible; merging happens before the trashing.
for _ in range(60):
if new_addr in remote_eval("bob_contact_alice.get_encryption_info()"):
break
time.sleep(1)
else:
pytest.fail("Bob never received the keyupdate")

# It also leaves no trace: no chat with Alice, no message anywhere,
Comment thread
link2xt marked this conversation as resolved.
# and no address-contact for the address it was sent from.
assert bob_sees() == before
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,13 @@ pub enum Config {
/// Whether automatic relay management successfully added the desired number of relays
AutorelayFinished,

/// Sorted, space-separated relay list for which no keyupdate is due.
KeyupdateBaseline,

/// For tests only: keyupdate debounce window in seconds.
#[strum(props(default = "30"))]
KeyupdateDebounce,

/// Whether to avoid using IMAP IDLE even if the server supports it.
///
/// This is a developer option for testing "fake idle".
Expand Down
12 changes: 11 additions & 1 deletion src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -330,6 +330,9 @@ 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.
pub(crate) next_keyupdate_check: AtomicI64,
}

/// The state of ongoing process.
Expand Down Expand Up @@ -506,6 +509,7 @@ impl Context {
self_fingerprint: OnceLock::new(),
self_public_key: Mutex::new(None),
published_connectivities: parking_lot::Mutex::new(Vec::new()),
next_keyupdate_check: AtomicI64::new(0),
};

let ctx = Context {
Expand Down Expand Up @@ -1062,6 +1066,12 @@ impl Context {
.await?
.to_string(),
);
res.insert(
"keyupdate_debounce",
self.get_config_int(Config::KeyupdateDebounce)
.await?
.to_string(),
);

let elapsed = time_elapsed(&self.creation_time);
res.insert("uptime", duration_to_str(elapsed));
Expand Down
1 change: 1 addition & 0 deletions src/context/context_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ async fn test_get_info_completeness() {
"stats_last_update",
"stats_last_old_contact_id",
"simulate_receive_imf_error", // only used in tests
"keyupdate_baseline", // Our own addresses, don't leak them to the logs.
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();
Expand Down
206 changes: 206 additions & 0 deletions src/keyupdate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
//! # Keyupdate messages.
//!
//! Contacts learn our relay list from the key our messages carry,
//! so after a relay change a "mutually silent" contact may keep writing
//! to relays we no longer read. A keyupdate tells them proactively,
//! carrying the re-signed key to [`KEYUPDATE_CHUNK_CONTACTS`] contacts at a time.
//!
//! Keyupdates are *unsigned*, because a signature
//! carries one intended recipient fingerprint per recipient,
//! which would tell everyone in the chunk who the others are.
//! Receivers still learn the key, as the Autocrypt header is merged
//! before any signature is checked, and trash the message itself.
//!
//! Nothing authenticates the sender and nothing needs to:
//! certificate merging verifies the relay list in the direct key signature
//! and keeps the newest one, which also defeats replaying an old keyupdate.
//!
//! Recipients ([`keyupdate_recipients`]) are the key-contacts who can
//! plausibly hold our key, aged out after [`KEYUPDATE_MAX_SILENCE`] and capped
//! at [`KEYUPDATE_MAX_RECIPIENTS`] so one relay change cannot cause unbounded traffic.
//!
//! A transport change only schedules a check,
//! debouncing several changes into one message,
//! which the SMTP loop sends once its queue is drained.
//! Whether anything is due is a diff against [`Config::KeyupdateBaseline`]:
//! a migration seeds it so upgrading sends nothing, and a device ingesting a sync
//! records the new list instead of sending ([`set_current_relays_as_keyupdate_baseline`]).

use std::collections::BTreeSet;
use std::sync::atomic::Ordering;

use anyhow::Result;
use deltachat_contact_tools::addr_normalize;
use rand::seq::SliceRandom;

use crate::chat::ChatId;
use crate::config::Config;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::key::{DcKey, SignedPublicKey};
use crate::log::warn;
use crate::mimefactory::render_keyupdate_message;
use crate::pgp::{pubkey_can_encrypt, relay_addrs};
use crate::smtp::insert_into_smtp;
use crate::tools::{create_outgoing_rfc724_mid, time};

/// Maximum number of contacts one (chunk of a) keyupdate message is encrypted to.
const KEYUPDATE_CHUNK_CONTACTS: usize = 200;

/// How long a contact may show no sign of life before keyupdates skip them.
const KEYUPDATE_MAX_SILENCE: i64 = 3 * 365 * 24 * 3600;

/// Upper bound on the contacts informed after a relay list change, keeping the freshest.
const KEYUPDATE_MAX_RECIPIENTS: usize = 5000;

/// A contact to inform: the relays to reach them at, and the key to encrypt to.
struct KeyupdateRecipient {
relays: Vec<String>,
public_key: SignedPublicKey,
}

/// Returns at most `max_recipients` key-contacts to inform.
async fn keyupdate_recipients(
context: &Context,
max_recipients: usize,
) -> Result<Vec<KeyupdateRecipient>> {
// Single chat contacts only become keyupdate recipient candidates
// if we have a record of a sent message or `last_seen` is not 0.
// Ephemeral expiry and `delete_device_after` trash messages and drop their `from_id`.
// The ephemeral timer change message is exempt and usually carries such a chat,
// but `delete_device_after` has no equivalent:
// if we only ever sent messages, now removed, and never received one,
// the contact does not qualify as a keyupdate recipient.
let alive_since = time().saturating_sub(KEYUPDATE_MAX_SILENCE);
let rows = context
.sql
.query_map_vec(
// The outer single-argument MAX aggregates over the contact's chats,
// the inner multi-argument one picks the newest signal per chat.
"SELECT c.addr, k.public_key,
MAX(MAX(c.last_seen,
CASE WHEN ch.type=? THEN 0 ELSE ch.created_timestamp END,
cc.add_timestamp,
CASE WHEN ch.type=? THEN IFNULL(
(SELECT MAX(m.timestamp) FROM msgs m
WHERE m.chat_id=ch.id AND m.from_id=?), 0)
ELSE 0 END)) AS freshness
Comment on lines +82 to +88

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would seem fine to only look at last_seen, and possibly add_timestamp, here. If the contact didn't write any message to us in the last 3 years, then they probably won't anytime soon.

But it's fine as-is, too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

happy about follow up PRs :)

FROM contacts c
INNER JOIN public_keys k ON k.fingerprint=c.fingerprint
INNER JOIN chats_contacts cc ON cc.contact_id=c.id
INNER JOIN chats ch ON ch.id=cc.chat_id
WHERE c.id>? AND c.fingerprint<>'' AND c.blocked=0
AND cc.add_timestamp >= cc.remove_timestamp
AND ch.id>? AND ch.type IN (?, ?, ?) AND ch.blocked=0
Comment thread
link2xt marked this conversation as resolved.
GROUP BY c.id
HAVING freshness>?
ORDER BY freshness DESC
LIMIT ?",
(
Chattype::Single,
Chattype::Single,
ContactId::SELF,
ContactId::LAST_SPECIAL,
ChatId::LAST_SPECIAL,
Chattype::Single,
Chattype::Group,
Chattype::InBroadcast,
alive_since,
max_recipients,
),
|row| {
let addr: String = row.get(0)?;
let public_key_bytes: Vec<u8> = row.get(1)?;
Ok((addr, public_key_bytes))
},
)
.await?;

let mut recipients = Vec::with_capacity(rows.len());
for (addr, public_key_bytes) in rows {
let public_key = match SignedPublicKey::from_slice(&public_key_bytes) {
Ok(public_key) => public_key,
Err(err) => {
warn!(context, "Cannot parse stored key for {addr:?}: {err:#}.");
continue;
}
};
if !pubkey_can_encrypt(&public_key) {
warn!(context, "Stored key for {addr:?} cannot be encrypted to.");
continue;
}
let relays = relay_addrs(&public_key, &addr);
debug_assert!(relays.iter().all(|relay| !relay.is_empty()));
if !relays.is_empty() {
recipients.push(KeyupdateRecipient { relays, public_key });
}
}

// Freshness decides who is informed at all, but it must not decide who shares a chunk:
// an envelope would otherwise group contacts by how active they are.
recipients.shuffle(&mut rand::rng());
Ok(recipients)
}

/// Returns the deduplicated relay addresses to put into the SMTP envelope
/// for the keyupdate encrypted to a `chunk` of recipients.
fn envelope_recipients(chunk: &[KeyupdateRecipient]) -> String {
let mut addrs = BTreeSet::new();
for recipient in chunk {
for relay in &recipient.relays {
addrs.insert(addr_normalize(relay));
}
}
Vec::from_iter(addrs).join(" ")
}

/// 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) async fn schedule_keyupdate_check(context: &Context) -> Result<()> {
let debounce = context.get_config_i64(Config::KeyupdateDebounce).await?;
context
.next_keyupdate_check
.store(time().saturating_add(debounce), Ordering::Relaxed);
Ok(())
}

/// 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 {
Comment thread
link2xt marked this conversation as resolved.
return Ok(());
}

let recipients = keyupdate_recipients(context, KEYUPDATE_MAX_RECIPIENTS).await?;
for chunk in recipients.chunks(KEYUPDATE_CHUNK_CONTACTS) {
let envelope = envelope_recipients(chunk);
let rfc724_mid = create_outgoing_rfc724_mid();
let keys = chunk.iter().map(|r| r.public_key.clone()).collect();
let rendered_message = render_keyupdate_message(context, &rfc724_mid, keys).await?;
insert_into_smtp(context, &rfc724_mid, &envelope, rendered_message).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;
Loading