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