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