1use crate::SystemTime;
8use crate::{
9 AuthenticatedMessage, DkimOutput, DkimResult, Error, MX, MessageAuthenticator, Parameters,
10 RecordSet, ResolverCache, Txt,
11 common::{
12 base32::Base32Writer,
13 cache::NoCache,
14 headers::Writer,
15 verify::{DomainKey, VerifySignature},
16 },
17 is_within_pct,
18};
19use crate::{DnsError, common::crypto::CryptoError};
20use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
21
22use super::{
23 Atps, DkimError, DomainKeyReport, Flag, HashAlgorithm, RR_DNS, RR_EXPIRATION, RR_OTHER,
24 RR_SIGNATURE, RR_VERIFICATION, Signature,
25};
26
27impl MessageAuthenticator {
28 #[inline(always)]
30 pub async fn verify_dkim<'x, TXT, MXX, IPV4, IPV6, PTR>(
31 &self,
32 params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
33 ) -> Vec<DkimOutput<'x>>
34 where
35 TXT: ResolverCache<Box<str>, Txt> + 'x,
36 MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
37 IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
38 IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
39 PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
40 {
41 self.verify_dkim_(
42 params.into(),
43 SystemTime::now()
44 .duration_since(SystemTime::UNIX_EPOCH)
45 .map_or(0, |d| d.as_secs()),
46 )
47 .await
48 }
49
50 pub(crate) async fn verify_dkim_<'x, TXT, MXX, IPV4, IPV6, PTR>(
51 &self,
52 params: Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>,
53 now: u64,
54 ) -> Vec<DkimOutput<'x>>
55 where
56 TXT: ResolverCache<Box<str>, Txt>,
57 MXX: ResolverCache<Box<str>, RecordSet<MX>>,
58 IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>>,
59 IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>>,
60 PTR: ResolverCache<IpAddr, RecordSet<Box<str>>>,
61 {
62 let message = params.params;
63 let mut output = Vec::with_capacity(message.dkim_headers.len() + message.errors.len());
64 let mut report_requested = false;
65
66 for header in &message.errors {
68 if let Error::Dkim(_) = &header.header {
69 output.push(DkimOutput::neutral(header.header.clone()));
70 }
71 }
72
73 for header in &message.dkim_headers {
75 let signature = &header.header;
76 if signature.r {
77 report_requested = true;
78 }
79
80 if !(signature.x == 0 || (signature.x > signature.t && signature.x > now)) {
81 output.push(
82 DkimOutput::neutral(Error::Dkim(DkimError::SignatureExpired))
83 .with_signature(signature),
84 );
85 continue;
86 }
87
88 let ha = HashAlgorithm::from(signature.a);
90 let bh = &message
91 .body_hashes
92 .iter()
93 .find(|(c, h, l, _)| c == &signature.cb && h == &ha && l == &signature.l)
94 .unwrap()
95 .3;
96
97 if bh != &signature.bh {
98 output.push(
99 DkimOutput::neutral(Error::Dkim(DkimError::FailedBodyHashMatch))
100 .with_signature(signature),
101 );
102 continue;
103 }
104
105 let record = match self
107 .txt_lookup::<DomainKey>(signature.domain_key(), params.cache_txt)
108 .await
109 {
110 Ok(record) => record,
111 Err(err) => {
112 output.push(DkimOutput::dns_error(err).with_signature(signature));
113 continue;
114 }
115 };
116
117 if !signature.validate_auid(&record) {
119 output.push(
120 DkimOutput::fail(Error::Dkim(DkimError::FailedAuidMatch))
121 .with_signature(signature),
122 );
123 continue;
124 }
125
126 let dkim_hdr_value = header.value.strip_signature();
128 let mut headers = message.signed_headers(&signature.h, header.name, &dkim_hdr_value);
129
130 if let Err(err) = record.verify(&mut headers, signature, signature.ch) {
132 output.push(DkimOutput::fail(err).with_signature(signature));
133 continue;
134 }
135
136 if let Some(atps) = &signature.atps {
138 let mut found = false;
139 for from in &message.from {
141 if let Some((_, domain)) = from.rsplit_once('@')
142 && domain.eq(atps)
143 {
144 found = true;
145 break;
146 }
147 }
148
149 if found {
150 let mut query_domain = match &signature.atpsh {
151 Some(algorithm) => {
152 let mut writer = Base32Writer::with_capacity(40);
153 let output = algorithm.hash(signature.d.as_bytes());
154 writer.write(output.as_ref());
155 writer.finalize()
156 }
157 None => signature.d.to_string(),
158 };
159 query_domain.push_str("._atps.");
160 query_domain.push_str(atps);
161 query_domain.push('.');
162
163 match self
164 .txt_lookup::<Atps>(query_domain, params.cache_txt)
165 .await
166 {
167 Ok(_) => {
168 output.push(DkimOutput::pass().with_atps().with_signature(signature));
170 }
171 Err(err) => {
172 output.push(
173 DkimOutput::dns_error(err)
174 .with_atps()
175 .with_signature(signature),
176 );
177 }
178 }
179 continue;
180 }
181 }
182
183 output.push(DkimOutput::pass().with_signature(signature));
185 }
186
187 if report_requested {
189 for dkim in &mut output {
190 let signature = if let Some(signature) = &dkim.signature {
192 if signature.r && dkim.result != DkimResult::Pass {
193 signature
194 } else {
195 continue;
196 }
197 } else {
198 continue;
199 };
200
201 let record = if let Ok(record) = self
203 .txt_lookup::<DomainKeyReport>(
204 format!("_report._domainkey.{}.", signature.d),
205 params.cache_txt,
206 )
207 .await
208 {
209 if is_within_pct(record.rp) {
210 record
211 } else {
212 continue;
213 }
214 } else {
215 continue;
216 };
217
218 dkim.report = match &dkim.result() {
220 DkimResult::Neutral(err)
221 | DkimResult::Fail(err)
222 | DkimResult::PermError(err)
223 | DkimResult::TempError(err) => {
224 let send_report = match err {
225 Error::Crypto(CryptoError::Library(_))
226 | Error::Io(_)
227 | Error::Crypto(CryptoError::FailedVerification)
228 | Error::Dkim(DkimError::FailedBodyHashMatch)
229 | Error::Dkim(DkimError::FailedAuidMatch) => {
230 (record.rr & RR_VERIFICATION) != 0
231 }
232 Error::Base64
233 | Error::Dkim(DkimError::UnsupportedVersion)
234 | Error::Dkim(DkimError::UnsupportedAlgorithm)
235 | Error::Dkim(DkimError::UnsupportedCanonicalization)
236 | Error::Dkim(DkimError::UnsupportedKeyType)
237 | Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
238 (record.rr & RR_SIGNATURE) != 0
239 }
240 Error::Dkim(DkimError::SignatureExpired) => {
241 (record.rr & RR_EXPIRATION) != 0
242 }
243 Error::Dns(DnsError::Resolver(_))
244 | Error::Dns(DnsError::RecordNotFound(_))
245 | Error::Dns(DnsError::InvalidRecordType)
246 | Error::ParseError
247 | Error::Dkim(DkimError::RevokedPublicKey) => (record.rr & RR_DNS) != 0,
248 #[cfg(feature = "arc")]
249 Error::Arc(_) => (record.rr & RR_OTHER) != 0,
250 Error::MissingParameters
251 | Error::NoHeadersFound
252 | Error::Dkim(DkimError::SignatureLength)
253 | Error::NotAligned
254 | Error::Dkim2(_) => (record.rr & RR_OTHER) != 0,
255 };
256
257 if send_report {
258 format!("{}@{}", record.ra, signature.d).into()
259 } else {
260 None
261 }
262 }
263 DkimResult::None | DkimResult::Pass => None,
264 };
265 }
266 }
267
268 output
269 }
270}
271
272impl<'x> AuthenticatedMessage<'x> {
273 pub async fn get_canonicalized_header(&self) -> Result<Vec<u8>, Error> {
274 let mut data = Vec::with_capacity(256);
277 for header in &self.dkim_headers {
278 let signature = &header.header;
280 if !(signature.x == 0 || (signature.x > signature.t)) {
281 continue;
282 }
283
284 let dkim_hdr_value = header.value.strip_signature();
286 let headers = self.signed_headers(&signature.h, header.name, &dkim_hdr_value);
287 signature.ch.canonicalize_headers(headers, &mut data);
288
289 return Ok(data);
290 }
291 Err(Error::Dkim(DkimError::FailedBodyHashMatch))
293 }
294
295 pub fn signed_headers<'z: 'x>(
296 &'z self,
297 headers: &'x [String],
298 dkim_hdr_name: &'x [u8],
299 dkim_hdr_value: &'x [u8],
300 ) -> impl Iterator<Item = (&'x [u8], &'x [u8])> {
301 let mut last_header_pos: Vec<(&[u8], usize)> = Vec::new();
302 headers
303 .iter()
304 .filter_map(move |h| {
305 let header_pos = if let Some((_, header_pos)) = last_header_pos
306 .iter_mut()
307 .find(|(lh, _)| lh.eq_ignore_ascii_case(h.as_bytes()))
308 {
309 header_pos
310 } else {
311 last_header_pos.push((h.as_bytes(), 0));
312 &mut last_header_pos.last_mut().unwrap().1
313 };
314 if let Some((last_pos, result)) = self
315 .headers
316 .iter()
317 .rev()
318 .enumerate()
319 .skip(*header_pos)
320 .find(|(_, (mh, _))| h.as_bytes().eq_ignore_ascii_case(mh))
321 {
322 *header_pos = last_pos + 1;
323 Some(*result)
324 } else {
325 *header_pos = self.headers.len();
326 None
327 }
328 })
329 .chain([(dkim_hdr_name, dkim_hdr_value)])
330 }
331}
332
333impl Signature {
334 #[allow(clippy::while_let_on_iterator)]
335 pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
336 if !self.i.is_empty() && record.has_flag(Flag::MatchDomain) {
338 let mut auid = self.i.chars();
339 let mut domain = self.d.chars();
340 while let Some(ch) = auid.next() {
341 if ch == '@' {
342 break;
343 }
344 }
345 while let Some(ch) = auid.next() {
346 if let Some(dch) = domain.next() {
347 if ch != dch {
348 return false;
349 }
350 } else {
351 break;
352 }
353 }
354 if domain.next().is_some() {
355 return false;
356 }
357 }
358
359 true
360 }
361}
362
363pub(crate) trait Verifier: Sized {
364 fn strip_signature(&self) -> Vec<u8>;
365}
366
367impl Verifier for &[u8] {
368 fn strip_signature(&self) -> Vec<u8> {
369 let mut unsigned_dkim = Vec::with_capacity(self.len());
370 let mut iter = self.iter().enumerate();
371 let mut last_ch = b';';
372 while let Some((pos, &ch)) = iter.next() {
373 match ch {
374 b'=' if last_ch == b'b' => {
375 unsigned_dkim.push(ch);
376 #[allow(clippy::while_let_on_iterator)]
377 while let Some((_, &ch)) = iter.next() {
378 if ch == b';' {
379 unsigned_dkim.push(b';');
380 break;
381 }
382 }
383 last_ch = 0;
384 }
385 b'b' | b'B' if last_ch == b';' => {
386 last_ch = b'b';
387 unsigned_dkim.push(ch);
388 }
389 b';' => {
390 last_ch = b';';
391 unsigned_dkim.push(ch);
392 }
393 b'\r' if pos == self.len() - 2 => (),
394 b'\n' if pos == self.len() - 1 => (),
395 _ => {
396 unsigned_dkim.push(ch);
397 if !ch.is_ascii_whitespace() {
398 last_ch = 0;
399 }
400 }
401 }
402 }
403 unsigned_dkim
404 }
405}
406
407impl<'x> From<&'x AuthenticatedMessage<'x>>
408 for Parameters<
409 'x,
410 &'x AuthenticatedMessage<'x>,
411 NoCache<Box<str>, Txt>,
412 NoCache<Box<str>, RecordSet<MX>>,
413 NoCache<Box<str>, RecordSet<Ipv4Addr>>,
414 NoCache<Box<str>, RecordSet<Ipv6Addr>>,
415 NoCache<IpAddr, RecordSet<Box<str>>>,
416 >
417{
418 fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
419 Parameters::new(params)
420 }
421}
422
423#[cfg(test)]
424#[allow(unused)]
425pub mod test {
426 use std::{
427 fs,
428 path::PathBuf,
429 time::{Duration, Instant},
430 };
431
432 use mail_parser::MessageParser;
433
434 use crate::{
435 AuthenticatedMessage, DkimResult, MessageAuthenticator,
436 common::{cache::test::DummyCaches, parse::TxtRecordParser, verify::DomainKey},
437 dkim::verify::Verifier,
438 };
439
440 #[tokio::test]
441 async fn dkim_verify() {
442 let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
443 test_dir.push("resources");
444 test_dir.push("dkim");
445 let resolver = MessageAuthenticator::new_system_conf().unwrap();
446
447 for file_name in fs::read_dir(&test_dir).unwrap() {
448 let file_name = file_name.unwrap().path();
449 println!("DKIM verifying {}", file_name.to_str().unwrap());
453
454 let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
455 let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
456 let caches = new_cache(dns_records);
457 let raw_message = raw_message.replace('\n', "\r\n");
458 let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
459 assert_eq!(
460 message,
461 AuthenticatedMessage::from_parsed(
462 &MessageParser::new().parse(&raw_message).unwrap(),
463 raw_message.as_bytes(),
464 true
465 )
466 );
467
468 let dkim = resolver
469 .verify_dkim_(caches.parameters(&message), 1667843664)
470 .await;
471
472 assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
473 }
474 }
475
476 #[test]
477 fn dkim_strip_signature() {
478 for (value, stripped_value) in [
479 ("b=abc;h=From\r\n", "b=;h=From"),
480 ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
481 ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
482 ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
483 ] {
484 assert_eq!(
485 String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
486 stripped_value
487 );
488 }
489 }
490
491 pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
492 let caches = DummyCaches::new();
493 for (key, value) in dns_records
494 .split('\n')
495 .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
496 {
497 caches.txt_add(
498 format!("{key}."),
499 DomainKey::parse(value).unwrap(),
500 Instant::now() + Duration::new(3200, 0),
501 );
502 }
503
504 caches
505 }
506}