Skip to content

introduce asymetrically encrypted keyupdate messages informing contacts about relay changes - #8621

Merged
hpk42 merged 2 commits into
hpk/prepare-keyupdatesfrom
hpk/add-keyupdates
Aug 29, 2026
Merged

introduce asymetrically encrypted keyupdate messages informing contacts about relay changes#8621
hpk42 merged 2 commits into
hpk/prepare-keyupdatesfrom
hpk/add-keyupdates

Conversation

@hpk42

@hpk42 hpk42 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

When the published relay list changes, key-contacts are informed with an unsigned message carrying the re-signed key, encrypted to a chunk of contacts at a time. It is shaped like a receipt notification naming no message, so cores that know nothing about keyupdates trash it as well. For more details see src/keyupdate.rs module docs.

This PR is based on the preparational #8620 and replaces #8588.

It shares some parts with #8601 and addresses review comments there, especially introducing two limits on the keyupdate recipient set computation: an absolute MAX number of recipients, and a 3-year cut of stale contacts (no interaction whtsoever with them since 3 years). While in #8601 an 8-year old profile with 7000 overall contacts, had 2000 keyupdate recipients, this branch here has 1400 recipients. Most profiles probably have well under 100 contacts, and fit into a single keyupdate chunk.

@link2xt link2xt left a comment

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.

I still have not finished reading it to the end, especially the tests.

Largest comment is that key updates are not generated on the fly and are not actually low priority. With an actually low priority queue maybe a KeyupdateDebounce would not have even been needed. Not suggesting to change this, i think it is better to merge it sooner without large changes.

Comment thread deltachat-rpc-client/tests/test_cross_core.py
Comment thread src/securejoin.rs
)
.await?;

let msg_id = message::insert_tombstone(context, &rfc724_mid).await?;

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.

For other reviewers: insert_into_smtp now creates a tombstone on its own.

Comment thread src/config.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
// The address only serves as a fallback for keys advertising no relays,
// and does not travel beyond the SMTP envelope.
let mut relays = relay_addrs(&public_key, &addr);
relays.retain(|relay| !relay.is_empty());

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.

