feat: Securejoin v3, encrypt all securejoin messages#7754
feat: Securejoin v3, encrypt all securejoin messages#7754
Conversation
59957ed to
ef97a7e
Compare
807b8c4 to
afb7e2a
Compare
src/mimeparser.rs
Outdated
| Ok(secret) | ||
| }) | ||
| .await?; | ||
| secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?); |
There was a problem hiding this comment.
There was the idea to use a new table rather than reusing the AUTH token.
I don't think this would be worth it:
- It would increase complexity, because in
decode_openpgp()and in "step 5+6" inhandle_securejoin_handshake(), we would need to lookup tokens from both the old and new table, in order to keep backwards compatibility, in case someone scans a QR code that was generated before the upgrade, because in that case, the AUTH token would only be in thetokenstable and not in the new table. - It would be another table to deprecate eventually. As it stands, we can just one day remove all the invitenumbers from
tokens, and ignore thenamespccolumn - All in all, we would need to juggle two tables rather than one.
...but it wouldn't be too much effort to switch to a new table, either.
afb7e2a to
81f79a9
Compare
…rt() and set_content_type_to_encrypted() functions
Previously, AUTH tokens were only synchronized if an INVITE token was generated at the same time. This led to several user-visible bugs. Now, we always send a sync message after generating a new AUTH token. Since the INVITE token may have stayed the same, I added a UNIQUE bound to the `tokens` table, in order to prevent saving the same INVITE token many times.
81f79a9 to
6b658a0
Compare
55d6919 to
fd61423
Compare
| //! ``` | ||
| //! | ||
| //! or, if you want to only run e.g. the 'Decrypt a symmetrically encrypted message' benchmark: | ||
| //! or, if you want to only run e.g. the 'Decrypt and parse a symmetrically encrypted message' benchmark: |
There was a problem hiding this comment.
I removed the "Decrypt a symmetrically/public-key encrypted message" benchmarks, because I don't need them anymore, it seems unlikely they will be needed soon, and they made it hard to implement the performance improvement in 61ae743
|
|
||
| let request = charlie.pop_sent_msg().await; | ||
| assert_eq!(request.recipients, "alice@example.org charlie@example.net"); | ||
| assert_eq!(request.recipients, "alice@example.org"); |
There was a problem hiding this comment.
The first two messages of the protocol aren't sent to self anymore, because in many cases, one's own other devices wouldn't be able to decrypt them, anyways.
| .into(), | ||
| )); | ||
| } | ||
|
|
There was a problem hiding this comment.
I moved this code up, in order to be able to extract the group_headers_by_confidentiality() function.
| /// to prevent downloading the same message in the future. | ||
| /// | ||
| /// Returns tombstone database row ID. | ||
| pub(crate) async fn insert_tombstone(context: &Context, rfc724_mid: &str) -> Result<MsgId> { |
There was a problem hiding this comment.
I moved this function from receive_imf.rs to here, so that it can be used from the securejoin code
| context | ||
| .sync_qr_code_tokens(Some(chat.grpid.as_str())) | ||
| .await?; | ||
| context.scheduler.interrupt_smtp().await; |
There was a problem hiding this comment.
This actually fixes an existing bug:
We now generate a new AUTH token every time a QR code is shown. Therefore, we always need to sync it.
| // and because Alice anyways couldn't verify his signature at this step, | ||
| // because she doesn't know his public key yet. | ||
| } else { | ||
| remove_header(headers, "secure-join-auth", removed); |
There was a problem hiding this comment.
Btw, why do we need to remove it at all, what security issues does this fix?
There was a problem hiding this comment.
I'm not sure. I can't come up with a concrete attack, but it somewhat feels wrong to allow it in an unencrypted message. @link2xt what do you think?
link2xt
left a comment
There was a problem hiding this comment.
Did not review everything, some questions are in #7396 (comment)
src/sql/migrations.rs
Outdated
| namespc INTEGER DEFAULT 0, | ||
| foreign_key TEXT DEFAULT '', | ||
| token TEXT DEFAULT '' UNIQUE, | ||
| timestamp INTEGER DEFAULT 0, |
There was a problem hiding this comment.
Can some or all columns get NOT NULL constraint? This may break some backwards compatibility if backup is imported into older version, this needs to be tested.
There was a problem hiding this comment.
If we have defaults, then backwards compatibility problems are unlikely, right? (except if we explicitly tried to insert NULL/None). But yes, I can test this.
I think we can even remove the foreign_id column, because it wasn't used when we last increased the DCBACKUP_VERSION (it has been unused for over a year now).
There was a problem hiding this comment.
OK, I tested that with 805e5ff, everything works fine when downgrading to v2.33.0 (the oldest version since the last increase of DCBACKUP_VERSION):
- Securejoin works after downgrading
- Starting a securejoin before the downgrade, and finishing it afterwards, works (of course it doesn't work if the other side wants to use v3 because the QR code said v=3, but that's a veeery edge case)
...both for groups and 1:1 chats.
|
|
||
| tcm.section("Bob's second device also receives these messages"); | ||
| bob1.recv_msg_trash(&auth_required).await; | ||
| bob1.recv_msg_trash(&vc_pubkey).await; |
There was a problem hiding this comment.
Can old devices handle vc-pubkey or will they get undecryptable message if Bob has not upgraded all devices at once?
There was a problem hiding this comment.
They will get an undecryptable message, but this message will just be trashed ignored. There will only be a warning in the log, nothing more.
2026-02-25T11:21:33.654Z core/event INFO "" 1 "src/imap/idle.rs:87: \"INBOX\": Idle has NewData ResponseData { raw: 4096, response: MailboxData(Exists(653)) }"
2026-02-25T11:21:33.698Z core/event INFO "" 1 "src/scheduler.rs:425: IMAP loop iteration for inbox finished, keeping the session."
2026-02-25T11:21:33.699Z core/event INFO "" 1 "src/imap.rs:606: fetch_new_msg_batch(INBOX): UIDVALIDITY=1770209638, UIDNEXT=4193."
2026-02-25T11:21:33.756Z core/event INFO "" 1 "src/imap.rs:1386: Starting a full FETCH of message set \"4193\"."
2026-02-25T11:21:33.802Z core/event INFO "" 1 "src/imap.rs:1495: Passing message UID 4193 to receive_imf()."
2026-02-25T11:21:33.803Z core/event WARNING "" 1 "src/mimeparser.rs:404: decryption failed: missing key"
2026-02-25T11:21:33.804Z core/event INFO "" 1 "src/receive_imf.rs:537: Receiving message \"44915ad4-6701-4ffb-a690-dfa397708b35@localhost\", seen=false..."
2026-02-25T11:21:33.804Z core/event INFO "" 1 "src/receive_imf.rs:663: Chat assignment is OneOneChat."
2026-02-25T11:21:33.805Z core/event INFO "" 1 "src/receive_imf.rs:1625: No chat id for message (TRASH)."
2026-02-25T11:21:33.809Z core/event INFO "" 1 "src/receive_imf.rs:2202: Message has 1 parts and is assigned to chat #Chat#Trash."
2026-02-25T11:21:33.810Z core/event INFO "" 1 "src/imap.rs:1540: Successfully received 1 UIDs."
2026-02-25T11:21:33.811Z core/event INFO "" 1 "src/imap.rs:799: 1 mails read from \"INBOX\"."
2026-02-25T11:21:33.949Z core/event INFO "" 1 "src/scheduler.rs:691: IMAP session in folder \"INBOX\" supports IDLE, using it."
2026-02-25T11:21:33.995Z core/event INFO "" 1 "src/imap/idle.rs:64: IDLE entering wait-on-remote state in folder \"INBOX\"."
| token: &str, | ||
| timestamp: i64, | ||
| ) -> Result<()> { | ||
| if token.is_empty() { |
There was a problem hiding this comment.
Why does it happen? Should it be ensure_and_debug_assert instead?
There was a problem hiding this comment.
In the future, we may want to remove the INVITENUMBER from QR codes, because it's not needed anymore. Then, reviving a QR code will try to save an empty INVITENUMBER, so we need some logic for this. test_withdraw_verifycontact_without_invite tests this.
| }) | ||
| } else if let Some(step) = mime_message.get_header(HeaderDef::SecureJoin) { | ||
| match step { | ||
| "vc-request-pubkey" => Some(SecureJoinStep::RequestPubkey), |
There was a problem hiding this comment.
Can we name the new messages request-pubkey and pubkey? There is no vg variant, even for legacy messages it is not used anymore.
There was a problem hiding this comment.
Not sure, then we have inconsistency between request-pubkey/pubkey and the others. Also, I really liked that I immediately knew that if something starts with vc-, it's a securejoin step.
OTOH, it's not important to me; if you insist, I'll just rename them.
Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
730da02 to
3e6083a
Compare
5b2faef to
4295c7a
Compare
| mail_builder::headers::address::Address::new_list(to.clone()).into(), | ||
| )); | ||
|
|
||
| headers.push(( |
There was a problem hiding this comment.
Why do we need it? Subject is an optional header
There was a problem hiding this comment.
Not sure, maybe some MTAs reject messages without a subject. @link2xt ?
group_headers_by_confidentiality() will only insert Subject: [...] in the unencrypted part if there is also an encrypted subject, but of course we could just add Subject: [...] to the unencrypted headers after the group_headers_by_confidentiality() call
There was a problem hiding this comment.
All our messages have Subject: [...]. I don't know which MTAs reject such messages, maybe it theoretically can be removed, but practically it is likely to trigger some filters.
There was a problem hiding this comment.
but of course we could just add
Subject: [...]to the unencrypted headers after thegroup_headers_by_confidentiality()call
Yes, i only meant that encrypted Subject can be omitted. But if that is complicated code-wise, it can be left for now
Close #7396. Before reviewing, you should read the issue description of #7396.
I recommend to review with hidden whitespace changes.
TODO:
auth_tokenstable with a proper UNIQUE bound, or put a UNIQUE bound on thetokenstableMaybe use a new table rather than reusing AUTH token.See feat: Securejoin v3, encrypt all securejoin messages #7754 (comment)request-pubkeyrather thanrequestandpubkeyrather thanauth-required; I'll do that in a follow-up PRBackwards compatibility:
Everything works seamlessly in my tests. If both devices are updated, then the new protocol is used; otherwise, the old protocol is used. If there is a not-yet-updated second device, it will correctly observe the protocol, and mark the chat partner as verified.
Note that I removed the
Auto-Submitted: auto-repliedheader from securejoin messages. We don't need it ourselves, it's a cleartext header that leaks too much information, and I can't see any reason to have it.