Files
chatmail-core/src/decrypt.rs
Hocuri d8d7f12af0 fix: Domain separation between securejoin auth tokens and broadcast channel secrets (#7981)
Can be reviewed commit-by-commit.

This fixes another silly thing you can do with securejoinv3: show Bob a
QR code with auth token that is a broadcast channel secret of a known
channel, then never respond. Bob will decrypt messages from the channel
and drop them because they are sent by the "wrong" sender.

This can be avoided with domain separation, instead of
encrypting/decrypting securejoinv3 messages directly with auth token,
encrypt/decrypt them with `securejoin/<auth token>` as the secret or
even `securejoinv3/<alice's fingerprint>/<auth token>`. For existing
broadcast channels we cannot do this, but for securejoinv3 that is not
released yet this looks like an improvement that avoids at least this
problem.

Credits to link2xt for noticing the problem.

This also adds Alice's fingerprint to the auth tokens, which
was pretty easy to do. I find it hard to develop an intuition for
whether this is important, or whether we will be annoyed by it in the
future.

**Note:** This means that QR code scans will not work if one of the chat
partners uses a self-compiled core between c724e2981 and merging this PR
here. This is fine; we will just have to tell the other developers to
update their self-compiled cores.
2026-03-13 22:01:19 +01:00

427 lines
17 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 anyhow::{Context as _, Result, bail};
use mailparse::ParsedMail;
use pgp::composed::Esk;
use pgp::composed::Message;
use pgp::composed::PlainSessionKey;
use pgp::composed::SignedSecretKey;
use pgp::composed::decrypt_session_key_with_password;
use pgp::packet::SymKeyEncryptedSessionKey;
use pgp::types::Password;
use pgp::types::StringToKey;
use crate::chat::ChatId;
use crate::constants::Chattype;
use crate::contact::ContactId;
use crate::context::Context;
use crate::key::self_fingerprint;
use crate::key::{Fingerprint, SignedPublicKey, load_self_secret_keyring};
use crate::token::Namespace;
/// Tries to decrypt the message,
/// returning a tuple of `(decrypted message, fingerprint)`.
///
/// If the message wasn't encrypted, returns `Ok(None)`.
///
/// If the message was asymmetrically encrypted, returns `Ok((decrypted message, None))`.
///
/// If the message was symmetrically encrypted, returns `Ok((decrypted message, Some(fingerprint)))`,
/// where `fingerprint` denotes which contact is allowed to send encrypted with this symmetric secret.
/// If the message is not signed by `fingerprint`, it must be dropped.
///
/// Otherwise, Eve could send a message to Alice
/// encrypted with the symmetric secret of someone else's broadcast channel.
/// If Alice sends an answer (or read receipt),
/// then Eve would know that Alice is in the broadcast channel.
pub(crate) async fn decrypt(
context: &Context,
mail: &mailparse::ParsedMail<'_>,
) -> Result<Option<(Message<'static>, Option<String>)>> {
// `pgp::composed::Message` is huge (>4kb), so, make sure that it is in a Box when held over an await point
let Some(msg) = get_encrypted_pgp_message_boxed(mail)? else {
return Ok(None);
};
let expected_sender_fingerprint: Option<String>;
let plain = if let Message::Encrypted { esk, .. } = &*msg
// We only allow one ESK for symmetrically encrypted messages
// to avoid dealing with messages that are encrypted to multiple symmetric keys
// or a mix of symmetric and asymmetric keys:
&& let [Esk::SymKeyEncryptedSessionKey(esk)] = &esk[..]
{
check_symmetric_encryption(esk)?;
let (psk, fingerprint) = decrypt_session_key_symmetrically(context, esk)
.await
.context("decrypt_session_key_symmetrically")?;
expected_sender_fingerprint = fingerprint;
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
let plain = msg
.decrypt_with_session_key(psk)
.context("decrypt_with_session_key")?;
let plain: Message<'static> = plain.decompress()?;
Ok(plain)
})
.await??
} else {
// Message is asymmetrically encrypted
let secret_keys: Vec<SignedSecretKey> = load_self_secret_keyring(context).await?;
expected_sender_fingerprint = None;
tokio::task::spawn_blocking(move || -> Result<Message<'_>> {
let empty_pw = Password::empty();
let secret_keys: Vec<&SignedSecretKey> = secret_keys.iter().collect();
let plain = msg
.decrypt_with_keys(vec![&empty_pw], secret_keys)
.context("decrypt_with_keys")?;
let plain: Message<'static> = plain.decompress()?;
Ok(plain)
})
.await??
};
Ok(Some((plain, expected_sender_fingerprint)))
}
async fn decrypt_session_key_symmetrically(
context: &Context,
esk: &SymKeyEncryptedSessionKey,
) -> Result<(PlainSessionKey, Option<String>)> {
let self_fp = self_fingerprint(context).await?;
let query_only = true;
context
.sql
.call(query_only, |conn| {
// First, try decrypting using AUTH tokens from scanned QR codes, stored in the bobstate,
// because usually there will only be 1 or 2 of it, so, it should be fast
let res: Option<(PlainSessionKey, String)> = try_decrypt_with_bobstate(esk, conn)?;
if let Some((plain_session_key, fingerprint)) = res {
return Ok((plain_session_key, Some(fingerprint)));
}
// Then, try decrypting using broadcast secrets
let res: Option<(PlainSessionKey, Option<String>)> =
try_decrypt_with_broadcast_secret(esk, conn)?;
if let Some((plain_session_key, fingerprint)) = res {
return Ok((plain_session_key, fingerprint));
}
// Finally, try decrypting using own AUTH tokens
// There can be a lot of AUTH tokens,
// because a new one is generated every time a QR code is shown
let res: Option<PlainSessionKey> = try_decrypt_with_auth_token(esk, conn, self_fp)?;
if let Some(plain_session_key) = res {
return Ok((plain_session_key, None));
}
bail!("Could not find symmetric secret for session key")
})
.await
}
fn try_decrypt_with_bobstate(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, String)>> {
let mut stmt = conn.prepare("SELECT invite FROM bobstate")?;
let mut rows = stmt.query(())?;
while let Some(row) = rows.next()? {
let invite: crate::securejoin::QrInvite = row.get(0)?;
let authcode = invite.authcode().to_string();
let alice_fp = invite.fingerprint().hex();
let shared_secret = format!("securejoin/{alice_fp}/{authcode}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
let fingerprint = invite.fingerprint().hex();
return Ok(Some((psk, fingerprint)));
}
}
Ok(None)
}
fn try_decrypt_with_broadcast_secret(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, Option<String>)>> {
let Some((psk, chat_id)) = try_decrypt_with_broadcast_secret_inner(esk, conn)? else {
return Ok(None);
};
let chat_type: Chattype =
conn.query_one("SELECT type FROM chats WHERE id=?", (chat_id,), |row| {
row.get(0)
})?;
let fp: Option<String> = if chat_type == Chattype::OutBroadcast {
// An attacker who knows the secret will also know who owns it,
// and it's easiest code-wise to just return None here.
// But we could alternatively return the self fingerprint here
None
} else if chat_type == Chattype::InBroadcast {
let contact_id: ContactId = conn
.query_one(
"SELECT contact_id FROM chats_contacts WHERE chat_id=? AND contact_id>9",
(chat_id,),
|row| row.get(0),
)
.context("Find InBroadcast owner")?;
let fp = conn
.query_one(
"SELECT fingerprint FROM contacts WHERE id=?",
(contact_id,),
|row| row.get(0),
)
.context("Find owner fingerprint")?;
Some(fp)
} else {
bail!("Chat {chat_id} is not a broadcast but {chat_type}")
};
Ok(Some((psk, fp)))
}
fn try_decrypt_with_broadcast_secret_inner(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
) -> Result<Option<(PlainSessionKey, ChatId)>> {
let mut stmt = conn.prepare("SELECT secret, chat_id FROM broadcast_secrets")?;
let mut rows = stmt.query(())?;
while let Some(row) = rows.next()? {
let secret: String = row.get(0)?;
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(secret)) {
let chat_id: ChatId = row.get(1)?;
return Ok(Some((psk, chat_id)));
}
}
Ok(None)
}
fn try_decrypt_with_auth_token(
esk: &SymKeyEncryptedSessionKey,
conn: &mut rusqlite::Connection,
self_fingerprint: &str,
) -> Result<Option<PlainSessionKey>> {
// ORDER BY id DESC to query the most-recently saved tokens are returned first.
// This improves performance when Bob scans a QR code that was just created.
let mut stmt = conn.prepare("SELECT token FROM tokens WHERE namespc=? ORDER BY id DESC")?;
let mut rows = stmt.query((Namespace::Auth,))?;
while let Some(row) = rows.next()? {
let token: String = row.get(0)?;
let shared_secret = format!("securejoin/{self_fingerprint}/{token}");
if let Ok(psk) = decrypt_session_key_with_password(esk, &Password::from(shared_secret)) {
return Ok(Some(psk));
}
}
Ok(None)
}
/// Returns Ok(()) if we want to try symmetrically decrypting the message,
/// and Err with a reason if symmetric decryption should not be tried.
///
/// A DoS attacker could send a message with a lot of encrypted session keys,
/// all of which use a very hard-to-compute string2key algorithm.
/// We would then try to decrypt all of the encrypted session keys
/// with all of the known shared secrets.
/// In order to prevent this, we do not try to symmetrically decrypt messages
/// that use a string2key algorithm other than 'Salted'.
pub(crate) fn check_symmetric_encryption(esk: &SymKeyEncryptedSessionKey) -> Result<()> {
match esk.s2k() {
Some(StringToKey::Salted { .. }) => Ok(()),
_ => bail!("unsupported string2key algorithm"),
}
}
/// Turns a [`ParsedMail`] into [`pgp::composed::Message`].
/// [`pgp::composed::Message`] is huge (over 4kb),
/// so, it is put on the heap using [`Box`].
pub fn get_encrypted_pgp_message_boxed<'a>(
mail: &'a ParsedMail<'a>,
) -> Result<Option<Box<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(Box::new(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) => {
crate::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(())
}
}