Skip to main content

mail_auth/dkim2/
sign.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use 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    /// Signs a message whose content has not changed (Originator or transparent
95    /// Forwarder). Adds a DKIM2-Signature, and a Message-Instance only if none
96    /// is present yet.
97    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    /// Signs a message whose content changed, computing the recipe by diffing
108    /// `original` against `modified` (the form to be sent).
109    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    /// Signs a changed message using a caller-supplied recipe describing how to
150    /// reconstruct the previous state from `modified`.
151    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    /// Signs a message with a caller-supplied `Message-Instance` header.
167    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
375/// Wraps an address into RFC5321 reverse-path
376pub(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}