deltachat/
decrypt.rs

1//! Helper functions for decryption.
2//! The actual decryption is done in the [`crate::pgp`] module.
3
4use std::collections::HashSet;
5use std::io::Cursor;
6
7use ::pgp::composed::Message;
8use anyhow::Result;
9use mailparse::ParsedMail;
10
11use crate::key::{Fingerprint, SignedPublicKey};
12use crate::pgp;
13
14pub fn get_encrypted_pgp_message<'a>(mail: &'a ParsedMail<'a>) -> Result<Option<Message<'static>>> {
15    let Some(encrypted_data_part) = get_encrypted_mime(mail) else {
16        return Ok(None);
17    };
18    let data = encrypted_data_part.get_body_raw()?;
19    let cursor = Cursor::new(data);
20    let (msg, _headers) = Message::from_armor(cursor)?;
21    Ok(Some(msg))
22}
23
24/// Returns a reference to the encrypted payload of a message.
25pub fn get_encrypted_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
26    get_autocrypt_mime(mail)
27        .or_else(|| get_mixed_up_mime(mail))
28        .or_else(|| get_attachment_mime(mail))
29}
30
31/// Returns a reference to the encrypted payload of a ["Mixed
32/// Up"][pgpmime-message-mangling] message.
33///
34/// According to [RFC 3156] encrypted messages should have
35/// `multipart/encrypted` MIME type and two parts, but Microsoft
36/// Exchange and ProtonMail IMAP/SMTP Bridge are known to mangle this
37/// structure by changing the type to `multipart/mixed` and prepending
38/// an empty part at the start.
39///
40/// ProtonMail IMAP/SMTP Bridge prepends a part literally saying
41/// "Empty Message", so we don't check its contents at all, checking
42/// only for `text/plain` type.
43///
44/// Returns `None` if the message is not a "Mixed Up" message.
45///
46/// [RFC 3156]: https://www.rfc-editor.org/info/rfc3156
47/// [pgpmime-message-mangling]: https://tools.ietf.org/id/draft-dkg-openpgp-pgpmime-message-mangling-00.html
48fn get_mixed_up_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
49    if mail.ctype.mimetype != "multipart/mixed" {
50        return None;
51    }
52    if let [first_part, second_part, third_part] = &mail.subparts[..] {
53        if first_part.ctype.mimetype == "text/plain"
54            && second_part.ctype.mimetype == "application/pgp-encrypted"
55            && third_part.ctype.mimetype == "application/octet-stream"
56        {
57            Some(third_part)
58        } else {
59            None
60        }
61    } else {
62        None
63    }
64}
65
66/// Returns a reference to the encrypted payload of a message turned into attachment.
67///
68/// Google Workspace has an option "Append footer" which appends standard footer defined
69/// by administrator to all outgoing messages. However, there is no plain text part in
70/// encrypted messages sent by Delta Chat, so Google Workspace turns the message into
71/// multipart/mixed MIME, where the first part is an empty plaintext part with a footer
72/// and the second part is the original encrypted message.
73fn get_attachment_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
74    if mail.ctype.mimetype != "multipart/mixed" {
75        return None;
76    }
77    if let [first_part, second_part] = &mail.subparts[..] {
78        if first_part.ctype.mimetype == "text/plain"
79            && second_part.ctype.mimetype == "multipart/encrypted"
80        {
81            get_autocrypt_mime(second_part)
82        } else {
83            None
84        }
85    } else {
86        None
87    }
88}
89
90/// Returns a reference to the encrypted payload of a valid PGP/MIME message.
91///
92/// Returns `None` if the message is not a valid PGP/MIME message.
93fn get_autocrypt_mime<'a, 'b>(mail: &'a ParsedMail<'b>) -> Option<&'a ParsedMail<'b>> {
94    if mail.ctype.mimetype != "multipart/encrypted" {
95        return None;
96    }
97    if let [first_part, second_part] = &mail.subparts[..] {
98        if first_part.ctype.mimetype == "application/pgp-encrypted"
99            && second_part.ctype.mimetype == "application/octet-stream"
100        {
101            Some(second_part)
102        } else {
103            None
104        }
105    } else {
106        None
107    }
108}
109
110/// Validates signatures of Multipart/Signed message part, as defined in RFC 1847.
111///
112/// Returns the signed part and the set of key
113/// fingerprints for which there is a valid signature.
114///
115/// Returns None if the message is not Multipart/Signed or doesn't contain necessary parts.
116pub(crate) fn validate_detached_signature<'a, 'b>(
117    mail: &'a ParsedMail<'b>,
118    public_keyring_for_validate: &[SignedPublicKey],
119) -> Option<(&'a ParsedMail<'b>, HashSet<Fingerprint>)> {
120    if mail.ctype.mimetype != "multipart/signed" {
121        return None;
122    }
123
124    if let [first_part, second_part] = &mail.subparts[..] {
125        // First part is the content, second part is the signature.
126        let content = first_part.raw_bytes;
127        let ret_valid_signatures = match second_part.get_body_raw() {
128            Ok(signature) => pgp::pk_validate(content, &signature, public_keyring_for_validate)
129                .unwrap_or_default(),
130            Err(_) => Default::default(),
131        };
132        Some((first_part, ret_valid_signatures))
133    } else {
134        None
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::receive_imf::receive_imf;
142    use crate::test_utils::TestContext;
143
144    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
145    async fn test_mixed_up_mime() -> Result<()> {
146        // "Mixed Up" mail as received when sending an encrypted
147        // message using Delta Chat Desktop via ProtonMail IMAP/SMTP
148        // Bridge.
149        let mixed_up_mime = include_bytes!("../test-data/message/protonmail-mixed-up.eml");
150        let mail = mailparse::parse_mail(mixed_up_mime)?;
151        assert!(get_autocrypt_mime(&mail).is_none());
152        assert!(get_mixed_up_mime(&mail).is_some());
153        assert!(get_attachment_mime(&mail).is_none());
154
155        // Same "Mixed Up" mail repaired by Thunderbird 78.9.0.
156        //
157        // It added `X-Enigmail-Info: Fixed broken PGP/MIME message`
158        // header although the repairing is done by the built-in
159        // OpenPGP support, not Enigmail.
160        let repaired_mime = include_bytes!("../test-data/message/protonmail-repaired.eml");
161        let mail = mailparse::parse_mail(repaired_mime)?;
162        assert!(get_autocrypt_mime(&mail).is_some());
163        assert!(get_mixed_up_mime(&mail).is_none());
164        assert!(get_attachment_mime(&mail).is_none());
165
166        // Another form of "Mixed Up" mail created by Google Workspace,
167        // where original message is turned into attachment to empty plaintext message.
168        let attachment_mime = include_bytes!("../test-data/message/google-workspace-mixed-up.eml");
169        let mail = mailparse::parse_mail(attachment_mime)?;
170        assert!(get_autocrypt_mime(&mail).is_none());
171        assert!(get_mixed_up_mime(&mail).is_none());
172        assert!(get_attachment_mime(&mail).is_some());
173
174        let bob = TestContext::new_bob().await;
175        receive_imf(&bob, attachment_mime, false).await?;
176        let msg = bob.get_last_msg().await;
177        // Subject should be prepended because the attachment doesn't have "Chat-Version".
178        assert_eq!(msg.text, "Hello, Bob! – Hello from Thunderbird!");
179
180        Ok(())
181    }
182
183    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
184    async fn test_mixed_up_mime_long() -> Result<()> {
185        // Long "mixed-up" mail as received when sending an encrypted message using Delta Chat
186        // Desktop via MS Exchange (actually made with TB though).
187        let mixed_up_mime = include_bytes!("../test-data/message/mixed-up-long.eml");
188        let bob = TestContext::new_bob().await;
189        receive_imf(&bob, mixed_up_mime, false).await?;
190        let msg = bob.get_last_msg().await;
191        assert!(!msg.get_text().is_empty());
192        assert!(msg.has_html());
193        assert!(msg.id.get_html(&bob).await?.unwrap().len() > 40000);
194        Ok(())
195    }
196}