mirror of
https://github.com/chatmail/core.git
synced 2026-04-27 18:36:30 +03:00
Yet another solution: Keep track of candidates for our authserv-id
This commit is contained in:
@@ -184,6 +184,16 @@ pub enum Config {
|
||||
/// In a future versions, this switch may be removed.
|
||||
#[strum(props(default = "0"))]
|
||||
SendSyncMsgs,
|
||||
|
||||
/// Space-separated list of all the authserv-ids which we believe
|
||||
/// may be the one of our email server.
|
||||
///
|
||||
/// When checking DKIM and SPF, our email server adds the results in an
|
||||
/// Authentication-Results... TODO documentation
|
||||
///
|
||||
/// See https://github.com/deltachat/deltachat-core-rust/issues/3507 for more
|
||||
/// info about the Authentication-Results header.
|
||||
AuthservIdCandidates,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
|
||||
157
src/decrypt.rs
157
src/decrypt.rs
@@ -1,8 +1,10 @@
|
||||
//! End-to-end decryption support.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use mailparse::MailHeaderMap;
|
||||
use mailparse::ParsedMail;
|
||||
|
||||
use crate::aheader::Aheader;
|
||||
@@ -15,6 +17,7 @@ use crate::keyring::Keyring;
|
||||
use crate::log::LogExt;
|
||||
use crate::peerstate::Peerstate;
|
||||
use crate::pgp;
|
||||
use crate::tools;
|
||||
|
||||
/// Tries to decrypt a message, but only if it is structured as an
|
||||
/// Autocrypt message.
|
||||
@@ -55,6 +58,126 @@ pub async fn try_decrypt(
|
||||
.await
|
||||
}
|
||||
|
||||
// TODO move somewhere else
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AuthenticationResults {
|
||||
dkim_passed: bool,
|
||||
}
|
||||
|
||||
type AuthservId = String;
|
||||
|
||||
fn parse_authentication_results(
|
||||
context: &Context,
|
||||
headers: &mailparse::headers::Headers<'_>,
|
||||
from: &str,
|
||||
) -> Result<HashMap<AuthservId, AuthenticationResults>> {
|
||||
// TODO old comment:
|
||||
// TODO this doesn't work for e.g. GMX which sells @gmx.de addresses, but uses gmx.net as its server
|
||||
// Config::ConfiguredProvider doesn't work for e.g. Gmail which uses mx.google.com.
|
||||
//
|
||||
// We could self-send a message during configure and use the Authentication-Results header from there -
|
||||
// this works for e.g. GMX, but not for Testrun and GMAIL.
|
||||
// -> Alternatively, we could send a message to nonexistent@example.com and wait for the NDN. This works
|
||||
// for Gmail, but the Testrun NDN doesn't contain such a header, and GMX returns an error directly
|
||||
// while sending.
|
||||
//
|
||||
// We could save this info in the provider db, but this only works for these providers.
|
||||
|
||||
// let from = match from.first() {
|
||||
// Some(f) => &f.addr,
|
||||
// None => return Ok(HashMap::new()),
|
||||
// }; // TODO
|
||||
let sender_domain = crate::tools::EmailAddress::new(from)?.domain;
|
||||
|
||||
let mut header_map: HashMap<AuthservId, Vec<String>> = HashMap::new();
|
||||
for header_value in headers.get_all_values(HeaderDef::AuthenticationResults.into()) {
|
||||
// TODO there could be a comment [CFWS] before the self domain. Do we care? Probably not.
|
||||
let authserv_id = header_value
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.context("Empty header")?; // TODO do we really want to return Err here if it's empty
|
||||
header_map
|
||||
.entry(authserv_id.to_string())
|
||||
.or_default()
|
||||
.push(header_value);
|
||||
}
|
||||
|
||||
let mut authresults_map = HashMap::new();
|
||||
for (authserv_id, headers) in header_map {
|
||||
let dkim_passed = authresults_dkim_passed(&headers, &sender_domain)?;
|
||||
authresults_map.insert(authserv_id, AuthenticationResults { dkim_passed });
|
||||
}
|
||||
|
||||
Ok(authresults_map)
|
||||
}
|
||||
|
||||
/// Parses the Authentication-Results headers belonging to a specific authserv-id
|
||||
/// and returns whether they say that DKIM passed.
|
||||
/// TODO document better
|
||||
/// TODO if there are multiple headers and one says `pass`, one says `fail`, `none`
|
||||
/// or whatever, then we still interpret that as `pass` - is this a problem?
|
||||
fn authresults_dkim_passed(headers: &[String], sender_domain: &str) -> Result<bool> {
|
||||
for header_value in headers {
|
||||
if let Some((_start, dkim_to_end)) = header_value.split_once("dkim=") {
|
||||
let dkim_part = dkim_to_end
|
||||
.split(';')
|
||||
.next()
|
||||
.context("what the hell TODO")?;
|
||||
let dkim_parts: Vec<_> = dkim_part.split_whitespace().collect();
|
||||
if let Some(&"pass") = dkim_parts.first() {
|
||||
let header_d: &str = &format!("header.d={}", &sender_domain);
|
||||
let header_i: &str = &format!("header.i=@{}", &sender_domain);
|
||||
|
||||
if dkim_parts.contains(&header_d) || dkim_parts.contains(&header_i) {
|
||||
// We have found a `dkim=pass` header!
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// TODO this is only half of the algorithm we thought of; we also wanted to save how sure we are
|
||||
// about the authserv id. Like, a same-domain email is more trustworthy.
|
||||
async fn update_authservid_candidates(
|
||||
context: &Context,
|
||||
authentication_results: &HashMap<AuthservId, AuthenticationResults>,
|
||||
) -> Result<()> {
|
||||
let mut new_ids: HashSet<_> = authentication_results.keys().map(String::as_str).collect();
|
||||
if new_ids.is_empty() {
|
||||
// The incoming message doesn't contain any authentication results, maybe it's a
|
||||
// self-sent or a mailer-daemon message
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ids_config;
|
||||
if let Some(ids_config_temp) = context
|
||||
.get_config(crate::config::Config::AuthservIdCandidates)
|
||||
.await?
|
||||
{
|
||||
ids_config = ids_config_temp;
|
||||
let old_ids: HashSet<_> = ids_config.split(' ').collect();
|
||||
if !old_ids.is_empty() {
|
||||
new_ids = old_ids.intersection(&new_ids).copied().collect();
|
||||
}
|
||||
}
|
||||
// If there were no AuthservIdCandidates previously, just start with
|
||||
// the ones from the incoming email
|
||||
|
||||
let new_config = new_ids.into_iter().collect::<Vec<_>>().join(" ");
|
||||
context
|
||||
.set_config(
|
||||
crate::config::Config::AuthservIdCandidates,
|
||||
Some(&new_config),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_decryption_info(
|
||||
context: &Context,
|
||||
mail: &ParsedMail<'_>,
|
||||
@@ -72,8 +195,35 @@ pub async fn create_decryption_info(
|
||||
.ok_or_log_msg(context, "Failed to parse Autocrypt header")
|
||||
.flatten();
|
||||
|
||||
let peerstate =
|
||||
get_autocrypt_peerstate(context, &from, autocrypt_header.as_ref(), message_time).await?;
|
||||
let authentication_results = parse_authentication_results(context, &mail.get_headers(), &from)?;
|
||||
update_authservid_candidates(context, &authentication_results).await?;
|
||||
// TODO code duplication with update_authservid_candidates()
|
||||
// TODO too much low-level code
|
||||
let mut dkim_passed = true; // TODO what do we want to do if there are multiple or no authservid candidates?
|
||||
if let Some(ids_config) = context
|
||||
.get_config(crate::config::Config::AuthservIdCandidates)
|
||||
.await?
|
||||
{
|
||||
let ids: HashSet<_> = ids_config.split(' ').collect();
|
||||
if let Some(authserv_id) = tools::single_value(ids) {
|
||||
// TODO unwrap
|
||||
dkim_passed = authentication_results.get(authserv_id).unwrap().dkim_passed;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO old comment Allow changes to the autocrypt key if DKIM passed.
|
||||
// If DKIM failed, we assume that the From address may have been forged
|
||||
// and therefore we prohibit changes to the autocrypt key.
|
||||
|
||||
let peerstate = get_autocrypt_peerstate(
|
||||
context,
|
||||
&from,
|
||||
autocrypt_header.as_ref(),
|
||||
message_time,
|
||||
true, // TODO key changes should not be allowed if the sending domain sent DKIM-valid emails
|
||||
// until now, but this one is DKIM-invalid.
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(DecryptionInfo {
|
||||
from,
|
||||
@@ -269,6 +419,7 @@ pub(crate) async fn get_autocrypt_peerstate(
|
||||
from: &str,
|
||||
autocrypt_header: Option<&Aheader>,
|
||||
message_time: i64,
|
||||
allow_change: bool,
|
||||
) -> Result<Option<Peerstate>> {
|
||||
let mut peerstate;
|
||||
|
||||
@@ -288,7 +439,7 @@ pub(crate) async fn get_autocrypt_peerstate(
|
||||
.await?;
|
||||
|
||||
if let Some(ref mut peerstate) = peerstate {
|
||||
if addr_cmp(&peerstate.addr, from) {
|
||||
if addr_cmp(&peerstate.addr, from) && allow_change {
|
||||
peerstate.apply_header(header, message_time);
|
||||
peerstate.save_to_db(&context.sql, false).await?;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ pub struct MimeMessage {
|
||||
pub decoded_data: Vec<u8>,
|
||||
|
||||
pub(crate) hop_info: String,
|
||||
authentication_results: AuthenticationResults,
|
||||
//pub(crate) authentication_results: HashMap<AuthservId, AuthenticationResults>, // TODO
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -199,9 +199,6 @@ impl MimeMessage {
|
||||
&mail.headers,
|
||||
);
|
||||
|
||||
let authentication_results =
|
||||
parse_authentication_results(context, &mail.get_headers(), &from).await?;
|
||||
|
||||
// Parse hidden headers.
|
||||
let mimetype = mail.ctype.mimetype.parse::<Mime>()?;
|
||||
if mimetype.type_() == mime::MULTIPART && mimetype.subtype().as_str() == "mixed" {
|
||||
@@ -341,7 +338,7 @@ impl MimeMessage {
|
||||
is_mime_modified: false,
|
||||
decoded_data: Vec::new(),
|
||||
hop_info,
|
||||
authentication_results,
|
||||
//authentication_results,
|
||||
};
|
||||
|
||||
match partial {
|
||||
@@ -1514,78 +1511,6 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum AuthenticationResults {
|
||||
Passed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
async fn parse_authentication_results(
|
||||
context: &Context,
|
||||
headers: &Headers<'_>,
|
||||
from: &[SingleInfo],
|
||||
) -> Result<AuthenticationResults> {
|
||||
// TODO this doesn't work for e.g. GMX which sells @gmx.de addresses, but uses gmx.net as its server
|
||||
// Config::ConfiguredProvider doesn't work for e.g. Gmail which uses mx.google.com.
|
||||
//
|
||||
// We could self-send a message during configure and use the Authentication-Results header from there -
|
||||
// this works for e.g. GMX, but not for Testrun and GMAIL.
|
||||
// -> Alternatively, we could send a message to nonexistent@example.com and wait for the NDN. This works
|
||||
// for Gmail, but the Testrun NDN doesn't contain such a header, and GMX returns an error directly
|
||||
// while sending.
|
||||
//
|
||||
// We could save this info in the provider db, but this only works for these providers.
|
||||
let self_domain = EmailAddress::new(&context.get_primary_self_addr().await?)?.domain;
|
||||
let from = match from.first() {
|
||||
Some(f) => &f.addr,
|
||||
None => return Ok(AuthenticationResults::Failed),
|
||||
};
|
||||
let sender_domain = EmailAddress::new(from)?.domain;
|
||||
|
||||
let mut header_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for header_value in headers.get_all_values(HeaderDef::AuthenticationResults.into()) {
|
||||
// TODO there could be a comment [CFWS] before the self domain. Do we care? Probably not.
|
||||
let authserv_id = header_value
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.context("Empty header")?;
|
||||
header_map
|
||||
.entry(authserv_id.to_string())
|
||||
.or_default()
|
||||
.push(header_value);
|
||||
}
|
||||
|
||||
for (_authserv_id, headers) in header_map {
|
||||
if !any_header_says_pass(&headers, &sender_domain)? {
|
||||
return Ok(AuthenticationResults::Failed);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuthenticationResults::Passed)
|
||||
}
|
||||
|
||||
fn any_header_says_pass(headers: &[String], sender_domain: &str) -> Result<bool> {
|
||||
for header_value in headers {
|
||||
if let Some((_start, dkim_to_end)) = header_value.split_once("dkim=") {
|
||||
let dkim_part = dkim_to_end
|
||||
.split(';')
|
||||
.next()
|
||||
.context("what the hell TODO malformed")?;
|
||||
let dkim_parts: Vec<_> = dkim_part.split_whitespace().collect();
|
||||
if let Some(&"pass") = dkim_parts.first() {
|
||||
let header_d: &str = format!("header.d={}", &sender_domain);
|
||||
let header_i: &str = format!("header.i=@{}", &sender_domain);
|
||||
|
||||
if dkim_parts.contains(&header_d) || dkim_parts.contains(&header_i) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Parses `Autocrypt-Gossip` headers from the email and applies them to peerstates.
|
||||
///
|
||||
/// Returns the set of mail recipient addresses for which valid gossip headers were found.
|
||||
|
||||
10
src/tools.rs
10
src/tools.rs
@@ -663,6 +663,16 @@ pub(crate) fn parse_receive_headers(headers: &Headers) -> String {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn single_value<T>(collection: impl IntoIterator<Item = T>) -> Option<T> {
|
||||
let mut iter = collection.into_iter();
|
||||
if let Some(value) = iter.next() {
|
||||
if iter.next().is_none() {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::indexing_slicing)]
|
||||
|
||||
Reference in New Issue
Block a user