1use base64::engine::general_purpose;
4use base64::Engine;
5use indexmap::map::IndexMap;
6use rsa::Pkcs1v15Sign;
7use rsa::RsaPrivateKey;
8use rsa::RsaPublicKey;
9use sha1::Sha1;
10use sha2::Sha256;
11use slog::debug;
12use std::array::TryFromSliceError;
13use std::collections::HashSet;
14use std::sync::Arc;
15use trust_dns_resolver::TokioAsyncResolver;
16
17use mailparse::MailHeaderMap;
18
19#[macro_use]
20extern crate quick_error;
21
22mod bytes;
23pub mod canonicalization;
24pub mod dns;
25mod errors;
26mod hash;
27mod header;
28mod parser;
29mod public_key;
30mod result;
31#[cfg(test)]
32mod roundtrip_test;
33mod sign;
34
35pub use errors::DKIMError;
36use header::{DKIMHeader, HEADER, REQUIRED_TAGS};
37pub use parser::tag_list as parse_tag_list;
38pub use parser::Tag;
39pub use result::DKIMResult;
40pub use sign::{DKIMSigner, SignerBuilder};
41
42const SIGN_EXPIRATION_DRIFT_MINS: i64 = 15;
43const DNS_NAMESPACE: &str = "_domainkey";
44
45#[derive(Debug)]
46pub(crate) enum DkimPublicKey {
47 Rsa(RsaPublicKey),
48 Ed25519(ed25519_dalek::VerifyingKey),
49}
50
51#[derive(Debug)]
52pub enum DkimPrivateKey {
53 Rsa(RsaPrivateKey),
54 Ed25519(ed25519_dalek::SigningKey),
55}
56
57fn validate_header(value: &str) -> Result<DKIMHeader, DKIMError> {
59 let (_, tags) =
60 parser::tag_list(value).map_err(|err| DKIMError::SignatureSyntaxError(err.to_string()))?;
61
62 {
64 let mut tag_names: HashSet<String> = HashSet::new();
65 for tag in &tags {
66 tag_names.insert(tag.name.clone());
67 }
68 for required in REQUIRED_TAGS {
69 if tag_names.get(*required).is_none() {
70 return Err(DKIMError::SignatureMissingRequiredTag(required));
71 }
72 }
73 }
74
75 let mut tags_map = IndexMap::new();
76 for tag in &tags {
77 tags_map.insert(tag.name.clone(), tag.clone());
78 }
79 let header = DKIMHeader {
80 tags: tags_map,
81 raw_bytes: value.to_owned(),
82 };
83 {
87 let version = header.get_required_tag("v");
88 if version != "1" {
89 return Err(DKIMError::IncompatibleVersion);
90 }
91 }
92
93 if let Some(user) = header.get_tag("i") {
96 let signing_domain = header.get_required_tag("d");
97 if !user.ends_with(&signing_domain) {
99 return Err(DKIMError::DomainMismatch);
100 }
101 }
102
103 {
105 let value = header.get_required_tag("h");
106 let headers = value.split(':');
107 let headers: Vec<String> = headers.map(|h| h.to_lowercase()).collect();
108 if !headers.contains(&"from".to_string()) {
109 return Err(DKIMError::FromFieldNotSigned);
110 }
111 }
112
113 if let Some(query_method) = header.get_tag("q") {
114 if query_method != "dns/txt" {
115 return Err(DKIMError::UnsupportedQueryMethod);
116 }
117 }
118
119 if let Some(expiration) = header.get_tag("x") {
121 let mut expiration = chrono::NaiveDateTime::from_timestamp_opt(
122 expiration.parse::<i64>().unwrap_or_default(),
123 0,
124 )
125 .ok_or(DKIMError::SignatureExpired)?;
126 expiration += chrono::Duration::minutes(SIGN_EXPIRATION_DRIFT_MINS);
127 let now = chrono::Utc::now().naive_utc();
128 if now > expiration {
129 return Err(DKIMError::SignatureExpired);
130 }
131 }
132
133 Ok(header)
134}
135
136fn verify_signature(
138 hash_algo: hash::HashAlgo,
139 header_hash: Vec<u8>,
140 signature: Vec<u8>,
141 public_key: DkimPublicKey,
142) -> Result<bool, DKIMError> {
143 Ok(match public_key {
144 DkimPublicKey::Rsa(public_key) => public_key
145 .verify(
146 match hash_algo {
147 hash::HashAlgo::RsaSha1 => Pkcs1v15Sign::new::<Sha1>(),
148 hash::HashAlgo::RsaSha256 => Pkcs1v15Sign::new::<Sha256>(),
149 hash => return Err(DKIMError::UnsupportedHashAlgorithm(format!("{:?}", hash))),
150 },
151 &header_hash,
152 &signature,
153 )
154 .is_ok(),
155 DkimPublicKey::Ed25519(public_key) => public_key
156 .verify_strict(
157 &header_hash,
158 &ed25519_dalek::Signature::from_bytes((&signature as &[u8]).try_into().map_err(
159 |err: TryFromSliceError| DKIMError::SignatureSyntaxError(err.to_string()),
160 )?),
161 )
162 .is_ok(),
163 })
164}
165
166async fn verify_email_header<'a>(
167 logger: &'a slog::Logger,
168 resolver: Arc<dyn dns::Lookup>,
169 dkim_header: &'a DKIMHeader,
170 email: &'a mailparse::ParsedMail<'a>,
171) -> Result<(canonicalization::Type, canonicalization::Type), DKIMError> {
172 let public_key = public_key::retrieve_public_key(
173 logger,
174 Arc::clone(&resolver),
175 dkim_header.get_required_tag("d"),
176 dkim_header.get_required_tag("s"),
177 )
178 .await?;
179
180 let (header_canonicalization_type, body_canonicalization_type) =
181 parser::parse_canonicalization(dkim_header.get_tag("c"))?;
182 let hash_algo = parser::parse_hash_algo(&dkim_header.get_required_tag("a"))?;
183 let computed_body_hash = hash::compute_body_hash(
184 body_canonicalization_type.clone(),
185 dkim_header.get_tag("l"),
186 hash_algo.clone(),
187 email,
188 )?;
189 let computed_headers_hash = hash::compute_headers_hash(
190 logger,
191 header_canonicalization_type.clone(),
192 &dkim_header.get_required_tag("h"),
193 hash_algo.clone(),
194 dkim_header,
195 email,
196 )?;
197 debug!(logger, "body_hash {:?}", computed_body_hash);
198
199 let header_body_hash = dkim_header.get_required_tag("bh");
200 if header_body_hash != computed_body_hash {
201 return Err(DKIMError::BodyHashDidNotVerify);
202 }
203
204 let signature = general_purpose::STANDARD
205 .decode(dkim_header.get_required_tag("b"))
206 .map_err(|err| {
207 DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
208 })?;
209 if !verify_signature(hash_algo, computed_headers_hash, signature, public_key)? {
210 return Err(DKIMError::SignatureDidNotVerify);
211 }
212
213 Ok((header_canonicalization_type, body_canonicalization_type))
214}
215
216pub async fn verify_email_with_resolver<'a>(
218 logger: &slog::Logger,
219 from_domain: &str,
220 email: &'a mailparse::ParsedMail<'a>,
221 resolver: Arc<dyn dns::Lookup>,
222) -> Result<DKIMResult, DKIMError> {
223 let mut last_error = None;
224
225 for h in email.headers.get_all_headers(HEADER) {
226 let value = String::from_utf8_lossy(h.get_value_raw());
227 debug!(logger, "checking signature {:?}", value);
228
229 let dkim_header = match validate_header(&value) {
230 Ok(v) => v,
231 Err(err) => {
232 debug!(logger, "failed to verify: {}", err);
233 last_error = Some(err);
234 continue;
235 }
236 };
237
238 let signing_domain = dkim_header.get_required_tag("d");
240 if signing_domain.to_lowercase() != from_domain.to_lowercase() {
241 continue;
242 }
243
244 match verify_email_header(logger, Arc::clone(&resolver), &dkim_header, email).await {
245 Ok((header_canonicalization_type, body_canonicalization_type)) => {
246 return Ok(DKIMResult::pass(
247 signing_domain,
248 header_canonicalization_type,
249 body_canonicalization_type,
250 ))
251 }
252 Err(err) => {
253 debug!(logger, "failed to verify: {}", err);
254 last_error = Some(err);
255 continue;
256 }
257 }
258 }
259
260 if let Some(err) = last_error {
261 Ok(DKIMResult::fail(err, from_domain.to_owned()))
262 } else {
263 Ok(DKIMResult::neutral(from_domain.to_owned()))
264 }
265}
266
267pub async fn verify_email<'a>(
269 logger: &slog::Logger,
270 from_domain: &str,
271 email: &'a mailparse::ParsedMail<'a>,
272) -> Result<DKIMResult, DKIMError> {
273 let resolver = TokioAsyncResolver::tokio_from_system_conf().map_err(|err| {
274 DKIMError::UnknownInternalError(format!("failed to create DNS resolver: {}", err))
275 })?;
276 let resolver = dns::from_tokio_resolver(resolver);
277
278 verify_email_with_resolver(logger, from_domain, email, resolver).await
279}
280
281#[cfg(test)]
282mod tests {
283 use crate::dns::Lookup;
284
285 use super::*;
286
287 struct MockResolver {}
288
289 impl Lookup for MockResolver {
290 fn lookup_txt<'a>(
291 &'a self,
292 name: &'a str,
293 ) -> futures::future::BoxFuture<'a, Result<Vec<String>, DKIMError>> {
294 match name {
295 "brisbane._domainkey.football.example.com" => {
296 Box::pin(futures::future::ready(Ok(vec![
297 "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
298 .to_string(),
299 ])))
300 }
301 "newengland._domainkey.example.com" => Box::pin(futures::future::ready(Ok(vec![
302 "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE=".to_string(),
303 ]))),
304 _ => {
305 println!("asked to resolve: {}", name);
306 todo!()
307 }
308 }
309 }
310 }
311
312 impl MockResolver {
313 fn new() -> Self {
314 MockResolver {}
315 }
316 }
317
318 #[test]
319 fn test_validate_header() {
320 let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane;
321c=relaxed/simple; q=dns/txt; i=foo@eng.example.net;
322t=1117574938; x=9118006938; l=200;
323h=from:to:subject:date:keywords:keywords;
324z=From:foo@eng.example.net|To:joe@example.com|
325Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;
326bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;
327b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZ
328 VoG4ZHRNiYzR
329 "#;
330 validate_header(header).unwrap();
331 }
332
333 #[test]
334 fn test_validate_header_missing_tag() {
335 let header = "v=1; a=rsa-sha256; bh=a; b=b";
336 assert_eq!(
337 validate_header(header).unwrap_err(),
338 DKIMError::SignatureMissingRequiredTag("d")
339 );
340 }
341
342 #[test]
343 fn test_validate_header_domain_mismatch() {
344 let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@hein.com; h=headers; bh=hash; b=hash
345 "#;
346 assert_eq!(
347 validate_header(header).unwrap_err(),
348 DKIMError::DomainMismatch
349 );
350 }
351
352 #[test]
353 fn test_validate_header_incompatible_version() {
354 let header = r#"v=3; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=headers; bh=hash; b=hash
355 "#;
356 assert_eq!(
357 validate_header(header).unwrap_err(),
358 DKIMError::IncompatibleVersion
359 );
360 }
361
362 #[test]
363 fn test_validate_header_missing_from_in_headers_signature() {
364 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
365 "#;
366 assert_eq!(
367 validate_header(header).unwrap_err(),
368 DKIMError::FromFieldNotSigned
369 );
370 }
371
372 #[test]
373 fn test_validate_header_expired_in_drift() {
374 let mut now = chrono::Utc::now().naive_utc();
375 now -= chrono::Duration::seconds(1);
376
377 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.timestamp());
378
379 assert!(validate_header(&header).is_ok());
380 }
381
382 #[test]
383 fn test_validate_header_expired() {
384 let mut now = chrono::Utc::now().naive_utc();
385 now -= chrono::Duration::hours(3);
386
387 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.timestamp());
388
389 assert_eq!(
390 validate_header(&header).unwrap_err(),
391 DKIMError::SignatureExpired
392 );
393 }
394
395 #[tokio::test]
396 async fn test_validate_email_header_ed25519() {
397 let raw_email = r#"DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
398 d=football.example.com; i=@football.example.com;
399 q=dns/txt; s=brisbane; t=1528637909; h=from : to :
400 subject : date : message-id : from : subject : date;
401 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
402 b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
403 Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
404DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
405 d=football.example.com; i=@football.example.com;
406 q=dns/txt; s=test; t=1528637909; h=from : to : subject :
407 date : message-id : from : subject : date;
408 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
409 b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
410 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
411 dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
412From: Joe SixPack <joe@football.example.com>
413To: Suzie Q <suzie@shopping.example.net>
414Subject: Is dinner ready?
415Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
416Message-ID: <20030712040037.46341.5F8J@football.example.com>
417
418Hi.
419
420We lost the game. Are you hungry yet?
421
422Joe."#
423 .replace('\n', "\r\n");
424
425 let email = mailparse::parse_mail(raw_email.as_bytes()).unwrap();
426 let h = email
427 .headers
428 .get_all_headers(HEADER)
429 .first()
430 .unwrap()
431 .get_value_raw();
432 let raw_header_dkim = String::from_utf8_lossy(h);
433
434 let resolver: Arc<dyn Lookup> = Arc::new(MockResolver::new());
435
436 let dkim_verify_result = verify_email_header(
437 &slog::Logger::root(slog::Discard, slog::o!()),
438 Arc::clone(&resolver),
439 &validate_header(&raw_header_dkim).unwrap(),
440 &email,
441 )
442 .await;
443
444 assert!(dkim_verify_result.is_ok());
445 }
446
447 #[tokio::test]
448 async fn test_validate_email_header_rsa() {
449 let raw_email =
453 r#"DKIM-Signature: a=rsa-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
454 c=simple/simple; d=example.com;
455 h=Received:From:To:Subject:Date:Message-ID; i=joe@football.example.com;
456 s=newengland; t=1615825284; v=1;
457 b=Xh4Ujb2wv5x54gXtulCiy4C0e+plRm6pZ4owF+kICpYzs/8WkTVIDBrzhJP0DAYCpnL62T0G
458 k+0OH8pi/yqETVjKtKk+peMnNvKkut0GeWZMTze0bfq3/JUK3Ln3jTzzpXxrgVnvBxeY9EZIL4g
459 s4wwFRRKz/1bksZGSjD8uuSU=
460Received: from client1.football.example.com [192.0.2.1]
461 by submitserver.example.com with SUBMISSION;
462 Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
463From: Joe SixPack <joe@football.example.com>
464To: Suzie Q <suzie@shopping.example.net>
465Subject: Is dinner ready?
466Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
467Message-ID: <20030712040037.46341.5F8J@football.example.com>
468
469Hi.
470
471We lost the game. Are you hungry yet?
472
473Joe.
474"#
475 .replace('\n', "\r\n");
476 let email = mailparse::parse_mail(raw_email.as_bytes()).unwrap();
477 let h = email
478 .headers
479 .get_all_headers(HEADER)
480 .first()
481 .unwrap()
482 .get_value_raw();
483 let raw_header_rsa = String::from_utf8_lossy(h);
484
485 let resolver: Arc<dyn Lookup> = Arc::new(MockResolver::new());
486
487 let dkim_verify_result = verify_email_header(
488 &slog::Logger::root(slog::Discard, slog::o!()),
489 Arc::clone(&resolver),
490 &validate_header(&raw_header_rsa).unwrap(),
491 &email,
492 )
493 .await;
494
495 assert!(dkim_verify_result.is_ok());
496 }
497}