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::test_utils::TestContext;
270    use crate::test_utils::TestContextManager;
271    use crate::tools;
272
273    #[test]
274    fn test_remove_comments() {
275        let header = "Authentication-Results: mx3.messagingengine.com;
276    dkim=pass (1024-bit rsa key sha256) header.d=riseup.net;"
277            .to_string();
278        assert_eq!(
279            remove_comments(&header),
280            "Authentication-Results: mx3.messagingengine.com;
281    dkim=pass   header.d=riseup.net;"
282        );
283
284        let header = ") aaa (".to_string();
285        assert_eq!(remove_comments(&header), ") aaa (");
286
287        let header = "((something weird) no comment".to_string();
288        assert_eq!(remove_comments(&header), "  no comment");
289
290        let header = "🎉(🎉(🎉))🎉(".to_string();
291        assert_eq!(remove_comments(&header), "🎉 )🎉(");
292
293        // Comments are allowed to include whitespace
294        let header = "(com\n\t\r\nment) no comment (comment)".to_string();
295        assert_eq!(remove_comments(&header), "  no comment  ");
296    }
297
298    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
299    async fn test_parse_authentication_results() -> Result<()> {
300        let t = TestContext::new().await;
301        t.configure_addr("alice@gmx.net").await;
302        let bytes = b"Authentication-Results:  gmx.net; dkim=pass header.i=@slack.com
303Authentication-Results:  gmx.net; dkim=pass header.i=@amazonses.com";
304        let mail = mailparse::parse_mail(bytes)?;
305        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
306        assert_eq!(
307            actual,
308            vec![
309                ("gmx.net".to_string(), DkimResult::Passed),
310                ("gmx.net".to_string(), DkimResult::Nothing)
311            ]
312        );
313
314        let bytes = b"Authentication-Results:  gmx.net; notdkim=pass header.i=@slack.com
315Authentication-Results:  gmx.net; notdkim=pass header.i=@amazonses.com";
316        let mail = mailparse::parse_mail(bytes)?;
317        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
318        assert_eq!(
319            actual,
320            vec![
321                ("gmx.net".to_string(), DkimResult::Nothing),
322                ("gmx.net".to_string(), DkimResult::Nothing)
323            ]
324        );
325
326        let bytes = b"Authentication-Results:  gmx.net; dkim=pass header.i=@amazonses.com";
327        let mail = mailparse::parse_mail(bytes)?;
328        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
329        assert_eq!(actual, vec![("gmx.net".to_string(), DkimResult::Nothing)],);
330
331        // Weird Authentication-Results from Outlook without an authserv-id
332        let bytes = b"Authentication-Results: spf=pass (sender IP is 40.92.73.85)
333    smtp.mailfrom=hotmail.com; dkim=pass (signature was verified)
334    header.d=hotmail.com;dmarc=pass action=none
335    header.from=hotmail.com;compauth=pass reason=100";
336        let mail = mailparse::parse_mail(bytes)?;
337        let actual = parse_authres_headers(&mail.get_headers(), "hotmail.com");
338        // At this point, the most important thing to test is that there are no
339        // authserv-ids with whitespace in them.
340        assert_eq!(
341            actual,
342            vec![("invalidAuthservId".to_string(), DkimResult::Passed)]
343        );
344
345        let bytes = b"Authentication-Results:  gmx.net; dkim=none header.i=@slack.com
346Authentication-Results:  gmx.net; dkim=pass header.i=@slack.com";
347        let mail = mailparse::parse_mail(bytes)?;
348        let actual = parse_authres_headers(&mail.get_headers(), "slack.com");
349        assert_eq!(
350            actual,
351            vec![
352                ("gmx.net".to_string(), DkimResult::Failed),
353                ("gmx.net".to_string(), DkimResult::Passed)
354            ]
355        );
356
357        // ';' in comments
358        let bytes = b"Authentication-Results: mx1.riseup.net;
359	dkim=pass (1024-bit key; unprotected) header.d=yandex.ru header.i=@yandex.ru header.a=rsa-sha256 header.s=mail header.b=avNJu6sw;
360	dkim-atps=neutral";
361        let mail = mailparse::parse_mail(bytes)?;
362        let actual = parse_authres_headers(&mail.get_headers(), "yandex.ru");
363        assert_eq!(
364            actual,
365            vec![("mx1.riseup.net".to_string(), DkimResult::Passed)]
366        );
367
368        let bytes = br#"Authentication-Results: box.hispanilandia.net;
369	dkim=fail reason="signature verification failed" (2048-bit key; secure) header.d=disroot.org header.i=@disroot.org header.b="kqh3WUKq";
370	dkim-atps=neutral
371Authentication-Results: box.hispanilandia.net; dmarc=pass (p=quarantine dis=none) header.from=disroot.org
372Authentication-Results: box.hispanilandia.net; spf=pass smtp.mailfrom=adbenitez@disroot.org"#;
373        let mail = mailparse::parse_mail(bytes)?;
374        let actual = parse_authres_headers(&mail.get_headers(), "disroot.org");
375        assert_eq!(
376            actual,
377            vec![
378                ("box.hispanilandia.net".to_string(), DkimResult::Failed),
379                ("box.hispanilandia.net".to_string(), DkimResult::Nothing),
380                ("box.hispanilandia.net".to_string(), DkimResult::Nothing),
381            ]
382        );
383
384        Ok(())
385    }
386
387    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
388    async fn test_update_authservid_candidates() -> Result<()> {
389        let t = TestContext::new_alice().await;
390
391        update_authservid_candidates_test(&t, &["mx3.messagingengine.com"]).await;
392        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
393        assert_eq!(candidates, "mx3.messagingengine.com");
394
395        // "mx4.messagingengine.com" seems to be the new authserv-id, DC should accept it
396        update_authservid_candidates_test(&t, &["mx4.messagingengine.com"]).await;
397        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
398        assert_eq!(candidates, "mx4.messagingengine.com");
399
400        // A message without any Authentication-Results headers shouldn't remove all
401        // candidates since it could be a mailer-daemon message or so
402        update_authservid_candidates_test(&t, &[]).await;
403        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
404        assert_eq!(candidates, "mx4.messagingengine.com");
405
406        update_authservid_candidates_test(&t, &["mx4.messagingengine.com", "someotherdomain.com"])
407            .await;
408        let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap();
409        assert_eq!(candidates, "mx4.messagingengine.com");
410
411        Ok(())
412    }
413
414    /// Calls update_authservid_candidates(), meant for using in a test.
415    ///
416    /// update_authservid_candidates() only looks at the keys of its
417    /// `authentication_results` parameter. So, this function takes `incoming_ids`
418    /// and adds some AuthenticationResults to get the HashMap we need.
419    async fn update_authservid_candidates_test(context: &Context, incoming_ids: &[&str]) {
420        let v = incoming_ids
421            .iter()
422            .map(|id| (id.to_string(), DkimResult::Passed))
423            .collect();
424        update_authservid_candidates(context, &v).await.unwrap()
425    }
426
427    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
428    async fn test_realworld_authentication_results() -> Result<()> {
429        let mut test_failed = false;
430
431        let dir = tools::read_dir("test-data/message/dkimchecks-2022-09-28/".as_ref())
432            .await
433            .unwrap();
434        let mut bytes = Vec::new();
435        for entry in dir {
436            if !entry.file_type().await.unwrap().is_dir() {
437                continue;
438            }
439            let self_addr = entry.file_name().into_string().unwrap();
440            let self_domain = EmailAddress::new(&self_addr).unwrap().domain;
441            let authres_parsing_works = [
442                "ik.me",
443                "web.de",
444                "posteo.de",
445                "gmail.com",
446                "hotmail.com",
447                "mail.ru",
448                "aol.com",
449                "yahoo.com",
450                "icloud.com",
451                "fastmail.com",
452                "mail.de",
453                "outlook.com",
454                "gmx.de",
455                "testrun.org",
456            ]
457            .contains(&self_domain.as_str());
458
459            let t = TestContext::new().await;
460            t.configure_addr(&self_addr).await;
461            if !authres_parsing_works {
462                println!("========= Receiving as {} =========", &self_addr);
463            }
464
465            // Simulate receiving all emails once, so that we have the correct authserv-ids
466            let mut dir = tools::read_dir(&entry.path()).await.unwrap();
467
468            // The ordering in which the emails are received can matter;
469            // the test _should_ pass for every ordering.
470            dir.sort_by_key(|d| d.file_name());
471            //rand::seq::SliceRandom::shuffle(&mut dir[..], &mut rand::thread_rng());
472
473            for entry in &dir {
474                let mut file = fs::File::open(entry.path()).await?;
475                bytes.clear();
476                file.read_to_end(&mut bytes).await.unwrap();
477
478                let mail = mailparse::parse_mail(&bytes)?;
479                let from = &mimeparser::get_from(&mail.headers).unwrap().addr;
480
481                let res = handle_authres(&t, &mail, from).await?;
482                let from_domain = EmailAddress::new(from).unwrap().domain;
483
484                // delta.blinzeln.de and gmx.de have invalid DKIM, so the DKIM check should fail
485                let expected_result = (from_domain != "delta.blinzeln.de") && (from_domain != "gmx.de")
486                    // These are (fictional) forged emails where the attacker added a fake
487                    // Authentication-Results before sending the email
488                    && from != "forged-authres-added@example.com"
489                    // Other forged emails
490                    && !from.starts_with("forged");
491
492                if res.dkim_passed != expected_result {
493                    if authres_parsing_works {
494                        println!(
495                            "!!!!!! FAILURE Receiving {:?} wrong result: !!!!!!",
496                            entry.path(),
497                        );
498                        test_failed = true;
499                    }
500                    println!("From {}: {}", from_domain, res.dkim_passed);
501                }
502            }
503        }
504
505        assert!(!test_failed);
506        Ok(())
507    }
508
509    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
510    async fn test_handle_authres() {
511        let t = TestContext::new().await;
512
513        // Even if the format is wrong and parsing fails, handle_authres() shouldn't
514        // return an Err because this would prevent the message from being added
515        // to the database and downloaded again and again
516        let bytes = b"From: invalid@from.com
517Authentication-Results: dkim=";
518        let mail = mailparse::parse_mail(bytes).unwrap();
519        handle_authres(&t, &mail, "invalid@rom.com").await.unwrap();
520    }
521
522    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
523    async fn test_authres_in_mailinglist_ignored() -> Result<()> {
524        let mut tcm = TestContextManager::new();
525        let alice = tcm.alice().await;
526        let bob = tcm.bob().await;
527
528        // Bob knows his server's authserv-id
529        bob.set_config(Config::AuthservIdCandidates, Some("example.net"))
530            .await?;
531
532        let alice_bob_chat = alice.create_chat(&bob).await;
533        let mut sent = alice.send_text(alice_bob_chat.id, "hellooo").await;
534        sent.payload
535            .insert_str(0, "List-Post: <mailto:deltachat-community.example.net>\n");
536        sent.payload
537            .insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
538        let rcvd = bob.recv_msg(&sent).await;
539        assert!(rcvd.error.is_none());
540
541        // Do the same without the mailing list header, this time the failed
542        // authres isn't ignored
543        let mut sent = alice
544            .send_text(alice_bob_chat.id, "hellooo without mailing list")
545            .await;
546        sent.payload
547            .insert_str(0, "Authentication-Results: example.net; dkim=fail\n");
548        let rcvd = bob.recv_msg(&sent).await;
549
550        // The message info should contain a warning:
551        assert!(rcvd
552            .id
553            .get_info(&bob)
554            .await
555            .unwrap()
556            .contains("DKIM Results: Passed=false"));
557
558        Ok(())
559    }
560}