Skip to main content

mail_auth/dkim2/
headers.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::{ChainBinding, MessageInstance, Signature, SignatureValue};
8use crate::common::headers::{HeaderFolder, HeaderWriter, Writer};
9use mail_builder::encoders::base64::base64_encode_mime;
10use std::{
11    fmt::{Display, Formatter},
12    io::Write,
13};
14
15struct Base64Writer<'x, W: Writer> {
16    inner: &'x mut W,
17}
18
19impl<'x, W: Writer> Write for Base64Writer<'x, W> {
20    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
21        self.inner.write(buf);
22        Ok(buf.len())
23    }
24
25    fn flush(&mut self) -> std::io::Result<()> {
26        Ok(())
27    }
28}
29
30fn write_base64(writer: &mut impl Writer, bytes: &[u8]) {
31    let _ = base64_encode_mime(bytes, Base64Writer { inner: writer }, true);
32}
33
34impl SignatureValue {
35    fn write(&self, writer: &mut impl Writer, empty: bool) {
36        writer.write(self.selector.as_bytes());
37        writer.write(b":");
38        writer.write(self.a.name().as_bytes());
39        writer.write(b":");
40        if !empty {
41            write_base64(writer, &self.b);
42        }
43    }
44}
45
46impl Signature {
47    pub(super) fn write_value(&self, writer: &mut impl Writer, empty_signature: bool) {
48        writer.write(b"i=");
49        writer.write(self.i.to_string().as_bytes());
50        writer.write(b"; m=");
51        writer.write(self.m.to_string().as_bytes());
52        writer.write(b"; t=");
53        writer.write(self.t.to_string().as_bytes());
54        writer.write(b"; d=");
55        writer.write(self.d.as_bytes());
56        writer.write(b"; ");
57
58        match &self.chain {
59            ChainBinding::Envelope { mail_from, rcpt_to } => {
60                writer.write(b"mf=");
61                write_base64(writer, mail_from.as_bytes());
62                writer.write(b"; rt=");
63                for (pos, rcpt) in rcpt_to.iter().enumerate() {
64                    if pos > 0 {
65                        writer.write(b",");
66                    }
67                    write_base64(writer, rcpt.as_bytes());
68                }
69            }
70            ChainBinding::NextDomain(domain) => {
71                writer.write(b"nd=");
72                writer.write(domain.as_bytes());
73            }
74        }
75
76        writer.write(b"; s=");
77        for (pos, value) in self.s.iter().enumerate() {
78            if pos > 0 {
79                writer.write(b",");
80            }
81            value.write(writer, empty_signature);
82        }
83        writer.write(b";");
84
85        if let Some(nonce) = &self.n {
86            writer.write(b" n=");
87            writer.write(nonce.as_bytes());
88            writer.write(b";");
89        }
90
91        if !self.flags.is_empty() {
92            writer.write(b" f=");
93            for (pos, flag) in self.flags.iter().enumerate() {
94                if pos > 0 {
95                    writer.write(b",");
96                }
97                writer.write(flag.as_bytes());
98            }
99            writer.write(b";");
100        }
101    }
102
103    pub fn write(&self, writer: &mut impl Writer) {
104        writer.write(b"DKIM2-Signature: ");
105        self.write_value(writer, false);
106        writer.write(b"\r\n");
107    }
108}
109
110impl MessageInstance {
111    pub(super) fn write_value(&self, writer: &mut impl Writer) {
112        writer.write(b"m=");
113        writer.write(self.m.to_string().as_bytes());
114        writer.write(b"; h=");
115        for (pos, hash) in self.hashes.iter().enumerate() {
116            if pos > 0 {
117                writer.write(b",");
118            }
119            writer.write(hash.name.map(|n| n.name().as_bytes()).unwrap_or(b""));
120            writer.write(b":");
121            write_base64(writer, &hash.header_hash);
122            writer.write(b":");
123            write_base64(writer, &hash.body_hash);
124        }
125
126        if let Some(recipe) = &self.recipe {
127            let mut json = Vec::new();
128            if recipe.to_json(&mut json).is_ok() {
129                writer.write(b"; r=");
130                write_base64(writer, &json);
131            }
132        }
133        writer.write(b";");
134    }
135
136    pub fn write(&self, writer: &mut impl Writer) {
137        writer.write(b"Message-Instance: ");
138        self.write_value(writer);
139        writer.write(b"\r\n");
140    }
141}
142
143impl HeaderWriter for Signature {
144    fn write_header(&self, writer: &mut impl Writer) {
145        self.write(&mut HeaderFolder::new(writer));
146    }
147}
148
149impl HeaderWriter for MessageInstance {
150    fn write_header(&self, writer: &mut impl Writer) {
151        self.write(&mut HeaderFolder::new(writer));
152    }
153}
154
155impl Display for Signature {
156    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
157        let mut buf = Vec::new();
158        self.write_value(&mut buf, false);
159        f.write_str(&String::from_utf8_lossy(&buf))
160    }
161}
162
163impl Display for MessageInstance {
164    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
165        let mut buf = Vec::new();
166        self.write_value(&mut buf);
167        f.write_str(&String::from_utf8_lossy(&buf))
168    }
169}
170
171#[cfg(test)]
172mod test {
173    use crate::common::crypto::{Algorithm, HashAlgorithm};
174    use crate::common::headers::HeaderWriter;
175    use crate::dkim2::{
176        ChainBinding, Flag, MessageHash, MessageInstance, Signature, SignatureValue,
177    };
178
179    const MAX_HEADER_LINE_LEN: usize = 76;
180
181    fn strip_all_ws(bytes: &[u8]) -> Vec<u8> {
182        bytes
183            .iter()
184            .copied()
185            .filter(|c| !matches!(c, b' ' | b'\t' | b'\r' | b'\n'))
186            .collect()
187    }
188
189    // draft-ietf-dkim-dkim2-spec-03 ยง9.6: the signature canonicalization unfolds
190    // and then deletes ALL whitespace, so a folded header field and its unfolded
191    // form MUST reduce to identical bytes. Also assert no physical line exceeds the
192    // fold limit (excluding the single leading fold tab).
193    fn assert_fold_is_transparent(field_name: &[u8], folded: &[u8], unfolded: &[u8]) {
194        assert!(
195            folded.ends_with(b"\r\n"),
196            "a folded header field must end with CRLF: {:?}",
197            String::from_utf8_lossy(folded)
198        );
199
200        for (n, line) in folded
201            .strip_suffix(b"\r\n")
202            .unwrap()
203            .split(|&c| c == b'\n')
204            .enumerate()
205        {
206            let line = line.strip_suffix(b"\r").unwrap_or(line);
207            let content = line.strip_prefix(b"\t").unwrap_or(line);
208            assert!(
209                content.len() <= MAX_HEADER_LINE_LEN,
210                "{}: line {n} is {} bytes, exceeds {MAX_HEADER_LINE_LEN}: {:?}",
211                String::from_utf8_lossy(field_name),
212                content.len(),
213                String::from_utf8_lossy(content),
214            );
215        }
216
217        for (i, &ch) in folded.iter().enumerate() {
218            if ch == b'\n' && i + 1 < folded.len() {
219                assert!(
220                    i >= 1 && folded[i - 1] == b'\r' && folded.get(i + 1) == Some(&b'\t'),
221                    "every interior LF must be part of a CRLF+TAB fold: {:?}",
222                    String::from_utf8_lossy(folded)
223                );
224            }
225        }
226
227        let mut expected = field_name.to_vec();
228        expected.extend_from_slice(unfolded);
229        assert_eq!(
230            strip_all_ws(folded),
231            strip_all_ws(&expected),
232            "folding changed the whitespace-stripped (signed) bytes of {}",
233            String::from_utf8_lossy(field_name)
234        );
235    }
236
237    fn dump_folded(title: &str, folded: &[u8]) {
238        println!("\n===== {title} ({} bytes) =====", folded.len());
239        for (n, line) in folded.split(|&c| c == b'\n').enumerate() {
240            if line.is_empty() {
241                continue;
242            }
243            let line = line.strip_suffix(b"\r").unwrap_or(line);
244            let content = line.strip_prefix(b"\t").unwrap_or(line);
245            let leader = if line.len() != content.len() {
246                "\\t"
247            } else {
248                "  "
249            };
250            println!(
251                "  line {n:>2} | {:>3} | {leader}{}",
252                content.len(),
253                String::from_utf8_lossy(content)
254            );
255        }
256        println!("----- raw (escaped) -----");
257        println!("  {}", String::from_utf8_lossy(folded).escape_debug());
258    }
259
260    fn value_of<'a>(folded: &'a [u8], field_name: &[u8]) -> &'a [u8] {
261        folded
262            .strip_prefix(field_name)
263            .expect("folded header must start with the field name")
264    }
265
266    fn big_signature() -> Signature {
267        Signature {
268            i: 3,
269            m: 2,
270            t: 1782394336,
271            d: "test.dkim2.eu".to_string(),
272            s: vec![
273                SignatureValue {
274                    selector: "rsa2048".to_string(),
275                    a: Algorithm::RsaSha256,
276                    b: (0u8..=255).cycle().take(256).collect(),
277                },
278                SignatureValue {
279                    selector: "ed25519".to_string(),
280                    a: Algorithm::Ed25519Sha256,
281                    b: (0u8..64).collect(),
282                },
283            ],
284            chain: ChainBinding::Envelope {
285                mail_from: "<sender@test.dkim2.eu>".to_string(),
286                rcpt_to: vec![
287                    "<recipient@example.com>".to_string(),
288                    "<second-recipient@another-example.org>".to_string(),
289                ],
290            },
291            n: Some("banana".to_string()),
292            flags: vec![Flag::Feedback, Flag::DoNotModify],
293        }
294    }
295
296    fn big_instance() -> MessageInstance {
297        MessageInstance {
298            m: 1,
299            hashes: vec![
300                MessageHash {
301                    name: Some(HashAlgorithm::Sha256),
302                    header_hash: (0u8..32).collect(),
303                    body_hash: (32u8..64).collect(),
304                },
305                MessageHash {
306                    name: Some(HashAlgorithm::Sha256),
307                    header_hash: (64u8..96).collect(),
308                    body_hash: (96u8..128).collect(),
309                },
310            ],
311            recipe: None,
312        }
313    }
314
315    #[test]
316    fn signature_write_header_folds_and_round_trips() {
317        let sig = big_signature();
318
319        let mut folded = Vec::new();
320        sig.write_header(&mut folded);
321
322        let mut unfolded = Vec::new();
323        sig.write_value(&mut unfolded, false);
324        unfolded.extend_from_slice(b"\r\n");
325
326        assert!(
327            folded.windows(3).any(|w| w == b"\r\n\t"),
328            "a large signature should have been folded: {:?}",
329            String::from_utf8_lossy(&folded)
330        );
331        dump_folded(
332            "DKIM2-Signature (large: rsa2048 + ed25519, 2 recipients)",
333            &folded,
334        );
335        assert_fold_is_transparent(b"DKIM2-Signature: ", &folded, &unfolded);
336
337        let value = value_of(&folded, b"DKIM2-Signature: ");
338        assert_eq!(
339            Signature::parse(value).unwrap(),
340            sig,
341            "folded signature must parse back to the original"
342        );
343    }
344
345    #[test]
346    fn message_instance_write_header_folds_and_round_trips() {
347        let instance = big_instance();
348
349        let mut folded = Vec::new();
350        instance.write_header(&mut folded);
351
352        assert!(
353            folded.windows(3).any(|w| w == b"\r\n\t"),
354            "a large message-instance should have been folded: {:?}",
355            String::from_utf8_lossy(&folded)
356        );
357        let mut unfolded = Vec::new();
358        instance.write_value(&mut unfolded);
359        unfolded.extend_from_slice(b"\r\n");
360        dump_folded("Message-Instance (large: 2 sha256 hash sets)", &folded);
361        assert_fold_is_transparent(b"Message-Instance: ", &folded, &unfolded);
362
363        let value = value_of(&folded, b"Message-Instance: ");
364        assert_eq!(
365            MessageInstance::parse(value).unwrap(),
366            instance,
367            "folded message-instance must parse back to the original"
368        );
369    }
370
371    #[test]
372    fn signature_folds_at_every_realistic_size() {
373        for extra_recipients in 0..6 {
374            let mut sig = big_signature();
375            if let ChainBinding::Envelope { rcpt_to, .. } = &mut sig.chain {
376                for n in 0..extra_recipients {
377                    rcpt_to.push(format!("<rcpt-{n}@padding-domain-for-length.example>"));
378                }
379            }
380
381            let mut folded = Vec::new();
382            sig.write_header(&mut folded);
383
384            let mut unfolded = Vec::new();
385            sig.write_value(&mut unfolded, false);
386            unfolded.extend_from_slice(b"\r\n");
387
388            dump_folded(
389                &format!("DKIM2-Signature ({} recipients)", extra_recipients + 2),
390                &folded,
391            );
392            assert_fold_is_transparent(b"DKIM2-Signature: ", &folded, &unfolded);
393            assert_eq!(
394                Signature::parse(value_of(&folded, b"DKIM2-Signature: ")).unwrap(),
395                sig
396            );
397        }
398    }
399
400    fn sample() -> Signature {
401        Signature {
402            i: 2,
403            m: 1,
404            t: 1700000000,
405            d: "example.com".to_string(),
406            s: vec![SignatureValue {
407                selector: "sel".to_string(),
408                a: Algorithm::Ed25519Sha256,
409                b: vec![1, 2, 3, 4],
410            }],
411            chain: ChainBinding::Envelope {
412                mail_from: "<a@example.com>".to_string(),
413                rcpt_to: vec!["<b@example.org>".to_string()],
414            },
415            n: None,
416            flags: Vec::new(),
417        }
418    }
419
420    #[test]
421    fn flags_emitted_in_order() {
422        let mut buf = Vec::new();
423        Signature {
424            flags: vec![Flag::DoNotModify, Flag::FeedHere],
425            ..sample()
426        }
427        .write_value(&mut buf, false);
428        let out = String::from_utf8(buf).unwrap();
429        assert!(out.contains("f=donotmodify,feedhere;"), "{out}");
430    }
431
432    #[test]
433    fn next_domain_emits_nd_and_omits_envelope() {
434        let mut buf = Vec::new();
435        Signature {
436            chain: ChainBinding::NextDomain("relay.example".to_string()),
437            ..sample()
438        }
439        .write_value(&mut buf, false);
440        let out = String::from_utf8(buf).unwrap();
441        assert!(out.contains("nd=relay.example"), "{out}");
442        assert!(!out.contains("mf="), "{out}");
443        assert!(!out.contains("rt="), "{out}");
444    }
445
446    #[test]
447    fn write_then_parse_round_trips() {
448        let sig = sample();
449        let mut buf = Vec::new();
450        sig.write_value(&mut buf, false);
451        assert_eq!(Signature::parse(&buf).unwrap(), sig);
452    }
453}