Skip to main content

mail_auth/dkim/
streaming.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//! Streaming DKIM signing API for reduced memory usage with large emails.
8
9use super::{DkimSigner, Done, Signature, canonicalize::BodyHasher, sign::SignableMessage};
10use crate::SystemTime;
11use crate::{
12    Error,
13    common::{
14        crypto::{HashContext, HashImpl, SigningKey},
15        headers::HeaderIterator,
16    },
17};
18use memchr::memmem;
19
20/// A streaming DKIM signer that allows signing messages in chunks.
21///
22/// This is useful when you want to avoid loading the entire message into
23/// memory before signing. Headers are buffered internally until the
24/// header/body boundary is detected, then body content is streamed through
25/// the hasher.
26///
27/// # Example
28///
29/// ```ignore
30/// let signer = DkimSigner::from_key(key)
31///     .domain("example.com")
32///     .selector("default")
33///     .headers(["From", "To", "Subject"]);
34///
35/// let mut stream = signer.sign_streaming();
36/// stream.write(b"From: sender@example.com\r\n");
37/// stream.write(b"To: recipient@example.com\r\n");
38/// stream.write(b"Subject: Test\r\n");
39/// stream.write(b"\r\n");
40/// stream.write(b"Body content here...");
41///
42/// let signature = stream.finish()?;
43/// ```
44pub struct DkimSigningStream<'a, T: SigningKey> {
45    template: Signature,
46    key: &'a T,
47    state: SigningState<<<T as SigningKey>::Hasher as HashImpl>::Context>,
48}
49
50enum SigningState<H> {
51    /// Accumulating headers until \r\n\r\n is found
52    ReadingHeaders { buffer: Vec<u8>, scanned: usize },
53    /// Header section buffered, now hashing body
54    HashingBody {
55        header_section: Vec<u8>,
56        body_hasher: BodyHasher<H>,
57    },
58    /// Finished or consumed
59    Done,
60}
61
62impl<T: SigningKey> DkimSigner<T, Done> {
63    /// Creates a streaming DKIM signer.
64    ///
65    /// Feed raw message data via [`DkimSigningStream::write`], then call
66    /// [`DkimSigningStream::finish`] to get the signature.
67    ///
68    /// Headers are buffered internally until the header/body boundary (`\r\n\r\n`)
69    /// is detected. After that, body content is streamed through the hasher
70    /// without additional buffering.
71    ///
72    /// # Example
73    ///
74    /// ```ignore
75    /// let mut stream = signer.sign_streaming();
76    /// for chunk in message_chunks {
77    ///     stream.write(chunk);
78    /// }
79    /// let signature = stream.finish()?;
80    /// ```
81    pub fn sign_streaming(&self) -> DkimSigningStream<'_, T> {
82        DkimSigningStream {
83            template: self.template.clone(),
84            key: &self.key,
85            state: SigningState::ReadingHeaders {
86                buffer: Vec::with_capacity(8192),
87                scanned: 0,
88            },
89        }
90    }
91}
92
93impl<T: SigningKey> DkimSigningStream<'_, T> {
94    /// Feed a chunk of raw message data to the signer.
95    ///
96    /// Data should be provided in order, starting with headers. The header/body
97    /// boundary (`\r\n\r\n`) is automatically detected.
98    ///
99    /// While reading headers, all data is buffered. Once the header/body boundary
100    /// is detected, subsequent body data is streamed directly to the hasher.
101    pub fn write(&mut self, chunk: &[u8]) {
102        match &mut self.state {
103            SigningState::ReadingHeaders { buffer, scanned } => {
104                buffer.extend_from_slice(chunk);
105
106                // Check for header/body boundary
107                let Some(boundary_pos) =
108                    find_header_boundary(&buffer[*scanned..]).map(|pos| *scanned + pos)
109                else {
110                    *scanned = buffer.len().saturating_sub(3);
111                    return;
112                };
113
114                let mut header_section = std::mem::take(buffer);
115
116                let mut body_hasher = BodyHasher::new(
117                    <T::Hasher as HashImpl>::hasher(),
118                    self.template.cb,
119                    if self.template.l > 0 { u64::MAX } else { 0 },
120                );
121
122                // Hash any body data that was in the buffer
123                body_hasher.write(&header_section[boundary_pos..]);
124                header_section.truncate(boundary_pos - 2);
125
126                self.state = SigningState::HashingBody {
127                    header_section,
128                    body_hasher,
129                };
130            }
131            SigningState::HashingBody { body_hasher, .. } => {
132                body_hasher.write(chunk);
133            }
134            SigningState::Done => {
135                // Ignore writes after finish
136            }
137        }
138    }
139
140    /// Finalize the signature.
141    ///
142    /// Consumes the stream and returns the DKIM signature. The current system
143    /// time is used for the `t=` timestamp.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if:
148    /// - No headers matching the signer's header list were found
149    /// - The cryptographic signing operation fails
150    /// - `finish()` was already called
151    pub fn finish(mut self) -> crate::Result<Signature>
152    where
153        <<T as SigningKey>::Hasher as HashImpl>::Context: HashContext,
154    {
155        let now = SystemTime::now()
156            .duration_since(SystemTime::UNIX_EPOCH)
157            .map(|d| d.as_secs())
158            .unwrap_or(0);
159
160        match std::mem::replace(&mut self.state, SigningState::Done) {
161            SigningState::ReadingHeaders { mut buffer, .. } => {
162                // Hash the body (may be empty)
163                let mut body_hasher = BodyHasher::new(
164                    <T::Hasher as HashImpl>::hasher(),
165                    self.template.cb,
166                    if self.template.l > 0 { u64::MAX } else { 0 },
167                );
168
169                // Never saw body boundary - check if we have any headers at all
170                // This handles the edge case of a message with no body
171                let header_len = match find_header_boundary(&buffer) {
172                    Some(boundary_pos) => {
173                        body_hasher.write(&buffer[boundary_pos..]);
174                        boundary_pos - 2
175                    }
176                    None => {
177                        // No boundary found - treat entire buffer as headers with empty body
178                        buffer.extend_from_slice(b"\r\n");
179                        buffer.len()
180                    }
181                };
182
183                let (hasher, body_len) = body_hasher.finish();
184                let body_hash = hasher.complete();
185
186                self.finish_with_parsed_data(&buffer[..header_len], body_hash, body_len, now)
187            }
188            SigningState::HashingBody {
189                header_section,
190                body_hasher,
191            } => {
192                let (hasher, body_len) = body_hasher.finish();
193                let body_hash = hasher.complete();
194                self.finish_with_parsed_data(&header_section, body_hash, body_len, now)
195            }
196            SigningState::Done => Err(Error::NoHeadersFound),
197        }
198    }
199
200    fn finish_with_parsed_data(
201        &self,
202        header_section: &[u8],
203        body_hash: crate::common::crypto::HashOutput,
204        body_len: u64,
205        now: u64,
206    ) -> crate::Result<Signature> {
207        // Filter headers to only those in template.h and build signed_headers list
208        let mut headers = Vec::with_capacity(self.template.h.len());
209        let mut found_headers = vec![false; self.template.h.len()];
210        let mut signed_headers = Vec::with_capacity(self.template.h.len());
211
212        for (name, value) in HeaderIterator::new(header_section) {
213            if let Some(pos) = self
214                .template
215                .h
216                .iter()
217                .position(|header| name.eq_ignore_ascii_case(header.as_bytes()))
218            {
219                headers.push((name, value));
220                found_headers[pos] = true;
221                signed_headers.push(std::str::from_utf8(name).unwrap_or_default().to_string());
222            }
223        }
224
225        if signed_headers.is_empty() {
226            return Err(Error::NoHeadersFound);
227        }
228
229        // Add any missing headers (in reverse order as per DKIM spec)
230        signed_headers.reverse();
231        for (header, found) in self.template.h.iter().zip(found_headers) {
232            if !found {
233                signed_headers.push(header.to_string());
234            }
235        }
236
237        // Build canonical headers
238        let canonical_headers = self.template.ch.canonical_headers(headers);
239
240        // Create Signature
241        let mut signature = self.template.clone();
242        signature.bh = body_hash.as_ref().to_vec();
243        signature.t = now;
244        signature.x = if signature.x > 0 {
245            now + signature.x
246        } else {
247            0
248        };
249        signature.h = signed_headers;
250        if signature.l > 0 {
251            signature.l = body_len;
252        }
253
254        // Sign
255        signature.b = self.key.sign(SignableMessage {
256            headers: canonical_headers,
257            signature: &signature,
258        })?;
259
260        Ok(signature)
261    }
262}
263
264/// Find the header/body boundary (\r\n\r\n) and return the position after it
265pub(crate) fn find_header_boundary(data: &[u8]) -> Option<usize> {
266    memmem::find(data, b"\r\n\r\n").map(|p| p + 4)
267}
268
269#[cfg(test)]
270#[allow(unused)]
271mod test {
272    use crate::{
273        common::crypto::{RsaKey, Sha256},
274        dkim::{Canonicalization, DkimSigner},
275    };
276
277    use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject};
278
279    const RSA_PRIVATE_KEY: &str = include_str!("../../resources/rsa-private.pem");
280
281    #[test]
282    fn streaming_sign_matches_regular_sign() {
283        // Test that sign_streaming() produces same body hash as sign()
284        let message = concat!(
285            "From: bill@example.com\r\n",
286            "To: jdoe@example.com\r\n",
287            "Subject: TPS Report\r\n",
288            "\r\n",
289            "I'm going to need those TPS reports ASAP. ",
290            "So, if you could do that, that'd be great.\r\n"
291        );
292
293        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
294            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
295        ))
296        .unwrap();
297
298        let signer = DkimSigner::from_key(pk_rsa)
299            .domain("example.com")
300            .selector("default")
301            .headers(["From", "To", "Subject"]);
302
303        // Regular sign
304        let sig1 = signer.sign(message.as_bytes()).unwrap();
305
306        // Streaming sign - single chunk
307        let mut stream = signer.sign_streaming();
308        stream.write(message.as_bytes());
309        let sig2 = stream.finish().unwrap();
310
311        // Body hashes should match
312        assert_eq!(sig1.bh, sig2.bh, "Body hashes should match");
313        // Signed headers should match
314        assert_eq!(sig1.h, sig2.h, "Signed headers should match");
315        // Signature should match (same key, same content, same body hash = same signature)
316        assert_eq!(sig1.b, sig2.b, "Signatures should match");
317    }
318
319    #[test]
320    fn streaming_sign_multiple_chunks() {
321        let header = "From: bill@example.com\r\nTo: jdoe@example.com\r\nSubject: Test\r\n\r\n";
322        let body = "Hello World! This is the body.\r\n";
323
324        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
325            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
326        ))
327        .unwrap();
328
329        let signer = DkimSigner::from_key(pk_rsa)
330            .domain("example.com")
331            .selector("default")
332            .headers(["From", "To", "Subject"]);
333
334        // Reference: single chunk
335        let full_message = format!("{}{}", header, body);
336        let reference_sig = signer.sign(full_message.as_bytes()).unwrap();
337
338        // Streaming: multiple chunks
339        let mut stream = signer.sign_streaming();
340        stream.write(header.as_bytes());
341        stream.write(body.as_bytes());
342        let streamed_sig = stream.finish().unwrap();
343
344        assert_eq!(
345            reference_sig.bh, streamed_sig.bh,
346            "Body hashes should match"
347        );
348    }
349
350    #[test]
351    fn streaming_sign_chunked_body() {
352        let message = concat!(
353            "From: test@example.com\r\n",
354            "Subject: Chunked Test\r\n",
355            "\r\n",
356            "Line 1\r\n",
357            "Line 2\r\n",
358            "Line 3\r\n",
359        );
360
361        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
362            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
363        ))
364        .unwrap();
365
366        let signer = DkimSigner::from_key(pk_rsa)
367            .domain("example.com")
368            .selector("default")
369            .headers(["From", "Subject"]);
370
371        // Reference
372        let reference_sig = signer.sign(message.as_bytes()).unwrap();
373
374        // Chunked at various sizes
375        for chunk_size in [1, 2, 5, 10, 20] {
376            let mut stream = signer.sign_streaming();
377            for chunk in message.as_bytes().chunks(chunk_size) {
378                stream.write(chunk);
379            }
380            let streamed_sig = stream.finish().unwrap();
381
382            assert_eq!(
383                reference_sig.bh, streamed_sig.bh,
384                "Body hash mismatch at chunk_size={}",
385                chunk_size
386            );
387        }
388    }
389
390    #[test]
391    fn streaming_sign_split_header_boundary() {
392        // Test where \r\n\r\n is split across chunks
393        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
394            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
395        ))
396        .unwrap();
397
398        let signer = DkimSigner::from_key(pk_rsa)
399            .domain("example.com")
400            .selector("default")
401            .headers(["From", "Subject"]);
402
403        // Reference
404        let message = "From: test@example.com\r\nSubject: Test\r\n\r\nBody";
405        let reference_sig = signer.sign(message.as_bytes()).unwrap();
406
407        // Split right at the boundary
408        let mut stream = signer.sign_streaming();
409        stream.write(b"From: test@example.com\r\n");
410        stream.write(b"Subject: Test\r\n");
411        stream.write(b"\r\n"); // The second \r\n completing the boundary
412        stream.write(b"Body");
413        let streamed_sig = stream.finish().unwrap();
414
415        assert_eq!(reference_sig.bh, streamed_sig.bh);
416    }
417
418    #[test]
419    fn streaming_sign_empty_body() {
420        let message = "From: test@example.com\r\nSubject: Empty\r\n\r\n";
421        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
422            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
423        ))
424        .unwrap();
425
426        let signer = DkimSigner::from_key(pk_rsa)
427            .domain("example.com")
428            .selector("default")
429            .headers(["From", "Subject"]);
430
431        let reference_sig = signer.sign(message.as_bytes()).unwrap();
432
433        let mut stream = signer.sign_streaming();
434        stream.write(message.as_bytes());
435        let streamed_sig = stream.finish().unwrap();
436
437        assert_eq!(reference_sig.bh, streamed_sig.bh);
438    }
439
440    #[test]
441    fn streaming_sign_simple_canonicalization() {
442        let message = concat!(
443            "From: test@example.com\r\n",
444            "Subject: Simple Canon Test\r\n",
445            "\r\n",
446            "Body with   spaces\r\n",
447        );
448
449        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
450            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
451        ))
452        .unwrap();
453
454        let signer = DkimSigner::from_key(pk_rsa)
455            .domain("example.com")
456            .selector("default")
457            .headers(["From", "Subject"])
458            .header_canonicalization(Canonicalization::Simple)
459            .body_canonicalization(Canonicalization::Simple);
460
461        let reference_sig = signer.sign(message.as_bytes()).unwrap();
462
463        let mut stream = signer.sign_streaming();
464        stream.write(message.as_bytes());
465        let streamed_sig = stream.finish().unwrap();
466
467        assert_eq!(reference_sig.bh, streamed_sig.bh);
468        assert_eq!(reference_sig.b, streamed_sig.b);
469    }
470
471    #[test]
472    fn streaming_sign_folded_headers() {
473        // Test with folded (multi-line) headers
474        let message = concat!(
475            "From: test@example.com\r\n",
476            "Subject: This is a very long subject line that\r\n",
477            " continues on the next line\r\n",
478            "\r\n",
479            "Body\r\n",
480        );
481        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
482            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
483        ))
484        .unwrap();
485
486        let signer = DkimSigner::from_key(pk_rsa)
487            .domain("example.com")
488            .selector("default")
489            .headers(["From", "Subject"]);
490
491        let reference_sig = signer.sign(message.as_bytes()).unwrap();
492
493        let mut stream = signer.sign_streaming();
494        stream.write(message.as_bytes());
495        let streamed_sig = stream.finish().unwrap();
496
497        assert_eq!(reference_sig.bh, streamed_sig.bh);
498    }
499
500    #[test]
501    fn streaming_sign_no_matching_headers_error() {
502        let message = "X-Custom: value\r\n\r\nBody\r\n";
503
504        let pk_rsa = RsaKey::<Sha256>::from_key_der(PrivateKeyDer::Pkcs1(
505            PrivatePkcs1KeyDer::from_pem_slice(RSA_PRIVATE_KEY.as_bytes()).unwrap(),
506        ))
507        .unwrap();
508
509        let signer = DkimSigner::from_key(pk_rsa)
510            .domain("example.com")
511            .selector("default")
512            .headers(["From", "Subject"]); // These headers don't exist in message
513
514        let mut stream = signer.sign_streaming();
515        stream.write(message.as_bytes());
516        let result = stream.finish();
517
518        assert!(matches!(result, Err(crate::Error::NoHeadersFound)));
519    }
520}