1use super::{
12 ChainBinding, ChainLink, Dkim2Error, Dkim2Hash, Dkim2Output, MessageHash, MessageInstance,
13 Signature, SignatureValue, Verification,
14 recipe::{BodyRecipe, apply_body_recipe, body_lines, headers_len},
15 sign::Envelope,
16};
17use crate::dns::DnsCache;
18use crate::{
19 AuthenticatedMessage, Dkim2Result, DnsError, Error, MessageAuthenticator, Parameters,
20 ResolverCache, TxtRecord,
21 crypto::{Algorithm, CryptoError},
22 dkim::{DkimError, DomainKey},
23 dkim2::parse::is_domain,
24 dkim2::{
25 canonicalize::{CanonicalizedHeaderWriter, Digests, body_digests, header_digests},
26 sign::now,
27 },
28 dns::to_a_label,
29 headers::{Header, HeaderIterator, Writer},
30};
31use mail_parser::{Address, HeaderForm};
32use outcome::{Failure, Tally};
33use std::borrow::Cow;
34
35pub(crate) mod outcome;
36
37const MAX_AGE: u64 = 14 * 86400;
38const MAX_FUTURE: u64 = 86400;
39const MAX_CHAIN_LENGTH: usize = 50;
40const MIN_RSA_BITS: usize = 1024;
41
42impl MessageAuthenticator {
43 pub async fn verify_dkim2<'x, C, A, R>(
113 &self,
114 params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, C>>,
115 envelope: Envelope<A, R>,
116 ) -> Dkim2Output<'x>
117 where
118 C: DnsCache + 'x,
119 A: AsRef<str>,
120 R: IntoIterator<Item: AsRef<str>>,
121 {
122 let params = params.into();
123 self.verify_dkim2_(
124 params.input,
125 envelope,
126 params.txt_cache(),
127 now(),
128 true,
129 Verification::Full,
130 )
131 .await
132 }
133
134 pub async fn verify_dkim2_provisionally<'x, C, A, R>(
150 &self,
151 params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, C>>,
152 envelope: Envelope<A, R>,
153 ) -> Dkim2Output<'x>
154 where
155 C: DnsCache + 'x,
156 A: AsRef<str>,
157 R: IntoIterator<Item: AsRef<str>>,
158 {
159 let params = params.into();
160 self.verify_dkim2_(
161 params.input,
162 envelope,
163 params.txt_cache(),
164 now(),
165 true,
166 Verification::Provisional,
167 )
168 .await
169 }
170
171 pub(crate) async fn verify_dkim2_<'x, TXT, A, R>(
172 &self,
173 message: &'x AuthenticatedMessage<'x>,
174 envelope: Envelope<A, R>,
175 cache_txt: Option<&TXT>,
176 now: u64,
177 body_present: bool,
178 verification: Verification,
179 ) -> Dkim2Output<'x>
180 where
181 TXT: ResolverCache<Box<str>, TxtRecord>,
182 A: AsRef<str>,
183 R: IntoIterator<Item: AsRef<str>>,
184 {
185 let result = self
186 .verify_chain(
187 message,
188 envelope,
189 cache_txt,
190 now,
191 body_present,
192 verification,
193 )
194 .await;
195 let signatures = message.dkim2_signatures.as_slice();
196 let instances = message.dkim2_instances.as_slice();
197 let verified = match verification {
198 Verification::Full => signatures,
199 Verification::Provisional => signatures
200 .len()
201 .checked_sub(1)
202 .and_then(|top| signatures.get(top..))
203 .unwrap_or_default(),
204 };
205 let chain = match result {
206 Ok(()) => verified
207 .iter()
208 .map(|sig_header| ChainLink {
209 signature: &sig_header.header,
210 instance: sig_header
211 .header
212 .m
213 .checked_sub(1)
214 .and_then(|index| instances.get(index as usize))
215 .map(|h| &h.header),
216 result: Dkim2Result::Pass,
217 custody_ok: true,
218 })
219 .collect(),
220 Err(_) => Vec::new(),
221 };
222 Dkim2Output {
223 result: match result {
224 Ok(()) => Dkim2Result::Pass,
225 Err(result) => result,
226 },
227 chain,
228 verification,
229 raw_message: message.raw_message,
230 signature_fields: signatures,
231 instance_fields: instances,
232 }
233 }
234
235 async fn verify_chain<TXT, A, R>(
236 &self,
237 message: &AuthenticatedMessage<'_>,
238 envelope: Envelope<A, R>,
239 cache_txt: Option<&TXT>,
240 now: u64,
241 body_present: bool,
242 verification: Verification,
243 ) -> Result<(), Dkim2Result>
244 where
245 TXT: ResolverCache<Box<str>, TxtRecord>,
246 A: AsRef<str>,
247 R: IntoIterator<Item: AsRef<str>>,
248 {
249 if message.has_dkim2_errors {
250 for header in &message.errors {
251 let name = header.name.trim_ascii();
252
253 if name.eq_ignore_ascii_case(b"dkim2-signature")
254 || name.eq_ignore_ascii_case(b"message-instance")
255 {
256 return Err(Dkim2Result::from(header.header.clone()));
257 }
258 }
259 }
260
261 let signatures = message.dkim2_signatures.as_slice();
262 let instances = message.dkim2_instances.as_slice();
263 if signatures.is_empty() {
264 return Err(if instances.is_empty() {
265 Dkim2Result::None
266 } else {
267 perm_error(Dkim2Error::SignatureMissing(1))
268 });
269 } else if signatures.len() > MAX_CHAIN_LENGTH || instances.len() > MAX_CHAIN_LENGTH {
270 return Err(perm_error(Dkim2Error::ChainTooLong));
271 }
272
273 check_numbering(signatures, instances).map_err(perm_error)?;
274 check_timestamps(signatures, now).map_err(fail)?;
275
276 let Some((top, previous)) = signatures.split_last() else {
277 return Err(perm_error(Dkim2Error::SignatureMissing(1)));
278 };
279 let top = &top.header;
280 check_envelope(top, envelope).map_err(fail)?;
281
282 match verification {
283 Verification::Full => {
284 for header in signatures {
285 check_mail_from_domain(&header.header).map_err(fail)?;
286 }
287 for pair in signatures.windows(2) {
288 if let [previous, current] = pair {
289 check_custody(&previous.header, ¤t.header).map_err(fail)?;
290 }
291 }
292 }
293 Verification::Provisional => check_mail_from_domain(top).map_err(fail)?,
294 }
295
296 let checked = match verification {
297 Verification::Full => signatures,
298 Verification::Provisional => signatures.get(previous.len()..).unwrap_or_default(),
299 };
300 match verification {
301 Verification::Full => check_instances(message, signatures, instances, body_present),
302 Verification::Provisional => instances.last().map_or(Ok(()), |header| {
303 StateHashes::default().check(
304 &header.header,
305 &message.headers,
306 body_present.then(|| message.raw_body()),
307 )
308 }),
309 }
310 .map_err(Dkim2Result::from)?;
311
312 let mut input = Vec::with_capacity(256 * (instances.len() + signatures.len()));
313 let mut temp_error = None;
314 for sig_header in checked {
315 match self
316 .check_signature(sig_header, signatures, instances, cache_txt, &mut input)
317 .await
318 {
319 Ok(()) => {}
320 Err(Failure::TempError(error)) => {
321 temp_error.get_or_insert(error);
322 }
323 Err(failure) => return Err(failure.into()),
324 }
325 }
326 temp_error.map_or(Ok(()), |error| Err(Failure::TempError(error).into()))
327 }
328
329 async fn check_signature<TXT>(
330 &self,
331 sig_header: &Header<'_, Signature>,
332 signatures: &[Header<'_, Signature>],
333 instances: &[Header<'_, MessageInstance>],
334 cache_txt: Option<&TXT>,
335 input: &mut Vec<u8>,
336 ) -> Result<(), Failure>
337 where
338 TXT: ResolverCache<Box<str>, TxtRecord>,
339 {
340 let signature = &sig_header.header;
341
342 input.clear();
343 for (name, value) in instances
344 .iter()
345 .filter(|h| h.header.m <= signature.m)
346 .map(|h| (h.name, h.value))
347 .chain(
348 signatures
349 .iter()
350 .take_while(|h| h.header.i < signature.i)
351 .map(|h| (h.name, h.value)),
352 )
353 {
354 let mut w = CanonicalizedHeaderWriter::new(input, name);
355 w.write(value);
356 w.finalize();
357 }
358 strip_and_canonicalize_signature(sig_header.value, input);
359
360 let mut groups = Tally::default();
361 for group in signature.s.chunk_by(|a, b| a.group == b.group) {
362 let mut entries = Tally::default();
363 for value in group {
364 entries.record(
365 self.check_signature_value(signature, value, input, cache_txt)
366 .await,
367 );
368 if entries.failed() {
369 break;
370 }
371 }
372 groups.record(entries.all());
373 if groups.passed() {
374 return Ok(());
375 }
376 }
377 groups.any(Dkim2Error::NoSignatureGroupPassed(signature.i))
378 }
379
380 async fn check_signature_value<TXT>(
381 &self,
382 signature: &Signature,
383 value: &SignatureValue,
384 input: &[u8],
385 cache_txt: Option<&TXT>,
386 ) -> Result<(), Failure>
387 where
388 TXT: ResolverCache<Box<str>, TxtRecord>,
389 {
390 let key_error = |error: fn(u32, Box<str>) -> Dkim2Error| {
391 error(signature.i, value.selector.as_str().into())
392 };
393 let Some(algorithm) = value.a.known() else {
394 return Err(Failure::PermError(key_error(|i, selector| {
395 Dkim2Error::UnknownAlgorithm { i, selector }
396 })));
397 };
398 let key = match self
399 .txt_lookup::<DomainKey>(
400 format!("{}._domainkey.{}.", value.selector, signature.d),
401 cache_txt,
402 )
403 .await
404 {
405 Ok(key) => key,
406 Err(Error::Dns(DnsError::Resolver(_))) => {
407 return Err(Failure::TempError(key_error(|i, selector| {
408 Dkim2Error::PublicKeyFetch { i, selector }
409 })));
410 }
411 Err(err) => {
412 return Err(Failure::PermError(key_error(match err {
413 Error::Dkim(DkimError::PublicKeyRevoked) => {
414 |i, selector| Dkim2Error::PublicKeyRevoked { i, selector }
415 }
416 Error::Dns(DnsError::RecordNotFound(_)) => {
417 |i, selector| Dkim2Error::PublicKeyMissing { i, selector }
418 }
419 Error::Dkim(DkimError::UnsupportedKeyType) => {
420 |i, selector| Dkim2Error::PublicKeyAlgorithmMismatch { i, selector }
421 }
422 Error::Crypto(CryptoError::MalformedPublicKey) => {
423 |i, selector| Dkim2Error::PublicKeyMalformed { i, selector }
424 }
425 _ => |i, selector| Dkim2Error::PublicKeySyntax { i, selector },
426 })));
427 }
428 };
429
430 if key.has_multiple_records() {
431 return Err(Failure::PermError(key_error(|i, selector| {
432 Dkim2Error::PublicKeyMultiple { i, selector }
433 })));
434 } else if matches!(algorithm, Algorithm::RsaSha256)
435 && key.p.public_key_bits() < MIN_RSA_BITS
436 {
437 return Err(Failure::PermError(key_error(|i, selector| {
438 Dkim2Error::PublicKeyMalformed { i, selector }
439 })));
440 }
441
442 match key.p.verify_bytes(input, &value.b, algorithm) {
443 Ok(()) => Ok(()),
444 Err(Error::Crypto(CryptoError::IncompatibleAlgorithms)) => {
445 Err(Failure::PermError(key_error(|i, selector| {
446 Dkim2Error::PublicKeyAlgorithmMismatch { i, selector }
447 })))
448 }
449 Err(_) => Err(Failure::Fail(key_error(|i, selector| {
450 Dkim2Error::IncorrectSignature { i, selector }
451 }))),
452 }
453 }
454}
455
456#[cold]
457fn perm_error(error: Dkim2Error) -> Dkim2Result {
458 Dkim2Result::PermError(Error::Dkim2(error))
459}
460
461#[cold]
462fn fail(error: Dkim2Error) -> Dkim2Result {
463 Dkim2Result::Fail(Error::Dkim2(error))
464}
465
466fn check_timestamps(signatures: &[Header<'_, Signature>], now: u64) -> Result<(), Dkim2Error> {
467 for header in signatures {
468 let signature = &header.header;
469 if now > signature.t && now - signature.t > MAX_AGE {
470 return Err(Dkim2Error::SignatureExpired(signature.i));
471 } else if signature.t > now.saturating_add(MAX_FUTURE) {
472 return Err(Dkim2Error::SignatureFromFuture(signature.i));
473 }
474 }
475 Ok(())
476}
477
478fn check_numbering(
479 signatures: &[Header<'_, Signature>],
480 instances: &[Header<'_, MessageInstance>],
481) -> Result<(), Dkim2Error> {
482 for (expected, header) in (1u32..).zip(signatures) {
483 let signature = &header.header;
484 if signature.i != expected {
485 return Err(Dkim2Error::SequenceGap(expected));
486 }
487 if let ChainBinding::Envelope { mail_from, rcpt_to } = &signature.chain
488 && require_reverse_path()
489 && !(is_reverse_path(mail_from) && rcpt_to.iter().all(|r| is_reverse_path(r)))
490 {
491 return Err(Dkim2Error::SignatureSyntax(signature.i));
492 }
493 }
494
495 for (expected, header) in (1u32..).zip(instances) {
496 if header.header.m != expected {
497 return Err(Dkim2Error::InstanceMissing(expected));
498 }
499 }
500
501 let highest_m = instances.last().map_or(0, |h| h.header.m);
502 let mut previous_m = 0;
503 for header in signatures {
504 let signature = &header.header;
505 if signature.m == 0 || signature.m > highest_m {
506 return Err(Dkim2Error::InstanceMissing(signature.m));
507 } else if signature.m < previous_m {
508 return Err(Dkim2Error::SignatureSyntax(signature.i));
509 }
510 previous_m = signature.m;
511 }
512 if highest_m > previous_m {
513 return Err(Dkim2Error::InstanceNotSigned(highest_m));
514 }
515
516 Ok(())
517}
518
519fn check_envelope<A, R>(top: &Signature, envelope: Envelope<A, R>) -> Result<(), Dkim2Error>
520where
521 A: AsRef<str>,
522 R: IntoIterator<Item: AsRef<str>>,
523{
524 match &top.chain {
525 ChainBinding::Envelope { mail_from, rcpt_to } => {
526 let envelope_from = envelope.mail_from.as_ref();
527 if !address_matches(envelope_from, mail_from) {
528 return Err(Dkim2Error::MailFromMismatch {
529 i: top.i,
530 value: envelope_from.into(),
531 });
532 }
533 for rcpt in envelope.rcpt_to {
534 let rcpt = rcpt.as_ref();
535 if !rcpt_to.iter().any(|r| address_matches(rcpt, r)) {
536 return Err(Dkim2Error::RcptToMismatch {
537 i: top.i,
538 value: rcpt.into(),
539 });
540 }
541 }
542 Ok(())
543 }
544 ChainBinding::NextDomain(_) => Err(Dkim2Error::SignatureTagUnexpected {
545 i: top.i,
546 tag: "nd",
547 }),
548 }
549}
550
551pub(crate) fn check_mail_from_domain(signature: &Signature) -> Result<(), Dkim2Error> {
552 match &signature.chain {
553 ChainBinding::Envelope { mail_from, .. }
554 if !is_null_path(mail_from)
555 && !relaxed_domain_match(local_and_domain(mail_from).1, &signature.d) =>
556 {
557 Err(Dkim2Error::MailFromDomainMismatch(signature.i))
558 }
559 _ => Ok(()),
560 }
561}
562
563pub(crate) fn check_custody(previous: &Signature, current: &Signature) -> Result<(), Dkim2Error> {
564 match (&previous.chain, ¤t.chain) {
565 (ChainBinding::NextDomain(next_domain), _) => {
566 if domain_eq(next_domain, ¤t.d) {
567 Ok(())
568 } else {
569 Err(Dkim2Error::NextDomainMismatch(current.i))
570 }
571 }
572 (ChainBinding::Envelope { rcpt_to, .. }, ChainBinding::Envelope { mail_from, .. })
573 if !is_null_path(mail_from) =>
574 {
575 let mail_from_domain = local_and_domain(mail_from).1;
576 if rcpt_to
577 .iter()
578 .any(|rcpt| relaxed_domain_match(mail_from_domain, local_and_domain(rcpt).1))
579 {
580 Ok(())
581 } else {
582 Err(Dkim2Error::CustodyMailFromMismatch {
583 i: current.i,
584 previous: previous.i,
585 })
586 }
587 }
588 (ChainBinding::Envelope { rcpt_to, .. }, _) => {
589 if rcpt_to
590 .iter()
591 .any(|rcpt| relaxed_domain_match(local_and_domain(rcpt).1, ¤t.d))
592 {
593 Ok(())
594 } else {
595 Err(Dkim2Error::CustodyDomainMismatch {
596 i: current.i,
597 previous: previous.i,
598 })
599 }
600 }
601 }
602}
603
604#[derive(Default)]
605pub(crate) struct StateHashes {
606 header: Digests,
607 body: Digests,
608}
609
610impl StateHashes {
611 pub(crate) fn check(
612 &mut self,
613 instance: &MessageInstance,
614 headers: &[(&[u8], &[u8])],
615 body: Option<&[u8]>,
616 ) -> Result<(), Failure> {
617 let mut header_wanted = [false; Dkim2Hash::COUNT];
618 let mut body_wanted = [false; Dkim2Hash::COUNT];
619 for algorithm in instance.hashes.iter().filter_map(|hash| hash.name.known()) {
620 let index = algorithm.index();
621 for (wanted, cached) in [
622 (&mut header_wanted, &self.header),
623 (&mut body_wanted, &self.body),
624 ] {
625 if let (Some(wanted), Some(None)) = (wanted.get_mut(index), cached.get(index)) {
626 *wanted = true;
627 }
628 }
629 }
630 if body.is_none() {
631 body_wanted = [false; Dkim2Hash::COUNT];
632 }
633 fill(
634 &mut self.header,
635 header_digests(headers.iter().copied(), header_wanted),
636 );
637 if let Some(body) = body.filter(|_| body_wanted.contains(&true)) {
638 fill(&mut self.body, body_digests(body, body_wanted));
639 }
640
641 let mut groups = Tally::default();
642 for group in instance.hashes.chunk_by(|a, b| a.group == b.group) {
643 let mut entries = Tally::default();
644 for hash in group {
645 entries.record(self.check_hash(instance.m, hash, body.is_some()));
646 if entries.failed() {
647 break;
648 }
649 }
650 groups.record(entries.all());
651 if groups.passed() {
652 return Ok(());
653 }
654 }
655 groups.any(Dkim2Error::NoHashGroupPassed(instance.m))
656 }
657
658 fn check_hash(&self, m: u32, hash: &MessageHash, body_present: bool) -> Result<(), Failure> {
659 let Some(algorithm) = hash.name.known() else {
660 return Err(Failure::PermError(Dkim2Error::UnknownHash {
661 m,
662 name: hash.name.as_str().into(),
663 }));
664 };
665 let index = algorithm.index();
666 let matches = |digests: &Digests, expected: &[u8]| {
667 digests
668 .get(index)
669 .and_then(Option::as_ref)
670 .is_some_and(|computed| computed.as_ref() == expected)
671 };
672 if !matches(&self.header, &hash.header_hash) {
673 Err(Failure::Fail(Dkim2Error::HeaderHashMismatch {
674 m,
675 hash: algorithm,
676 }))
677 } else if body_present && !matches(&self.body, &hash.body_hash) {
678 Err(Failure::Fail(Dkim2Error::BodyHashMismatch {
679 m,
680 hash: algorithm,
681 }))
682 } else {
683 Ok(())
684 }
685 }
686}
687
688fn fill(cache: &mut Digests, computed: Digests) {
689 for (slot, digest) in cache.iter_mut().zip(computed) {
690 if digest.is_some() {
691 *slot = digest;
692 }
693 }
694}
695
696fn check_instances(
697 message: &AuthenticatedMessage<'_>,
698 signatures: &[Header<'_, Signature>],
699 instances: &[Header<'_, MessageInstance>],
700 body_present: bool,
701) -> Result<(), Failure> {
702 let mut hashes = StateHashes::default();
703 let mut header_buffer;
704 let mut body_buffer;
705 let mut headers: Cow<'_, [(&[u8], &[u8])]> = Cow::Borrowed(&message.headers);
706 let mut body = body_present.then(|| message.raw_body());
707 let mut from = from_domain(&message.headers);
708 let mut null_body_recipe = None;
709
710 for header in instances.iter().rev() {
711 let instance = &header.header;
712 if let Some(m) = null_body_recipe {
713 return Err(Failure::Fail(Dkim2Error::NullBodyRecipe(m)));
714 }
715 hashes.check(instance, &headers, body)?;
716
717 let Some(recipe) = instance.recipe.as_ref().filter(|_| instance.m > 1) else {
718 continue;
719 };
720 let replaces_from = recipe
721 .headers
722 .iter()
723 .any(|header| header.name.eq_ignore_ascii_case("from"));
724 if replaces_from {
725 let signature = signatures
726 .iter()
727 .map(|h| &h.header)
728 .find(|signature| signature.m >= instance.m)
729 .ok_or(Failure::PermError(Dkim2Error::InstanceNotSigned(
730 instance.m,
731 )))?;
732 check_from_authentic(from.as_deref(), signature).map_err(Failure::Fail)?;
733 }
734
735 match (&recipe.body, body) {
736 (BodyRecipe::Steps(steps), Some(current)) => {
737 let mut previous = Vec::with_capacity(current.len());
738 apply_body_recipe(&body_lines(current), steps, &mut previous);
739 body_buffer = previous;
740 body = Some(&body_buffer);
741 hashes.body = Default::default();
742 }
743 (BodyRecipe::Unreconstructable, Some(_)) => {
744 null_body_recipe = Some(instance.m);
745 body = None;
746 }
747 (BodyRecipe::Unreconstructable, None) => {}
748 (BodyRecipe::None, _) | (BodyRecipe::Steps(_), None) => {}
749 }
750
751 if !recipe.headers.is_empty() {
752 let mut previous = Vec::with_capacity(headers_len(&headers));
753 recipe.apply_headers(&headers, &mut previous);
754 header_buffer = previous;
755 headers = Cow::Owned(HeaderIterator::new(&header_buffer).collect());
756 hashes.header = Default::default();
757 if replaces_from {
758 from = from_domain(&headers);
759 }
760 }
761 }
762
763 signatures.first().map_or(Ok(()), |first| {
764 check_from_authentic(from.as_deref(), &first.header).map_err(Failure::Fail)
765 })
766}
767
768pub(crate) fn from_domain(headers: &[(&[u8], &[u8])]) -> Option<String> {
769 let mut from = headers
770 .iter()
771 .filter(|(name, _)| name.trim_ascii().eq_ignore_ascii_case(b"from"));
772 let (Some((_, value)), None) = (from.next(), from.next()) else {
773 return None;
774 };
775 let parsed = HeaderForm::Addresses.parse(value);
776 let list = parsed.value().as_address()?;
777 let mut addresses = list.iter();
778 let (Some(Address::Mailbox(mailbox)), None) = (addresses.next(), addresses.next()) else {
779 return None;
780 };
781 let (local, domain) = mailbox.address()?.split_once('@')?;
782 let valid = !local.is_empty()
783 && local.bytes().chain(domain.bytes()).all(|ch| {
784 !ch.is_ascii_whitespace() && !ch.is_ascii_control() && !b"@<>()[],;:\"".contains(&ch)
785 });
786 let domain = to_a_label(domain);
787 (valid && is_domain(domain.as_bytes())).then(|| domain.into_owned())
788}
789
790fn check_from_authentic(domain: Option<&str>, signature: &Signature) -> Result<(), Dkim2Error> {
791 if is_from_authentic(domain, &signature.d) {
792 Ok(())
793 } else {
794 Err(Dkim2Error::FromNotAuthentic(signature.i))
795 }
796}
797
798pub(crate) fn is_from_authentic(from_domain: Option<&str>, signing_domain: &str) -> bool {
799 from_domain.is_some_and(|domain| relaxed_domain_match(domain, signing_domain))
800}
801
802fn local_and_domain(address: &str) -> (&str, &str) {
803 let address = address.strip_prefix('<').unwrap_or(address);
804 let address = address.strip_suffix('>').unwrap_or(address);
805 match address.rsplit_once('@') {
806 Some((local, domain)) => (local, domain),
807 None => (address, ""),
808 }
809}
810
811fn is_null_path(path: &str) -> bool {
812 matches!(path.trim(), "<>" | "")
813}
814
815fn domain_eq(a: &str, b: &str) -> bool {
816 if a.is_ascii() && b.is_ascii() {
817 a.eq_ignore_ascii_case(b)
818 } else {
819 to_a_label(a) == to_a_label(b)
820 }
821}
822
823fn address_matches(envelope: &str, signed: &str) -> bool {
825 let (el, ed) = local_and_domain(envelope);
826 let (sl, sd) = local_and_domain(signed);
827 el == sl && domain_eq(ed, sd)
828}
829
830#[inline(always)]
832fn is_reverse_path(value: &str) -> bool {
833 value.starts_with('<') && value.ends_with('>')
834}
835
836#[inline(always)]
839fn require_reverse_path() -> bool {
840 #[cfg(test)]
841 {
842 test_reverse_path::required()
843 }
844 #[cfg(not(test))]
845 {
846 true
847 }
848}
849
850pub(crate) fn relaxed_domain_match(parameter1: &str, parameter2: &str) -> bool {
851 if parameter1.starts_with('[') || parameter2.starts_with('[') {
852 return false;
853 }
854 let (parameter1, parameter2) = if parameter1.is_ascii() && parameter2.is_ascii() {
855 (Cow::Borrowed(parameter1), Cow::Borrowed(parameter2))
856 } else {
857 (to_a_label(parameter1), to_a_label(parameter2))
858 };
859 let mut current = parameter1.as_ref();
860 loop {
861 if current.eq_ignore_ascii_case(¶meter2) {
862 return true;
863 }
864 match current.split_once('.') {
865 Some((_, rest)) if !rest.is_empty() => current = rest,
866 _ => return false,
867 }
868 }
869}
870
871fn strip_and_canonicalize_signature(signature: &[u8], out: &mut Vec<u8>) {
874 out.extend_from_slice(b"dkim2-signature:");
875 for (pos, spec) in signature.split(|&ch| ch == b';').enumerate() {
876 if pos > 0 {
877 out.push(b';');
878 }
879 let is_signature_tag = spec
880 .split(|&ch| ch == b'=')
881 .next()
882 .is_some_and(|name| name.trim_ascii() == b"s")
883 && spec.contains(&b'=');
884 if !is_signature_tag {
885 out.extend(spec.iter().filter(|ch| !ch.is_ascii_whitespace()));
886 continue;
887 }
888 let mut colons = 0;
889 for &ch in spec.iter().filter(|ch| !ch.is_ascii_whitespace()) {
890 if matches!(ch, b',' | b'|') {
891 colons = 0;
892 out.push(ch);
893 } else if colons < 2 {
894 out.push(ch);
895 colons += usize::from(ch == b':');
896 }
897 }
898 }
899 out.extend_from_slice(b"\r\n");
900}
901
902#[cfg(test)]
903mod canonicalize_test {
904 #[test]
905 fn strip_and_canonicalize_signature() {
906 for (value, expected) in [
907 (
908 "i=1; m=1; t=5; d=ex.com; mf=YQ==; rt=Yg==; s=sel:alg:U0lH;",
909 "dkim2-signature:i=1;m=1;t=5;d=ex.com;mf=YQ==;rt=Yg==;s=sel:alg:;\r\n",
910 ),
911 ("i=1; s=sel:alg:U0lH", "dkim2-signature:i=1;s=sel:alg:\r\n"),
912 ("s=a:b:U0lH,c:d:WkZa;", "dkim2-signature:s=a:b:,c:d:;\r\n"),
913 (
914 "s=a:b:U0lH|c:d:WkZa, e:f:QUJD;",
915 "dkim2-signature:s=a:b:|c:d:,e:f:;\r\n",
916 ),
917 (
918 "s=sel:alg:U0lH; f=feedback;",
919 "dkim2-signature:s=sel:alg:;f=feedback;\r\n",
920 ),
921 (
922 "i=1;\r\n m=1;\r\n\ts=sel:alg:U0\r\n lH;",
923 "dkim2-signature:i=1;m=1;s=sel:alg:;\r\n",
924 ),
925 (" i=1; s=a:b:CC; ", "dkim2-signature:i=1;s=a:b:;\r\n"),
926 ("s=a:b:CC; i=1;", "dkim2-signature:s=a:b:;i=1;\r\n"),
927 (
928 "i=1;s=a:b:CC;f=exploded;",
929 "dkim2-signature:i=1;s=a:b:;f=exploded;\r\n",
930 ),
931 ("n=foo; s=a:b:CC;", "dkim2-signature:n=foo;s=a:b:;\r\n"),
932 (
933 "d=sub.ex.com; s=ed25519:ed25519-sha256:F//Dt+leS4H;",
934 "dkim2-signature:d=sub.ex.com;s=ed25519:ed25519-sha256:;\r\n",
935 ),
936 ("", "dkim2-signature:\r\n"),
937 ("d=as; s=a:b:CC;", "dkim2-signature:d=as;s=a:b:;\r\n"),
938 (
939 "s=badset; n=a:b:c;",
940 "dkim2-signature:s=badset;n=a:b:c;\r\n",
941 ),
942 ("s=; n=a:b:c;", "dkim2-signature:s=;n=a:b:c;\r\n"),
943 ("s=sel:alg; i=1;", "dkim2-signature:s=sel:alg;i=1;\r\n"),
944 (
945 "i=1; mf=QQ s=; s=a:b:CC;",
946 "dkim2-signature:i=1;mf=QQs=;s=a:b:;\r\n",
947 ),
948 ("mf=QQ s=; n=a:b:c;", "dkim2-signature:mf=QQs=;n=a:b:c;\r\n"),
949 (
950 "rt=QQ s=,WWW; s=a:b:CC;",
951 "dkim2-signature:rt=QQs=,WWW;s=a:b:;\r\n",
952 ),
953 ("s=se l:al g:CC;", "dkim2-signature:s=sel:alg:;\r\n"),
954 ("n= s=a:b:c;", "dkim2-signature:n=s=a:b:c;\r\n"),
955 (
956 "x=1 s=a:b:c; s=a:b:c;",
957 "dkim2-signature:x=1s=a:b:c;s=a:b:;\r\n",
958 ),
959 ("\r\n\ts\r\n =a:b:c", "dkim2-signature:s=a:b:\r\n"),
960 ] {
961 let mut out = Vec::new();
962 super::strip_and_canonicalize_signature(value.as_bytes(), &mut out);
963 assert_eq!(
964 String::from_utf8(out).unwrap(),
965 expected,
966 "input: {value:?}"
967 );
968 }
969 }
970}
971
972#[cfg(test)]
973pub(crate) mod test_reverse_path {
974 use std::cell::Cell;
975
976 thread_local! {
977 static REQUIRED: Cell<bool> = const { Cell::new(true) };
978 }
979
980 pub(super) fn required() -> bool {
981 REQUIRED.with(Cell::get)
982 }
983
984 pub(crate) struct LenientReversePath;
987
988 impl LenientReversePath {
989 pub(crate) fn new() -> Self {
990 REQUIRED.with(|r| r.set(false));
991 LenientReversePath
992 }
993 }
994
995 impl Drop for LenientReversePath {
996 fn drop(&mut self) {
997 REQUIRED.with(|r| r.set(true));
998 }
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests;