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::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 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 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 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 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 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 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 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 let mut dir = tools::read_dir(&entry.path()).await.unwrap();
467
468 dir.sort_by_key(|d| d.file_name());
471 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 let expected_result = (from_domain != "delta.blinzeln.de") && (from_domain != "gmx.de")
486 && from != "forged-authres-added@example.com"
489 && !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 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.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 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 assert!(rcvd
552 .id
553 .get_info(&bob)
554 .await
555 .unwrap()
556 .contains("DKIM Results: Passed=false"));
557
558 Ok(())
559 }
560}