1use crate::SystemTime;
11use crate::dns::DnsCache;
12use crate::{
13 AuthenticatedMessage, DkimOutput, DkimResult, Error, MessageAuthenticator, Parameters,
14 dkim::{DomainKey, VerifySignature},
15 dns::cache::NoCache,
16 sampling::is_within_pct,
17};
18use crate::{DnsError, crypto::CryptoError};
19use encodify::base32;
20
21use super::{
22 AtpsRecord, DkimError, DkimReportRecord, HashAlgorithm, RR_DNS, RR_EXPIRATION, RR_OTHER,
23 RR_SIGNATURE, RR_VERIFICATION, Signature,
24};
25
26impl MessageAuthenticator {
27 #[inline(always)]
75 pub async fn verify_dkim<'x, C>(
76 &self,
77 params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, C>>,
78 ) -> Vec<DkimOutput<'x>>
79 where
80 C: DnsCache + 'x,
81 {
82 self.verify_dkim_(
83 params.into(),
84 SystemTime::now()
85 .duration_since(SystemTime::UNIX_EPOCH)
86 .map_or(0, |d| d.as_secs()),
87 )
88 .await
89 }
90
91 pub(crate) async fn verify_dkim_<'x, C>(
92 &self,
93 params: Parameters<'x, &'x AuthenticatedMessage<'x>, C>,
94 now: u64,
95 ) -> Vec<DkimOutput<'x>>
96 where
97 C: DnsCache,
98 {
99 let message = params.input;
100 let mut output = Vec::with_capacity(message.dkim_headers.len() + message.errors.len());
101 let mut report_requested = false;
102
103 for header in &message.errors {
104 if let Error::Dkim(_) = &header.header {
105 output.push(DkimOutput::neutral(header.header.clone()));
106 }
107 }
108
109 for header in &message.dkim_headers {
110 let signature = &header.header;
111 if signature.r {
112 report_requested = true;
113 }
114
115 if !(signature.x == 0 || (signature.x > signature.t && signature.x > now)) {
116 output.push(
117 DkimOutput::neutral(Error::Dkim(DkimError::SignatureExpired))
118 .with_signature(signature),
119 );
120 continue;
121 }
122
123 let ha = HashAlgorithm::from(signature.a);
124 let bh = &message
125 .body_hashes
126 .iter()
127 .find(|(c, h, l, _)| c == &signature.cb && h == &ha && l == &signature.l)
128 .unwrap()
129 .3;
130
131 if bh != &signature.bh {
132 output.push(
133 DkimOutput::neutral(Error::Dkim(DkimError::BodyHashMismatch))
134 .with_signature(signature),
135 );
136 continue;
137 }
138
139 let record = match self
140 .txt_lookup::<DomainKey>(signature.domain_key(), params.txt_cache())
141 .await
142 {
143 Ok(record) => record,
144 Err(err) => {
145 output.push(DkimOutput::dns_error(err).with_signature(signature));
146 continue;
147 }
148 };
149
150 if !signature.validate_auid(&record) {
151 output.push(
152 DkimOutput::fail(Error::Dkim(DkimError::AuidMismatch))
153 .with_signature(signature),
154 );
155 continue;
156 }
157
158 let dkim_hdr_value = header.value.strip_signature();
159 let mut headers = message.signed_headers(&signature.h, header.name, &dkim_hdr_value);
160
161 if let Err(err) = record.verify(&mut headers, signature, signature.ch) {
162 output.push(DkimOutput::fail(err).with_signature(signature));
163 continue;
164 }
165
166 if let Some(atps) = &signature.atps {
167 let mut found = false;
168 for from in &message.from {
169 if let Some((_, domain)) = from.rsplit_once('@')
170 && domain.eq(atps)
171 {
172 found = true;
173 break;
174 }
175 }
176
177 if found {
178 let mut query_domain = match &signature.atpsh {
179 Some(algorithm) => {
180 base32::STANDARD_NO_PAD.encode(algorithm.hash(signature.d.as_bytes()))
181 }
182 None => signature.d.to_string(),
183 };
184 query_domain.push_str("._atps.");
185 query_domain.push_str(atps);
186 query_domain.push('.');
187
188 match self
189 .txt_lookup::<AtpsRecord>(query_domain, params.txt_cache())
190 .await
191 {
192 Ok(_) => {
193 output.push(DkimOutput::pass().with_atps().with_signature(signature));
194 }
195 Err(err) => {
196 output.push(
197 DkimOutput::dns_error(err)
198 .with_atps()
199 .with_signature(signature),
200 );
201 }
202 }
203 continue;
204 }
205 }
206
207 output.push(DkimOutput::pass().with_signature(signature));
208 }
209
210 if report_requested {
211 for dkim in &mut output {
212 let signature = if let Some(signature) = &dkim.signature {
213 if signature.r && dkim.result != DkimResult::Pass {
214 signature
215 } else {
216 continue;
217 }
218 } else {
219 continue;
220 };
221
222 let record = if let Ok(record) = self
223 .txt_lookup::<DkimReportRecord>(
224 format!("_report._domainkey.{}.", signature.d),
225 params.txt_cache(),
226 )
227 .await
228 {
229 if is_within_pct(record.rp) {
230 record
231 } else {
232 continue;
233 }
234 } else {
235 continue;
236 };
237
238 dkim.report = match &dkim.result() {
239 DkimResult::Neutral(err)
240 | DkimResult::Fail(err)
241 | DkimResult::PermError(err)
242 | DkimResult::TempError(err) => {
243 let send_report = match err {
244 Error::Crypto(CryptoError::Library(_))
245 | Error::Io(_)
246 | Error::Crypto(CryptoError::FailedVerification)
247 | Error::Dkim(DkimError::BodyHashMismatch)
248 | Error::Dkim(DkimError::AuidMismatch) => {
249 (record.rr & RR_VERIFICATION) != 0
250 }
251 Error::Base64
252 | Error::Dkim(DkimError::UnsupportedVersion)
253 | Error::Dkim(DkimError::UnsupportedAlgorithm)
254 | Error::Dkim(DkimError::UnsupportedCanonicalization)
255 | Error::Dkim(DkimError::UnsupportedKeyType)
256 | Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
257 (record.rr & RR_SIGNATURE) != 0
258 }
259 Error::Dkim(DkimError::SignatureExpired) => {
260 (record.rr & RR_EXPIRATION) != 0
261 }
262 Error::Dns(DnsError::Resolver(_))
263 | Error::Dns(DnsError::RecordNotFound(_))
264 | Error::Dns(DnsError::InvalidRecordType)
265 | Error::Parse
266 | Error::Dkim(DkimError::PublicKeyRevoked) => (record.rr & RR_DNS) != 0,
267 #[cfg(feature = "arc")]
268 Error::Arc(_) => (record.rr & RR_OTHER) != 0,
269 Error::MissingParameters
270 | Error::NoHeadersFound
271 | Error::Dkim(DkimError::BodyLengthTag)
272 | Error::NotAligned
273 | Error::Dkim2(_) => (record.rr & RR_OTHER) != 0,
274 };
275
276 if send_report {
277 format!("{}@{}", record.ra, signature.d).into()
278 } else {
279 None
280 }
281 }
282 DkimResult::None | DkimResult::Pass => None,
283 };
284 }
285 }
286
287 output
288 }
289}
290
291impl<'x> AuthenticatedMessage<'x> {
292 pub fn canonicalized_dkim_headers(&self) -> Option<Vec<u8>> {
301 let header = self
302 .dkim_headers
303 .iter()
304 .find(|header| header.header.x == 0 || header.header.x > header.header.t)?;
305 let signature = &header.header;
306 let dkim_hdr_value = header.value.strip_signature();
307 let headers = self.signed_headers(&signature.h, header.name, &dkim_hdr_value);
308 let mut data = Vec::with_capacity(256);
309 signature.ch.canonicalize_headers(headers, &mut data);
310 Some(data)
311 }
312
313 pub(crate) fn signed_headers<'z: 'x>(
314 &'z self,
315 headers: &'x [String],
316 dkim_hdr_name: &'x [u8],
317 dkim_hdr_value: &'x [u8],
318 ) -> impl Iterator<Item = (&'x [u8], &'x [u8])> {
319 let mut last_header_pos: Vec<(&[u8], usize)> = Vec::with_capacity(headers.len());
320 headers
321 .iter()
322 .filter_map(move |h| {
323 let name = h.as_bytes();
324 let slot = match last_header_pos
325 .iter()
326 .position(|(lh, _)| lh.eq_ignore_ascii_case(name))
327 {
328 Some(slot) => slot,
329 None => {
330 last_header_pos.push((name, 0));
331 last_header_pos.len() - 1
332 }
333 };
334 let header_pos = last_header_pos.get(slot).map_or(0, |(_, pos)| *pos);
335 let (next_pos, result) = match self
336 .headers
337 .iter()
338 .rev()
339 .enumerate()
340 .skip(header_pos)
341 .find(|(_, (mh, _))| name.eq_ignore_ascii_case(mh))
342 {
343 Some((last_pos, result)) => (last_pos + 1, Some(*result)),
344 None => (self.headers.len(), None),
345 };
346 if let Some((_, pos)) = last_header_pos.get_mut(slot) {
347 *pos = next_pos;
348 }
349 result
350 })
351 .chain([(dkim_hdr_name, dkim_hdr_value)])
352 }
353}
354
355impl Signature {
356 pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
357 if self.i.is_empty() {
358 return true;
359 }
360
361 let auid_domain = self
362 .i
363 .split_once('@')
364 .map_or("", |(_, auid_domain)| auid_domain)
365 .as_bytes();
366 let domain = self.d.as_bytes();
367
368 match auid_domain
369 .len()
370 .checked_sub(domain.len())
371 .and_then(|split| auid_domain.split_at_checked(split))
372 {
373 Some((parent, suffix)) if suffix.eq_ignore_ascii_case(domain) => {
374 parent.is_empty() || (!record.requires_strict_identity() && parent.ends_with(b"."))
375 }
376 _ => false,
377 }
378 }
379}
380
381pub(crate) trait Verifier: Sized {
382 fn strip_signature(&self) -> Vec<u8>;
383}
384
385#[derive(Clone, Copy, PartialEq, Eq)]
386enum TagState {
387 Semicolon,
388 Tag,
389 Value,
390 Signature,
391}
392
393fn strip_tag_segment(
394 segment: &[u8],
395 terminated: bool,
396 state: TagState,
397 unsigned_dkim: &mut Vec<u8>,
398) -> TagState {
399 if state != TagState::Semicolon {
400 unsigned_dkim.extend_from_slice(segment);
401 return if terminated {
402 TagState::Semicolon
403 } else {
404 state
405 };
406 }
407
408 let tag = segment
409 .iter()
410 .position(|ch| !ch.is_ascii_whitespace())
411 .unwrap_or(segment.len());
412
413 if !matches!(segment.get(tag), Some(b'b' | b'B')) {
414 unsigned_dkim.extend_from_slice(segment);
415 return if terminated || tag == segment.len() {
416 TagState::Semicolon
417 } else {
418 TagState::Value
419 };
420 }
421
422 let after_tag = &segment[tag + 1..];
423 let equals = after_tag
424 .iter()
425 .position(|ch| !ch.is_ascii_whitespace())
426 .unwrap_or(after_tag.len());
427
428 if after_tag.get(equals) != Some(&b'=') {
429 unsigned_dkim.extend_from_slice(segment);
430 return if terminated {
431 TagState::Semicolon
432 } else if equals == after_tag.len() {
433 TagState::Tag
434 } else {
435 TagState::Value
436 };
437 }
438
439 unsigned_dkim.extend_from_slice(&segment[..tag + equals + 2]);
440 if terminated {
441 unsigned_dkim.push(b';');
442 TagState::Value
443 } else {
444 TagState::Signature
445 }
446}
447
448fn strip_tag_list(mut rest: &[u8], unsigned_dkim: &mut Vec<u8>) -> TagState {
449 let mut state = TagState::Semicolon;
450 loop {
451 match memchr::memchr(b';', rest) {
452 Some(position) => {
453 let (segment, tail) = rest.split_at(position + 1);
454 state = strip_tag_segment(segment, true, state, unsigned_dkim);
455 rest = tail;
456 }
457 None => return strip_tag_segment(rest, false, state, unsigned_dkim),
458 }
459 }
460}
461
462fn strip_trailing_byte(ch: u8, discard: bool, state: &mut TagState, unsigned_dkim: &mut Vec<u8>) {
463 if *state == TagState::Signature {
464 if ch == b';' {
465 unsigned_dkim.push(b';');
466 *state = TagState::Semicolon;
467 }
468 return;
469 }
470
471 match ch {
472 b'=' if *state == TagState::Tag => {
473 unsigned_dkim.push(ch);
474 *state = TagState::Signature;
475 }
476 b'b' | b'B' if *state == TagState::Semicolon => {
477 unsigned_dkim.push(ch);
478 *state = TagState::Tag;
479 }
480 b';' => {
481 unsigned_dkim.push(ch);
482 *state = TagState::Semicolon;
483 }
484 _ if discard => (),
485 _ => {
486 unsigned_dkim.push(ch);
487 if !ch.is_ascii_whitespace() {
488 *state = TagState::Value;
489 }
490 }
491 }
492}
493
494impl Verifier for &[u8] {
495 fn strip_signature(&self) -> Vec<u8> {
496 let mut unsigned_dkim = Vec::with_capacity(self.len());
497 let (head, tail) = match self.len() {
498 0 => return unsigned_dkim,
499 1 => self.split_at(0),
500 len => self.split_at(len - 2),
501 };
502
503 let mut state = strip_tag_list(head, &mut unsigned_dkim);
504 match tail {
505 [cr, lf] => {
506 strip_trailing_byte(*cr, *cr == b'\r', &mut state, &mut unsigned_dkim);
507 strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim);
508 }
509 [lf] => strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim),
510 _ => (),
511 }
512
513 unsigned_dkim
514 }
515}
516
517impl<'x> From<&'x AuthenticatedMessage<'x>>
518 for Parameters<'x, &'x AuthenticatedMessage<'x>, NoCache>
519{
520 fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
521 Parameters::new(params)
522 }
523}
524
525#[cfg(test)]
526#[allow(unused)]
527pub(crate) mod test {
528 use std::{
529 fs,
530 path::PathBuf,
531 time::{Duration, Instant},
532 };
533
534 use encodify::base32;
535 use mail_parser::MessageParser;
536
537 use crate::{
538 AuthenticatedMessage, DkimResult, MessageAuthenticator,
539 dkim::DomainKey,
540 dkim::{HashAlgorithm, Signature, verify::Verifier},
541 dns::cache::test::DummyCaches,
542 parse::TxtRecordParser,
543 };
544
545 #[test]
546 fn validate_auid() {
547 let strict = DomainKey::parse(
548 b"v=DKIM1; k=ed25519; t=s; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
549 )
550 .unwrap();
551 let relaxed =
552 DomainKey::parse(b"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=")
553 .unwrap();
554
555 for (auid, strict_expected, relaxed_expected) in [
556 ("", true, true),
557 ("@example.com", true, true),
558 ("john@example.com", true, true),
559 ("@EXAMPLE.com", true, true),
560 ("@sub.example.com", false, true),
561 ("john@deep.sub.example.com", false, true),
562 ("@example.com.evil", false, false),
563 ("@xexample.com", false, false),
564 ("@other.org", false, false),
565 ("john", false, false),
566 ("@", false, false),
567 ] {
568 let signature = Signature {
569 i: auid.to_string(),
570 d: "example.com".to_string(),
571 ..Default::default()
572 };
573 assert_eq!(
574 signature.validate_auid(&strict),
575 strict_expected,
576 "t=s {auid:?}"
577 );
578 assert_eq!(
579 signature.validate_auid(&relaxed),
580 relaxed_expected,
581 "{auid:?}"
582 );
583 }
584 }
585
586 #[tokio::test]
587 async fn dkim_retain_signatures_reports_rejected() {
588 let raw = concat!(
589 "DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s; h=From; ",
590 "bh=YWJj; b=YWJj\r\n",
591 "From: a@example.com\r\n\r\nbody\r\n"
592 );
593 let mut message = AuthenticatedMessage::parse(raw.as_bytes()).unwrap();
594 assert_eq!(message.dkim_signatures().len(), 1);
595 assert!(!message.has_dkim_errors());
596
597 message.retain_dkim_signatures(|_| Err(crate::dkim::DkimError::UnsupportedAlgorithm));
598 assert!(message.dkim_signatures().is_empty());
599 assert!(message.has_dkim_errors());
600
601 let outputs = MessageAuthenticator::new_system_conf()
602 .unwrap()
603 .verify_dkim(&message)
604 .await;
605 assert_eq!(outputs.len(), 1);
606 assert_eq!(
607 outputs[0].result(),
608 &DkimResult::Neutral(crate::Error::Dkim(
609 crate::dkim::DkimError::UnsupportedAlgorithm
610 ))
611 );
612 }
613
614 #[tokio::test]
615 async fn dkim_verify() {
616 let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
617 test_dir.push("resources");
618 test_dir.push("dkim");
619 let resolver = MessageAuthenticator::new_system_conf().unwrap();
620
621 for file_name in fs::read_dir(&test_dir).unwrap() {
622 let file_name = file_name.unwrap().path();
623 println!("DKIM verifying {}", file_name.to_str().unwrap());
627
628 let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
629 let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
630 let caches = new_cache(dns_records);
631 let raw_message = raw_message.replace('\n', "\r\n");
632 let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
633 assert_eq!(
634 message,
635 AuthenticatedMessage::from_parsed(
636 &MessageParser::new().parse(&raw_message).unwrap(),
637 raw_message.as_bytes(),
638 true
639 )
640 );
641
642 let dkim = resolver
643 .verify_dkim_(caches.parameters(&message), 1667843664)
644 .await;
645
646 assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
647 }
648 }
649
650 #[test]
651 fn dkim_strip_signature() {
652 for (value, stripped_value) in [
653 ("b=abc;h=From\r\n", "b=;h=From"),
654 ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
655 ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
656 ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
657 ] {
658 assert_eq!(
659 String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
660 stripped_value
661 );
662 }
663 }
664
665 #[test]
666 fn atps_query_label() {
667 for (domain, label) in [
668 ("one.example.net", "QSP4I4D24CRHOPDZ3O3ZIU2KSGS3X6Z6"),
669 ("two.example.net", "ZTZGRRV3F45A4U6HLDKBF3ZCOW4V2AJX"),
670 ] {
671 assert_eq!(
672 base32::STANDARD_NO_PAD.encode(HashAlgorithm::Sha1.hash(domain.as_bytes())),
673 label
674 );
675 }
676 }
677
678 pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
679 let caches = DummyCaches::new();
680 for (key, value) in dns_records
681 .split('\n')
682 .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
683 {
684 caches.txt_add(
685 format!("{key}."),
686 DomainKey::parse(value).unwrap(),
687 Instant::now() + Duration::new(3200, 0),
688 );
689 }
690
691 caches
692 }
693}