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::Crypto(CryptoError::MalformedPublicKey)
246 | Error::Io(_)
247 | Error::Crypto(CryptoError::FailedVerification)
248 | Error::Dkim(DkimError::BodyHashMismatch)
249 | Error::Dkim(DkimError::AuidMismatch) => {
250 (record.rr & RR_VERIFICATION) != 0
251 }
252 Error::Base64
253 | Error::Dkim(DkimError::UnsupportedVersion)
254 | Error::Dkim(DkimError::UnsupportedAlgorithm)
255 | Error::Dkim(DkimError::UnsupportedCanonicalization)
256 | Error::Dkim(DkimError::UnsupportedKeyType)
257 | Error::Crypto(CryptoError::IncompatibleAlgorithms) => {
258 (record.rr & RR_SIGNATURE) != 0
259 }
260 Error::Dkim(DkimError::SignatureExpired) => {
261 (record.rr & RR_EXPIRATION) != 0
262 }
263 Error::Dns(DnsError::Resolver(_))
264 | Error::Dns(DnsError::RecordNotFound(_))
265 | Error::Dns(DnsError::InvalidRecordType)
266 | Error::Parse
267 | Error::Dkim(DkimError::PublicKeyRevoked) => (record.rr & RR_DNS) != 0,
268 #[cfg(feature = "arc")]
269 Error::Arc(_) => (record.rr & RR_OTHER) != 0,
270 Error::MissingParameters
271 | Error::NoHeadersFound
272 | Error::Dkim(DkimError::BodyLengthTag)
273 | Error::NotAligned
274 | Error::Dkim2(_) => (record.rr & RR_OTHER) != 0,
275 };
276
277 if send_report {
278 format!("{}@{}", record.ra, signature.d).into()
279 } else {
280 None
281 }
282 }
283 DkimResult::None | DkimResult::Pass => None,
284 };
285 }
286 }
287
288 output
289 }
290}
291
292impl<'x> AuthenticatedMessage<'x> {
293 pub fn canonicalized_dkim_headers(&self) -> Option<Vec<u8>> {
302 let header = self
303 .dkim_headers
304 .iter()
305 .find(|header| header.header.x == 0 || header.header.x > header.header.t)?;
306 let signature = &header.header;
307 let dkim_hdr_value = header.value.strip_signature();
308 let headers = self.signed_headers(&signature.h, header.name, &dkim_hdr_value);
309 let mut data = Vec::with_capacity(256);
310 signature.ch.canonicalize_headers(headers, &mut data);
311 Some(data)
312 }
313
314 pub(crate) fn signed_headers<'z: 'x>(
315 &'z self,
316 headers: &'x [String],
317 dkim_hdr_name: &'x [u8],
318 dkim_hdr_value: &'x [u8],
319 ) -> impl Iterator<Item = (&'x [u8], &'x [u8])> {
320 let mut last_header_pos: Vec<(&[u8], usize)> = Vec::with_capacity(headers.len());
321 headers
322 .iter()
323 .filter_map(move |h| {
324 let name = h.as_bytes();
325 let slot = match last_header_pos
326 .iter()
327 .position(|(lh, _)| lh.eq_ignore_ascii_case(name))
328 {
329 Some(slot) => slot,
330 None => {
331 last_header_pos.push((name, 0));
332 last_header_pos.len() - 1
333 }
334 };
335 let header_pos = last_header_pos.get(slot).map_or(0, |(_, pos)| *pos);
336 let (next_pos, result) = match self
337 .headers
338 .iter()
339 .rev()
340 .enumerate()
341 .skip(header_pos)
342 .find(|(_, (mh, _))| name.eq_ignore_ascii_case(mh))
343 {
344 Some((last_pos, result)) => (last_pos + 1, Some(*result)),
345 None => (self.headers.len(), None),
346 };
347 if let Some((_, pos)) = last_header_pos.get_mut(slot) {
348 *pos = next_pos;
349 }
350 result
351 })
352 .chain([(dkim_hdr_name, dkim_hdr_value)])
353 }
354}
355
356impl Signature {
357 pub(crate) fn validate_auid(&self, record: &DomainKey) -> bool {
358 if self.i.is_empty() {
359 return true;
360 }
361
362 let auid_domain = self
363 .i
364 .split_once('@')
365 .map_or("", |(_, auid_domain)| auid_domain)
366 .as_bytes();
367 let domain = self.d.as_bytes();
368
369 match auid_domain
370 .len()
371 .checked_sub(domain.len())
372 .and_then(|split| auid_domain.split_at_checked(split))
373 {
374 Some((parent, suffix)) if suffix.eq_ignore_ascii_case(domain) => {
375 parent.is_empty() || (!record.requires_strict_identity() && parent.ends_with(b"."))
376 }
377 _ => false,
378 }
379 }
380}
381
382pub(crate) trait Verifier: Sized {
383 fn strip_signature(&self) -> Vec<u8>;
384}
385
386#[derive(Clone, Copy, PartialEq, Eq)]
387enum TagState {
388 Semicolon,
389 Tag,
390 Value,
391 Signature,
392}
393
394fn strip_tag_segment(
395 segment: &[u8],
396 terminated: bool,
397 state: TagState,
398 unsigned_dkim: &mut Vec<u8>,
399) -> TagState {
400 if state != TagState::Semicolon {
401 unsigned_dkim.extend_from_slice(segment);
402 return if terminated {
403 TagState::Semicolon
404 } else {
405 state
406 };
407 }
408
409 let tag = segment
410 .iter()
411 .position(|ch| !ch.is_ascii_whitespace())
412 .unwrap_or(segment.len());
413
414 if !matches!(segment.get(tag), Some(b'b' | b'B')) {
415 unsigned_dkim.extend_from_slice(segment);
416 return if terminated || tag == segment.len() {
417 TagState::Semicolon
418 } else {
419 TagState::Value
420 };
421 }
422
423 let after_tag = &segment[tag + 1..];
424 let equals = after_tag
425 .iter()
426 .position(|ch| !ch.is_ascii_whitespace())
427 .unwrap_or(after_tag.len());
428
429 if after_tag.get(equals) != Some(&b'=') {
430 unsigned_dkim.extend_from_slice(segment);
431 return if terminated {
432 TagState::Semicolon
433 } else if equals == after_tag.len() {
434 TagState::Tag
435 } else {
436 TagState::Value
437 };
438 }
439
440 unsigned_dkim.extend_from_slice(&segment[..tag + equals + 2]);
441 if terminated {
442 unsigned_dkim.push(b';');
443 TagState::Value
444 } else {
445 TagState::Signature
446 }
447}
448
449fn strip_tag_list(mut rest: &[u8], unsigned_dkim: &mut Vec<u8>) -> TagState {
450 let mut state = TagState::Semicolon;
451 loop {
452 match memchr::memchr(b';', rest) {
453 Some(position) => {
454 let (segment, tail) = rest.split_at(position + 1);
455 state = strip_tag_segment(segment, true, state, unsigned_dkim);
456 rest = tail;
457 }
458 None => return strip_tag_segment(rest, false, state, unsigned_dkim),
459 }
460 }
461}
462
463fn strip_trailing_byte(ch: u8, discard: bool, state: &mut TagState, unsigned_dkim: &mut Vec<u8>) {
464 if *state == TagState::Signature {
465 if ch == b';' {
466 unsigned_dkim.push(b';');
467 *state = TagState::Semicolon;
468 }
469 return;
470 }
471
472 match ch {
473 b'=' if *state == TagState::Tag => {
474 unsigned_dkim.push(ch);
475 *state = TagState::Signature;
476 }
477 b'b' | b'B' if *state == TagState::Semicolon => {
478 unsigned_dkim.push(ch);
479 *state = TagState::Tag;
480 }
481 b';' => {
482 unsigned_dkim.push(ch);
483 *state = TagState::Semicolon;
484 }
485 _ if discard => (),
486 _ => {
487 unsigned_dkim.push(ch);
488 if !ch.is_ascii_whitespace() {
489 *state = TagState::Value;
490 }
491 }
492 }
493}
494
495impl Verifier for &[u8] {
496 fn strip_signature(&self) -> Vec<u8> {
497 let mut unsigned_dkim = Vec::with_capacity(self.len());
498 let (head, tail) = match self.len() {
499 0 => return unsigned_dkim,
500 1 => self.split_at(0),
501 len => self.split_at(len - 2),
502 };
503
504 let mut state = strip_tag_list(head, &mut unsigned_dkim);
505 match tail {
506 [cr, lf] => {
507 strip_trailing_byte(*cr, *cr == b'\r', &mut state, &mut unsigned_dkim);
508 strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim);
509 }
510 [lf] => strip_trailing_byte(*lf, *lf == b'\n', &mut state, &mut unsigned_dkim),
511 _ => (),
512 }
513
514 unsigned_dkim
515 }
516}
517
518impl<'x> From<&'x AuthenticatedMessage<'x>>
519 for Parameters<'x, &'x AuthenticatedMessage<'x>, NoCache>
520{
521 fn from(params: &'x AuthenticatedMessage<'x>) -> Self {
522 Parameters::new(params)
523 }
524}
525
526#[cfg(test)]
527#[allow(unused)]
528pub(crate) mod test {
529 use std::{
530 fs,
531 path::PathBuf,
532 time::{Duration, Instant},
533 };
534
535 use encodify::base32;
536 use mail_parser::MessageParser;
537
538 use crate::{
539 AuthenticatedMessage, DkimResult, MessageAuthenticator,
540 dkim::DomainKey,
541 dkim::{HashAlgorithm, Signature, verify::Verifier},
542 dns::cache::test::DummyCaches,
543 parse::TxtRecordParser,
544 };
545
546 #[test]
547 fn validate_auid() {
548 let strict = DomainKey::parse(
549 b"v=DKIM1; k=ed25519; t=s; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
550 )
551 .unwrap();
552 let relaxed =
553 DomainKey::parse(b"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=")
554 .unwrap();
555
556 for (auid, strict_expected, relaxed_expected) in [
557 ("", true, true),
558 ("@example.com", true, true),
559 ("john@example.com", true, true),
560 ("@EXAMPLE.com", true, true),
561 ("@sub.example.com", false, true),
562 ("john@deep.sub.example.com", false, true),
563 ("@example.com.evil", false, false),
564 ("@xexample.com", false, false),
565 ("@other.org", false, false),
566 ("john", false, false),
567 ("@", false, false),
568 ] {
569 let signature = Signature {
570 i: auid.to_string(),
571 d: "example.com".to_string(),
572 ..Default::default()
573 };
574 assert_eq!(
575 signature.validate_auid(&strict),
576 strict_expected,
577 "t=s {auid:?}"
578 );
579 assert_eq!(
580 signature.validate_auid(&relaxed),
581 relaxed_expected,
582 "{auid:?}"
583 );
584 }
585 }
586
587 #[tokio::test]
588 async fn dkim_retain_signatures_reports_rejected() {
589 let raw = concat!(
590 "DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s; h=From; ",
591 "bh=YWJj; b=YWJj\r\n",
592 "From: a@example.com\r\n\r\nbody\r\n"
593 );
594 let mut message = AuthenticatedMessage::parse(raw.as_bytes()).unwrap();
595 assert_eq!(message.dkim_signatures().len(), 1);
596 assert!(!message.has_dkim_errors());
597
598 message.retain_dkim_signatures(|_| Err(crate::dkim::DkimError::UnsupportedAlgorithm));
599 assert!(message.dkim_signatures().is_empty());
600 assert!(message.has_dkim_errors());
601
602 let outputs = MessageAuthenticator::new_system_conf()
603 .unwrap()
604 .verify_dkim(&message)
605 .await;
606 assert_eq!(outputs.len(), 1);
607 assert_eq!(
608 outputs[0].result(),
609 &DkimResult::Neutral(crate::Error::Dkim(
610 crate::dkim::DkimError::UnsupportedAlgorithm
611 ))
612 );
613 }
614
615 #[tokio::test]
616 async fn dkim_verify() {
617 let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
618 test_dir.push("resources");
619 test_dir.push("dkim");
620 let resolver = MessageAuthenticator::new_system_conf().unwrap();
621
622 for file_name in fs::read_dir(&test_dir).unwrap() {
623 let file_name = file_name.unwrap().path();
624 println!("DKIM verifying {}", file_name.to_str().unwrap());
628
629 let test = String::from_utf8(fs::read(&file_name).unwrap()).unwrap();
630 let (dns_records, raw_message) = test.split_once("\n\n").unwrap();
631 let caches = new_cache(dns_records);
632 let raw_message = raw_message.replace('\n', "\r\n");
633 let message = AuthenticatedMessage::parse(raw_message.as_bytes()).unwrap();
634 assert_eq!(
635 message,
636 AuthenticatedMessage::from_parsed(
637 &MessageParser::new().parse(&raw_message).unwrap(),
638 raw_message.as_bytes(),
639 true
640 )
641 );
642
643 let dkim = resolver
644 .verify_dkim_(caches.parameters(&message), 1667843664)
645 .await;
646
647 assert_eq!(dkim.last().unwrap().result(), &DkimResult::Pass);
648 }
649 }
650
651 #[test]
652 fn dkim_strip_signature() {
653 for (value, stripped_value) in [
654 ("b=abc;h=From\r\n", "b=;h=From"),
655 ("bh=B64b=;h=From;b=abc\r\n", "bh=B64b=;h=From;b="),
656 ("h=From; b = abc\r\ndef\r\n; v=1\r\n", "h=From; b =; v=1"),
657 ("B\r\n=abc;v=1\r\n", "B\r\n=;v=1"),
658 ] {
659 assert_eq!(
660 String::from_utf8(value.as_bytes().strip_signature()).unwrap(),
661 stripped_value
662 );
663 }
664 }
665
666 #[test]
667 fn atps_query_label() {
668 for (domain, label) in [
669 ("one.example.net", "QSP4I4D24CRHOPDZ3O3ZIU2KSGS3X6Z6"),
670 ("two.example.net", "ZTZGRRV3F45A4U6HLDKBF3ZCOW4V2AJX"),
671 ] {
672 assert_eq!(
673 base32::STANDARD_NO_PAD.encode(HashAlgorithm::Sha1.hash(domain.as_bytes())),
674 label
675 );
676 }
677 }
678
679 pub(crate) fn new_cache(dns_records: &str) -> DummyCaches {
680 let caches = DummyCaches::new();
681 for (key, value) in dns_records
682 .split('\n')
683 .filter_map(|r| r.split_once(' ').map(|(a, b)| (a, b.as_bytes())))
684 {
685 caches.txt_add(
686 format!("{key}."),
687 DomainKey::parse(value).unwrap(),
688 Instant::now() + Duration::new(3200, 0),
689 );
690 }
691
692 caches
693 }
694}