deltachat/
authres.rs

1//! Parsing and handling of the Authentication-Results header.
2//! See the comment on [`handle_authres`] for more.
3
4use std::borrow::Cow;
5use std::collections::BTreeSet;
6use std::fmt;
7use std::sync::LazyLock;
8
9use anyhow::Result;
10use deltachat_contact_tools::EmailAddress;
11use mailparse::MailHeaderMap;
12use mailparse::ParsedMail;
13
14use crate::config::Config;
15use crate::context::Context;
16use crate::headerdef::HeaderDef;
17
18/// `authres` is short for the Authentication-Results header, defined in
19/// <https://datatracker.ietf.org/doc/html/rfc8601>, which contains info
20/// about whether DKIM and SPF passed.
21///
22/// To mitigate From forgery, we remember for each sending domain whether it is known
23/// to have valid DKIM. If an email from such a domain comes with invalid DKIM,
24/// we don't allow changing the autocrypt key.
25///
26/// See <https://github.com/deltachat/deltachat-core-rust/issues/3507>.
27pub(crate) async fn handle_authres(
28    context: &Context,
29    mail: &ParsedMail<'_>,
30    from: &str,
31) -> Result<DkimResults> {
32    let from_domain = match EmailAddress::new(from) {
33        Ok(email) => email.domain,
34        Err(e) => {
35            return Err(anyhow::format_err!("invalid email {}: {:#}", from, e));
36        }
37    };
38
39    let authres = parse_authres_headers(&mail.get_headers(), &from_domain);
40    update_authservid_candidates(context, &authres).await?;
41    compute_dkim_results(context, authres).await
42}
43
44#[derive(Debug)]
45pub(crate) struct DkimResults {
46    /// Whether DKIM passed for this particular e-mail.
47    pub dkim_passed: bool,
48}
49
50impl fmt::Display for DkimResults {
51    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
52        write!(fmt, "DKIM Results: Passed={}", self.dkim_passed)?;
53        Ok(())
54    }
55}
56
57type AuthservId = String;
58
59#[derive(Debug, PartialEq)]
60enum DkimResult {
61    /// The header explicitly said that DKIM passed
62    Passed,
63    /// The header explicitly said that DKIM failed
64    Failed,
65    /// The header didn't say anything about DKIM; this might mean that it wasn't
66    /// checked, but it might also mean that it failed. This is because some providers
67    /// (e.g. ik.me, mail.ru, posteo.de) don't add `dkim=none` to their
68    /// Authentication-Results if there was no DKIM.
69    Nothing,
70}
71
72type ParsedAuthresHeaders = Vec<(AuthservId, DkimResult)>;
73
74fn parse_authres_headers(
75    headers: &mailparse::headers::Headers<'_>,
76    from_domain: &str,
77) -> ParsedAuthresHeaders {
78    let mut res = Vec::new();
79    for header_value in headers.get_all_values(HeaderDef::AuthenticationResults.into()) {
80        let header_value = remove_comments(&header_value);
81
82        if let Some(mut authserv_id) = header_value.split(';').next() {
83            if authserv_id.contains(char::is_whitespace) || authserv_id.is_empty() {
84                // Outlook violates the RFC by not adding an authserv-id at all, which we notice
85                // because there is whitespace in the first identifier before the ';'.
86                // Authentication-Results-parsing still works securely because they remove incoming
87                // Authentication-Results headers.
88                // We just use an arbitrary authserv-id, it will work for Outlook, and in general,
89                // with providers not implementing the RFC correctly, someone can trick us
90                // into thinking that an incoming email is DKIM-correct, anyway.
91                // The most important thing here is that we have some valid `authserv_id`.
92                authserv_id = "invalidAuthservId";
93            }
94            let dkim_passed = parse_one_authres_header(&header_value, from_domain);
95            res.push((authserv_id.to_string(), dkim_passed));
96        }
97    }
98
99    res
100}
101
102/// The headers can contain comments that look like this:
103/// ```text
104/// Authentication-Results: (this is a comment) gmx.net; (another; comment) dkim=pass;
105/// ```
106fn remove_comments(header: &str) -> Cow<'_, str> {
107    // In Pomsky, this is:
108    //     "(" Codepoint* lazy ")"
109    // See https://playground.pomsky-lang.org/?text=%22(%22%20Codepoint*%20lazy%20%22)%22
110    static RE: LazyLock<regex::Regex> =
111        LazyLock::new(|| regex::Regex::new(r"\([\s\S]*?\)").unwrap());
112
113    RE.replace_all(header, " ")
114}
115
116/// Parses a single Authentication-Results header, like:
117///
118/// ```text
119/// Authentication-Results:  gmx.net; dkim=pass header.i=@slack.com
120/// ```
121fn parse_one_authres_header(header_value: &str, from_domain: &str) -> DkimResult {
122    if let Some((before_dkim_part, dkim_to_end)) = header_value.split_once("dkim=") {
123        // Check that the character right before `dkim=` is a space or a tab
124        // so that we wouldn't e.g. mistake `notdkim=pass` for `dkim=pass`
125        if before_dkim_part.ends_with(' ') || before_dkim_part.ends_with('\t') {
126            let dkim_part = dkim_to_end.split(';').next().unwrap_or_default();
127            let dkim_parts: Vec<_> = dkim_part.split_whitespace().collect();
128            if let Some(&"pass") = dkim_parts.first() {
129                // DKIM headers contain a header.d or header.i field
130                // that says which domain signed. We have to check ourselves
131                // that this is the same domain as in the From header.
132                let header_d: &str = &format!("header.d={}", &from_domain);
133                let header_i: &str = &format!("header.i=@{}", &from_domain);
134
135                if dkim_parts.contains(&header_d) || dkim_parts.contains(&header_i) {
136                    // We have found a `dkim=pass` header!
137                    return DkimResult::Passed;
138                }
139            } else {
140                // dkim=fail, dkim=none, ...
141                return DkimResult::Failed;
142            }
143        }
144    }
145
146    DkimResult::Nothing
147}
148
149/// ## About authserv-ids
150///
151/// After having checked DKIM, our email server adds an Authentication-Results header.
152///
153/// Now, an attacker could just add an Authentication-Results header that says dkim=pass
154/// in order to make us think that DKIM was correct in their From-forged email.
155///
156/// In order to prevent this, each email server adds its authserv-id to the
157/// Authentication-Results header, e.g. Testrun's authserv-id is `testrun.org`, Gmail's
158/// is `mx.google.com`. When Testrun gets a mail delivered from outside, it will then
159/// remove any Authentication-Results headers whose authserv-id is also `testrun.org`.
160///
161/// We need to somehow find out the authserv-id(s) of our email server, so that
162/// we can use the Authentication-Results with the right authserv-id.
163///
164/// ## What this function does
165///
166/// When receiving an email, this function is called and updates the candidates for
167/// our server's authserv-id, i.e. what we think our server's authserv-id is.
168///
169/// Usually, every incoming email has Authentication-Results  with our server's
170/// authserv-id, so, the intersection of the existing authserv-ids and the incoming
171/// authserv-ids for our server's authserv-id is a good guess for our server's
172/// authserv-id. When this intersection is empty, we assume that the authserv-id has
173/// changed and start over with the new authserv-ids.
174///
175/// See [`handle_authres`].
176async fn update_authservid_candidates(
177    context: &Context,
178    authres: &ParsedAuthresHeaders,
179) -> Result<()> {
180    let mut new_ids: BTreeSet<&str> = authres
181        .iter()
182        .map(|(authserv_id, _dkim_passed)| authserv_id.as_str())
183        .collect();
184    if new_ids.is_empty() {
185        // The incoming message doesn't contain any authentication results, maybe it's a
186        // self-sent or a mailer-daemon message
187        return Ok(());
188    }
189
190    let old_config = context.get_config(Config::AuthservIdCandidates).await?;
191    let old_ids = parse_authservid_candidates_config(&old_config);
192    let intersection: BTreeSet<&str> = old_ids.intersection(&new_ids).copied().collect();
193    if !intersection.is_empty() {
194        new_ids = intersection;
195    }
196    // If there were no AuthservIdCandidates previously, just start with
197    // the ones from the incoming email
198
199    if old_ids != new_ids {
200        let new_config = new_ids.into_iter().collect::<Vec<_>>().join(" ");
201        context
202            .set_config_internal(Config::AuthservIdCandidates, Some(&new_config))
203            .await?;
204    }
205    Ok(())
206}
207
208/// Use the parsed authres and the authservid candidates to compute whether DKIM passed
209/// and whether a keychange should be allowed.
210///
211/// We track in the `sending_domains` table whether we get positive Authentication-Results
212/// for mails from a contact (meaning that their provider properly authenticates against
213/// our provider).
214///
215/// Once a contact is known to come with positive Authentication-Resutls (dkim: pass),
216/// we don't accept Autocrypt key changes if they come with negative Authentication-Results.
217async fn compute_dkim_results(
218    context: &Context,
219    mut authres: ParsedAuthresHeaders,
220) -> Result<DkimResults> {
221    let mut dkim_passed = false;
222
223    let ids_config = context.get_config(Config::AuthservIdCandidates).await?;
224    let ids = parse_authservid_candidates_config(&ids_config);
225
226    // Remove all foreign authentication results
227    authres.retain(|(authserv_id, _dkim_passed)| ids.contains(authserv_id.as_str()));
228
229    if authres.is_empty() {
230        // If the authentication results are empty, then our provider doesn't add them
231        // and an attacker could just add their own Authentication-Results, making us
232        // think that DKIM passed. So, in this case, we can as well assume that DKIM passed.
233        dkim_passed = true;
234    } else {
235        for (_authserv_id, current_dkim_passed) in authres {
236            match current_dkim_passed {
237                DkimResult::Passed => {
238                    dkim_passed = true;
239                    break;
240                }
241                DkimResult::Failed => {
242                    dkim_passed = false;
243                    break;
244                }
245                DkimResult::Nothing => {
246                    // Continue looking for an Authentication-Results header
247                }
248            }
249        }
250    }
251
252    Ok(DkimResults { dkim_passed })
253}
254
255fn parse_authservid_candidates_config(config: &Option<String>) -> BTreeSet<&str> {
256    config
257        .as_deref()
258        .map(|c| c.split_whitespace().collect())
259        .unwrap_or_default()
260}
261
262#[cfg(test)]
263mod tests {
264    use tokio::fs;
265    use tokio::io::AsyncReadExt;
266
267    use super::*;
268    use crate::mimeparser;
269    use crate::peerstate::Peerstate;
270    use crate::test_utils::TestContext;
271    use crate::test_utils::TestContextManager;
272    use crate::tools;
273
274    #[test]
275    fn test_remove_comments() {
276        let header = "Authentication-Results: mx3.messagingengine.com;
277    dkim=pass (1024-bit rsa key sha256) header.d=riseup.net;"
278            .to_string();
279        assert_eq!(
280            remove_comments(&header),
281            "Authentication-Results: mx3.messagingengine.com;
282    dkim=pass   header.d=riseup.net;"
283        );
284
285        let header = ") aaa (".to_string();
286        assert_eq!(remove_comments(&header), ") aaa (");
287
288        let header = "((something weird) no comment".to_string();
289        assert_eq!(remove_comments(&header), "  no comment");
290
291        let header = "🎉(🎉(🎉))🎉(".to_string();
292        assert_eq!(remove_comments(&header), "🎉 )🎉(");
293
294        // Comments are allowed to include whitespace
295        let header = "(com\n\t\r\nment) no comment (comment)".to_string();
296        assert_eq!(remove_comments(&header), "  no comment  ");
297    }
298
299    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
300    async fn test_parse_authentication_results() -> Result<()> {
301        let t = TestContext::new().await;
302        t.configure_addr("alice@gmx.net").await;
303        let bytes = b"Authentication-Results:  gmx.net; dkim=pass header.i=@slack.com
304Authentication-Results:  gmx.net; dkim=pass header.i=@amazonses.com";
305        let mail = mailparse::parse_mail(bytes)?;
306        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
307        assert_eq!(
308            actual,
309            vec![
310                ("gmx.net".to_string(), DkimResult::Passed),
311                ("gmx.net".to_string(), DkimResult::Nothing)
312            ]
313        );
314
315        let bytes = b"Authentication-Results:  gmx.net; notdkim=pass header.i=@slack.com
316Authentication-Results:  gmx.net; notdkim=pass header.i=@amazonses.com";
317        let mail = mailparse::parse_mail(bytes)?;
318        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
319        assert_eq!(
320            actual,
321            vec![
322                ("gmx.net".to_string(), DkimResult::Nothing),
323                ("gmx.net".to_string(), DkimResult::Nothing)
324            ]
325        );
326
327        let bytes = b"Authentication-Results:  gmx.net; dkim=pass header.i=@amazonses.com";
328        let mail = mailparse::parse_mail(bytes)?;
329        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
330        assert_eq!(actual, vec![("gmx.net".to_string(), DkimResult::Nothing)],);
331
332        // Weird Authentication-Results from Outlook without an authserv-id
333        let bytes = b"Authentication-Results: spf=pass (sender IP is 40.92.73.85)
334    smtp.mailfrom=hotmail.com; dkim=pass (signature was verified)
335    header.d=hotmail.com;dmarc=pass action=none
336    header.from=hotmail.com;compauth=pass reason=100";
337        let mail = mailparse::parse_mail(bytes)?;
338        let actual = parse_authres_headers(&mail.get_headers(), "hotmail.com");
339        // At this point, the most important thing to test is that there are no
340        // authserv-ids with whitespace in them.
341        assert_eq!(
342            actual,
343            vec![("invalidAuthservId".to_string(), DkimResult::Passed)]
344        );
345
346        let bytes = b"Authentication-Results:  gmx.net; dkim=none header.i=@slack.com
347Authentication-Results:  gmx.net; dkim=pass header.i=@slack.com";
348        let mail = mailparse::parse_mail(bytes)?;
349        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
350        assert_eq!(
351            actual,
352            vec![
353                ("gmx.net".to_string(), DkimResult::Failed),
354                ("gmx.net".to_string(), DkimResult::Passed)
355            ]
356        );
357
358        // ';' in comments
359        let bytes = b"Authentication-Results: mx1.riseup.net;
360	dkim=pass (1024-bit key; unprotected) header.d=yandex.ru header.i=@yandex.ru header.a=rsa-sha256 header.s=mail header.b=avNJu6sw;
361	dkim-atps=neutral";
362        let mail = mailparse::parse_mail(bytes)?;
363        let actual = parse_authres_headers(&mail.get_headers(), "yandex.ru");
364        assert_eq!(
365            actual,
366            vec![("mx1.riseup.net".to_string(), DkimResult::Passed)]
367        );
368
369        let bytes = br#"Authentication-Results: box.hispanilandia.net;
370	dkim=fail reason="signature verification failed" (2048-bit key; secure) header.d=disroot.org header.i=@disroot.org header.b="kqh3WUKq";
371	dkim-atps=neutral
372Authentication-Results: box.hispanilandia.net; dmarc=pass (p=quarantine dis=none) header.from=disroot.org
373Authentication-Results: box.hispanilandia.net; spf=pass smtp.mailfrom=adbenitez@disroot.org"#;
374        let mail = mailparse::parse_mail(bytes)?;
375        let actual = parse_authres_headers(&mail.get_headers(), "disroot.org");
376        assert_eq!(
377            actual,
378            vec![
379                ("box.hispanilandia.net".to_string(), DkimResult::Failed),
380                ("box.hispanilandia.net".to_string(), DkimResult::Nothing),
381                ("box.hispanilandia.net".to_string(), DkimResult::Nothing),
382            ]
383        );
384
385        Ok(())
386    }
387
388    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
389    async fn test_update_authservid_candidates() -> Result<()> {
390        let t = TestContext::new_alice().await;
391
392        update_authservid_candidates_test(&t, &["mx3.messagingengine.com"]).await;
393        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
394        assert_eq!(candidates, "mx3.messagingengine.com");
395
396        // "mx4.messagingengine.com" seems to be the new authserv-id, DC should accept it
397        update_authservid_candidates_test(&t, &["mx4.messagingengine.com"]).await;
398        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
399        assert_eq!(candidates, "mx4.messagingengine.com");
400
401        // A message without any Authentication-Results headers shouldn't remove all
402        // candidates since it could be a mailer-daemon message or so
403        update_authservid_candidates_test(&t, &[]).await;
404        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
405        assert_eq!(candidates, "mx4.messagingengine.com");
406
407        update_authservid_candidates_test(&t, &["mx4.messagingengine.com", "someotherdomain.com"])
408            .await;
409        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
410        assert_eq!(candidates, "mx4.messagingengine.com");
411
412        Ok(())
413    }
414
415    /// Calls update_authservid_candidates(), meant for using in a test.
416    ///
417    /// update_authservid_candidates() only looks at the keys of its
418    /// `authentication_results` parameter. So, this function takes `incoming_ids`
419    /// and adds some AuthenticationResults to get the HashMap we need.
420    async fn update_authservid_candidates_test(context: &Context, incoming_ids: &[&str]) {
421        let v = incoming_ids
422            .iter()
423            .map(|id| (id.to_string(), DkimResult::Passed))
424            .collect();
425        update_authservid_candidates(context, &v).await.unwrap()
426    }
427
428    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
429    async fn test_realworld_authentication_results() -> Result<()> {
430        let mut test_failed = false;
431
432        let dir = tools::read_dir("test-data/message/dkimchecks-2022-09-28/".as_ref())
433            .await
434            .unwrap();
435        let mut bytes = Vec::new();
436        for entry in dir {
437            if !entry.file_type().await.unwrap().is_dir() {
438                continue;
439            }
440            let self_addr = entry.file_name().into_string().unwrap();
441            let self_domain = EmailAddress::new(&self_addr).unwrap().domain;
442            let authres_parsing_works = [
443                "ik.me",
444                "web.de",
445                "posteo.de",
446                "gmail.com",
447                "hotmail.com",
448                "mail.ru",
449                "aol.com",
450                "yahoo.com",
451                "icloud.com",
452                "fastmail.com",
453                "mail.de",
454                "outlook.com",
455                "gmx.de",
456                "testrun.org",
457            ]
458            .contains(&self_domain.as_str());
459
460            let t = TestContext::new().await;
461            t.configure_addr(&self_addr).await;
462            if !authres_parsing_works {
463                println!("========= Receiving as {} =========", &self_addr);
464            }
465
466            // Simulate receiving all emails once, so that we have the correct authserv-ids
467            let mut dir = tools::read_dir(&entry.path()).await.unwrap();
468
469            // The ordering in which the emails are received can matter;
470            // the test _should_ pass for every ordering.
471            dir.sort_by_key(|d| d.file_name());
472            //rand::seq::SliceRandom::shuffle(&mut dir[..], &mut rand::thread_rng());
473
474            for entry in &dir {
475                let mut file = fs::File::open(entry.path()).await?;
476                bytes.clear();
477                file.read_to_end(&mut bytes).await.unwrap();
478
479                let mail = mailparse::parse_mail(&bytes)?;
480                let from = &mimeparser::get_from(&mail.headers).unwrap().addr;
481
482                let res = handle_authres(&t, &mail, from).await?;
483                let from_domain = EmailAddress::new(from).unwrap().domain;
484
485                // delta.blinzeln.de and gmx.de have invalid DKIM, so the DKIM check should fail
486                let expected_result = (from_domain != "delta.blinzeln.de") && (from_domain != "gmx.de")
487                    // These are (fictional) forged emails where the attacker added a fake
488                    // Authentication-Results before sending the email
489                    && from != "forged-authres-added@example.com"
490                    // Other forged emails
491                    && !from.starts_with("forged");
492
493                if res.dkim_passed != expected_result {
494                    if authres_parsing_works {
495                        println!(
496                            "!!!!!! FAILURE Receiving {:?} wrong result: !!!!!!",
497                            entry.path(),
498                        );
499                        test_failed = true;
500                    }
501                    println!("From {}: {}", from_domain, res.dkim_passed);
502                }
503            }
504        }
505
506        assert!(!test_failed);
507        Ok(())
508    }
509
510    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
511    async fn test_handle_authres() {
512        let t = TestContext::new().await;
513
514        // Even if the format is wrong and parsing fails, handle_authres() shouldn't
515        // return an Err because this would prevent the message from being added
516        // to the database and downloaded again and again
517        let bytes = b"From: invalid@from.com
518Authentication-Results: dkim=";
519        let mail = mailparse::parse_mail(bytes).unwrap();
520        handle_authres(&t, &mail, "invalid@rom.com").await.unwrap();
521    }
522
523    // Test that Autocrypt works with mailing list.
524    //
525    // Previous versions of Delta Chat ignored Autocrypt based on the List-Post header.
526    // This is not needed: comparing of the From address to Autocrypt header address is enough.
527    // If the mailing list is not rewriting the From header, Autocrypt should be applied.
528    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
529    async fn test_autocrypt_in_mailinglist_not_ignored() -> Result<()> {
530        let mut tcm = TestContextManager::new();
531        let alice = tcm.alice().await;
532        let bob = tcm.bob().await;
533
534        let alice_bob_chat = alice.create_chat(&bob).await;
535        let bob_alice_chat = bob.create_chat(&alice).await;
536        let mut sent = alice.send_text(alice_bob_chat.id, "hellooo").await;
537        sent.payload
538            .insert_str(0, "List-Post: <mailto:deltachat-community.example.net>\n");
539        bob.recv_msg(&sent).await;
540        let peerstate = Peerstate::from_addr(&bob, "alice@example.org").await?;
541        assert!(peerstate.is_some());
542
543        // Bob can now write encrypted to Alice:
544        let mut sent = bob
545            .send_text(bob_alice_chat.id, "hellooo in the mailinglist again")
546            .await;
547        assert!(sent.load_from_db().await.get_showpadlock());
548
549        sent.payload
550            .insert_str(0, "List-Post: <mailto:deltachat-community.example.net>\n");
551        let rcvd = alice.recv_msg(&sent).await;
552        assert!(rcvd.get_showpadlock());
553        assert_eq!(&rcvd.text, "hellooo in the mailinglist again");
554
555        Ok(())
556    }
557
558    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
559    async fn test_authres_in_mailinglist_ignored() -> Result<()> {
560        let mut tcm = TestContextManager::new();
561        let alice = tcm.alice().await;
562        let bob = tcm.bob().await;
563
564        // Bob knows his server's authserv-id
565        bob.set_config(Config::AuthservIdCandidates, Some("example.net"))
566            .await?;
567
568        let alice_bob_chat = alice.create_chat(&bob).await;
569        let mut sent = alice.send_text(alice_bob_chat.id, "hellooo").await;
570        sent.payload
571            .insert_str(0, "List-Post: <mailto:deltachat-community.example.net>\n");
572        sent.payload
573            .insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
574        let rcvd = bob.recv_msg(&sent).await;
575        assert!(rcvd.error.is_none());
576
577        // Do the same without the mailing list header, this time the failed
578        // authres isn't ignored
579        let mut sent = alice
580            .send_text(alice_bob_chat.id, "hellooo without mailing list")
581            .await;
582        sent.payload
583            .insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
584        let rcvd = bob.recv_msg(&sent).await;
585
586        // The message info should contain a warning:
587        assert!(rcvd
588            .id
589            .get_info(&bob)
590            .await
591            .unwrap()
592            .contains("DKIM Results: Passed=false"));
593
594        Ok(())
595    }
596}