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