1use 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
18pub(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 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 Passed,
63 Failed,
65 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 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
102fn remove_comments(header: &str) -> Cow<'_, str> {
107 static RE: LazyLock<regex::Regex> =
111 LazyLock::new(|| regex::Regex::new(r"\([\s\S]*?\)").unwrap());
112
113 RE.replace_all(header, " ")
114}
115
116fn 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 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 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 return DkimResult::Passed;
138 }
139 } else {
140 return DkimResult::Failed;
142 }
143 }
144 }
145
146 DkimResult::Nothing
147}
148
149async 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 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 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
208async 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 authres.retain(|(authserv_id, _dkim_passed)| ids.contains(authserv_id.as_str()));
228
229 if authres.is_empty() {
230 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 }
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 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 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 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 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 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 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 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 let mut dir = tools::read_dir(&entry.path()).await.unwrap();
468
469 dir.sort_by_key(|d| d.file_name());
472 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 let expected_result = (from_domain != "delta.blinzeln.de") && (from_domain != "gmx.de")
487 && from != "forged-authres-added@example.com"
490 && !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 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 #[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 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.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 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 assert!(rcvd
588 .id
589 .get_info(&bob)
590 .await
591 .unwrap()
592 .contains("DKIM Results: Passed=false"));
593
594 Ok(())
595 }
596}