fix(dkim): Make simple header canonicalization work properly (#53)

Previously header parsing caused leading whitespaces to be
trimmed in HeaderBody, which resulted in incorrect simple
header canonicalization.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Estera Ślązak
2026-02-19 14:33:40 +01:00
committed by GitHub
parent 083ec1a60b
commit 7d4fac698f
3 changed files with 191 additions and 88 deletions
+88 -32
View File
@@ -116,17 +116,67 @@ impl LookupTxt for CachedResolver {
} }
} }
/// Dummy resolver that always returns the same TXT record, for testing purposes.
#[derive(Clone)]
struct MockResolver(String);
impl LookupTxt for MockResolver {
type Answer = Box<dyn Iterator<Item = io::Result<Vec<u8>>>>;
type Query<'a> = Pin<Box<dyn Future<Output = io::Result<Self::Answer>> + Send + 'a>>;
fn lookup_txt(&self, _domain: &str) -> Self::Query<'_> {
Box::pin(async move {
let txts: Self::Answer = Box::new(std::iter::once(Ok(self.0.clone().into_bytes())));
Ok(txts)
})
}
}
/// Either a real resolver or a mock.
#[allow(clippy::large_enum_variant)]
#[derive(Clone)]
enum Resolver {
/// A [`CachedResolver`]
Real(CachedResolver),
/// A [`MockResolver`]
Mock(MockResolver),
}
impl LookupTxt for Resolver {
type Answer = Box<dyn Iterator<Item = io::Result<Vec<u8>>>>;
type Query<'a> = Pin<Box<dyn Future<Output = io::Result<Self::Answer>> + Send + 'a>>;
fn lookup_txt(&self, domain: &str) -> Self::Query<'_> {
match self {
Resolver::Real(resolver) => resolver.lookup_txt(domain),
Resolver::Mock(resolver) => resolver.lookup_txt(domain),
}
}
}
impl From<CachedResolver> for Resolver {
fn from(value: CachedResolver) -> Self {
Resolver::Real(value)
}
}
impl From<MockResolver> for Resolver {
fn from(value: MockResolver) -> Self {
Resolver::Mock(value)
}
}
/// DKIM verifier using a pre-configured [`viadkim`] verifier, a [`CachedResolver`] for DNS lookups, /// DKIM verifier using a pre-configured [`viadkim`] verifier, a [`CachedResolver`] for DNS lookups,
/// and strict domain name alignment check. /// and strict domain name alignment check.
pub struct DkimVerifier { pub struct DkimVerifier {
resolver: CachedResolver, resolver: Resolver,
config: viadkim::Config, config: viadkim::Config,
} }
impl DkimVerifier { impl DkimVerifier {
/// Creates a new [`DkimVerifier`] with the provided resolver. /// Creates a new [`DkimVerifier`] with the provided resolver.
pub fn new() -> Result<Self, crate::error::Error> { pub fn new() -> Result<Self, crate::error::Error> {
let resolver = CachedResolver::new()?; let resolver = CachedResolver::new()?.into();
let config = viadkim::Config { let config = viadkim::Config {
lookup_timeout: Duration::from_secs(60), lookup_timeout: Duration::from_secs(60),
..Default::default() ..Default::default()
@@ -134,44 +184,34 @@ impl DkimVerifier {
Ok(Self { resolver, config }) Ok(Self { resolver, config })
} }
/// Creates a new [`DkimVerifier`] with a mock resolver that always returns the provided TXT record.
#[cfg(test)]
fn mock(txt: String) -> Self {
let resolver = MockResolver(txt).into();
let config = viadkim::Config {
lookup_timeout: Duration::from_secs(60),
..Default::default()
};
Self { resolver, config }
}
/// Verifies the DKIM signature of a raw email message and its alignment with the provided /// Verifies the DKIM signature of a raw email message and its alignment with the provided
/// domain. /// domain.
pub async fn verify(&self, raw_mail: &[u8], from_domain: &str) -> Result<(), String> { pub async fn verify(&self, raw_mail: &[u8], from_domain: &str) -> Result<(), String> {
let (headers, body_start) = { let mail_data = String::from_utf8_lossy(raw_mail);
use viadkim::{FieldBody, FieldName, HeaderField}; let (header, body) = mail_data
.split_once("\r\n\r\n")
.ok_or("554 Malformed data")?;
let Ok((headers, body_start)) = mailparse::parse_headers(raw_mail) else { let header = header.parse().map_err(|_| "554 Malformed header")?;
return Err("500 Failed to parse message headers".to_string());
};
let mut viadkim_headers: Vec<HeaderField> = Vec::new();
for header in headers {
match (
FieldName::new(header.get_key()),
FieldBody::new(header.get_value_raw()),
) {
(Ok(name), Ok(body)) => viadkim_headers.push((name, body)),
(Err(e), _) | (_, Err(e)) => {
log::debug!("Failed to parse header {header:?}, skipping: {e}");
}
}
}
let viadkim_headers = viadkim::HeaderFields::new(viadkim_headers).map_err(|e| {
log::error!("Failed to parse headers for DKIM verification: {e}");
"500 Failed to parse message headers".to_string()
})?;
(viadkim_headers, body_start)
};
let Some(mut verifier) = let Some(mut verifier) =
viadkim::Verifier::verify_header(&self.resolver, &headers, &self.config).await viadkim::Verifier::verify_header(&self.resolver, &header, &self.config).await
else { else {
return Err("554 5.7.1 No DKIM signature found".to_string()); return Err("554 5.7.1 No DKIM signature found".to_string());
}; };
'hasher: for chunk in raw_mail.get(body_start..).unwrap_or_default().chunks(8192) { 'hasher: for chunk in body.as_bytes().chunks(8192) {
if verifier.process_body_chunk(chunk) == BodyHasherStance::Done { if verifier.process_body_chunk(chunk) == BodyHasherStance::Done {
break 'hasher; break 'hasher;
} }
@@ -189,8 +229,10 @@ impl DkimVerifier {
log::debug!("Signature {}: Verification failed, skipping", res.index); log::debug!("Signature {}: Verification failed, skipping", res.index);
// We only invalidate cache on actual validation error, and not alignment error. // We only invalidate cache on actual validation error, and not alignment error.
// TODO: ideally we should retry without cache and swap cached value only on success. // TODO: ideally we should retry without cache and swap cached value only on success.
self.resolver if let Resolver::Real(resolver) = &self.resolver {
.invalidate_cache(signature.selector.as_ref(), signature.domain.as_ref()); resolver
.invalidate_cache(signature.selector.as_ref(), signature.domain.as_ref());
}
continue; continue;
} }
@@ -212,3 +254,17 @@ impl DkimVerifier {
Err("554 5.7.1 No valid DKIM signature found".to_string()) Err("554 5.7.1 No valid DKIM signature found".to_string())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_dkim_verifier() {
let verifier = DkimVerifier::mock(
r#"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5krC4Xi5Wkr6eMlla38LCFmV645E3FLAgsRl2YJ0SrZ4N2Vw1/yH0mefvtk7HYE7ytV7RQl/er2CkSsaHLJSYLmPCBw5CO6PSsBSXuh6DBqdylh/1t9vVQ9p38fTwn9gU1QvplcpRQL9eepRra1k24VMIaVy2ZZcu3LI9zkPsR7o7TyNaeMhsL8ouWInWc1NSid+p0SgliQuwHIejZhlTPE60JLbJE0OR9I4wmq3377H6z/QrO8XeabCgtmTuzE/hTRyIyNS40jql/99pjlhIcjM2U+P2B0FjwYt7BwLHsgANr74ctlnKY+SdH25rNwVpPmkotaULG5SJCByKBkfCwIDAQAB;s=email;t=s"#.to_string()
);
let raw_mail = include_bytes!("../test_data/dkim-abjadiyah.eml");
verifier.verify(raw_mail, "abjadiyah.xyz").await.unwrap();
}
}
+47
View File
@@ -0,0 +1,47 @@
DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=abjadiyah.xyz;
s=default; t=1771356412;
bh=EVfAHeUMDygbJe0SkMWJHjgXGjtiTLZnMQbyWqzsrCY=;
h=From:To:Subject:Date:From;
b=3GHd4aCp7sjNR7RfHzLUUQ0PLk4FM8WLlOO5d8BXvpa6oCjERP0vsP545EDo4lBFC
xg9dlSrhIwEWsrJDXSFStA7VU1WmWBVTzKoN8bZk5w/2HJf7BcB1BG2SQ0tyu2iEtl
sv21HCXLw1IAMd2IiZNPQRlk0PgrKDqFmgULbktIWkc917GNAWhJaVCQhk+YF6cfql
AKP3i8NPK53JY6vsMBO3YuYb/DcRGyvhUUJGofgG2+NwESA20ayI85EPiUzbzGpx0z
r5IsLwiMApO5W0svfzgU52VhtkAqVyi38ZvjYsCJlKXB885SRsHsojCCSIznvFSmNw
pWF4qKBvNrQpQ==
From: <deltatest@abjadiyah.xyz>
To: "hidden-recipients": ;
Subject: [...]
Date: Sat, 14 Feb 2026 21:13:38 +0000
X-Last-TLS-Session-Version: TLSv1.3
X-Spamd-Result: default: False [5.18 / 15.00];
MISSING_MID(2.50)[];
FORGED_RECIPIENTS(2.00)[m:,s:link2xt@testrun.org];
DATE_IN_PAST(1.00)[70];
DMARC_POLICY_ALLOW(-0.50)[abjadiyah.xyz,reject];
R_MISSING_CHARSET(0.50)[];
R_DKIM_ALLOW(-0.20)[abjadiyah.xyz:s=default];
R_SPF_ALLOW(-0.20)[+mx];
MIME_GOOD(-0.10)[text/plain];
FISHY_TLD(0.10)[abjadiyah.xyz];
ONCE_RECEIVED(0.10)[];
IP_REPUTATION_HAM(-0.01)[asn: 29670(0.00), country: DE(-0.01), ip: 2001:67c:1400:21d0::(0.00)];
MX_GOOD(-0.01)[];
MISSING_XM_UA(0.00)[];
RCPT_COUNT_ONE(0.00)[1];
BCC(0.00)[];
ARC_NA(0.00)[];
MIME_TRACE(0.00)[0:+];
DWL_DNSWL_BLOCKED(0.00)[abjadiyah.xyz:dkim];
DKIM_TRACE(0.00)[abjadiyah.xyz:+];
RCPT_MAILCOW_DOMAIN(0.00)[testrun.org];
SINGLE_SHORT_PART(0.00)[];
ARC_SIGNED(0.00)[testrun.org:s=dkim:i=1];
RCVD_COUNT_ZERO(0.00)[0];
TO_DN_ALL(0.00)[];
ASN(0.00)[asn:29670, ipnet:2001:67c:1400::/45, country:DE];
FROM_NO_DN(0.00)[];
FROM_EQ_ENVFROM(0.00)[]
X-Rspamd-Queue-Id: E471A4105C
Hello!