1use super::{
8 ChainBinding, Dkim2Signer, Done, MessageHash, MessageInstance, Signature, SignatureValue,
9 recipe::Recipe,
10};
11use crate::SystemTime;
12use crate::dkim2::BodyRecipe;
13use crate::{
14 AuthenticatedMessage, Error,
15 common::{
16 crypto::{HashAlgorithm, SigningKey},
17 headers::{Header, Writable, Writer},
18 },
19 dkim2::canonicalize::CanonicalizedHeaderWriter,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Envelope<A, R>
24where
25 A: AsRef<str>,
26 R: IntoIterator<Item: AsRef<str>>,
27{
28 pub mail_from: A,
29 pub rcpt_to: R,
30}
31
32impl<A, R> Envelope<A, R>
33where
34 A: AsRef<str>,
35 R: IntoIterator<Item: AsRef<str>>,
36{
37 pub fn new(mail_from: A, rcpt_to: R) -> Self {
38 Envelope { mail_from, rcpt_to }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Hop<A, R, I>
44where
45 A: AsRef<str>,
46 R: IntoIterator<Item: AsRef<str>>,
47 I: Into<String>,
48{
49 Real(Envelope<A, R>),
50 Imaginary { next_domain: I },
51}
52
53impl<A, R> Hop<A, R, String>
54where
55 A: AsRef<str>,
56 R: IntoIterator<Item: AsRef<str>>,
57{
58 pub fn real(mail_from: A, rcpt_to: R) -> Self {
59 Hop::Real(Envelope::new(mail_from, rcpt_to))
60 }
61}
62
63impl<I> Hop<&'static str, [&'static str; 0], I>
64where
65 I: Into<String>,
66{
67 pub fn imaginary(next_domain: I) -> Self {
68 Hop::Imaginary { next_domain }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Dkim2Signed {
74 pub message_instance: Option<MessageInstance>,
75 pub signature: Signature,
76}
77
78impl Dkim2Signed {
79 pub fn write(&self, writer: &mut impl Writer) {
80 self.signature.write(writer);
81 if let Some(instance) = &self.message_instance {
82 instance.write(writer);
83 }
84 }
85
86 pub fn to_header(&self) -> String {
87 let mut buf = Vec::new();
88 self.write(&mut buf);
89 String::from_utf8(buf).unwrap_or_default()
90 }
91}
92
93impl Dkim2Signer<Done> {
94 pub fn sign<'x, M, A, R, I>(&self, message: M, hop: Hop<A, R, I>) -> crate::Result<Dkim2Signed>
98 where
99 M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
100 A: AsRef<str>,
101 R: IntoIterator<Item: AsRef<str>>,
102 I: Into<String>,
103 {
104 self.sign_at(message, hop, now())
105 }
106
107 pub fn sign_revised<'x, O, M, A, R, I>(
110 &self,
111 original: O,
112 modified: M,
113 hop: Hop<A, R, I>,
114 ) -> crate::Result<Dkim2Signed>
115 where
116 O: TryInto<AuthenticatedMessage<'x>, Error = Error>,
117 M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
118 A: AsRef<str>,
119 R: IntoIterator<Item: AsRef<str>>,
120 I: Into<String>,
121 {
122 self.sign_revised_at(original, modified, hop, now())
123 }
124
125 pub(crate) fn sign_revised_at<'x, O, M, A, R, I>(
126 &self,
127 original: O,
128 modified: M,
129 hop: Hop<A, R, I>,
130 now: u64,
131 ) -> crate::Result<Dkim2Signed>
132 where
133 O: TryInto<AuthenticatedMessage<'x>, Error = Error>,
134 M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
135 A: AsRef<str>,
136 R: IntoIterator<Item: AsRef<str>>,
137 I: Into<String>,
138 {
139 let modified = modified.try_into()?;
140 let new_instance = MessageInstance::from_message(&modified, Some(&original.try_into()?));
141
142 self.sign_internal(&modified, hop, new_instance.as_ref(), now)
143 .map(|signature| Dkim2Signed {
144 message_instance: new_instance,
145 signature,
146 })
147 }
148
149 pub fn sign_with_recipe<'x, M, A, R, I>(
152 &self,
153 modified: M,
154 recipe: Recipe,
155 hop: Hop<A, R, I>,
156 ) -> crate::Result<Dkim2Signed>
157 where
158 M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
159 A: AsRef<str>,
160 R: IntoIterator<Item: AsRef<str>>,
161 I: Into<String>,
162 {
163 self.sign_with_recipe_at(modified, recipe, hop, now())
164 }
165
166 pub fn sign_with_message_instance<'x, A, R, I>(
168 &self,
169 modified: &AuthenticatedMessage<'x>,
170 instance: Option<&MessageInstance>,
171 hop: Hop<A, R, I>,
172 ) -> crate::Result<Signature>
173 where
174 A: AsRef<str>,
175 R: IntoIterator<Item: AsRef<str>>,
176 I: Into<String>,
177 {
178 self.sign_internal(modified, hop, instance, now())
179 }
180
181 pub(crate) fn sign_at<'x, M, A, R, I>(
182 &self,
183 message: M,
184 hop: Hop<A, R, I>,
185 now: u64,
186 ) -> crate::Result<Dkim2Signed>
187 where
188 M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
189 A: AsRef<str>,
190 R: IntoIterator<Item: AsRef<str>>,
191 I: Into<String>,
192 {
193 let message = message.try_into()?;
194 let new_instance = MessageInstance::from_message(&message, None);
195
196 self.sign_internal(&message, hop, new_instance.as_ref(), now)
197 .map(|signature| Dkim2Signed {
198 message_instance: new_instance,
199 signature,
200 })
201 }
202
203 pub(crate) fn sign_with_recipe_at<'x, M, A, R, I>(
204 &self,
205 modified: M,
206 recipe: Recipe,
207 hop: Hop<A, R, I>,
208 now: u64,
209 ) -> crate::Result<Dkim2Signed>
210 where
211 M: TryInto<AuthenticatedMessage<'x>, Error = Error>,
212 A: AsRef<str>,
213 R: IntoIterator<Item: AsRef<str>>,
214 I: Into<String>,
215 {
216 let message = modified.try_into()?;
217 let new_instance = MessageInstance::from_recipe(&message, Some(recipe));
218
219 self.sign_internal(&message, hop, new_instance.as_ref(), now)
220 .map(|signature| Dkim2Signed {
221 message_instance: new_instance,
222 signature,
223 })
224 }
225
226 fn sign_internal<'x, A, R, I>(
227 &self,
228 parsed: &AuthenticatedMessage<'x>,
229 hop: Hop<A, R, I>,
230 new_instance: Option<&MessageInstance>,
231 now: u64,
232 ) -> crate::Result<Signature>
233 where
234 A: AsRef<str>,
235 R: IntoIterator<Item: AsRef<str>>,
236 I: Into<String>,
237 {
238 let instances = parsed.dkim2_instances.as_slice();
239 let signatures = parsed.dkim2_signatures.as_slice();
240
241 let highest_m = instances.last().map(|h| h.header.m).unwrap_or(0);
242 let highest_i = signatures.last().map(|h| h.header.i).unwrap_or(0);
243
244 let new_m = new_instance.as_ref().map(|mi| mi.m).unwrap_or(highest_m);
245 let next_i = highest_i
246 .checked_add(1)
247 .ok_or(Error::Dkim2(super::Dkim2Error::SequenceOverflow))?;
248
249 let chain = match hop {
250 Hop::Real(envelope) => ChainBinding::Envelope {
251 mail_from: to_reverse_path(envelope.mail_from.as_ref()),
252 rcpt_to: envelope
253 .rcpt_to
254 .into_iter()
255 .map(|rcpt| to_reverse_path(rcpt.as_ref()))
256 .collect(),
257 },
258 Hop::Imaginary { next_domain } => ChainBinding::NextDomain(next_domain.into()),
259 };
260
261 let mut signature = Signature {
262 i: next_i,
263 m: new_m,
264 t: now,
265 d: self.domain.clone(),
266 s: self
267 .keys
268 .iter()
269 .map(|entry| SignatureValue {
270 selector: entry.selector.clone(),
271 a: entry.key.algorithm(),
272 b: Vec::new(),
273 })
274 .collect(),
275 chain,
276 n: self.nonce.clone(),
277 flags: self.flags.clone(),
278 };
279
280 let mut input = Vec::with_capacity(256);
281 SignatureInput {
282 instances,
283 signatures,
284 new_signature: &signature,
285 new_instance,
286 }
287 .write(&mut input);
288
289 for (index, entry) in self.keys.iter().enumerate() {
290 signature.s[index].b = entry.key.sign(input.as_slice())?;
291 }
292
293 Ok(signature)
294 }
295}
296
297impl MessageInstance {
298 pub fn from_message(
299 message: &AuthenticatedMessage<'_>,
300 original: Option<&AuthenticatedMessage<'_>>,
301 ) -> Option<Self> {
302 Self::from_recipe(
303 message,
304 original
305 .map(|original| Recipe::diff(original, message))
306 .filter(|r| !r.headers.is_empty() || r.body != BodyRecipe::None),
307 )
308 }
309
310 pub fn from_recipe(message: &AuthenticatedMessage<'_>, recipe: Option<Recipe>) -> Option<Self> {
311 let instances = message.dkim2_instances.as_slice();
312
313 (instances.is_empty() || recipe.is_some()).then(|| MessageInstance {
314 m: instances
315 .last()
316 .map(|h| h.header.m)
317 .unwrap_or(0)
318 .saturating_add(1),
319 hashes: vec![MessageHash {
320 name: HashAlgorithm::Sha256.into(),
321 header_hash: HashAlgorithm::Sha256
322 .headers_hash(message.headers.iter().copied())
323 .as_ref()
324 .to_vec(),
325 body_hash: HashAlgorithm::Sha256
326 .body_hash(message.raw_body())
327 .as_ref()
328 .to_vec(),
329 }],
330 recipe,
331 })
332 }
333}
334
335struct SignatureInput<'x> {
336 instances: &'x [Header<'x, MessageInstance>],
337 signatures: &'x [Header<'x, Signature>],
338 new_signature: &'x Signature,
339 new_instance: Option<&'x MessageInstance>,
340}
341
342impl<'x> Writable for SignatureInput<'x> {
343 fn write(self, writer: &mut impl Writer) {
344 for header in self.instances {
345 let mut w = CanonicalizedHeaderWriter::new(writer, header.name);
346 w.write(header.value);
347 w.finalize();
348 }
349
350 if let Some(instance) = self.new_instance {
351 let mut w = CanonicalizedHeaderWriter::new(writer, b"Message-Instance");
352 instance.write_value(&mut w);
353 w.finalize();
354 }
355
356 for header in self.signatures {
357 let mut w = CanonicalizedHeaderWriter::new(writer, header.name);
358 w.write(header.value);
359 w.finalize();
360 }
361
362 let mut w = CanonicalizedHeaderWriter::new(writer, b"DKIM2-Signature");
363 self.new_signature.write_value(&mut w, true);
364 w.finalize();
365 }
366}
367
368pub(crate) fn now() -> u64 {
369 SystemTime::now()
370 .duration_since(SystemTime::UNIX_EPOCH)
371 .map(|d| d.as_secs())
372 .unwrap_or(0)
373}
374
375pub(super) fn to_reverse_path(addr: &str) -> String {
377 let addr = addr.trim();
378 if addr.starts_with('<') && addr.ends_with('>') {
379 addr.to_string()
380 } else {
381 format!("<{addr}>")
382 }
383}
384
385#[cfg(test)]
386mod test {
387 use super::{Dkim2Signer, Hop};
388 use crate::{
389 common::crypto::{DkimKey, Ed25519Key, RsaKey, Sha256},
390 dkim2::{Dkim2Signed, MessageInstance},
391 };
392 use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
393 use std::{borrow::Cow, path::PathBuf};
394
395 const TIMESTAMP: u64 = 1740000000;
396
397 fn normalize_crlf(message: &[u8]) -> Cow<'_, [u8]> {
398 let mut needs_fix = false;
399 let mut iter = message.iter().peekable();
400 while let Some(&ch) = iter.next() {
401 match ch {
402 b'\r' => {
403 if iter.peek() != Some(&&b'\n') {
404 needs_fix = true;
405 break;
406 }
407 iter.next();
408 }
409 b'\n' => {
410 needs_fix = true;
411 break;
412 }
413 _ => {}
414 }
415 }
416
417 if !needs_fix {
418 return Cow::Borrowed(message);
419 }
420
421 let mut out = Vec::with_capacity(message.len() + 16);
422 for ch in message {
423 match ch {
424 b'\r' => {}
425 b'\n' => out.extend_from_slice(b"\r\n"),
426 _ => out.push(*ch),
427 }
428 }
429 Cow::Owned(out)
430 }
431
432 fn resource(parts: &[&str]) -> PathBuf {
433 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
434 path.push("resources/dkim2");
435 for part in parts {
436 path.push(part);
437 }
438 path
439 }
440
441 fn load_ed25519(domain: &str, selector: &str) -> Ed25519Key {
442 let pem = std::fs::read(resource(&[
443 "keys",
444 &format!("{selector}._domainkey.{domain}.pem"),
445 ]))
446 .unwrap();
447 let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
448 panic!("expected PKCS8 key");
449 };
450 Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
451 }
452
453 fn load_rsa(domain: &str, selector: &str) -> RsaKey<Sha256> {
454 let pem = std::fs::read(resource(&[
455 "keys",
456 &format!("{selector}._domainkey.{domain}.pem"),
457 ]))
458 .unwrap();
459 RsaKey::<Sha256>::from_key_der(PrivateKeyDer::from_pem_slice(&pem).unwrap()).unwrap()
460 }
461
462 fn prepend(signed: &Dkim2Signed, message: &[u8]) -> Vec<u8> {
463 let mut out = Vec::with_capacity(message.len() + 512);
464 signed.write(&mut out);
465 out.extend_from_slice(message);
466 out
467 }
468
469 fn run_vector<K: Into<DkimKey>>(
470 key: K,
471 domain: &str,
472 selector: &str,
473 email: &str,
474 mail_from: &str,
475 rcpt_to: &[&str],
476 expected_file: &str,
477 ) {
478 let input_raw = std::fs::read(resource(&["emails", email])).unwrap();
479 let input = input_raw.as_slice().try_into().unwrap();
480 let signer = Dkim2Signer::from_key(key).domain(domain).selector(selector);
481 let instance = MessageInstance::from_message(&input, None);
482 let signed = signer
483 .sign_internal(
484 &input,
485 Hop::real(mail_from, rcpt_to),
486 instance.as_ref(),
487 TIMESTAMP,
488 )
489 .map(|signature| Dkim2Signed {
490 message_instance: instance,
491 signature,
492 })
493 .unwrap();
494 let normalized = normalize_crlf(&input_raw);
495 let produced = prepend(&signed, normalized.as_ref());
496
497 if std::env::var("REGEN_DKIM2").is_ok() {
498 std::fs::write(resource(&["expected", expected_file]), &produced).unwrap();
499 return;
500 }
501
502 let mut expected = std::fs::read(resource(&["expected", expected_file])).unwrap();
503 if expected.ends_with(b"\r") {
504 expected.push(b'\n');
505 }
506 assert_eq!(
507 String::from_utf8_lossy(&produced),
508 String::from_utf8_lossy(&expected),
509 "vector {expected_file}"
510 );
511 }
512
513 #[test]
514 fn sign_golden_single_hop() {
515 let recipient: &[&str] = &["recipient@example.com"];
516
517 run_vector(
518 load_ed25519("test1.dkim2.com", "ed25519"),
519 "test1.dkim2.com",
520 "ed25519",
521 "simple.eml",
522 "sender@test1.dkim2.com",
523 recipient,
524 "simple-ed25519.eml",
525 );
526 run_vector(
527 load_rsa("test1.dkim2.com", "sel1"),
528 "test1.dkim2.com",
529 "sel1",
530 "simple.eml",
531 "sender@test1.dkim2.com",
532 recipient,
533 "simple-rsa2048.eml",
534 );
535 run_vector(
536 load_rsa("test1.dkim2.com", "sel2"),
537 "test1.dkim2.com",
538 "sel2",
539 "simple.eml",
540 "sender@test1.dkim2.com",
541 recipient,
542 "simple-sel2.eml",
543 );
544 run_vector(
545 load_rsa("test1.dkim2.com", "sel3"),
546 "test1.dkim2.com",
547 "sel3",
548 "simple.eml",
549 "sender@test1.dkim2.com",
550 recipient,
551 "simple-sel3.eml",
552 );
553 run_vector(
554 load_ed25519("test2.dkim2.com", "ed25519"),
555 "test2.dkim2.com",
556 "ed25519",
557 "multiheader.eml",
558 "sender@test2.dkim2.com",
559 recipient,
560 "multiheader-ed25519.eml",
561 );
562 run_vector(
563 load_ed25519("test3.dkim2.com", "ed25519"),
564 "test3.dkim2.com",
565 "ed25519",
566 "trailingblank.eml",
567 "sender@test3.dkim2.com",
568 recipient,
569 "trailingblank-ed25519.eml",
570 );
571 run_vector(
572 load_ed25519("test4.dkim2.com", "ed25519"),
573 "test4.dkim2.com",
574 "ed25519",
575 "emptybody.eml",
576 "sender@test4.dkim2.com",
577 recipient,
578 "emptybody-ed25519.eml",
579 );
580 run_vector(
581 load_ed25519("test5.dkim2.com", "ed25519"),
582 "test5.dkim2.com",
583 "ed25519",
584 "multirecipient.eml",
585 "sender@test5.dkim2.com",
586 &[
587 "alice@example.com",
588 "bob@example.com",
589 "charlie@example.com",
590 ],
591 "multirecipient-ed25519.eml",
592 );
593 run_vector(
594 load_ed25519("test1.dkim2.com", "ed25519"),
595 "test1.dkim2.com",
596 "ed25519",
597 "simple.eml",
598 "<>",
599 recipient,
600 "dsn-ed25519.eml",
601 );
602 run_vector(
603 load_ed25519("test1.dkim2.com", "ed25519"),
604 "test1.dkim2.com",
605 "ed25519",
606 "dupheaders.eml",
607 "sender@test1.dkim2.com",
608 recipient,
609 "dupheaders-ed25519.eml",
610 );
611 }
612
613 #[test]
614 fn sign_sequence_overflow_returns_error_not_panic() {
615 let pkcs8 = Ed25519Key::generate_pkcs8().unwrap();
616 let key = Ed25519Key::from_pkcs8_der(&pkcs8).unwrap();
617 let signer = Dkim2Signer::from_key(key).domain("ex.com").selector("sel");
618
619 let msg = concat!(
620 "DKIM2-Signature: i=4294967295; m=4294967295; t=0; d=ex.com; mf=YQ==; rt=Yg==; s=sel:ed25519-sha256:QQ==;\r\n",
621 "Message-Instance: m=4294967295; h=sha256:QQ==:Qg==;\r\n",
622 "From: a@ex.com\r\n",
623 "\r\n",
624 "body\r\n",
625 );
626 let result = signer.sign_at(msg.as_bytes(), Hop::real("a@ex.com", ["b@x.com"]), 1000);
627 assert!(result.is_err(), "sequence overflow must be a clean error");
628 }
629}