kumo_dkim/
lib.rs

1// Implementation of DKIM: https://datatracker.ietf.org/doc/html/rfc6376
2
3use crate::errors::Status;
4use crate::hash::HeaderList;
5use crate::header::TaggedHeader;
6use dns_resolver::{HickoryResolver, Resolver};
7use ed25519_dalek::pkcs8::DecodePrivateKey;
8use ed25519_dalek::SigningKey;
9use mailparsing::AuthenticationResult;
10use openssl::md::Md;
11use openssl::pkey::PKey;
12use openssl::pkey_ctx::PkeyCtx;
13use openssl::rsa::{Padding, Rsa};
14use std::collections::BTreeMap;
15
16pub mod arc;
17pub mod canonicalization;
18mod errors;
19mod hash;
20mod header;
21mod parsed_email;
22mod parser;
23mod public_key;
24#[cfg(test)]
25mod roundtrip_test;
26mod sign;
27
28pub use errors::DKIMError;
29use header::{DKIMHeader, DKIM_SIGNATURE_HEADER_NAME};
30pub use parsed_email::ParsedEmail;
31pub use parser::{tag_list as parse_tag_list, Tag};
32pub use sign::{Signer, SignerBuilder};
33
34const DNS_NAMESPACE: &str = "_domainkey";
35
36#[derive(Debug)]
37pub(crate) enum DkimPublicKey {
38    Rsa(PKey<openssl::pkey::Public>),
39    Ed25519(ed25519_dalek::VerifyingKey),
40}
41
42#[allow(clippy::large_enum_variant)]
43#[derive(Debug)]
44pub enum DkimPrivateKey {
45    Ed25519(SigningKey),
46    OpenSSLRsa(Rsa<openssl::pkey::Private>),
47}
48
49impl DkimPrivateKey {
50    /// Parse RSA key data into a DkimPrivateKey
51    pub fn rsa_key(data: &[u8]) -> Result<Self, DKIMError> {
52        let mut errors = vec![];
53
54        match Rsa::private_key_from_pem(data) {
55            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
56            Err(err) => errors.push(format!("openssl private_key_from_pem: {err:#}")),
57        };
58        match Rsa::private_key_from_der(data) {
59            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
60            Err(err) => errors.push(format!("openssl private_key_from_der: {err:#}")),
61        };
62
63        Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
64    }
65
66    /// Load RSA key data from a file and parse it into a DkimPrivateKey
67    pub fn rsa_key_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, DKIMError> {
68        let path = path.as_ref();
69        let data = std::fs::read(path).map_err(|err| {
70            DKIMError::PrivateKeyLoadError(format!(
71                "rsa_key_file: failed to read file {path:?}: {err:#}"
72            ))
73        })?;
74        Self::rsa_key(&data)
75    }
76
77    /// Parse PKCS8 encoded ed25519 key data into a DkimPrivateKey.
78    /// Both DER and PEM are supported
79    pub fn ed25519_key(data: &[u8]) -> Result<Self, DKIMError> {
80        let mut errors = vec![];
81
82        match SigningKey::from_pkcs8_der(data) {
83            Ok(key) => return Ok(Self::Ed25519(key)),
84            Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_der: {err:#}")),
85        }
86
87        match std::str::from_utf8(data) {
88            Ok(s) => match SigningKey::from_pkcs8_pem(s) {
89                Ok(key) => return Ok(Self::Ed25519(key)),
90                Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_pem: {err:#}")),
91            },
92            Err(err) => errors.push(format!("ed25519_key: data is not UTF-8: {err:#}")),
93        }
94
95        Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
96    }
97
98    /// Parse either an RSA or ed25519 key into a DkimPrivateKey.
99    /// Both DER and PEM are supported.
100    pub fn key(data: &[u8]) -> Result<Self, DKIMError> {
101        let mut errors = vec![];
102
103        match Rsa::private_key_from_pem(data) {
104            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
105            Err(err) => errors.push(format!("openssl private_key_from_pem: {err:#}")),
106        }
107        match Rsa::private_key_from_der(data) {
108            Ok(key) => return Ok(Self::OpenSSLRsa(key)),
109            Err(err) => errors.push(format!("openssl private_key_from_der: {err:#}")),
110        }
111        match SigningKey::from_pkcs8_der(data) {
112            Ok(key) => return Ok(Self::Ed25519(key)),
113            Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_der: {err:#}")),
114        }
115        match std::str::from_utf8(data) {
116            Ok(s) => match SigningKey::from_pkcs8_pem(s) {
117                Ok(key) => return Ok(Self::Ed25519(key)),
118                Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_pem: {err:#}")),
119            },
120            Err(err) => errors.push(format!("ed25519_key: data is not UTF-8: {err:#}")),
121        }
122
123        Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
124    }
125}
126
127// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3 Step 4
128fn verify_signature(
129    hash_algo: hash::HashAlgo,
130    header_hash: &[u8],
131    signature: &[u8],
132    public_key: DkimPublicKey,
133) -> Result<bool, DKIMError> {
134    Ok(match public_key {
135        DkimPublicKey::Rsa(public_key) => {
136            let md = match hash_algo {
137                hash::HashAlgo::RsaSha1 => Md::sha1(),
138                hash::HashAlgo::RsaSha256 => Md::sha256(),
139                hash @ hash::HashAlgo::Ed25519Sha256 => {
140                    // Algo is not compatible with RSA.
141                    // This case can happen when we're looking a DKIM-Header
142                    // with a=ed25519-sha256, but where domain publishes both
143                    // an rsa and an ed25519 public key in dns
144                    return Err(DKIMError::UnsupportedHashAlgorithm(format!("{hash:?}")));
145                }
146            };
147
148            let mut ctx = PkeyCtx::new(&public_key).map_err(|err| {
149                DKIMError::SignatureSyntaxError(format!("Error loading RSA public key: {err}"))
150            })?;
151
152            ctx.verify_init().map_err(|err| {
153                DKIMError::UnknownInternalError(format!("ctx.verify_init failed: {err}"))
154            })?;
155            ctx.set_rsa_padding(Padding::PKCS1).map_err(|err| {
156                DKIMError::UnknownInternalError(format!("ctx.set_rsa_padding failed: {err}"))
157            })?;
158            ctx.set_signature_md(md).map_err(|err| {
159                DKIMError::UnknownInternalError(format!("ctx.set_signature_md failed: {err}"))
160            })?;
161            ctx.verify(header_hash, signature).unwrap_or_default()
162        }
163        DkimPublicKey::Ed25519(public_key) => {
164            let mut sig_bytes = [0u8; ed25519_dalek::Signature::BYTE_SIZE];
165            if signature.len() != sig_bytes.len() {
166                return Err(DKIMError::SignatureSyntaxError(format!(
167                    "ed25519 signatures should be {} bytes in length, have: {}",
168                    ed25519_dalek::Signature::BYTE_SIZE,
169                    signature.len()
170                )));
171            }
172            sig_bytes.copy_from_slice(signature);
173
174            public_key
175                .verify_strict(
176                    header_hash,
177                    &ed25519_dalek::Signature::from_bytes(&sig_bytes),
178                )
179                .is_ok()
180        }
181    })
182}
183
184async fn verify_email_header<'a>(
185    resolver: &dyn Resolver,
186    signature_header_name: &str,
187    dkim_header: &'a TaggedHeader,
188    email: &'a ParsedEmail<'a>,
189) -> Result<(), DKIMError> {
190    let public_keys = public_key::retrieve_public_keys(
191        resolver,
192        dkim_header.get_required_tag("d"),
193        dkim_header.get_required_tag("s"),
194    )
195    .await?;
196
197    let (header_canonicalization_type, body_canonicalization_type) =
198        parser::parse_canonicalization(dkim_header.get_tag("c"))?;
199    let hash_algo = parser::parse_hash_algo(dkim_header.get_required_tag("a"))?;
200
201    let header_list: Vec<String> = dkim_header
202        .get_required_tag("h")
203        .split(':')
204        .map(|s| s.trim().to_ascii_lowercase())
205        .collect();
206    let header_list = HeaderList::new(header_list).compute_concrete_header_list(email);
207
208    let computed_headers_hash = hash::compute_headers_hash(
209        header_canonicalization_type,
210        &header_list,
211        hash_algo,
212        dkim_header,
213        signature_header_name,
214    )?;
215
216    if let Some(header_body_hash) = dkim_header.get_tag("bh") {
217        let computed_body_hash = hash::compute_body_hash(
218            body_canonicalization_type,
219            dkim_header.parse_tag("l")?,
220            hash_algo,
221            email,
222        )?;
223        tracing::debug!("body_hash {:?}", computed_body_hash);
224        if header_body_hash != computed_body_hash {
225            return Err(DKIMError::BodyHashDidNotVerify);
226        }
227    }
228
229    let signature = data_encoding::BASE64
230        .decode(dkim_header.get_required_tag("b").as_bytes())
231        .map_err(|err| {
232            DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
233        })?;
234
235    let mut errors = vec![];
236    for public_key in public_keys {
237        match verify_signature(hash_algo, &computed_headers_hash, &signature, public_key) {
238            Ok(true) => return Ok(()),
239            Ok(false) => {}
240            Err(err) => {
241                errors.push(err);
242            }
243        }
244    }
245
246    if let Some(err) = errors.pop() {
247        // Something definitely failed
248        return Err(err);
249    }
250
251    // There were no errors and all keys returned false from verify_signature().
252    // That means that the signature is not verified.
253    Err(DKIMError::SignatureDidNotVerify)
254}
255
256/// Run the DKIM verification on the email providing an existing resolver
257pub async fn verify_email_with_resolver<'a>(
258    email: &'a ParsedEmail<'a>,
259    resolver: &dyn Resolver,
260) -> Result<Vec<AuthenticationResult>, DKIMError> {
261    fn populate_props(tagged_header: &TaggedHeader) -> BTreeMap<String, bstr::BString> {
262        let mut props = BTreeMap::new();
263
264        if let Some(signing_domain) = tagged_header.get_tag("d") {
265            props.insert("header.d".into(), signing_domain.into());
266            props.insert("header.i".into(), format!("@{signing_domain}").into());
267        }
268        if let Some(a_tag) = tagged_header.get_tag("a") {
269            props.insert("header.a".into(), a_tag.into());
270        }
271        if let Some(s_tag) = tagged_header.get_tag("s") {
272            props.insert("header.s".into(), s_tag.into());
273        }
274
275        if let Some(c_tag) = tagged_header.get_tag("c") {
276            props.insert("header.c".into(), c_tag.into());
277        }
278        if let Some(t_tag) = tagged_header.get_tag("t") {
279            props.insert("header.t".into(), t_tag.into());
280        }
281        if let Some(x_tag) = tagged_header.get_tag("x") {
282            props.insert("header.x".into(), x_tag.into());
283        }
284
285        props
286    }
287
288    let mut results = vec![];
289
290    let mut dkim_headers = vec![];
291
292    for h in email.get_headers().iter_named(DKIM_SIGNATURE_HEADER_NAME) {
293        // Limit DoS impact if a malicious message is filled with signatures.
294        // Both failed parses (results) and successful ones (dkim_headers, each
295        // of which drives a DNS lookup and a verification) count against the cap.
296        if results.len() + dkim_headers.len() > 10 {
297            break;
298        }
299
300        let raw_header = h.get_raw_value_string()?;
301        match DKIMHeader::parse(&raw_header) {
302            Ok(v) => {
303                dkim_headers.push(v);
304            }
305            Err(err) => {
306                let props = TaggedHeader::parse(&raw_header)
307                    .map(|tagged| populate_props(&tagged))
308                    .unwrap_or_default();
309                results.push(AuthenticationResult {
310                    method: "dkim".into(),
311                    method_version: None,
312                    result: "permerror".into(),
313                    reason: Some(format!("{err}").into()),
314                    props,
315                });
316            }
317        }
318    }
319
320    /// <https://datatracker.ietf.org/doc/html/rfc6008>
321    /// The value associated with this item in the header field MUST be
322    /// at least the first eight characters of the digital signature
323    /// (the "b=" tag from a DKIM-Signature) for which a result is being
324    /// relayed, and MUST be long enough to be unique among the results being
325    /// reported.
326    fn compute_header_b(b_tag: &str, headers: &[DKIMHeader]) -> String {
327        let total = b_tag.chars().count();
328
329        // At least the first eight characters, and never more than the whole tag.
330        let mut needed = total.min(8);
331
332        for h in headers {
333            let candidate = h.get_required_tag("b");
334            if candidate == b_tag {
335                continue;
336            }
337            // One character past the shared leading run distinguishes b_tag from
338            // this candidate; extend to cover it, up to the whole tag.
339            let shared = b_tag
340                .chars()
341                .zip(candidate.chars())
342                .take_while(|(a, b)| a == b)
343                .count();
344            needed = needed.max((shared + 1).min(total));
345        }
346
347        b_tag.chars().take(needed).collect()
348    }
349
350    for dkim_header in &dkim_headers {
351        let mut props = populate_props(dkim_header);
352
353        let b_tag = compute_header_b(dkim_header.get_required_tag("b"), &dkim_headers);
354        props.insert("header.b".into(), b_tag.into());
355
356        let mut reason = None;
357        let result =
358            match verify_email_header(resolver, DKIM_SIGNATURE_HEADER_NAME, dkim_header, email)
359                .await
360            {
361                Ok(()) => "pass",
362                Err(err) => {
363                    reason.replace(format!("{err}"));
364                    match err.status() {
365                        Status::Tempfail => "temperror",
366                        Status::Permfail => "permerror",
367                    }
368                }
369            };
370
371        results.push(AuthenticationResult {
372            method: "dkim".into(),
373            method_version: None,
374            result: result.into(),
375            reason: reason.map(Into::into),
376            props,
377        });
378    }
379
380    Ok(results)
381}
382
383/// Run the DKIM verification on the email
384pub async fn verify_email<'a>(
385    email: &'a ParsedEmail<'a>,
386) -> Result<Vec<AuthenticationResult>, DKIMError> {
387    let resolver = HickoryResolver::new().map_err(|err| {
388        DKIMError::UnknownInternalError(format!("failed to create DNS resolver: {}", err))
389    })?;
390
391    verify_email_with_resolver(email, &resolver).await
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use bstr::ByteSlice;
398    use dns_resolver::TestResolver;
399
400    const NEW_ENGLAND_DKIM: (&str, &str) = (
401        "newengland._domainkey.example.com",
402        "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE=",
403    );
404
405    #[test]
406    fn test_validate_header() {
407        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane;
408c=relaxed/simple; q=dns/txt; i=foo@eng.example.net;
409t=1117574938; x=9118006938; l=200;
410h=from:to:subject:date:keywords:keywords;
411z=From:foo@eng.example.net|To:joe@example.com|
412Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;
413bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;
414b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZ
415      VoG4ZHRNiYzR
416        "#;
417        DKIMHeader::parse(header).unwrap();
418    }
419
420    #[test]
421    fn test_validate_header_missing_tag() {
422        let header = "v=1; a=rsa-sha256; bh=a; b=b";
423        assert_eq!(
424            DKIMHeader::parse(header).unwrap_err(),
425            DKIMError::SignatureMissingRequiredTag("d")
426        );
427    }
428
429    #[test]
430    fn test_validate_header_domain_mismatch() {
431        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@hein.com; h=headers; bh=hash; b=hash
432        "#;
433        assert_eq!(
434            DKIMHeader::parse(header).unwrap_err(),
435            DKIMError::DomainMismatch
436        );
437
438        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane.net; i=foo@fexample.net; h=headers; bh=hash; b=hash
439        "#;
440        assert_eq!(
441            DKIMHeader::parse(header).unwrap_err(),
442            DKIMError::DomainMismatch
443        );
444    }
445
446    #[test]
447    fn test_validate_header_incompatible_version() {
448        let header = r#"v=3; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=headers; bh=hash; b=hash
449        "#;
450        assert_eq!(
451            DKIMHeader::parse(header).unwrap_err(),
452            DKIMError::IncompatibleVersion
453        );
454    }
455
456    #[test]
457    fn test_validate_header_missing_from_in_headers_signature() {
458        let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=Subject:A:B; bh=hash; b=hash
459        "#;
460        assert_eq!(
461            DKIMHeader::parse(header).unwrap_err(),
462            DKIMError::FromFieldNotSigned
463        );
464    }
465
466    #[test]
467    fn test_validate_header_expired_in_drift() {
468        let mut now = chrono::Utc::now().naive_utc();
469        now -= chrono::Duration::try_seconds(1).expect("1 second to be valid");
470
471        let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.and_utc().timestamp());
472
473        assert!(DKIMHeader::parse(&header).is_ok());
474    }
475
476    #[test]
477    fn test_validate_header_expired() {
478        let mut now = chrono::Utc::now().naive_utc();
479        now -= chrono::Duration::try_hours(3).expect("3 hours to be legit");
480
481        let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.and_utc().timestamp());
482
483        assert_eq!(
484            DKIMHeader::parse(&header).unwrap_err(),
485            DKIMError::SignatureExpired
486        );
487    }
488
489    #[tokio::test]
490    async fn test_short_b_tag_does_not_panic() {
491        // Two signatures whose b= tags don't share a common prefix, where one
492        // b= is shorter than the 8 character minimum that compute_header_b
493        // starts from. The shorter value must not be sliced past its length.
494        let raw_email = concat!(
495            "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=sel1; h=From; bh=AAAA; b=short\r\n",
496            "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=sel2; h=From; bh=AAAA; b=alongerbase64valuethatexceedseightbytes\r\n",
497            "From: user@example.com\r\n\r\nhello",
498        );
499
500        let email = ParsedEmail::parse(raw_email).unwrap();
501        let resolver = TestResolver::default();
502
503        let res = verify_email_with_resolver(&email, &resolver).await.unwrap();
504        assert_eq!(res.len(), 2);
505
506        // The short tag is reported whole. The long tag is trimmed to the eight
507        // character minimum since it doesn't share a prefix with the short one.
508        assert_eq!(
509            res[0].props.get("header.b").unwrap().to_str().unwrap(),
510            "short"
511        );
512        assert_eq!(
513            res[1].props.get("header.b").unwrap().to_str().unwrap(),
514            "alongerb"
515        );
516    }
517
518    async fn header_b_values(raw_email: &str) -> Vec<String> {
519        let email = ParsedEmail::parse(raw_email).unwrap();
520        let resolver = TestResolver::default();
521        verify_email_with_resolver(&email, &resolver)
522            .await
523            .unwrap()
524            .iter()
525            .map(|r| {
526                r.props
527                    .get("header.b")
528                    .unwrap()
529                    .to_str()
530                    .unwrap()
531                    .to_string()
532            })
533            .collect()
534    }
535
536    #[tokio::test]
537    async fn test_header_b_prefix_extension() {
538        // Two b= tags sharing exactly the eight character floor must each
539        // extend to nine to stay distinct, proving the floor is not a ceiling.
540        assert_eq!(
541            header_b_values(concat!(
542                "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=s1; h=From; bh=AAAA; b=AAAAAAAAX\r\n",
543                "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=s2; h=From; bh=AAAA; b=AAAAAAAAY\r\n",
544                "From: user@example.com\r\n\r\nhello",
545            ))
546            .await,
547            vec!["AAAAAAAAX", "AAAAAAAAY"]
548        );
549
550        // A long shared prefix drives the length past the floor. The length is
551        // the max across candidates (not the nearest one), capped at the tag
552        // itself when a tag is a strict prefix of another.
553        assert_eq!(
554            header_b_values(concat!(
555                "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=s1; h=From; bh=AAAA; b=COMMONPREFIXAAAAAA\r\n",
556                "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=s2; h=From; bh=AAAA; b=COMMONPREFIXAB\r\n",
557                "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=s3; h=From; bh=AAAA; b=COMMONXYZ\r\n",
558                "From: user@example.com\r\n\r\nhello",
559            ))
560            .await,
561            vec!["COMMONPREFIXAA", "COMMONPREFIXAB", "COMMONXY"]
562        );
563    }
564
565    #[tokio::test]
566    async fn test_expired_signature_populates_props() {
567        let x_value = "1";
568
569        let raw_email = format!(
570            "DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=example.net; s=brisbane; h=From; bh=hash; b=abcdefgh12345678; t=1; x={}\r\nFrom: user@example.net\r\n\r\nhello",
571            x_value,
572        );
573
574        let email = ParsedEmail::parse(raw_email).unwrap();
575        let resolver = TestResolver::default();
576
577        let res = verify_email_with_resolver(&email, &resolver).await.unwrap();
578        assert_eq!(res.len(), 1);
579
580        let result = &res[0];
581        assert_eq!(result.result, "permerror");
582        assert_eq!(
583            result.props.get("header.d").unwrap().to_str().unwrap(),
584            "example.net"
585        );
586        assert_eq!(
587            result.props.get("header.a").unwrap().to_str().unwrap(),
588            "rsa-sha256"
589        );
590        assert_eq!(
591            result.props.get("header.s").unwrap().to_str().unwrap(),
592            "brisbane"
593        );
594        assert_eq!(
595            result.props.get("header.c").unwrap().to_str().unwrap(),
596            "simple/simple"
597        );
598        assert_eq!(result.props.get("header.t").unwrap().to_str().unwrap(), "1");
599        assert_eq!(
600            result.props.get("header.x").unwrap().to_str().unwrap(),
601            x_value
602        );
603        assert!(result.props.get("header.b").is_none());
604    }
605
606    #[tokio::test]
607    async fn test_validate_email_header_ed25519() {
608        let raw_email = r#"DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
609 d=football.example.com; i=@football.example.com;
610 q=dns/txt; s=brisbane; t=1528637909; h=from : to :
611 subject : date : message-id : from : subject : date;
612 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
613 b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
614 Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
615DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
616 d=football.example.com; i=@football.example.com;
617 q=dns/txt; s=test; t=1528637909; h=from : to : subject :
618 date : message-id : from : subject : date;
619 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
620 b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
621 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
622 dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
623From: Joe SixPack <joe@football.example.com>
624To: Suzie Q <suzie@shopping.example.net>
625Subject: Is dinner ready?
626Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
627Message-ID: <20030712040037.46341.5F8J@football.example.com>
628
629Hi.
630
631We lost the game.  Are you hungry yet?
632
633Joe."#
634            .replace('\n', "\r\n");
635
636        let email = ParsedEmail::parse(raw_email).unwrap();
637        let raw_header_dkim = email
638            .get_headers()
639            .iter_named(DKIM_SIGNATURE_HEADER_NAME)
640            .next()
641            .unwrap()
642            .get_raw_value_string()
643            .unwrap();
644
645        const DKIM_BRISBANE: &str = r#"
646$ORIGIN brisbane._domainkey.football.example.com
647@     300 TXT "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE="
648          TXT "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
649"#;
650
651        let resolver = TestResolver::default().with_zone(DKIM_BRISBANE).unwrap();
652
653        verify_email_header(
654            &resolver,
655            DKIM_SIGNATURE_HEADER_NAME,
656            &DKIMHeader::parse(raw_header_dkim).unwrap(),
657            &email,
658        )
659        .await
660        .unwrap();
661    }
662
663    #[tokio::test]
664    async fn test_validate_email_header_rsa() {
665        // unfortunately the original RFC spec had a typo, and the mail content differs
666        // between algorithms
667        // https://www.rfc-editor.org/errata_search.php?rfc=6376&rec_status=0
668        let raw_email =
669            r#"DKIM-Signature: a=rsa-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
670 c=simple/simple; d=example.com;
671 h=Received:From:To:Subject:Date:Message-ID; i=joe@football.example.com;
672 s=newengland; t=1615825284; v=1;
673 b=Xh4Ujb2wv5x54gXtulCiy4C0e+plRm6pZ4owF+kICpYzs/8WkTVIDBrzhJP0DAYCpnL62T0G
674 k+0OH8pi/yqETVjKtKk+peMnNvKkut0GeWZMTze0bfq3/JUK3Ln3jTzzpXxrgVnvBxeY9EZIL4g
675 s4wwFRRKz/1bksZGSjD8uuSU=
676Received: from client1.football.example.com  [192.0.2.1]
677      by submitserver.example.com with SUBMISSION;
678      Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
679From: Joe SixPack <joe@football.example.com>
680To: Suzie Q <suzie@shopping.example.net>
681Subject: Is dinner ready?
682Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
683Message-ID: <20030712040037.46341.5F8J@football.example.com>
684
685Hi.
686
687We lost the game. Are you hungry yet?
688
689Joe.
690"#
691            .replace('\n', "\r\n");
692        let email = ParsedEmail::parse(raw_email).unwrap();
693        let raw_header_rsa = email
694            .get_headers()
695            .iter_named(DKIM_SIGNATURE_HEADER_NAME)
696            .next()
697            .unwrap()
698            .get_raw_value_string()
699            .unwrap();
700
701        let resolver =
702            TestResolver::default().with_txt(NEW_ENGLAND_DKIM.0, NEW_ENGLAND_DKIM.1.to_owned());
703
704        verify_email_header(
705            &resolver,
706            DKIM_SIGNATURE_HEADER_NAME,
707            &DKIMHeader::parse(raw_header_rsa).unwrap(),
708            &email,
709        )
710        .await
711        .unwrap();
712    }
713}