1use crate::errors::Status;
4use crate::hash::HeaderList;
5use dns_resolver::{HickoryResolver, Resolver};
6use ed25519_dalek::pkcs8::DecodePrivateKey;
7use ed25519_dalek::SigningKey;
8use mailparsing::AuthenticationResult;
9use openssl::md::Md;
10use openssl::pkey::PKey;
11use openssl::pkey_ctx::PkeyCtx;
12use openssl::rsa::{Padding, Rsa};
13use std::collections::BTreeMap;
14
15pub mod canonicalization;
16mod errors;
17mod hash;
18mod header;
19mod parsed_email;
20mod parser;
21mod public_key;
22#[cfg(test)]
23mod roundtrip_test;
24mod sign;
25
26pub use errors::DKIMError;
27use header::{DKIMHeader, HEADER};
28pub use parsed_email::ParsedEmail;
29pub use parser::{tag_list as parse_tag_list, Tag};
30pub use sign::{Signer, SignerBuilder};
31
32const DNS_NAMESPACE: &str = "_domainkey";
33
34#[derive(Debug)]
35pub(crate) enum DkimPublicKey {
36 Rsa(PKey<openssl::pkey::Public>),
37 Ed25519(ed25519_dalek::VerifyingKey),
38}
39
40#[allow(clippy::large_enum_variant)]
41#[derive(Debug)]
42pub enum DkimPrivateKey {
43 Ed25519(SigningKey),
44 OpenSSLRsa(Rsa<openssl::pkey::Private>),
45}
46
47impl DkimPrivateKey {
48 pub fn rsa_key(data: &[u8]) -> Result<Self, DKIMError> {
50 let mut errors = vec![];
51
52 match Rsa::private_key_from_pem(data) {
53 Ok(key) => return Ok(Self::OpenSSLRsa(key)),
54 Err(err) => errors.push(format!("openssl private_key_from_pem: {err:#}")),
55 };
56 match Rsa::private_key_from_der(data) {
57 Ok(key) => return Ok(Self::OpenSSLRsa(key)),
58 Err(err) => errors.push(format!("openssl private_key_from_der: {err:#}")),
59 };
60
61 Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
62 }
63
64 pub fn rsa_key_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self, DKIMError> {
66 let path = path.as_ref();
67 let data = std::fs::read(path).map_err(|err| {
68 DKIMError::PrivateKeyLoadError(format!(
69 "rsa_key_file: failed to read file {path:?}: {err:#}"
70 ))
71 })?;
72 Self::rsa_key(&data)
73 }
74
75 pub fn ed25519_key(data: &[u8]) -> Result<Self, DKIMError> {
78 let mut errors = vec![];
79
80 match SigningKey::from_pkcs8_der(data) {
81 Ok(key) => return Ok(Self::Ed25519(key)),
82 Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_der: {err:#}")),
83 }
84
85 match std::str::from_utf8(data) {
86 Ok(s) => match SigningKey::from_pkcs8_pem(s) {
87 Ok(key) => return Ok(Self::Ed25519(key)),
88 Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_pem: {err:#}")),
89 },
90 Err(err) => errors.push(format!("ed25519_key: data is not UTF-8: {err:#}")),
91 }
92
93 Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
94 }
95
96 pub fn key(data: &[u8]) -> Result<Self, DKIMError> {
99 let mut errors = vec![];
100
101 match Rsa::private_key_from_pem(data) {
102 Ok(key) => return Ok(Self::OpenSSLRsa(key)),
103 Err(err) => errors.push(format!("openssl private_key_from_pem: {err:#}")),
104 }
105 match Rsa::private_key_from_der(data) {
106 Ok(key) => return Ok(Self::OpenSSLRsa(key)),
107 Err(err) => errors.push(format!("openssl private_key_from_der: {err:#}")),
108 }
109 match SigningKey::from_pkcs8_der(data) {
110 Ok(key) => return Ok(Self::Ed25519(key)),
111 Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_der: {err:#}")),
112 }
113 match std::str::from_utf8(data) {
114 Ok(s) => match SigningKey::from_pkcs8_pem(s) {
115 Ok(key) => return Ok(Self::Ed25519(key)),
116 Err(err) => errors.push(format!("Ed25519 SigningKey::from_pkcs8_pem: {err:#}")),
117 },
118 Err(err) => errors.push(format!("ed25519_key: data is not UTF-8: {err:#}")),
119 }
120
121 Err(DKIMError::PrivateKeyLoadError(errors.join(". ")))
122 }
123}
124
125fn verify_signature(
127 hash_algo: hash::HashAlgo,
128 header_hash: &[u8],
129 signature: &[u8],
130 public_key: DkimPublicKey,
131) -> Result<bool, DKIMError> {
132 Ok(match public_key {
133 DkimPublicKey::Rsa(public_key) => {
134 let md = match hash_algo {
135 hash::HashAlgo::RsaSha1 => Md::sha1(),
136 hash::HashAlgo::RsaSha256 => Md::sha256(),
137 hash => return Err(DKIMError::UnsupportedHashAlgorithm(format!("{:?}", hash))),
138 };
139
140 let mut ctx = PkeyCtx::new(&public_key).map_err(|err| {
141 DKIMError::SignatureSyntaxError(format!("Error loading RSA public key: {err}"))
142 })?;
143
144 ctx.verify_init().map_err(|err| {
145 DKIMError::UnknownInternalError(format!("ctx.verify_init failed: {err}"))
146 })?;
147 ctx.set_rsa_padding(Padding::PKCS1).map_err(|err| {
148 DKIMError::UnknownInternalError(format!("ctx.set_rsa_padding failed: {err}"))
149 })?;
150 ctx.set_signature_md(md).map_err(|err| {
151 DKIMError::UnknownInternalError(format!("ctx.set_signature_md failed: {err}"))
152 })?;
153 ctx.verify(header_hash, signature).unwrap_or_default()
154 }
155 DkimPublicKey::Ed25519(public_key) => {
156 let mut sig_bytes = [0u8; ed25519_dalek::Signature::BYTE_SIZE];
157 if signature.len() != sig_bytes.len() {
158 return Err(DKIMError::SignatureSyntaxError(format!(
159 "ed25519 signatures should be {} bytes in length, have: {}",
160 ed25519_dalek::Signature::BYTE_SIZE,
161 signature.len()
162 )));
163 }
164 sig_bytes.copy_from_slice(signature);
165
166 public_key
167 .verify_strict(
168 header_hash,
169 &ed25519_dalek::Signature::from_bytes(&sig_bytes),
170 )
171 .is_ok()
172 }
173 })
174}
175
176async fn verify_email_header<'a>(
177 resolver: &dyn Resolver,
178 dkim_header: &'a DKIMHeader,
179 email: &'a ParsedEmail<'a>,
180) -> Result<(), DKIMError> {
181 let public_keys = public_key::retrieve_public_keys(
182 resolver,
183 dkim_header.get_required_tag("d"),
184 dkim_header.get_required_tag("s"),
185 )
186 .await?;
187
188 let (header_canonicalization_type, body_canonicalization_type) =
189 parser::parse_canonicalization(dkim_header.get_tag("c"))?;
190 let hash_algo = parser::parse_hash_algo(dkim_header.get_required_tag("a"))?;
191 let computed_body_hash = hash::compute_body_hash(
192 body_canonicalization_type,
193 dkim_header.parse_tag("l")?,
194 hash_algo,
195 email,
196 )?;
197
198 let header_list: Vec<String> = dkim_header
199 .get_required_tag("h")
200 .split(':')
201 .map(|s| s.trim().to_ascii_lowercase())
202 .collect();
203
204 let computed_headers_hash = hash::compute_headers_hash(
205 header_canonicalization_type,
206 &HeaderList::new(header_list),
207 hash_algo,
208 dkim_header,
209 email,
210 )?;
211 tracing::debug!("body_hash {:?}", computed_body_hash);
212
213 let header_body_hash = dkim_header.get_required_tag("bh");
214 if header_body_hash != computed_body_hash {
215 return Err(DKIMError::BodyHashDidNotVerify);
216 }
217
218 let signature = data_encoding::BASE64
219 .decode(dkim_header.get_required_tag("b").as_bytes())
220 .map_err(|err| {
221 DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
222 })?;
223
224 let mut errors = vec![];
225 for public_key in public_keys {
226 match verify_signature(hash_algo, &computed_headers_hash, &signature, public_key) {
227 Ok(true) => return Ok(()),
228 Ok(false) => {}
229 Err(err) => {
230 errors.push(err);
231 }
232 }
233 }
234
235 if let Some(err) = errors.pop() {
236 return Err(err);
238 }
239
240 Err(DKIMError::SignatureDidNotVerify)
243}
244
245pub async fn verify_email_with_resolver<'a>(
247 from_domain: &str,
248 email: &'a ParsedEmail<'a>,
249 resolver: &dyn Resolver,
250) -> Result<Vec<AuthenticationResult>, DKIMError> {
251 let mut results = vec![];
252
253 let mut dkim_headers = vec![];
254
255 for h in email.get_headers().iter_named(HEADER) {
256 if results.len() > 10 {
257 break;
260 }
261
262 let value = h.get_raw_value();
263 match DKIMHeader::parse(value) {
264 Ok(v) => {
265 dkim_headers.push(v);
266 }
267 Err(err) => {
268 results.push(AuthenticationResult {
269 method: "dkim".to_string(),
270 method_version: None,
271 result: "permerror".to_string(),
272 reason: Some(format!("{err}")),
273 props: BTreeMap::new(),
274 });
275 }
276 }
277 }
278
279 fn compute_header_b(b_tag: &str, headers: &[DKIMHeader]) -> String {
286 let mut len = 8;
287
288 'bigger: while len < b_tag.len() {
289 for h in headers {
290 let candidate = h.get_required_tag("b");
291 if candidate == b_tag {
292 continue;
293 }
294 if b_tag[0..len] == candidate[0..len] {
295 len += 2;
296 continue 'bigger;
297 }
298 }
299 return b_tag[0..len].to_string();
300 }
301 b_tag.to_string()
302 }
303
304 for dkim_header in &dkim_headers {
305 let signing_domain = dkim_header.get_required_tag("d");
306 let mut props = BTreeMap::new();
307
308 props.insert("header.d".to_string(), signing_domain.to_string());
309 props.insert("header.i".to_string(), format!("@{signing_domain}"));
310 props.insert(
311 "header.a".to_string(),
312 dkim_header.get_required_tag("a").to_string(),
313 );
314 props.insert(
315 "header.s".to_string(),
316 dkim_header.get_required_tag("s").to_string(),
317 );
318
319 let b_tag = compute_header_b(dkim_header.get_required_tag("b"), &dkim_headers);
320 props.insert("header.b".to_string(), b_tag);
321
322 let mut reason = None;
323 let result = match verify_email_header(resolver, dkim_header, email).await {
324 Ok(()) => {
325 if signing_domain.eq_ignore_ascii_case(from_domain) {
326 "pass"
327 } else {
328 let why = "mail-from-mismatch-signing-domain".to_string();
329 reason.replace(why.clone());
330 props.insert("policy.dkim-rules".to_string(), why);
331 "policy"
332 }
333 }
334 Err(err) => {
335 reason.replace(format!("{err}"));
336 match err.status() {
337 Status::Tempfail => "temperror",
338 Status::Permfail => "permerror",
339 }
340 }
341 };
342
343 results.push(AuthenticationResult {
344 method: "dkim".to_string(),
345 method_version: None,
346 result: result.to_string(),
347 reason,
348 props,
349 });
350 }
351
352 Ok(results)
353}
354
355pub async fn verify_email<'a>(
357 from_domain: &str,
358 email: &'a ParsedEmail<'a>,
359) -> Result<Vec<AuthenticationResult>, DKIMError> {
360 let resolver = HickoryResolver::new().map_err(|err| {
361 DKIMError::UnknownInternalError(format!("failed to create DNS resolver: {}", err))
362 })?;
363
364 verify_email_with_resolver(from_domain, email, &resolver).await
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use dns_resolver::TestResolver;
371
372 const NEW_ENGLAND_DKIM: (&str, &str) = (
373 "newengland._domainkey.example.com",
374 "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE=",
375 );
376
377 #[test]
378 fn test_validate_header() {
379 let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane;
380c=relaxed/simple; q=dns/txt; i=foo@eng.example.net;
381t=1117574938; x=9118006938; l=200;
382h=from:to:subject:date:keywords:keywords;
383z=From:foo@eng.example.net|To:joe@example.com|
384Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;
385bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;
386b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZ
387 VoG4ZHRNiYzR
388 "#;
389 DKIMHeader::parse(header).unwrap();
390 }
391
392 #[test]
393 fn test_validate_header_missing_tag() {
394 let header = "v=1; a=rsa-sha256; bh=a; b=b";
395 assert_eq!(
396 DKIMHeader::parse(header).unwrap_err(),
397 DKIMError::SignatureMissingRequiredTag("d")
398 );
399 }
400
401 #[test]
402 fn test_validate_header_domain_mismatch() {
403 let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@hein.com; h=headers; bh=hash; b=hash
404 "#;
405 assert_eq!(
406 DKIMHeader::parse(header).unwrap_err(),
407 DKIMError::DomainMismatch
408 );
409 }
410
411 #[test]
412 fn test_validate_header_incompatible_version() {
413 let header = r#"v=3; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=headers; bh=hash; b=hash
414 "#;
415 assert_eq!(
416 DKIMHeader::parse(header).unwrap_err(),
417 DKIMError::IncompatibleVersion
418 );
419 }
420
421 #[test]
422 fn test_validate_header_missing_from_in_headers_signature() {
423 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
424 "#;
425 assert_eq!(
426 DKIMHeader::parse(header).unwrap_err(),
427 DKIMError::FromFieldNotSigned
428 );
429 }
430
431 #[test]
432 fn test_validate_header_expired_in_drift() {
433 let mut now = chrono::Utc::now().naive_utc();
434 now -= chrono::Duration::try_seconds(1).expect("1 second to be valid");
435
436 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());
437
438 assert!(DKIMHeader::parse(&header).is_ok());
439 }
440
441 #[test]
442 fn test_validate_header_expired() {
443 let mut now = chrono::Utc::now().naive_utc();
444 now -= chrono::Duration::try_hours(3).expect("3 hours to be legit");
445
446 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());
447
448 assert_eq!(
449 DKIMHeader::parse(&header).unwrap_err(),
450 DKIMError::SignatureExpired
451 );
452 }
453
454 #[tokio::test]
455 async fn test_validate_email_header_ed25519() {
456 let raw_email = r#"DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
457 d=football.example.com; i=@football.example.com;
458 q=dns/txt; s=brisbane; t=1528637909; h=from : to :
459 subject : date : message-id : from : subject : date;
460 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
461 b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
462 Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
463DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
464 d=football.example.com; i=@football.example.com;
465 q=dns/txt; s=test; t=1528637909; h=from : to : subject :
466 date : message-id : from : subject : date;
467 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
468 b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
469 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
470 dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
471From: Joe SixPack <joe@football.example.com>
472To: Suzie Q <suzie@shopping.example.net>
473Subject: Is dinner ready?
474Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
475Message-ID: <20030712040037.46341.5F8J@football.example.com>
476
477Hi.
478
479We lost the game. Are you hungry yet?
480
481Joe."#
482 .replace('\n', "\r\n");
483
484 let email = ParsedEmail::parse(raw_email).unwrap();
485 let raw_header_dkim = email
486 .get_headers()
487 .iter_named(HEADER)
488 .next()
489 .unwrap()
490 .get_raw_value();
491
492 const DKIM_BRISBANE: &str = r#"
493$ORIGIN brisbane._domainkey.football.example.com
494@ 300 TXT "v=DKIM1; p=MIGJAoGBALVI635dLK4cJJAH3Lx6upo3X/Lm1tQz3mezcWTA3BUBnyIsdnRf57aD5BtNmhPrYYDlWlzw3UgnKisIxktkk5+iMQMlFtAS10JB8L3YadXNJY+JBcbeSi5TgJe4WFzNgW95FWDAuSTRXSWZfA/8xjflbTLDx0euFZOM7C4T0GwLAgMBAAE="
495 TXT "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
496"#;
497
498 let resolver = TestResolver::default().with_zone(DKIM_BRISBANE);
499
500 verify_email_header(
501 &resolver,
502 &DKIMHeader::parse(raw_header_dkim).unwrap(),
503 &email,
504 )
505 .await
506 .unwrap();
507 }
508
509 #[tokio::test]
510 async fn test_validate_email_header_rsa() {
511 let raw_email =
515 r#"DKIM-Signature: a=rsa-sha256; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
516 c=simple/simple; d=example.com;
517 h=Received:From:To:Subject:Date:Message-ID; i=joe@football.example.com;
518 s=newengland; t=1615825284; v=1;
519 b=Xh4Ujb2wv5x54gXtulCiy4C0e+plRm6pZ4owF+kICpYzs/8WkTVIDBrzhJP0DAYCpnL62T0G
520 k+0OH8pi/yqETVjKtKk+peMnNvKkut0GeWZMTze0bfq3/JUK3Ln3jTzzpXxrgVnvBxeY9EZIL4g
521 s4wwFRRKz/1bksZGSjD8uuSU=
522Received: from client1.football.example.com [192.0.2.1]
523 by submitserver.example.com with SUBMISSION;
524 Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
525From: Joe SixPack <joe@football.example.com>
526To: Suzie Q <suzie@shopping.example.net>
527Subject: Is dinner ready?
528Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
529Message-ID: <20030712040037.46341.5F8J@football.example.com>
530
531Hi.
532
533We lost the game. Are you hungry yet?
534
535Joe.
536"#
537 .replace('\n', "\r\n");
538 let email = ParsedEmail::parse(raw_email).unwrap();
539 let raw_header_rsa = email
540 .get_headers()
541 .iter_named(HEADER)
542 .next()
543 .unwrap()
544 .get_raw_value();
545
546 let resolver =
547 TestResolver::default().with_txt(NEW_ENGLAND_DKIM.0, NEW_ENGLAND_DKIM.1.to_owned());
548
549 verify_email_header(
550 &resolver,
551 &DKIMHeader::parse(raw_header_rsa).unwrap(),
552 &email,
553 )
554 .await
555 .unwrap();
556 }
557}