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