Skip to content

feat: Securejoin v3, encrypt all securejoin messages#7754

Open
Hocuri wants to merge 51 commits intomainfrom
hoc/securejoin-v3
Open

feat: Securejoin v3, encrypt all securejoin messages#7754
Hocuri wants to merge 51 commits intomainfrom
hoc/securejoin-v3

Conversation

@Hocuri
Copy link
Collaborator

@Hocuri Hocuri commented Jan 20, 2026

Close #7396. Before reviewing, you should read the issue description of #7396.
I recommend to review with hidden whitespace changes.

TODO:

  • Implement the new protocol
  • Make Rust tests pass
  • Make Python tests pass
  • Test it manually on a phone
  • Print the sent messages, and check that they look how they should: test_secure_join_group_with_mime_printed.txt
  • Fix bug: If Alice has a second device, then Bob's chat won't be shown yet on that second device. Also, Bob's contact isn't shown in her contact list. As soon as either party writes something into the chat, the that shows up and everything is fine. All of this is still a way better UX than in WhatsApp, where Bob always has to write first 😂 Still, I should fix that.
    • This is actually caused by a larger bug: AUTH tokens aren't synced if there is no corresponding INVITE token.
    • Fixed by 6b658a0
  • Either make a new auth_tokens table with a proper UNIQUE bound, or put a UNIQUE bound on the tokens table
  • Benchmarking
  • TODOs in the code, maybe change naming of the new functions
  • Write test for interop with older DC (esp. that the original securejoin runs if you remove the &v=3 param)
  • From a cryptography perspective, is it fine that vc-request is encrypted with AUTH, rather than a separate secret (like INVITE)?
  • Make sure that QR codes without INVITE work, so that we can remove it eventually
  • Self-review, and comment on some of my code changes to explain what they do
  • Maybe use a new table rather than reusing AUTH token. See feat: Securejoin v3, encrypt all securejoin messages #7754 (comment)
  • Update documentation; I'll do that in a separate PR. All necessary information is in the Securejoin v3: 4-step protocol with symmetric encryption #7396 issue description
  • Update tests and other code to use the new names (e.g. request-pubkey rather than request and pubkey rather than auth-required; I'll do that in a follow-up PR

Backwards 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-replied header 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.

@Hocuri Hocuri changed the title [WIP] Securejoin v3: Encrypt all securejoin messages [WIP] feat: Securejoin v3, encrypt all securejoin messages Jan 20, 2026
@Hocuri Hocuri force-pushed the hoc/securejoin-v3 branch 2 times, most recently from 59957ed to ef97a7e Compare January 21, 2026 15:44
@Hocuri Hocuri force-pushed the hoc/securejoin-v3 branch from 807b8c4 to afb7e2a Compare January 22, 2026 17:06
Ok(secret)
})
.await?;
secrets.extend(token::lookup_all(context, token::Namespace::Auth).await?);
Copy link
Collaborator Author

@Hocuri Hocuri Jan 26, 2026

Choose a reason for hiding this comment

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

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" in handle_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 the tokens table 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 the namespc column
  • 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.

@Hocuri Hocuri force-pushed the hoc/securejoin-v3 branch from afb7e2a to 81f79a9 Compare January 26, 2026 18:18
Hocuri added 19 commits January 27, 2026 12:31
…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.
@Hocuri Hocuri force-pushed the hoc/securejoin-v3 branch from 81f79a9 to 6b658a0 Compare January 30, 2026 16:46
//! ```
//!
//! 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:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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");
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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(),
));
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I moved this function from receive_imf.rs to here, so that it can be used from the securejoin code

Comment on lines +156 to +159
context
.sync_qr_code_tokens(Some(chat.grpid.as_str()))
.await?;
context.scheduler.interrupt_smtp().await;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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.

@Hocuri Hocuri changed the title [WIP] feat: Securejoin v3, encrypt all securejoin messages feat: Securejoin v3, encrypt all securejoin messages Feb 10, 2026
@Hocuri Hocuri requested review from iequidoo and link2xt February 10, 2026 20:52
// 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);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Btw, why do we need to remove it at all, what security issues does this fix?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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?

Copy link
Collaborator

@link2xt link2xt left a comment

Choose a reason for hiding this comment

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

Did not review everything, some questions are in #7396 (comment)

namespc INTEGER DEFAULT 0,
foreign_key TEXT DEFAULT '',
token TEXT DEFAULT '' UNIQUE,
timestamp INTEGER DEFAULT 0,
Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Collaborator Author

@Hocuri Hocuri Feb 25, 2026

Choose a reason for hiding this comment

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

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can old devices handle vc-pubkey or will they get undecryptable message if Bob has not upgraded all devices at once?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why does it happen? Should it be ensure_and_debug_assert instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we name the new messages request-pubkey and pubkey? There is no vg variant, even for legacy messages it is not used anymore.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

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.

mail_builder::headers::address::Address::new_list(to.clone()).into(),
));

headers.push((
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we need it? Subject is an optional header

Copy link
Collaborator Author

@Hocuri Hocuri Feb 25, 2026

Choose a reason for hiding this comment

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

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Collaborator

Choose a reason for hiding this comment

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

but of course we could just add Subject: [...] to the unencrypted headers after the group_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

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.

Securejoin v3: 4-step protocol with symmetric encryption

3 participants