Should this be a debug_assert!(relays.iter().all(|s| !s.is_empty())? Maybe even in relay_addrs, to make sure it never returns invalid addresses. How does it happen that we get an empty relay address in this vector?

I see addresses_from_public_key may return a single empty relay because of how split works, but only if someone sends a key with a notation subpacket and no relays, should not happen normally.

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.

contacts.addr is empty for fingerprint-only contacts so moving the assert does not work i think.
add_or_lookup_ext allows such contacts

core/src/contact.rs

Lines 948 to 951 in 26923f9

ensure!(
!addr.is_empty() || !fingerprint.is_empty(),
"Can not add_or_lookup empty address"
);

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.

I commented at https://github.com/chatmail/core/pull/8620/changes#r3874278090, i think relay_addrs should then make sure to filter out empty addr. And here can be a debug assert.

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.

did it here a1be4cb

@hpk42
hpk42 force-pushed the hpk/add-keyupdates branch from 795ab48 to 26923f9 Compare August 27, 2026 09:46
@hpk42

hpk42 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Largest comment is that key updates are not generated on the fly and are not actually low priority. With an actually low priority queue maybe a KeyupdateDebounce would not have even been needed. Not suggesting to change this, i think it is better to merge it sooner without large changes.

Yes, in hindsight a new smtp_keyupdates table might be better. In practise, i don't think it matters too much and, as you note already, we can change/migrate in a separate PR if needed. Keyupdates are rare and they are small, and most people have less than 100 contacts. They basically just serve as an accelerator so 100% delivery success is not possible and not needed. What do you think about upping KEYUPDATE_CHUNK_CONTACTS to 200 (or more), to reduce the number of messages for medium/larger profiles?

@hpk42
hpk42 force-pushed the hpk/add-keyupdates branch from 26923f9 to 6ae1fad Compare August 27, 2026 12:57
@link2xt

link2xt commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

What do you think about upping KEYUPDATE_CHUNK_CONTACTS to 200 (or more), to reduce the number of messages for medium/larger profiles?

With autocrypt2 each recipient will add 1 kB ("32-octet X25519 ciphertext, 1088-octet ML-KEM-768 ciphertext" according to https://www.rfc-editor.org/info/rfc9980/#section-11-7.12). 200 kB of PKESK packets looks fine.

@link2xt link2xt left a comment

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.

I have read everything, most important points:

  1. Do we need to store the baseline, can we simply store the time of the relay change (which is currently stored only in memory) and send key updates if it is in the past? It looks like baseline and comparing relay list to it was meant to help with complex scenarios, but if it does not really work with multi-device case anyway, it only protects against the case when the user reverts the change quickly on the same device, adding and immediately removing the relay. If we store the timestamp (that is currently stored in memory) and remove the baseline, then we also don't need schedule_keyupdate_check in the beginning of SMTP loop.
  2. Comment about the SQL statement and the test test_keyupdate_from_unknown_sender, maybe not send key updates simply because we have created a chat with a contact that we never sent anything to?

Comment thread src/keyupdate/keyupdate_tests.rs Outdated
Comment thread src/keyupdate/keyupdate_tests.rs Outdated
Comment thread src/keyupdate/keyupdate_tests.rs
Comment thread src/keyupdate/keyupdate_tests.rs
Comment thread src/context.rs Outdated
Comment thread src/scheduler.rs Outdated
Comment thread src/test_utils.rs Outdated
Comment thread src/transport.rs Outdated
.store(true, Ordering::Relaxed);
context.emit_event(EventType::TransportsModified);

// Ingesting devices only record the baseline, see `keyupdate.rs`.

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.

Repeating my comment from other places, keyupdate.rs is huge and this kind of references often bitrot, we also had "see receive_imf" in the past and then nobody could recall what the comment referred to so it was deleted.
This sentence mostly says what happens on the next line if i understand it correctly ("ingesting devices" meaning devices that receive/ingest the sync message).

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.

i dropped two references to keyupdate.rs in favor of a direct explanation, but not sure what you mean with "huge"?

keyupdate.rs has 244 LOCs and receive_imf is 4350 LOCs.

You probably mean that keyupdate.rs has a 64 LOC module doc, but i do think we need a place where everything related to keyupdates is documented. Sprinkling some references to the module doc string would help so anyone changing code considers looking up the documented context.

FWIW ephemeral.rs has a 62 LOC doc string, and i think it's helpful, and i think rather more than less modules should have that.

Comment thread src/transport.rs Outdated
context.emit_event(EventType::TransportsModified);

// Ingesting devices only record the baseline, see `keyupdate.rs`.
// Acceptable gap: with concurrent changes on two devices,

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.

So the scenario is:

  1. Device 1 and Device 2 have relay A.
  2. Device 1 adds relay B and sends key updates.
  3. Device 2 adds relay C and sends key updates.

Then both devices receive sync messages and have relays A, B and C, but all key updates sent have either relays A,B or relays A,C.

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.

yes, that is correct. I improved the docstring. A user doing concurrent multi-relay changes on two devices in the same 30 seconds is probably rare (for now). If it happens, then whatever contacts take as the current relay set, should not be wrong, and any chatting and read receipts would heal it, because both devices have a merged set. I don't think it's worth to do much on it, or document much more about this concurrent multi-device changes for now.

Comment thread src/transport.rs
// Ingesting devices only record the 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?;

@link2xt link2xt Aug 27, 2026

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.

What changes if we don't do this? Key updates are only sent when we set keyupdate_check_deadline and this only happens when relays are changed on our device (not via sync, manually or by automatic relay management (to be renamed into "autorelay" if #8623 is merged). Is this only to avoid sending key updates in the case where user adds a relay and quickly deletes it back?

I wonder if we actually need to store baseline at all since it does not help with merging, can we just store the time we have last changed the relays on our device and if we did it more than debounce (30) seconds away or if this time is in the future (something happened to the clock), send key updates and reset it to 0.

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.

If we don't set the baseline, this fails:

assert!(alice2.pop_sent_msg_opt().await.is_none());

The second device wrongly sends the same keyupdate out, so every synced device would duplicate keyupdate sending.

The deadline is not only set by local relay changes. sync_transports sets restart_io_after_fetch (transport.rs:681), and the restarting SMTP loop calls schedule_keyupdate_check.

And no, it is not only about adding a relay and quickly removing it again. That case is handled on the sender side in maybe_send_keyupdate_message. Here, setting the baseline is about second devices receiving the sync message. Both go through the baseline, which is probably why they look like one mechanism.

Under your timestamp suggestion, the line could indeed go, though.

@hpk42 hpk42 left a comment

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.

  1. Do we need to store the baseline, can we simply store the time of the relay change (which is currently stored only in memory) and send key updates if it is in the past? It looks like baseline and comparing relay list to it was meant to help with complex scenarios, but if it does not really work with multi-device case anyway, it only protects against the case when the user reverts the change quickly on the same device, adding and immediately removing the relay. If we store the timestamp (that is currently stored in memory) and remove the baseline, then we also don't need schedule_keyupdate_check in the beginning of SMTP loop.

I find KeyupdateBaseline and how our current relay setup diverges from it easier to reason about than a "send-keyupdate-deadline" where we would need to make sure that all call paths (now and in the future) triggering it would not cause unnecessary updates.

  1. Comment about the SQL statement and the test test_keyupdate_from_unknown_sender, maybe not send key updates simply because we have created a chat with a contact that we never sent anything to?

The test you refer to is about receiving, not sending. But i did a commit filtering out contacts with empty single chats only, where we never sent or received anything. a603c5a

Comment thread src/transport.rs
// Ingesting devices only record the 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?;

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.

If we don't set the baseline, this fails:

assert!(alice2.pop_sent_msg_opt().await.is_none());

The second device wrongly sends the same keyupdate out, so every synced device would duplicate keyupdate sending.

The deadline is not only set by local relay changes. sync_transports sets restart_io_after_fetch (transport.rs:681), and the restarting SMTP loop calls schedule_keyupdate_check.

And no, it is not only about adding a relay and quickly removing it again. That case is handled on the sender side in maybe_send_keyupdate_message. Here, setting the baseline is about second devices receiving the sync message. Both go through the baseline, which is probably why they look like one mechanism.

Under your timestamp suggestion, the line could indeed go, though.

Comment thread src/scheduler.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs
Comment thread src/keyupdate/keyupdate_tests.rs
Comment thread src/keyupdate/keyupdate_tests.rs
Comment thread src/keyupdate.rs
Comment thread src/test_utils.rs Outdated
Comment thread src/transport.rs Outdated
.store(true, Ordering::Relaxed);
context.emit_event(EventType::TransportsModified);

// Ingesting devices only record the baseline, see `keyupdate.rs`.

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.

i dropped two references to keyupdate.rs in favor of a direct explanation, but not sure what you mean with "huge"?

keyupdate.rs has 244 LOCs and receive_imf is 4350 LOCs.

You probably mean that keyupdate.rs has a 64 LOC module doc, but i do think we need a place where everything related to keyupdates is documented. Sprinkling some references to the module doc string would help so anyone changing code considers looking up the documented context.

FWIW ephemeral.rs has a 62 LOC doc string, and i think it's helpful, and i think rather more than less modules should have that.

@Hocuri Hocuri left a comment

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.

Partial review

Comment thread src/keyupdate/keyupdate_tests.rs
Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment on lines +4 to +8
//! Contacts learn our relay list from messages carrying our key,
//! but without transport changes causing keyupdates the next message
//! of a "mutually silent" contact might go to relays that we no longer read.
//! Keyupdates can help speed up recovery during wider-scale network degradation
//! which typically leads to attempts to change a profile's relay setup.

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.

To make this more understandable:

Suggested change
//! Contacts learn our relay list from messages carrying our key,
//! but without transport changes causing keyupdates the next message
//! of a "mutually silent" contact might go to relays that we no longer read.
//! Keyupdates can help speed up recovery during wider-scale network degradation
//! which typically leads to attempts to change a profile's relay setup.
//! The public key contains the list of relays,
//! so that the list of relays is distributed to our contacts
//! whenever our key is attached or gossiped.
//! On relay changes, we send proactive keyupdate messages
//! to inform our contacts about the new relay list,
//! in order to retain connectivity after all our relays changed.
//! This could happen e.g. in these cases:
//! - The user played around with the relay settings,
//! and replaced all existing relays.
//! - After a wide-scale network degradation,
//! all the previous relays stopped working and new ones were added.

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.

see 8d3820a where i cut the docstring to <50% -- i'd kindly ask both you and link2xt to rather do a PR against the docs if you see any more needs, instead of us litigating comments and docs here further.

Comment thread src/keyupdate.rs Outdated
Comment thread src/keyupdate.rs Outdated
Comment on lines +17 to +29
//! Keyupdates are therefore *unsigned*:
//!
//! - Receivers generally apply the key while parsing the Autocrypt header
//! (`mimeparser` imports and merges it before looking at any signature),
//! so an older core that never heard of keyupdates still learns the update.
//!
//! - An unsigned message does not count as encrypted for `receive_imf`,
//! which drops it if `Config::ForceEncryption` is set (default)
//! before a contact, chat or a `last_seen` update can come out of it.
//! Cores with keyupdates trash it even earlier as a report naming no message,
//! and cores with encryption enforcement turned off still trash it,
//! because a keyupdate is a `multipart/report` (`mimefactory::keyupdate_body`)
//! which lacks an `Original-Message-ID`.

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.

Suggested change
//! Keyupdates are therefore *unsigned*:
//!
//! - Receivers generally apply the key while parsing the Autocrypt header
//! (`mimeparser` imports and merges it before looking at any signature),
//! so an older core that never heard of keyupdates still learns the update.
//!
//! - An unsigned message does not count as encrypted for `receive_imf`,
//! which drops it if `Config::ForceEncryption` is set (default)
//! before a contact, chat or a `last_seen` update can come out of it.
//! Cores with keyupdates trash it even earlier as a report naming no message,
//! and cores with encryption enforcement turned off still trash it,
//! because a keyupdate is a `multipart/report` (`mimefactory::keyupdate_body`)
//! which lacks an `Original-Message-ID`.
//! Keyupdates are therefore *unsigned*.
//! On the receiver's side, they key is applied
//! while parsing the Autocrypt header and before the signature is checked,
//! and the message is trashed later.
  • Not necessary to explain the details of receive_imf; if the receive_imf logic changes, we won't remember to update the explanation here
  • Not necessary to explain how old cores handle this, this info will become irrelevant in 6-12 months
  • Removed the word "generally", not sure why it was there

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.

your suggestion drops the fact that keyupdates are sent as MDN without referenced message id.

Also, i use function references to make the docs lines show up if any of the functions is moved or renamed, because usually this happens via "grep" for it or equivalent IDE tooling. It's how i did it a lot in other projects.

But i won't argue this here, after a lot of discussion about comments with link2xt already.

I cut the module docstring in half 8d3820a

Comment thread src/keyupdate.rs Outdated
//! 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

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.

Suggested change
//! so real messages are never delayed and a keyupdate is only attempted
//! so real messages are not delayed and a keyupdate is only attempted

Comment thread src/context.rs Outdated

@Hocuri Hocuri left a comment

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.

I'm almost done with reviewing.

I tested this on my main account. Overall, it went well; the only problem is that apparently, testrun.org rejected a full message because one of the recipients in there does not exist anymore:

08-29 16:59:21.749  5737  5766 I DeltaChat: [accId=2] src/smtp.rs:206: SMTP failed to send: Permanent(Response { code: Code { severity: PermanentNegativeCompletion, category: MailSystem, detail: Zero }, message: ["5.1.1 <xstore@testrun.org>: Recipient address rejected: User unknown in virtual mailbox table"] }).
08-29 16:59:21.749  5737  5766 I DeltaChat: [accId=2] src/smtp.rs:239: Permanent error, message sending failed.
08-29 16:59:21.749  5737  5766 I DeltaChat: [accId=2] src/smtp.rs:282: Failed to send message over SMTP, disconnecting.
08-29 16:59:21.749  5737  5766 E DeltaChat: [accId=2] Failed to load Msg#319115 to mark it as failed: Message Msg#319115 does not exist.
08-29 16:59:21.749  5737  5766 E DeltaChat: Failed to load Msg#319115 to mark it as failed: Message Msg#319115 does not exist.
08-29 16:59:22.065  5737  5766 W DeltaChat: [accId=2] src/scheduler.rs:583: send_smtp_messages failed: Failed to send message: Permanent SMTP error: permanent: 5.1.1 <xstore@testrun.org>: Recipient address rejected: User unknown in virtual mailbox table.

...but we could solve this later, maybe create in issue for it for now.

Apart from that, I got quite some non-delivery-notifications, which is expected and nothing to worry about.

What I also got is 52 "Missing attachment" warnings:

08-29 16:59:09.418  5737  5766 W DeltaChat: [accId=2] src/mimeparser.rs:1371: Missing attachment

The reason for this log are mesages (or message parts) with application/pgp-encrypted and application/octet-stream mimetype that confuse core because it can't find the attachment's filename, but I didn't dig deeper than that.

Comment thread src/keyupdate.rs
Comment on lines +82 to +88
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

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 :)

Comment thread src/scheduler.rs Outdated
Comment thread src/keyupdate/keyupdate_tests.rs Outdated
@hpk42

hpk42 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

I'm almost done with reviewing.

I tested this on my main account. Overall, it went well; the only problem is that apparently, testrun.org rejected a full message because one of the recipients in there does not exist anymore:

thanks for testing and reviewing! I filed two related issues,

#8640
chatmail/async-smtp#72

They are not super-high prio because relays work, and people can change to a relay for sending, but obviously good to fix in the not do distant future.

hpk42 added 2 commits August 29, 2026 22:49
…nges

When the published relay list changes, key-contacts are informed with an
unsigned message carrying the re-signed key, encrypted to a chunk of contacts
at a time. It is shaped like a receipt notification naming no message, so that
cores which know nothing about keyupdates trash it as well.

See the src/keyupdate.rs module docs for the design.
`relay_addrs()` fell back to the contact address even when it is empty,
which happens for key-contacts created from a sync message or for the
self-contact, putting an empty string into the SMTP recipient list.
@hpk42
hpk42 force-pushed the hpk/add-keyupdates branch from c32da47 to 6ef0cd1 Compare August 29, 2026 20:52
@hpk42
hpk42 merged commit 3d61e0f into main Aug 29, 2026
30 checks passed
@hpk42
hpk42 deleted the hpk/add-keyupdates branch August 29, 2026 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants