1use super::{
8 ChainBinding, ChainLink, Dkim2Error, Dkim2Output, Flag, MessageInstance, Signature,
9 sign::Envelope,
10};
11use crate::{
12 AuthenticatedMessage, Dkim2Result, DnsError, Error, MX, MessageAuthenticator, Parameters,
13 RecordSet, ResolverCache, Txt,
14 common::{
15 crypto::{Algorithm, CryptoError, HashAlgorithm},
16 headers::{Header, HeaderIterator, HeaderStream, Writer},
17 verify::DomainKey,
18 },
19 dkim::DkimError,
20 dkim2::{canonicalize::CanonicalizedHeaderWriter, sign::now},
21};
22use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
23
24const MAX_AGE: u64 = 14 * 86400;
25
26impl MessageAuthenticator {
27 pub async fn verify_dkim2<'x, TXT, MXX, IPV4, IPV6, PTR, A, R>(
29 &self,
30 params: impl Into<Parameters<'x, &'x AuthenticatedMessage<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
31 envelope: Envelope<A, R>,
32 ) -> Dkim2Output<'x>
33 where
34 TXT: ResolverCache<Box<str>, Txt> + 'x,
35 MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
36 IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
37 IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
38 PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
39 A: AsRef<str>,
40 R: IntoIterator<Item: AsRef<str>>,
41 {
42 let params = params.into();
43 self.verify_dkim2_(params.params, envelope, params.cache_txt, now(), true)
44 .await
45 }
46
47 pub(crate) async fn verify_dkim2_<'x, TXT, A, R>(
48 &self,
49 message: &'x AuthenticatedMessage<'x>,
50 envelope: Envelope<A, R>,
51 cache_txt: Option<&TXT>,
52 now: u64,
53 body_present: bool,
54 ) -> Dkim2Output<'x>
55 where
56 TXT: ResolverCache<Box<str>, Txt>,
57 A: AsRef<str>,
58 R: IntoIterator<Item: AsRef<str>>,
59 {
60 if message.has_dkim2_errors {
61 for header in &message.errors {
62 let name = header.name.trim_ascii();
63
64 if name.eq_ignore_ascii_case(b"dkim2-signature")
65 || name.eq_ignore_ascii_case(b"message-instance")
66 {
67 return Dkim2Result::from(header.header.clone()).into();
68 }
69 }
70 }
71
72 if message.dkim2_signatures.is_empty() {
73 return Dkim2Result::None.into();
74 }
75
76 let signatures = message.dkim2_signatures.as_slice();
77 let instances = message.dkim2_instances.as_slice();
78
79 for (index, header) in signatures.iter().enumerate() {
80 let signature = &header.header;
81 let expected = index as u32 + 1;
82 if signature.i != expected {
83 return Dkim2Result::None.into();
84 }
85 for (present, tag) in [(signature.m != 0, "m"), (!signature.d.is_empty(), "d")] {
86 if !present {
87 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureTagMissing {
88 i: signature.i,
89 tag,
90 }))
91 .into();
92 }
93 }
94 if let ChainBinding::Envelope { mail_from, rcpt_to } = &signature.chain {
95 if mail_from.is_empty() && rcpt_to.is_empty() {
96 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureTagMissing {
97 i: signature.i,
98 tag: "mf",
99 }))
100 .into();
101 }
102 if require_reverse_path()
103 && !(is_reverse_path(mail_from) && rcpt_to.iter().all(|r| is_reverse_path(r)))
104 {
105 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureSyntax(
106 signature.i,
107 )))
108 .into();
109 }
110 }
111 if now > signature.t && now - signature.t > MAX_AGE {
112 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureExpired(
113 signature.i,
114 )))
115 .into();
116 }
117 }
118
119 for (index, header) in instances.iter().enumerate() {
120 let instance = &header.header;
121 if instance.m != index as u32 + 1 {
122 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::InstanceMissing(
123 index as u32 + 1,
124 )))
125 .into();
126 }
127 }
128
129 let highest_sig_m = signatures.last().map(|h| h.header.m).unwrap_or(0);
130 let highest_mi_m = instances.last().map(|h| h.header.m).unwrap_or(0);
131 if highest_mi_m == 0 {
132 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::InstanceMissing(1))).into();
133 }
134 if highest_mi_m != highest_sig_m {
135 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::InstanceAboveSignature(
136 highest_mi_m,
137 )))
138 .into();
139 }
140
141 let top_signature = &signatures.last().unwrap().header;
142 match &top_signature.chain {
143 ChainBinding::Envelope { mail_from, rcpt_to } => {
144 if !address_matches(envelope.mail_from.as_ref(), mail_from) {
145 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::MailFromMismatch(
146 top_signature.i,
147 )))
148 .into();
149 }
150 for rcpt in envelope.rcpt_to {
151 if !rcpt_to.iter().any(|r| address_matches(rcpt.as_ref(), r)) {
152 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::RcptToMismatch(
153 top_signature.i,
154 )))
155 .into();
156 }
157 }
158 if mail_from != "<>" {
159 let (_, domain) = local_and_domain(mail_from);
160 if !relaxed_domain_match(domain, &top_signature.d) {
161 return Dkim2Result::PermError(Error::Dkim2(
162 Dkim2Error::MailFromDomainMismatch(top_signature.i),
163 ))
164 .into();
165 }
166 }
167 }
168 ChainBinding::NextDomain(_) => {
169 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::SignatureTagUnexpected {
170 i: top_signature.i,
171 tag: "nd",
172 }))
173 .into();
174 }
175 }
176
177 for window in signatures.windows(2) {
178 let previous = &window[0].header;
179 let current = &window[1].header;
180 match &previous.chain {
181 ChainBinding::NextDomain(next_domain) => {
182 if !next_domain.eq_ignore_ascii_case(¤t.d) {
183 return Dkim2Result::PermError(Error::Dkim2(
184 Dkim2Error::NextDomainMismatch(current.i),
185 ))
186 .into();
187 }
188 }
189 ChainBinding::Envelope { rcpt_to, .. } => {
190 let custody_ok =
191 if let ChainBinding::Envelope { mail_from, .. } = ¤t.chain {
192 let (_, current_domain) = local_and_domain(mail_from);
193 rcpt_to.iter().any(|rcpt| {
194 let (_, rcpt_domain) = local_and_domain(rcpt);
195 relaxed_domain_match(current_domain, rcpt_domain)
196 })
197 } else {
198 false
199 };
200 if !custody_ok {
201 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::MailFromMismatch(
202 current.i,
203 )))
204 .into();
205 }
206 }
207 }
208 }
209
210 for sig_header in signatures {
211 let signature = &sig_header.header;
212 if signature.s.is_empty() {
213 return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::NoValidAlgorithm(signature.i)))
214 .into();
215 }
216
217 let mut input = Vec::with_capacity(256);
218 for (name, value) in instances
219 .iter()
220 .filter(|h| h.header.m <= signature.m)
221 .map(|h| (h.name, h.value))
222 .chain(
223 signatures
224 .iter()
225 .filter(|h| h.header.i < signature.i)
226 .map(|h| (h.name, h.value)),
227 )
228 {
229 let mut w = CanonicalizedHeaderWriter::new(&mut input, name);
230 w.write(value);
231 w.finalize();
232 }
233 strip_and_canonicalize_signature(sig_header.value, &mut input);
234
235 for value in &signature.s {
236 let key = match self
237 .txt_lookup::<DomainKey>(
238 format!("{}._domainkey.{}.", value.selector, signature.d),
239 cache_txt,
240 )
241 .await
242 {
243 Ok(key) => key,
244 Err(Error::Dns(DnsError::Resolver(_))) => {
245 return Dkim2Result::TempError(Error::Dkim2(Dkim2Error::PublicKeyFetch(
246 signature.i,
247 )))
248 .into();
249 }
250 Err(Error::Dkim(DkimError::RevokedPublicKey)) => {
251 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::PublicKeyRevoked(
252 signature.i,
253 )))
254 .into();
255 }
256 Err(_) => {
257 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::PublicKeyMissing(
258 signature.i,
259 )))
260 .into();
261 }
262 };
263
264 if matches!(value.a, Algorithm::RsaSha256 | Algorithm::RsaSha1)
265 && key.p.public_key_bits() < 1024
266 {
267 return Dkim2Result::PermError(Error::Dkim2(Dkim2Error::PublicKeySyntax(
268 signature.i,
269 )))
270 .into();
271 }
272
273 match key.p.verify_bytes(&input, &value.b, value.a) {
274 Ok(()) => {}
275 Err(Error::Crypto(CryptoError::IncompatibleAlgorithms)) => {
276 return Dkim2Result::PermError(Error::Dkim2(
277 Dkim2Error::PublicKeyAlgorithmMismatch(signature.i),
278 ))
279 .into();
280 }
281 Err(_) => {
282 return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::IncorrectSignature(
283 signature.i,
284 )))
285 .into();
286 }
287 }
288 }
289 }
290
291 let algorithm = HashAlgorithm::Sha256;
292 let mut new_body = vec![];
293 let mut new_haders = vec![];
294 let mut last_body = message.raw_body();
295 let mut last_headers = message.headers.as_slice();
296
297 for header in instances.iter().rev() {
298 let instance = &header.header;
299 let Some(recorded) = instance.hashes.iter().find(|h| h.name == Some(algorithm)) else {
300 continue;
301 };
302
303 let header_hash = algorithm.headers_hash(last_headers.iter().copied());
304
305 if header_hash.as_ref() != recorded.header_hash {
306 return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::HeaderHashMismatch(instance.m)))
307 .into();
308 }
309 if !body_present {
310 break;
311 }
312 let body_hash = algorithm.body_hash(last_body);
313 if body_hash.as_ref() != recorded.body_hash {
314 return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::BodyHashMismatch(instance.m)))
315 .into();
316 }
317 if instance.m > 1
318 && let Some(recipe) = &instance.recipe
319 {
320 match recipe.apply(last_headers, last_body) {
321 Ok(previous) => {
322 new_body = previous;
323 let mut iter = HeaderIterator::new(&new_body);
324 new_haders = iter.by_ref().collect();
325 last_body = iter.body();
326 last_headers = new_haders.as_slice();
327 }
328 Err(_) => {
329 return Dkim2Result::Fail(Error::Dkim2(Dkim2Error::HeaderHashMismatch(
330 instance.m,
331 )))
332 .into();
333 }
334 }
335 }
336 }
337
338 if let Some(error) = flag_violation(signatures, instances, algorithm) {
339 return Dkim2Result::Fail(Error::Dkim2(error)).into();
340 }
341
342 Dkim2Output {
343 result: Dkim2Result::Pass,
344 chain: signatures
345 .iter()
346 .map(|sig_header| ChainLink {
347 signature: &sig_header.header,
348 instance: instances
349 .iter()
350 .find(|h| h.header.m == sig_header.header.m)
351 .map(|h| &h.header),
352 result: Dkim2Result::Pass,
353 custody_ok: true,
354 })
355 .collect(),
356 }
357 }
358}
359
360fn flag_violation(
361 signatures: &[Header<'_, Signature>],
362 instances: &[Header<'_, MessageInstance>],
363 algorithm: HashAlgorithm,
364) -> Option<Dkim2Error> {
365 let mut protected_m: Option<u32> = None;
366 let mut protected_i: Option<u32> = None;
367 for header in signatures {
368 let signature = &header.header;
369 if signature.flags.contains(&Flag::DoNotModify) {
370 protected_m = Some(protected_m.map_or(signature.m, |m| m.min(signature.m)));
371 }
372 if signature.flags.contains(&Flag::DoNotExplode) {
373 protected_i = Some(protected_i.map_or(signature.i, |i| i.min(signature.i)));
374 }
375 }
376
377 if let Some(protected_m) = protected_m
378 && let Some(reference) = instances
379 .iter()
380 .find(|h| h.header.m == protected_m)
381 .and_then(|h| h.header.hashes.iter().find(|h| h.name == Some(algorithm)))
382 .map(|h| (h.header_hash.as_slice(), h.body_hash.as_slice()))
383 {
384 for header in instances {
385 let instance = &header.header;
386 if instance.m > protected_m
387 && let Some(hashes) = instance.hashes.iter().find(|h| h.name == Some(algorithm))
388 && (hashes.header_hash.as_slice(), hashes.body_hash.as_slice()) != reference
389 {
390 return Some(Dkim2Error::Modified);
391 }
392 }
393 }
394
395 if let Some(protected_i) = protected_i
396 && signatures
397 .iter()
398 .any(|h| h.header.i > protected_i && h.header.flags.contains(&Flag::Exploded))
399 {
400 return Some(Dkim2Error::Exploded);
401 }
402
403 None
404}
405
406fn local_and_domain(address: &str) -> (&str, &str) {
407 let address = address.strip_prefix('<').unwrap_or(address);
408 let address = address.strip_suffix('>').unwrap_or(address);
409 match address.rsplit_once('@') {
410 Some((local, domain)) => (local, domain),
411 None => (address, ""),
412 }
413}
414
415fn address_matches(envelope: &str, signed: &str) -> bool {
417 let (el, ed) = local_and_domain(envelope);
418 let (sl, sd) = local_and_domain(signed);
419 el == sl && ed.eq_ignore_ascii_case(sd)
420}
421
422#[inline(always)]
424fn is_reverse_path(value: &str) -> bool {
425 value.starts_with('<') && value.ends_with('>')
426}
427
428#[inline(always)]
430fn require_reverse_path() -> bool {
431 #[cfg(test)]
432 {
433 test_reverse_path::required()
434 }
435 #[cfg(not(test))]
436 {
437 true
438 }
439}
440
441pub(crate) fn relaxed_domain_match(mail_from_domain: &str, signing_domain: &str) -> bool {
442 let mut current = mail_from_domain;
443 loop {
444 if current.eq_ignore_ascii_case(signing_domain) {
445 return true;
446 }
447 match current.split_once('.') {
448 Some((_, rest)) if !rest.is_empty() => current = rest,
449 _ => return false,
450 }
451 }
452}
453
454fn strip_and_canonicalize_signature(signature: &[u8], out: &mut Vec<u8>) {
456 out.extend(b"dkim2-signature:".as_slice());
457 let mut iter = signature.iter().peekable();
458 let mut last_ch = b' ';
459 while let Some(&ch) = iter.next() {
460 if !ch.is_ascii_whitespace() {
461 if matches!(ch, b's' | b'S') && matches!(last_ch, b' ' | b';') {
462 let mut found_eq = false;
463 while let Some(next_ch) = iter.peek() {
464 match next_ch {
465 b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' => {
466 iter.next();
467 }
468 b'=' => {
469 found_eq = true;
470 iter.next();
471 break;
472 }
473 _ => break,
474 }
475 }
476
477 if found_eq {
478 out.push(ch);
479 out.push(b'=');
480 'next_signature: loop {
481 let mut found_colon = false;
483 for &ch in iter.by_ref() {
484 match ch {
485 b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' => {}
486 b':' => {
487 out.push(ch);
488 if !found_colon {
489 found_colon = true;
490 } else {
491 break;
492 }
493 }
494 b';' => {
495 out.push(ch);
496 break 'next_signature;
497 }
498 b',' => {
499 out.push(ch);
500 continue 'next_signature;
501 }
502 _ => {
503 out.push(ch);
504 }
505 }
506 }
507
508 for &ch in iter.by_ref() {
510 match ch {
511 b';' => {
512 out.push(ch);
513 break 'next_signature;
514 }
515 b',' => {
516 out.push(ch);
517 continue 'next_signature;
518 }
519 _ => {}
520 }
521 }
522
523 break;
524 }
525 last_ch = b' ';
526 continue;
527 }
528 }
529
530 out.push(ch);
531 last_ch = ch;
532 } else {
533 last_ch = b' ';
534 }
535 }
536
537 out.extend(b"\r\n");
538}
539
540#[cfg(test)]
541mod canonicalize_test {
542 #[test]
543 fn strip_and_canonicalize_signature() {
544 for (value, expected) in [
545 (
547 "i=1; m=1; t=5; d=ex.com; mf=YQ==; rt=Yg==; s=sel:alg:U0lH;",
548 "dkim2-signature:i=1;m=1;t=5;d=ex.com;mf=YQ==;rt=Yg==;s=sel:alg:;\r\n",
549 ),
550 ("i=1; s=sel:alg:U0lH", "dkim2-signature:i=1;s=sel:alg:\r\n"),
552 ("s=a:b:U0lH,c:d:WkZa;", "dkim2-signature:s=a:b:,c:d:;\r\n"),
554 (
556 "s=sel:alg:U0lH; f=donotmodify;",
557 "dkim2-signature:s=sel:alg:;f=donotmodify;\r\n",
558 ),
559 (
561 "i=1;\r\n m=1;\r\n\ts=sel:alg:U0\r\n lH;",
562 "dkim2-signature:i=1;m=1;s=sel:alg:;\r\n",
563 ),
564 (" i=1; s=a:b:CC; ", "dkim2-signature:i=1;s=a:b:;\r\n"),
566 ("s=a:b:CC; i=1;", "dkim2-signature:s=a:b:;i=1;\r\n"),
568 (
570 "i=1;s=a:b:CC;f=exploded;",
571 "dkim2-signature:i=1;s=a:b:;f=exploded;\r\n",
572 ),
573 ("n=foo; s=a:b:CC;", "dkim2-signature:n=foo;s=a:b:;\r\n"),
575 (
577 "d=sub.ex.com; s=ed25519:ed25519-sha256:F//Dt+leS4H;",
578 "dkim2-signature:d=sub.ex.com;s=ed25519:ed25519-sha256:;\r\n",
579 ),
580 ("", "dkim2-signature:\r\n"),
582 ("d=as; s=a:b:CC;", "dkim2-signature:d=as;s=a:b:;\r\n"),
584 ("S=sel:alg:CC;", "dkim2-signature:S=sel:alg:;\r\n"),
586 (
588 "s=badset; n=a:b:c;",
589 "dkim2-signature:s=badset;n=a:b:c;\r\n",
590 ),
591 ("s=; n=a:b:c;", "dkim2-signature:s=;n=a:b:c;\r\n"),
593 ("s=sel:alg; i=1;", "dkim2-signature:s=sel:alg;i=1;\r\n"),
595 (
597 "i=1; mf=QQ s=; s=a:b:CC;",
598 "dkim2-signature:i=1;mf=QQs=;s=a:b:;\r\n",
599 ),
600 ("mf=QQ s=; n=a:b:c;", "dkim2-signature:mf=QQs=;n=a:b:c;\r\n"),
602 (
604 "rt=QQ s=,WWW; s=a:b:CC;",
605 "dkim2-signature:rt=QQs=,WWW;s=a:b:;\r\n",
606 ),
607 ("s=se l:al g:CC;", "dkim2-signature:s=sel:alg:;\r\n"),
609 ] {
610 let mut out = Vec::new();
611 super::strip_and_canonicalize_signature(value.as_bytes(), &mut out);
612 assert_eq!(
613 String::from_utf8(out).unwrap(),
614 expected,
615 "input: {value:?}"
616 );
617 }
618 }
619}
620
621#[cfg(test)]
622pub(crate) mod test_reverse_path {
623 use std::cell::Cell;
624
625 thread_local! {
626 static REQUIRED: Cell<bool> = const { Cell::new(true) };
627 }
628
629 pub(super) fn required() -> bool {
630 REQUIRED.with(Cell::get)
631 }
632
633 pub(crate) struct LenientReversePath;
636
637 impl LenientReversePath {
638 pub(crate) fn new() -> Self {
639 REQUIRED.with(|r| r.set(false));
640 LenientReversePath
641 }
642 }
643
644 impl Drop for LenientReversePath {
645 fn drop(&mut self) {
646 REQUIRED.with(|r| r.set(true));
647 }
648 }
649}
650
651#[cfg(test)]
652mod test {
653 use super::{Envelope, flag_violation};
654 use crate::dkim2::{ChainBinding, Dkim2Signed};
655 use crate::{
656 AuthenticatedMessage, Dkim2Result, Error, MessageAuthenticator,
657 common::{
658 cache::test::DummyCaches, crypto::HashAlgorithm, headers::Header,
659 parse::TxtRecordParser, verify::DomainKey,
660 },
661 dkim2::{Dkim2Error, Flag, MessageHash, MessageInstance, Signature},
662 };
663
664 fn wrap_sigs(s: &[Signature]) -> Vec<Header<'static, Signature>> {
665 s.iter()
666 .map(|x| Header::new(b"".as_slice(), b"".as_slice(), x.clone()))
667 .collect()
668 }
669
670 fn wrap_mis(m: &[MessageInstance]) -> Vec<Header<'static, MessageInstance>> {
671 m.iter()
672 .map(|x| Header::new(b"".as_slice(), b"".as_slice(), x.clone()))
673 .collect()
674 }
675
676 #[test]
677 fn flag_violation_single_pass() {
678 let alg = HashAlgorithm::Sha256;
679 let mi = |m: u32, h: &[u8]| MessageInstance {
680 m,
681 hashes: vec![MessageHash {
682 name: Some(alg),
683 header_hash: h.to_vec(),
684 body_hash: h.to_vec(),
685 }],
686 recipe: None,
687 };
688 let sig = |i: u32, m: u32, flags: Vec<Flag>| Signature {
689 i,
690 m,
691 flags,
692 ..Default::default()
693 };
694
695 let changed = [mi(1, b"a"), mi(2, b"b")];
696 let unchanged = [mi(1, b"a")];
697
698 let donotmodify = [sig(1, 1, vec![Flag::DoNotModify]), sig(2, 2, vec![])];
699 assert_eq!(
700 flag_violation(&wrap_sigs(&donotmodify), &wrap_mis(&changed), alg),
701 Some(Dkim2Error::Modified)
702 );
703 assert_eq!(
704 flag_violation(&wrap_sigs(&donotmodify[..1]), &wrap_mis(&unchanged), alg),
705 None
706 );
707
708 let explode = [
709 sig(1, 1, vec![Flag::DoNotExplode]),
710 sig(2, 1, vec![Flag::Exploded]),
711 ];
712 assert_eq!(
713 flag_violation(&wrap_sigs(&explode), &wrap_mis(&unchanged), alg),
714 Some(Dkim2Error::Exploded)
715 );
716 let explode_before = [
717 sig(1, 1, vec![Flag::Exploded]),
718 sig(2, 1, vec![Flag::DoNotExplode]),
719 ];
720 assert_eq!(
721 flag_violation(&wrap_sigs(&explode_before), &wrap_mis(&unchanged), alg),
722 None
723 );
724 }
725 use std::{
726 path::PathBuf,
727 time::{Duration, Instant},
728 };
729
730 const NOW: u64 = 1740002100;
731
732 fn resource(parts: &[&str]) -> PathBuf {
733 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
734 path.push("resources/dkim2");
735 for part in parts {
736 path.push(part);
737 }
738 path
739 }
740
741 fn load_caches() -> DummyCaches {
742 let caches = DummyCaches::new();
743 let dns = std::fs::read(resource(&["dns.json"])).unwrap();
744 let dns: serde_json::Value = serde_json::from_slice(&dns).unwrap();
745 let valid_until = Instant::now() + Duration::new(3600, 0);
746 for (domain, selectors) in dns.as_object().unwrap() {
747 for (selector, records) in selectors.as_object().unwrap() {
748 let record = records[0][1].as_str().unwrap();
749 let name = format!("{selector}.{domain}.");
750 caches.txt_add(
751 name,
752 DomainKey::parse(record.as_bytes()).unwrap(),
753 valid_until,
754 );
755 }
756 }
757 caches
758 }
759
760 async fn verify_file<A, R>(
761 resolver: &MessageAuthenticator,
762 caches: &DummyCaches,
763 name: &str,
764 envelope: Envelope<A, R>,
765 ) -> Dkim2Result
766 where
767 A: AsRef<str>,
768 R: IntoIterator<Item: AsRef<str>>,
769 {
770 let raw = std::fs::read(resource(&["expected", name])).unwrap();
771 let message = AuthenticatedMessage::parse(&raw).unwrap();
772 let params = caches.parameters(&message);
773 resolver
774 .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
775 .await
776 .result()
777 .clone()
778 }
779
780 fn top_envelope(name: &str) -> (String, Vec<String>) {
781 let raw = std::fs::read(resource(&["expected", name])).unwrap();
782 let message = AuthenticatedMessage::parse(&raw).unwrap();
783 let top = message
784 .dkim2_signatures
785 .iter()
786 .map(|h| &h.header)
787 .max_by_key(|s| s.i)
788 .unwrap();
789 match &top.chain {
790 ChainBinding::Envelope { mail_from, rcpt_to } => (mail_from.clone(), rcpt_to.clone()),
791 ChainBinding::NextDomain(_) => panic!("top signature has nd="),
792 }
793 }
794
795 #[tokio::test]
796 async fn verify_golden_vectors() {
797 let resolver = MessageAuthenticator::new_system_conf().unwrap();
798 let caches = load_caches();
799
800 verify_pass_list(
801 &resolver,
802 &caches,
803 &[
804 "simple-ed25519.eml",
805 "simple-rsa2048.eml",
806 "simple-sel2.eml",
807 "simple-sel3.eml",
808 "multiheader-ed25519.eml",
809 "trailingblank-ed25519.eml",
810 "emptybody-ed25519.eml",
811 "multirecipient-ed25519.eml",
812 "dsn-ed25519.eml",
813 "dupheaders-ed25519.eml",
814 ],
815 )
816 .await;
817
818 let _lenient = super::test_reverse_path::LenientReversePath::new();
819 verify_pass_list(
820 &resolver,
821 &caches,
822 &[
823 "simple-rsa1024.eml",
824 "multihop-header-add.eml",
825 "multihop-body-footer.eml",
826 "multihop-header-replace.eml",
827 "multihop-dup-headers.eml",
828 "multihop-3hop-dup-headers.eml",
829 ],
830 )
831 .await;
832 }
833
834 async fn verify_pass_list(
835 resolver: &MessageAuthenticator,
836 caches: &DummyCaches,
837 names: &[&str],
838 ) {
839 for &name in names {
840 let (mail_from, rcpt_to) = top_envelope(name);
841 let result =
842 verify_file(resolver, caches, name, Envelope::new(&mail_from, &rcpt_to)).await;
843 assert_eq!(result, Dkim2Result::Pass, "vector {name}");
844 }
845 }
846
847 fn prepend(signed: &Dkim2Signed, message: &[u8]) -> Vec<u8> {
848 let mut out = Vec::with_capacity(message.len() + 512);
849 signed.write(&mut out);
850 out.extend_from_slice(message);
851 out
852 }
853
854 #[tokio::test]
855 async fn sign_then_verify_multi_hop() {
856 use crate::{
857 common::crypto::Ed25519Key,
858 dkim2::{Dkim2Signer, Envelope, Hop},
859 };
860 use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
861
862 let load = |domain: &str, selector: &str| {
863 let pem = std::fs::read(resource(&[
864 "keys",
865 &format!("{selector}._domainkey.{domain}.pem"),
866 ]))
867 .unwrap();
868 let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
869 panic!("expected PKCS8 key");
870 };
871 Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
872 };
873
874 let original = std::fs::read(resource(&["emails", "simple.eml"])).unwrap();
875
876 let hop1 = Dkim2Signer::from_key(load("test1.dkim2.com", "ed25519"))
877 .domain("test1.dkim2.com")
878 .selector("ed25519");
879 let sign1 = hop1
880 .sign(
881 &original,
882 Hop::real("sender@test1.dkim2.com", ["list@test2.dkim2.com"]),
883 )
884 .unwrap();
885 let message1 = prepend(&sign1, &original);
886
887 let hop2 = Dkim2Signer::from_key(load("test2.dkim2.com", "ed25519"))
888 .domain("test2.dkim2.com")
889 .selector("ed25519");
890 let sign2 = hop2
891 .sign(
892 &message1,
893 Hop::real("relay@test2.dkim2.com", ["recipient@example.com"]),
894 )
895 .unwrap();
896 let message2 = prepend(&sign2, &message1);
897
898 let resolver = MessageAuthenticator::new_system_conf().unwrap();
899 let caches = load_caches();
900 let message = AuthenticatedMessage::parse(&message2).unwrap();
901 let params = caches.parameters(&message);
902 let envelope = Envelope::new("relay@test2.dkim2.com", ["recipient@example.com"]);
903 let output = resolver
904 .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
905 .await;
906 assert_eq!(
907 output.result(),
908 &Dkim2Result::Pass,
909 "{:?}",
910 output.failure_reason()
911 );
912 assert_eq!(output.chain().len(), 2);
913 }
914
915 #[tokio::test]
916 async fn sign_multi_algorithm_then_verify() {
917 use crate::{
918 common::crypto::{Algorithm, Ed25519Key, RsaKey, Sha256},
919 dkim2::{Dkim2Signer, Envelope, Hop},
920 };
921 use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
922
923 let load_ed = |domain: &str, selector: &str| {
924 let pem = std::fs::read(resource(&[
925 "keys",
926 &format!("{selector}._domainkey.{domain}.pem"),
927 ]))
928 .unwrap();
929 let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
930 panic!("expected PKCS8 key");
931 };
932 Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
933 };
934 let load_rsa = |domain: &str, selector: &str| {
935 let pem = std::fs::read(resource(&[
936 "keys",
937 &format!("{selector}._domainkey.{domain}.pem"),
938 ]))
939 .unwrap();
940 RsaKey::<Sha256>::from_key_der(PrivateKeyDer::from_pem_slice(&pem).unwrap()).unwrap()
941 };
942
943 let original = std::fs::read(resource(&["emails", "simple.eml"])).unwrap();
944
945 let signed = Dkim2Signer::from_key(load_ed("test1.dkim2.com", "ed25519"))
946 .domain("test1.dkim2.com")
947 .selector("ed25519")
948 .additional_key(load_rsa("test1.dkim2.com", "sel1"), "sel1")
949 .sign(
950 &original,
951 Hop::real("sender@test1.dkim2.com", ["recipient@example.com"]),
952 )
953 .unwrap();
954
955 assert_eq!(signed.signature.s.len(), 2);
956 assert_eq!(signed.signature.s[0].selector, "ed25519");
957 assert_eq!(signed.signature.s[0].a, Algorithm::Ed25519Sha256);
958 assert_eq!(signed.signature.s[1].selector, "sel1");
959 assert_eq!(signed.signature.s[1].a, Algorithm::RsaSha256);
960
961 let message = prepend(&signed, &original);
962 let resolver = MessageAuthenticator::new_system_conf().unwrap();
963 let caches = load_caches();
964 let parsed = AuthenticatedMessage::parse(&message).unwrap();
965 let params = caches.parameters(&parsed);
966 let envelope = Envelope::new("sender@test1.dkim2.com", ["recipient@example.com"]);
967 let output = resolver
968 .verify_dkim2_(&parsed, envelope, params.cache_txt, NOW, true)
969 .await;
970 assert_eq!(
971 output.result(),
972 &Dkim2Result::Pass,
973 "{:?}",
974 output.failure_reason()
975 );
976 }
977
978 #[tokio::test]
979 async fn verify_rejects_wrong_envelope() {
980 let resolver = MessageAuthenticator::new_system_conf().unwrap();
981 let caches = load_caches();
982 let envelope = Envelope::new("attacker@evil.example", ["recipient@example.com"]);
983 let result = verify_file(&resolver, &caches, "simple-ed25519.eml", envelope).await;
984 assert!(
985 matches!(result, Dkim2Result::PermError(_)),
986 "got {result:?}"
987 );
988 }
989
990 #[tokio::test]
991 async fn verify_rejects_tampered_body() {
992 let resolver = MessageAuthenticator::new_system_conf().unwrap();
993 let caches = load_caches();
994 let raw = std::fs::read(resource(&["expected", "simple-ed25519.eml"])).unwrap();
995 let mut tampered = raw.clone();
996 let pos = tampered.windows(5).position(|w| w == b"Hello").unwrap();
997 tampered[pos] = b'J';
998 let message = AuthenticatedMessage::parse(&tampered).unwrap();
999 let params = caches.parameters(&message);
1000 let envelope = Envelope::new("sender@test1.dkim2.com", ["recipient@example.com"]);
1001 let result = resolver
1002 .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
1003 .await;
1004 assert!(
1005 matches!(result.result(), Dkim2Result::Fail(_)),
1006 "got {:?}",
1007 result.result()
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn verify_rejects_tampered_header() {
1013 let resolver = MessageAuthenticator::new_system_conf().unwrap();
1014 let caches = load_caches();
1015 let raw = std::fs::read(resource(&["expected", "simple-ed25519.eml"])).unwrap();
1016 let mut tampered = raw.clone();
1017 let pos = tampered.windows(6).position(|w| w == b"Simple").unwrap();
1018 tampered[pos] = b'X';
1019 let message = AuthenticatedMessage::parse(&tampered).unwrap();
1020 let params = caches.parameters(&message);
1021 let envelope = Envelope::new("sender@test1.dkim2.com", ["recipient@example.com"]);
1022 let result = resolver
1023 .verify_dkim2_(&message, envelope, params.cache_txt, NOW, true)
1024 .await;
1025 assert!(
1026 matches!(
1027 result.result(),
1028 Dkim2Result::Fail(Error::Dkim2(Dkim2Error::HeaderHashMismatch(_)))
1029 ),
1030 "got {:?}",
1031 result.result()
1032 );
1033 }
1034
1035 #[tokio::test]
1036 async fn verify_rejects_rcpt_not_in_rt() {
1037 let resolver = MessageAuthenticator::new_system_conf().unwrap();
1038 let caches = load_caches();
1039 let envelope = Envelope::new("sender@test1.dkim2.com", ["someone-else@example.com"]);
1040 let result = verify_file(&resolver, &caches, "simple-ed25519.eml", envelope).await;
1041 assert!(
1042 matches!(
1043 result,
1044 Dkim2Result::PermError(Error::Dkim2(Dkim2Error::RcptToMismatch(_)))
1045 ),
1046 "got {result:?}"
1047 );
1048 }
1049
1050 fn state_matches(expected: &str, result: &Dkim2Result) -> bool {
1051 match expected {
1052 "pass" => matches!(result, Dkim2Result::Pass),
1053 "fail" => matches!(result, Dkim2Result::Fail(_)),
1054 "permerror" => matches!(result, Dkim2Result::PermError(_)),
1055 "temperror" => matches!(result, Dkim2Result::TempError(_)),
1056 other => panic!("unknown expected state {other}"),
1057 }
1058 }
1059
1060 #[tokio::test]
1061 async fn test_vectors() {
1062 let resolver = MessageAuthenticator::new_system_conf().unwrap();
1063 let caches = load_caches();
1064
1065 let cases = std::fs::read(resource(&["cases.json"])).unwrap();
1066 let cases: serde_json::Value = serde_json::from_slice(&cases).unwrap();
1067 let cases = cases.as_array().unwrap();
1068 assert!(!cases.is_empty(), "no imported vectors found");
1069
1070 let mut failures = Vec::new();
1071 for case in cases {
1072 let name = case["name"].as_str().unwrap();
1073 let expected = case["expected"].as_str().unwrap();
1074 let file = case["file"].as_str().unwrap();
1075 let mail_from = case["mail_from"].as_str().unwrap().to_string();
1076 let rcpt_to: Vec<String> = case["rcpt_to"]
1077 .as_array()
1078 .unwrap()
1079 .iter()
1080 .map(|r| r.as_str().unwrap().to_string())
1081 .collect();
1082
1083 let now = case["now"]
1084 .as_u64()
1085 .expect("vector manifest must carry now");
1086 let strict = case["strict"].as_bool().unwrap_or(true);
1087
1088 let raw = std::fs::read(resource(&["expected", file])).unwrap();
1089 let Some(message) = AuthenticatedMessage::parse(&raw) else {
1090 failures.push(format!("{name}: message failed to parse"));
1091 continue;
1092 };
1093 let params = caches.parameters(&message);
1094 let envelope = Envelope::new(&mail_from, &rcpt_to);
1095 let lenient = (!strict).then(super::test_reverse_path::LenientReversePath::new);
1096 let output = resolver
1097 .verify_dkim2_(&message, envelope, params.cache_txt, now, true)
1098 .await;
1099 drop(lenient);
1100 if !state_matches(expected, output.result()) {
1101 failures.push(format!(
1102 "{name}: expected {expected}, got {:?} ({:?})",
1103 output.result(),
1104 output.failure_reason()
1105 ));
1106 }
1107 }
1108
1109 assert!(
1110 failures.is_empty(),
1111 "{} of {} vectors diverged:\n{}",
1112 failures.len(),
1113 cases.len(),
1114 failures.join("\n")
1115 );
1116 }
1117}