Files
chatmail-core/src/decrypt.rs
Hocuri c724e2981c feat: Securejoin v3, encrypt all securejoin messages (#7754)
Close https://github.com/chatmail/core/issues/7396. Before reviewing,
you should read the issue description of
https://github.com/chatmail/core/issues/7396.
I recommend to review with hidden whitespace changes.

TODO:
- [x] Implement the new protocol
- [x] Make Rust tests pass
- [x] Make Python tests pass
- [x] Test it manually on a phone
- [x] Print the sent messages, and check that they look how they should:
[test_secure_join_group_with_mime_printed.txt](https://github.com/user-attachments/files/24800556/test_secure_join_group.txt)
- [x] 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 6b658a0e0
- [x] Either make a new `auth_tokens` table with a proper UNIQUE bound,
or put a UNIQUE bound on the `tokens` table
- [x] Benchmarking
- [x] TODOs in the code, maybe change naming of the new functions
- [x] Write test for interop with older DC (esp. that the original
securejoin runs if you remove the &v=3 param)
- [x] From a cryptography perspective, is it fine that vc-request is
encrypted with AUTH, rather than a separate secret (like INVITE)?
- [x] Make sure that QR codes without INVITE work, so that we can remove
it eventually
- [x] Self-review, and comment on some of my code changes to explain
what they do
- [x] ~~Maybe use a new table rather than reusing AUTH token.~~ See
https://github.com/chatmail/core/pull/7754#discussion_r2728544725
- [ ] Update documentation; I'll do that in a separate PR. All necessary
information is in the https://github.com/chatmail/core/issues/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.

---------

Co-authored-by: iequidoo <117991069+iequidoo@users.noreply.github.com>
2026-03-02 16:37:14 +00:00

197 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Helper functions for decryption.
//! The actual decryption is done in the [`crate::pgp`] module.
use std::collections::HashSet;
use std::io::Cursor;
use ::pgp::composed::Message;
use anyhow::Result;
use mailparse::ParsedMail;
use crate::key::{Fingerprint, SignedPublicKey};
use crate::pgp;
pub fn get_encrypted_pgp_message<'a>(mail: &'a ParsedMail<'a>) -> Result<Option<Message<'static>>> {
let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
return Ok(None);
};
let data = encrypted_data_part.get_body_raw()?;
let cursor = Cursor::new(data);
let (msg, _headers) = Message::from_armor(cursor)?;
Ok(Some(msg))
}
/// Returns a reference to the encrypted payload of a message.
pub fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
get_autocrypt_mime(mail)
.or_else(|| get_mixed_up_mime(mail))
.or_else(|| get_attachment_mime(mail))
}
/// Returns a reference to the encrypted payload of a ["Mixed
/// Up"][pgpmime-message-mangling] message.
///
/// According to [RFC 3156] encrypted messages should have
/// `multipart/encrypted` MIME type and two parts, but Microsoft
/// Exchange and ProtonMail IMAP/SMTP Bridge are known to mangle this
/// structure by changing the type to `multipart/mixed` and prepending
/// an empty part at the start.
///
/// ProtonMail IMAP/SMTP Bridge prepends a part literally saying
/// "Empty Message", so we don't check its contents at all, checking
/// only for `text/plain` type.
///
/// Returns `None` if the message is not a "Mixed Up" message.
///
/// [RFC 3156]: https://www.rfc-editor.org/info/rfc3156
/// [pgpmime-message-mangling]: https://tools.ietf.org/id/draft-dkg-openpgp-pgpmime-message-mangling-00.html
fn get_mixed_up_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
if mail.ctype.mimetype != "multipart/mixed" {
return None;
}
if let [first_part, second_part, third_part] = &mail.subparts[..] {
if first_part.ctype.mimetype == "text/plain"
&& second_part.ctype.mimetype == "application/pgp-encrypted"
&& third_part.ctype.mimetype == "application/octet-stream"
{
Some(third_part)
} else {
None
}
} else {
None
}
}
/// Returns a reference to the encrypted payload of a message turned into attachment.
///
/// Google Workspace has an option "Append footer" which appends standard footer defined
/// by administrator to all outgoing messages. However, there is no plain text part in
/// encrypted messages sent by Delta Chat, so Google Workspace turns the message into
/// multipart/mixed MIME, where the first part is an empty plaintext part with a footer
/// and the second part is the original encrypted message.
fn get_attachment_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
if mail.ctype.mimetype != "multipart/mixed" {
return None;
}
if let [first_part, second_part] = &mail.subparts[..] {
if first_part.ctype.mimetype == "text/plain"
&& second_part.ctype.mimetype == "multipart/encrypted"
{
get_autocrypt_mime(second_part)
} else {
None
}
} else {
None
}
}
/// Returns a reference to the encrypted payload of a valid PGP/MIME message.
///
/// Returns `None` if the message is not a valid PGP/MIME message.
fn get_autocrypt_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
if mail.ctype.mimetype != "multipart/encrypted" {
return None;
}
if let [first_part, second_part] = &mail.subparts[..] {
if first_part.ctype.mimetype == "application/pgp-encrypted"
&& second_part.ctype.mimetype == "application/octet-stream"
{
Some(second_part)
} else {
None
}
} else {
None
}
}
/// Validates signatures of Multipart/Signed message part, as defined in RFC 1847.
///
/// Returns the signed part and the set of key
/// fingerprints for which there is a valid signature.
///
/// Returns None if the message is not Multipart/Signed or doesn't contain necessary parts.
pub(crate) fn validate_detached_signature<'a, 'b>(
mail: &'a ParsedMail<'b>,
public_keyring_for_validate: &[SignedPublicKey],
) -> Option<(&'a ParsedMail<'b>, HashSet<Fingerprint>)> {
if mail.ctype.mimetype != "multipart/signed" {
return None;
}
if let [first_part, second_part] = &mail.subparts[..] {
// First part is the content, second part is the signature.
let content = first_part.raw_bytes;
let ret_valid_signatures = match second_part.get_body_raw() {
Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
.unwrap_or_default(),
Err(_) => Default::default(),
};
Some((first_part, ret_valid_signatures))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receive_imf::receive_imf;
use crate::test_utils::TestContext;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mixed_up_mime() -> Result<()> {
// "Mixed Up" mail as received when sending an encrypted
// message using Delta Chat Desktop via ProtonMail IMAP/SMTP
// Bridge.
let mixed_up_mime = include_bytes!("../test-data/message/protonmail-mixed-up.eml");
let mail = mailparse::parse_mail(mixed_up_mime)?;
assert!(get_autocrypt_mime(&mail).is_none());
assert!(get_mixed_up_mime(&mail).is_some());
assert!(get_attachment_mime(&mail).is_none());
// Same "Mixed Up" mail repaired by Thunderbird 78.9.0.
//
// It added `X-Enigmail-Info: Fixed broken PGP/MIME message`
// header although the repairing is done by the built-in
// OpenPGP support, not Enigmail.
let repaired_mime = include_bytes!("../test-data/message/protonmail-repaired.eml");
let mail = mailparse::parse_mail(repaired_mime)?;
assert!(get_autocrypt_mime(&mail).is_some());
assert!(get_mixed_up_mime(&mail).is_none());
assert!(get_attachment_mime(&mail).is_none());
// Another form of "Mixed Up" mail created by Google Workspace,
// where original message is turned into attachment to empty plaintext message.
let attachment_mime = include_bytes!("../test-data/message/google-workspace-mixed-up.eml");
let mail = mailparse::parse_mail(attachment_mime)?;
assert!(get_autocrypt_mime(&mail).is_none());
assert!(get_mixed_up_mime(&mail).is_none());
assert!(get_attachment_mime(&mail).is_some());
let bob = TestContext::new_bob().await;
receive_imf(&bob, attachment_mime, false).await?;
let msg = bob.get_last_msg().await;
// Subject should be prepended because the attachment doesn't have "Chat-Version".
assert_eq!(msg.text, "Hello, Bob! Hello from Thunderbird!");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mixed_up_mime_long() -> Result<()> {
// Long "mixed-up" mail as received when sending an encrypted message using Delta Chat
// Desktop via MS Exchange (actually made with TB though).
let mixed_up_mime = include_bytes!("../test-data/message/mixed-up-long.eml");
let bob = TestContext::new_bob().await;
receive_imf(&bob, mixed_up_mime, false).await?;
let msg = bob.get_last_msg().await;
assert!(!msg.get_text().is_empty());
assert!(msg.has_html());
assert!(msg.id.get_html(&bob).await?.unwrap().len() > 40000);
Ok(())
}
}