Skip to content

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
I don't need them anymore, it seems unlikely they will be needed soon,
and they made it hard to implement a performance improvement I'm
planning
@Hocuri
Copy link
Collaborator Author

Hocuri commented Feb 10, 2026

Benchmarking results:

Details

Loading 5000 auth tokens (a reeeally high number) slows down receiving a pk-encrypted message by ~7%

➜  deltachat-core-rust git:(hoc/securejoin-v3) ✗ NUM_BROADCAST_SECRETS=499 NUM_AUTH_TOKENS=2 c bench --bench decrypting --features="internals" -- 'Decrypt and parse';pling
firejail version 0.9.78

Parent pid 167445, child pid 167446
Base filesystem installed in 0.09 ms
Child process initialized in 19.40 ms
    Finished `bench` profile [optimized] target(s) in 0.30s
     Running benches/decrypting.rs (target/release/deps/decrypting-92a1aefcc2bf4270)
NUM_BROADCAST_SECRETS=499
NUM_AUTH_TOKENS=2
Decrypt/Decrypt and parse a symmetrically encrypted message
                        time:   [11.192 ms 11.198 ms 11.206 ms]
                        change: [−10.279% −10.136% −9.9857%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 2 outliers among 10 measurements (20.00%)
  1 (10.00%) low mild
  1 (10.00%) high mild
Decrypt/Decrypt and parse a public-key encrypted message
                        time:   [12.466 ms 12.469 ms 12.475 ms]
                        change: [−7.0396% −6.9896% −6.9373%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 1 outliers among 10 measurements (10.00%)
  1 (10.00%) high mild


Parent is shutting down, bye...
➜  deltachat-core-rust git:(hoc/securejoin-v3) ✗ NUM_BROADCAST_SECRETS=499 NUM_AUTH_TOKENS=4999 c bench --bench decrypting --features="internals" -- 'Decrypt and parse';pling
firejail version 0.9.78

Parent pid 167569, child pid 167570
Base filesystem installed in 0.11 ms
Child process initialized in 20.70 ms
    Finished `bench` profile [optimized] target(s) in 0.30s
     Running benches/decrypting.rs (target/release/deps/decrypting-92a1aefcc2bf4270)
NUM_BROADCAST_SECRETS=499
NUM_AUTH_TOKENS=4999
Decrypt/Decrypt and parse a symmetrically encrypted message
                        time:   [12.435 ms 12.439 ms 12.444 ms]
                        change: [+10.951% +11.106% +11.267%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 1 outliers among 10 measurements (10.00%)
  1 (10.00%) high severe
Decrypt/Decrypt and parse a public-key encrypted message
                        time:   [13.384 ms 13.395 ms 13.408 ms]
                        change: [+7.3096% +7.4043% +7.5107%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 1 outliers among 10 measurements (10.00%)
  1 (10.00%) high mild


Parent is shutting down, bye...

We don't need to load the secrets for messages that are asymmetrically encrypted see dbd6360.

Replacing ORDER BY id DESC with calling rev() in Rust doesn't improve performance:

NUM_BROADCAST_SECRETS=500
NUM_AUTH_TOKENS=5000
Decrypt/Decrypt and parse a symmetrically encrypted message
                        time:   [12.420 ms 12.432 ms 12.446 ms]
                        change: [+0.4867% +0.5933% +0.7196%] (p = 0.00 < 0.05)
                        Change within noise threshold.
Found 6 outliers among 100 measurements (6.00%)
  1 (1.00%) high mild
  5 (5.00%) high severe
Decrypt/Decrypt and parse a public-key encrypted message
                        time:   [12.263 ms 12.280 ms 12.302 ms]
                        change: [−0.1650% +0.0110% +0.2077%] (p = 0.92 > 0.05)
                        No change in performance detected.
Found 16 outliers among 100 measurements (16.00%)
  4 (4.00%) high mild
  12 (12.00%) high severe

This improves performance, though not a lot - 8% performance improvement
if there is a huge number of shared secrets. Anyways, I think it's worth
it.

```
NUM_BROADCAST_SECRETS=500
NUM_AUTH_TOKENS=5000
Decrypt/Decrypt and parse a symmetrically encrypted message
                        time:   [12.354 ms 12.364 ms 12.376 ms]
                        change: [−0.5375% −0.3815% −0.2345%] (p = 0.00 < 0.05)
                        Change within noise threshold.
Found 7 outliers among 100 measurements (7.00%)
  3 (3.00%) high mild
  4 (4.00%) high severe
Decrypt/Decrypt and parse a public-key encrypted message
                        time:   [12.269 ms 12.273 ms 12.278 ms]
                        change: [−7.9163% −7.8439% −7.7724%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 13 outliers among 100 measurements (13.00%)
  8 (8.00%) high mild
  5 (5.00%) high severe
```
//! ```
//!
//! 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
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

1 participant