| | |
| | |
| |
|
| | use std::borrow::Cow; |
| | use std::collections::BTreeSet; |
| | use std::fmt; |
| | use std::sync::LazyLock; |
| |
|
| | use anyhow::Result; |
| | use deltachat_contact_tools::EmailAddress; |
| | use mailparse::MailHeaderMap; |
| | use mailparse::ParsedMail; |
| |
|
| | use crate::config::Config; |
| | use crate::context::Context; |
| | use crate::headerdef::HeaderDef; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | pub(crate) async fn handle_authres( |
| | context: &Context, |
| | mail: &ParsedMail<'_>, |
| | from: &str, |
| | ) -> Result<DkimResults> { |
| | let from_domain = match EmailAddress::new(from) { |
| | Ok(email) => email.domain, |
| | Err(e) => { |
| | return Err(anyhow::format_err!("invalid email {from}: {e:#}")); |
| | } |
| | }; |
| |
|
| | let authres = parse_authres_headers(&mail.get_headers(), &from_domain); |
| | update_authservid_candidates(context, &authres).await?; |
| | compute_dkim_results(context, authres).await |
| | } |
| |
|
| | #[derive(Debug)] |
| | pub(crate) struct DkimResults { |
| | |
| | pub dkim_passed: bool, |
| | } |
| |
|
| | impl fmt::Display for DkimResults { |
| | fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { |
| | write!(fmt, "DKIM Results: Passed={}", self.dkim_passed)?; |
| | Ok(()) |
| | } |
| | } |
| |
|
| | type AuthservId = String; |
| |
|
| | #[derive(Debug, PartialEq)] |
| | enum DkimResult { |
| | |
| | Passed, |
| | |
| | Failed, |
| | |
| | |
| | |
| | |
| | Nothing, |
| | } |
| |
|
| | type ParsedAuthresHeaders = Vec<(AuthservId, DkimResult)>; |
| |
|
| | fn parse_authres_headers( |
| | headers: &mailparse::headers::Headers<'_>, |
| | from_domain: &str, |
| | ) -> ParsedAuthresHeaders { |
| | let mut res = Vec::new(); |
| | for header_value in headers.get_all_values(HeaderDef::AuthenticationResults.into()) { |
| | let header_value = remove_comments(&header_value); |
| |
|
| | if let Some(mut authserv_id) = header_value.split(';').next() { |
| | if authserv_id.contains(char::is_whitespace) || authserv_id.is_empty() { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | authserv_id = "invalidAuthservId"; |
| | } |
| | let dkim_passed = parse_one_authres_header(&header_value, from_domain); |
| | res.push((authserv_id.to_string(), dkim_passed)); |
| | } |
| | } |
| |
|
| | res |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | fn remove_comments(header: &str) -> Cow<'_, str> { |
| | |
| | |
| | |
| | static RE: LazyLock<regex::Regex> = |
| | LazyLock::new(|| regex::Regex::new(r"\([\s\S]*?\)").unwrap()); |
| |
|
| | RE.replace_all(header, " ") |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | fn parse_one_authres_header(header_value: &str, from_domain: &str) -> DkimResult { |
| | if let Some((before_dkim_part, dkim_to_end)) = header_value.split_once("dkim=") { |
| | |
| | |
| | if before_dkim_part.ends_with(' ') || before_dkim_part.ends_with('\t') { |
| | let dkim_part = dkim_to_end.split(';').next().unwrap_or_default(); |
| | let dkim_parts: Vec<_> = dkim_part.split_whitespace().collect(); |
| | if let Some(&"pass") = dkim_parts.first() { |
| | |
| | |
| | |
| | let header_d: &str = &format!("header.d={}", &from_domain); |
| | let header_i: &str = &format!("header.i=@{}", &from_domain); |
| |
|
| | if dkim_parts.contains(&header_d) || dkim_parts.contains(&header_i) { |
| | |
| | return DkimResult::Passed; |
| | } |
| | } else { |
| | |
| | return DkimResult::Failed; |
| | } |
| | } |
| | } |
| |
|
| | DkimResult::Nothing |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async fn update_authservid_candidates( |
| | context: &Context, |
| | authres: &ParsedAuthresHeaders, |
| | ) -> Result<()> { |
| | let mut new_ids: BTreeSet<&str> = authres |
| | .iter() |
| | .map(|(authserv_id, _dkim_passed)| authserv_id.as_str()) |
| | .collect(); |
| | if new_ids.is_empty() { |
| | |
| | |
| | return Ok(()); |
| | } |
| |
|
| | let old_config = context.get_config(Config::AuthservIdCandidates).await?; |
| | let old_ids = parse_authservid_candidates_config(&old_config); |
| | let intersection: BTreeSet<&str> = old_ids.intersection(&new_ids).copied().collect(); |
| | if !intersection.is_empty() { |
| | new_ids = intersection; |
| | } |
| | |
| | |
| |
|
| | if old_ids != new_ids { |
| | let new_config = new_ids.into_iter().collect::<Vec<_>>().join(" "); |
| | context |
| | .set_config_internal(Config::AuthservIdCandidates, Some(&new_config)) |
| | .await?; |
| | } |
| | Ok(()) |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | async fn compute_dkim_results( |
| | context: &Context, |
| | mut authres: ParsedAuthresHeaders, |
| | ) -> Result<DkimResults> { |
| | let mut dkim_passed = false; |
| |
|
| | let ids_config = context.get_config(Config::AuthservIdCandidates).await?; |
| | let ids = parse_authservid_candidates_config(&ids_config); |
| |
|
| | |
| | authres.retain(|(authserv_id, _dkim_passed)| ids.contains(authserv_id.as_str())); |
| |
|
| | if authres.is_empty() { |
| | |
| | |
| | |
| | dkim_passed = true; |
| | } else { |
| | for (_authserv_id, current_dkim_passed) in authres { |
| | match current_dkim_passed { |
| | DkimResult::Passed => { |
| | dkim_passed = true; |
| | break; |
| | } |
| | DkimResult::Failed => { |
| | dkim_passed = false; |
| | break; |
| | } |
| | DkimResult::Nothing => { |
| | |
| | } |
| | } |
| | } |
| | } |
| |
|
| | Ok(DkimResults { dkim_passed }) |
| | } |
| |
|
| | fn parse_authservid_candidates_config(config: &Option<String>) -> BTreeSet<&str> { |
| | config |
| | .as_deref() |
| | .map(|c| c.split_whitespace().collect()) |
| | .unwrap_or_default() |
| | } |
| |
|
| | #[cfg(test)] |
| | mod tests { |
| | use tokio::fs; |
| | use tokio::io::AsyncReadExt; |
| |
|
| | use super::*; |
| | use crate::mimeparser; |
| | use crate::test_utils::TestContext; |
| | use crate::test_utils::TestContextManager; |
| | use crate::tools; |
| |
|
| | #[test] |
| | fn test_remove_comments() { |
| | let header = "Authentication-Results: mx3.messagingengine.com; |
| | dkim=pass (1024-bit rsa key sha256) header.d=riseup.net;" |
| | .to_string(); |
| | assert_eq!( |
| | remove_comments(&header), |
| | "Authentication-Results: mx3.messagingengine.com; |
| | dkim=pass header.d=riseup.net;" |
| | ); |
| |
|
| | let header = ") aaa (".to_string(); |
| | assert_eq!(remove_comments(&header), ") aaa ("); |
| |
|
| | let header = "((something weird) no comment".to_string(); |
| | assert_eq!(remove_comments(&header), " no comment"); |
| |
|
| | let header = "🎉(🎉(🎉))🎉(".to_string(); |
| | assert_eq!(remove_comments(&header), "🎉 )🎉("); |
| |
|
| | |
| | let header = "(com\n\t\r\nment) no comment (comment)".to_string(); |
| | assert_eq!(remove_comments(&header), " no comment "); |
| | } |
| |
|
| | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| | async fn test_parse_authentication_results() -> Result<()> { |
| | let t = TestContext::new().await; |
| | t.configure_addr("alice@gmx.net").await; |
| | let bytes = b"Authentication-Results: gmx.net; dkim=pass header.i=@slack.com |
| | Authentication-Results: gmx.net; dkim=pass header.i=@amazonses.com"; |
| | let mail = mailparse::parse_mail(bytes)?; |
| | let actual = parse_authres_headers(&mail.get_headers(), "slack.com"); |
| | assert_eq!( |
| | actual, |
| | vec![ |
| | ("gmx.net".to_string(), DkimResult::Passed), |
| | ("gmx.net".to_string(), DkimResult::Nothing) |
| | ] |
| | ); |
| |
|
| | let bytes = b"Authentication-Results: gmx.net; notdkim=pass header.i=@slack.com |
| | Authentication-Results: gmx.net; notdkim=pass header.i=@amazonses.com"; |
| | let mail = mailparse::parse_mail(bytes)?; |
| | let actual = parse_authres_headers(&mail.get_headers(), "slack.com"); |
| | assert_eq!( |
| | actual, |
| | vec![ |
| | ("gmx.net".to_string(), DkimResult::Nothing), |
| | ("gmx.net".to_string(), DkimResult::Nothing) |
| | ] |
| | ); |
| |
|
| | let bytes = b"Authentication-Results: gmx.net; dkim=pass header.i=@amazonses.com"; |
| | let mail = mailparse::parse_mail(bytes)?; |
| | let actual = parse_authres_headers(&mail.get_headers(), "slack.com"); |
| | assert_eq!(actual, vec![("gmx.net".to_string(), DkimResult::Nothing)],); |
| |
|
| | |
| | let bytes = b"Authentication-Results: spf=pass (sender IP is 40.92.73.85) |
| | smtp.mailfrom=hotmail.com; dkim=pass (signature was verified) |
| | header.d=hotmail.com;dmarc=pass action=none |
| | header.from=hotmail.com;compauth=pass reason=100"; |
| | let mail = mailparse::parse_mail(bytes)?; |
| | let actual = parse_authres_headers(&mail.get_headers(), "hotmail.com"); |
| | |
| | |
| | assert_eq!( |
| | actual, |
| | vec![("invalidAuthservId".to_string(), DkimResult::Passed)] |
| | ); |
| |
|
| | let bytes = b"Authentication-Results: gmx.net; dkim=none header.i=@slack.com |
| | Authentication-Results: gmx.net; dkim=pass header.i=@slack.com"; |
| | let mail = mailparse::parse_mail(bytes)?; |
| | let actual = parse_authres_headers(&mail.get_headers(), "slack.com"); |
| | assert_eq!( |
| | actual, |
| | vec![ |
| | ("gmx.net".to_string(), DkimResult::Failed), |
| | ("gmx.net".to_string(), DkimResult::Passed) |
| | ] |
| | ); |
| |
|
| | |
| | let bytes = b"Authentication-Results: mx1.riseup.net; |
| | dkim=pass (1024-bit key; unprotected) header.d=yandex.ru header.i=@yandex.ru header.a=rsa-sha256 header.s=mail header.b=avNJu6sw; |
| | dkim-atps=neutral"; |
| | let mail = mailparse::parse_mail(bytes)?; |
| | let actual = parse_authres_headers(&mail.get_headers(), "yandex.ru"); |
| | assert_eq!( |
| | actual, |
| | vec![("mx1.riseup.net".to_string(), DkimResult::Passed)] |
| | ); |
| |
|
| | let bytes = br#"Authentication-Results: box.hispanilandia.net; |
| | dkim=fail reason="signature verification failed" (2048-bit key; secure) header.d=disroot.org header.i=@disroot.org header.b="kqh3WUKq"; |
| | dkim-atps=neutral |
| | Authentication-Results: box.hispanilandia.net; dmarc=pass (p=quarantine dis=none) header.from=disroot.org |
| | Authentication-Results: box.hispanilandia.net; spf=pass smtp.mailfrom=adbenitez@disroot.org"#; |
| | let mail = mailparse::parse_mail(bytes)?; |
| | let actual = parse_authres_headers(&mail.get_headers(), "disroot.org"); |
| | assert_eq!( |
| | actual, |
| | vec![ |
| | ("box.hispanilandia.net".to_string(), DkimResult::Failed), |
| | ("box.hispanilandia.net".to_string(), DkimResult::Nothing), |
| | ("box.hispanilandia.net".to_string(), DkimResult::Nothing), |
| | ] |
| | ); |
| |
|
| | Ok(()) |
| | } |
| |
|
| | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| | async fn test_update_authservid_candidates() -> Result<()> { |
| | let t = TestContext::new_alice().await; |
| |
|
| | update_authservid_candidates_test(&t, &["mx3.messagingengine.com"]).await; |
| | let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap(); |
| | assert_eq!(candidates, "mx3.messagingengine.com"); |
| |
|
| | |
| | update_authservid_candidates_test(&t, &["mx4.messagingengine.com"]).await; |
| | let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap(); |
| | assert_eq!(candidates, "mx4.messagingengine.com"); |
| |
|
| | |
| | |
| | update_authservid_candidates_test(&t, &[]).await; |
| | let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap(); |
| | assert_eq!(candidates, "mx4.messagingengine.com"); |
| |
|
| | update_authservid_candidates_test(&t, &["mx4.messagingengine.com", "someotherdomain.com"]) |
| | .await; |
| | let candidates = t.get_config(Config::AuthservIdCandidates).await?.unwrap(); |
| | assert_eq!(candidates, "mx4.messagingengine.com"); |
| |
|
| | Ok(()) |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | async fn update_authservid_candidates_test(context: &Context, incoming_ids: &[&str]) { |
| | let v = incoming_ids |
| | .iter() |
| | .map(|id| (id.to_string(), DkimResult::Passed)) |
| | .collect(); |
| | update_authservid_candidates(context, &v).await.unwrap() |
| | } |
| |
|
| | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| | async fn test_realworld_authentication_results() -> Result<()> { |
| | let mut test_failed = false; |
| |
|
| | let dir = tools::read_dir("test-data/message/dkimchecks-2022-09-28/".as_ref()) |
| | .await |
| | .unwrap(); |
| | let mut bytes = Vec::new(); |
| | for entry in dir { |
| | if !entry.file_type().await.unwrap().is_dir() { |
| | continue; |
| | } |
| | let self_addr = entry.file_name().into_string().unwrap(); |
| | let self_domain = EmailAddress::new(&self_addr).unwrap().domain; |
| | let authres_parsing_works = [ |
| | "ik.me", |
| | "web.de", |
| | "posteo.de", |
| | "gmail.com", |
| | "hotmail.com", |
| | "mail.ru", |
| | "aol.com", |
| | "yahoo.com", |
| | "icloud.com", |
| | "fastmail.com", |
| | "mail.de", |
| | "outlook.com", |
| | "gmx.de", |
| | "testrun.org", |
| | ] |
| | .contains(&self_domain.as_str()); |
| |
|
| | let t = TestContext::new().await; |
| | t.configure_addr(&self_addr).await; |
| | if !authres_parsing_works { |
| | println!("========= Receiving as {} =========", &self_addr); |
| | } |
| |
|
| | |
| | let mut dir = tools::read_dir(&entry.path()).await.unwrap(); |
| |
|
| | |
| | |
| | dir.sort_by_key(|d| d.file_name()); |
| | |
| |
|
| | for entry in &dir { |
| | let mut file = fs::File::open(entry.path()).await?; |
| | bytes.clear(); |
| | file.read_to_end(&mut bytes).await.unwrap(); |
| |
|
| | let mail = mailparse::parse_mail(&bytes)?; |
| | let from = &mimeparser::get_from(&mail.headers).unwrap().addr; |
| |
|
| | let res = handle_authres(&t, &mail, from).await?; |
| | let from_domain = EmailAddress::new(from).unwrap().domain; |
| |
|
| | |
| | let expected_result = (from_domain != "delta.blinzeln.de") && (from_domain != "gmx.de") |
| | |
| | |
| | && from != "forged-authres-added@example.com" |
| | |
| | && !from.starts_with("forged"); |
| |
|
| | if res.dkim_passed != expected_result { |
| | if authres_parsing_works { |
| | println!( |
| | "!!!!!! FAILURE Receiving {:?} wrong result: !!!!!!", |
| | entry.path(), |
| | ); |
| | test_failed = true; |
| | } |
| | println!("From {}: {}", from_domain, res.dkim_passed); |
| | } |
| | } |
| | } |
| |
|
| | assert!(!test_failed); |
| | Ok(()) |
| | } |
| |
|
| | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| | async fn test_handle_authres() { |
| | let t = TestContext::new().await; |
| |
|
| | |
| | |
| | |
| | let bytes = b"From: invalid@from.com |
| | Authentication-Results: dkim="; |
| | let mail = mailparse::parse_mail(bytes).unwrap(); |
| | handle_authres(&t, &mail, "invalid@rom.com").await.unwrap(); |
| | } |
| |
|
| | #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| | async fn test_authres_in_mailinglist_ignored() -> Result<()> { |
| | let mut tcm = TestContextManager::new(); |
| | let alice = tcm.alice().await; |
| | let bob = tcm.bob().await; |
| |
|
| | |
| | bob.set_config(Config::AuthservIdCandidates, Some("example.net")) |
| | .await?; |
| |
|
| | let alice_bob_chat = alice.create_chat(&bob).await; |
| | let mut sent = alice.send_text(alice_bob_chat.id, "hellooo").await; |
| | sent.payload |
| | .insert_str(0, "List-Post: <mailto:deltachat-community.example.net>\n"); |
| | sent.payload |
| | .insert_str(0, "Authentication-Results: example.net; dkim=fail\n"); |
| | let rcvd = bob.recv_msg(&sent).await; |
| | assert!(rcvd.error.is_none()); |
| |
|
| | |
| | |
| | let mut sent = alice |
| | .send_text(alice_bob_chat.id, "hellooo without mailing list") |
| | .await; |
| | sent.payload |
| | .insert_str(0, "Authentication-Results: example.net; dkim=fail\n"); |
| | let rcvd = bob.recv_msg(&sent).await; |
| |
|
| | |
| | assert!( |
| | rcvd.id |
| | .get_info(&bob) |
| | .await |
| | .unwrap() |
| | .contains("DKIM Results: Passed=false") |
| | ); |
| |
|
| | Ok(()) |
| | } |
| | } |
| |
|