Commit
Author: mdecimus [mauro@stalw.art]
Hash: 0f7cc111f0386290c7fa071f475f742e300c1985
Timestamp: Sat, 18 May 2024 08:28:34 +0000 (5 months ago)

+136 -77 +/-7 browse
v0.4.0
1diff --git a/CHANGELOG.md b/CHANGELOG.md
2index 5a9e8c0..c87aa76 100644
3--- a/CHANGELOG.md
4+++ b/CHANGELOG.md
5 @@ -1,3 +1,8 @@
6+ mail-auth 0.4.0
7+ ================================
8+ - DKIM verification defaults to `strict` mode and ignores signatures with a `l=` tag to avoid exploits (see https://stalw.art/blog/dkim-exploit). Use `AuthenticatedMessage::parse_with_opts(&message, false)` to enable `relaxed` mode.
9+ - Parsed fields are now public.
10+
11 mail-auth 0.3.11
12 ================================
13 - Added: DKIM keypair generation for both RSA and Ed25519.
14 diff --git a/Cargo.toml b/Cargo.toml
15index 1899fe4..2ddd4af 100644
16--- a/Cargo.toml
17+++ b/Cargo.toml
18 @@ -1,7 +1,7 @@
19 [package]
20 name = "mail-auth"
21 description = "DKIM, ARC, SPF and DMARC library for Rust"
22- version = "0.3.11"
23+ version = "0.4.0"
24 edition = "2021"
25 authors = [ "Stalwart Labs <hello@stalw.art>"]
26 license = "Apache-2.0 OR MIT"
27 @@ -38,7 +38,7 @@ serde_json = "1.0"
28 sha1 = { version = "0.10", features = ["oid"], optional = true }
29 sha2 = { version = "0.10.6", features = ["oid"], optional = true }
30 hickory-resolver = { version = "0.24", features = ["dns-over-rustls", "dnssec-ring"] }
31- zip = "0.6.3"
32+ zip = "1.3.0"
33 rand = { version = "0.8.5", optional = true }
34
35 [dev-dependencies]
36 diff --git a/src/common/auth_results.rs b/src/common/auth_results.rs
37index cb79d54..d8c384a 100644
38--- a/src/common/auth_results.rs
39+++ b/src/common/auth_results.rs
40 @@ -344,6 +344,7 @@ impl AsAuthResult for Error {
41 Error::ArcBrokenChain => "broken ARC chain",
42 Error::NotAligned => "policy not aligned",
43 Error::InvalidRecordType => "invalid dns record type",
44+ Error::SignatureLength => "signature length ignored due to security risk",
45 });
46 header.push(')');
47 }
48 diff --git a/src/common/message.rs b/src/common/message.rs
49index e570a29..ec413db 100644
50--- a/src/common/message.rs
51+++ b/src/common/message.rs
52 @@ -16,6 +16,10 @@ use super::headers::{AuthenticatedHeader, Header, HeaderParser};
53
54 impl<'x> AuthenticatedMessage<'x> {
55 pub fn parse(raw_message: &'x [u8]) -> Option<Self> {
56+ Self::parse_with_opts(raw_message, true)
57+ }
58+
59+ pub fn parse_with_opts(raw_message: &'x [u8], strict: bool) -> Option<Self> {
60 let mut message = AuthenticatedMessage {
61 headers: Vec::new(),
62 from: Vec::new(),
63 @@ -35,90 +39,103 @@ impl<'x> AuthenticatedMessage<'x> {
64 let mut has_arc_errors = false;
65
66 for (header, value) in &mut headers {
67- let name = match header {
68- AuthenticatedHeader::Ds(name) => {
69- let signature = dkim::Signature::parse(value);
70- if let Ok(signature) = &signature {
71- let ha = HashAlgorithm::from(signature.a);
72- if !message
73- .body_hashes
74- .iter()
75- .any(|(c, h, l, _)| c == &signature.cb && h == &ha && l == &signature.l)
76- {
77- message
78- .body_hashes
79- .push((signature.cb, ha, signature.l, Vec::new()));
80- }
81- }
82- message
83- .dkim_headers
84- .push(Header::new(name, value, signature));
85- name
86- }
87- AuthenticatedHeader::Aar(name) => {
88- let results = arc::Results::parse(value);
89- if !has_arc_errors {
90- has_arc_errors = results.is_err();
91+ let name =
92+ match header {
93+ AuthenticatedHeader::Ds(name) => {
94+ let signature = match dkim::Signature::parse(value) {
95+ Ok(signature) if signature.l == 0 || !strict => {
96+ let ha = HashAlgorithm::from(signature.a);
97+ if !message.body_hashes.iter().any(|(c, h, l, _)| {
98+ c == &signature.cb && h == &ha && l == &signature.l
99+ }) {
100+ message.body_hashes.push((
101+ signature.cb,
102+ ha,
103+ signature.l,
104+ Vec::new(),
105+ ));
106+ }
107+ Ok(signature)
108+ }
109+ Ok(_) => Err(crate::Error::SignatureLength),
110+ Err(err) => Err(err),
111+ };
112+
113+ message
114+ .dkim_headers
115+ .push(Header::new(name, value, signature));
116+ name
117 }
118- message.aar_headers.push(Header::new(name, value, results));
119- name
120- }
121- AuthenticatedHeader::Ams(name) => {
122- let signature = arc::Signature::parse(value);
123-
124- if let Ok(signature) = &signature {
125- let ha = HashAlgorithm::from(signature.a);
126- if !message
127- .body_hashes
128- .iter()
129- .any(|(c, h, l, _)| c == &signature.cb && h == &ha && l == &signature.l)
130- {
131- message
132- .body_hashes
133- .push((signature.cb, ha, signature.l, Vec::new()));
134+ AuthenticatedHeader::Aar(name) => {
135+ let results = arc::Results::parse(value);
136+ if !has_arc_errors {
137+ has_arc_errors = results.is_err();
138 }
139- } else {
140- has_arc_errors = true;
141+ message.aar_headers.push(Header::new(name, value, results));
142+ name
143 }
144-
145- message
146- .ams_headers
147- .push(Header::new(name, value, signature));
148- name
149- }
150- AuthenticatedHeader::As(name) => {
151- let seal = arc::Seal::parse(value);
152- if !has_arc_errors {
153- has_arc_errors = seal.is_err();
154+ AuthenticatedHeader::Ams(name) => {
155+ let signature = match arc::Signature::parse(value) {
156+ Ok(signature) if signature.l == 0 || !strict => {
157+ let ha = HashAlgorithm::from(signature.a);
158+ if !message.body_hashes.iter().any(|(c, h, l, _)| {
159+ c == &signature.cb && h == &ha && l == &signature.l
160+ }) {
161+ message.body_hashes.push((
162+ signature.cb,
163+ ha,
164+ signature.l,
165+ Vec::new(),
166+ ));
167+ }
168+ Ok(signature)
169+ }
170+ Ok(_) => {
171+ has_arc_errors = true;
172+ Err(crate::Error::SignatureLength)
173+ }
174+ Err(err) => {
175+ has_arc_errors = true;
176+ Err(err)
177+ }
178+ };
179+
180+ message
181+ .ams_headers
182+ .push(Header::new(name, value, signature));
183+ name
184 }
185- message.as_headers.push(Header::new(name, value, seal));
186- name
187- }
188- AuthenticatedHeader::From(name) => {
189- match MessageStream::new(value).parse_address() {
190- HeaderValue::Address(Address::List(list)) => {
191- message.from.extend(
192- list.into_iter()
193- .filter_map(|a| a.address.map(|a| a.to_lowercase())),
194- );
195+ AuthenticatedHeader::As(name) => {
196+ let seal = arc::Seal::parse(value);
197+ if !has_arc_errors {
198+ has_arc_errors = seal.is_err();
199 }
200- HeaderValue::Address(Address::Group(group_list)) => {
201- message
202+ message.as_headers.push(Header::new(name, value, seal));
203+ name
204+ }
205+ AuthenticatedHeader::From(name) => {
206+ match MessageStream::new(value).parse_address() {
207+ HeaderValue::Address(Address::List(list)) => {
208+ message.from.extend(
209+ list.into_iter()
210+ .filter_map(|a| a.address.map(|a| a.to_lowercase())),
211+ );
212+ }
213+ HeaderValue::Address(Address::Group(group_list)) => message
214 .from
215 .extend(group_list.into_iter().flat_map(|group| {
216 group
217 .addresses
218 .into_iter()
219 .filter_map(|a| a.address.map(|a| a.to_lowercase()))
220- }))
221+ })),
222+ _ => (),
223 }
224- _ => (),
225- }
226
227- name
228- }
229- AuthenticatedHeader::Other(name) => name,
230- };
231+ name
232+ }
233+ AuthenticatedHeader::Other(name) => name,
234+ };
235
236 message.headers.push((name, value));
237 }
238 diff --git a/src/dkim/sign.rs b/src/dkim/sign.rs
239index 7e1c9ec..e3ef82c 100644
240--- a/src/dkim/sign.rs
241+++ b/src/dkim/sign.rs
242 @@ -106,6 +106,7 @@ impl<'a> Writable for SignableMessage<'a> {
243 #[cfg(test)]
244 #[allow(unused)]
245 pub mod test {
246+ use core::str;
247 use std::time::{Duration, Instant};
248
249 use hickory_resolver::proto::op::ResponseCode;
250 @@ -351,12 +352,12 @@ pub mod test {
251 )
252 .await;
253
254- dbg!("Test RSA-SHA256 simple/relaxed with fixed body length");
255+ dbg!("Test RSA-SHA256 simple/relaxed with fixed body length (relaxed)");
256 #[cfg(feature = "rust-crypto")]
257 let pk_rsa = RsaKey::<Sha256>::from_pkcs1_pem(RSA_PRIVATE_KEY).unwrap();
258 #[cfg(all(feature = "ring", not(feature = "rust-crypto")))]
259 let pk_rsa = RsaKey::<Sha256>::from_rsa_pem(RSA_PRIVATE_KEY).unwrap();
260- verify(
261+ verify_with_opts(
262 &resolver,
263 DkimSigner::from_key(pk_rsa)
264 .domain("example.com")
265 @@ -368,6 +369,28 @@ pub mod test {
266 .unwrap(),
267 &(message.to_string() + "\r\n----- Mailing list"),
268 Ok(()),
269+ false,
270+ )
271+ .await;
272+
273+ dbg!("Test RSA-SHA256 simple/relaxed with fixed body length (strict)");
274+ #[cfg(feature = "rust-crypto")]
275+ let pk_rsa = RsaKey::<Sha256>::from_pkcs1_pem(RSA_PRIVATE_KEY).unwrap();
276+ #[cfg(all(feature = "ring", not(feature = "rust-crypto")))]
277+ let pk_rsa = RsaKey::<Sha256>::from_rsa_pem(RSA_PRIVATE_KEY).unwrap();
278+ verify_with_opts(
279+ &resolver,
280+ DkimSigner::from_key(pk_rsa)
281+ .domain("example.com")
282+ .selector("default")
283+ .headers(["From", "To", "Subject"])
284+ .header_canonicalization(Canonicalization::Simple)
285+ .body_length(true)
286+ .sign(message.as_bytes())
287+ .unwrap(),
288+ &(message.to_string() + "\r\n----- Mailing list"),
289+ Err(super::Error::SignatureLength),
290+ true,
291 )
292 .await;
293
294 @@ -486,17 +509,18 @@ pub mod test {
295 .await;
296 }
297
298- pub async fn verify<'x>(
299+ pub async fn verify_with_opts<'x>(
300 resolver: &Resolver,
301 signature: Signature,
302 message_: &'x str,
303 expect: Result<(), super::Error>,
304+ strict: bool,
305 ) -> Vec<DkimOutput<'x>> {
306 let mut message = Vec::with_capacity(message_.len() + 100);
307 signature.write(&mut message, true);
308 message.extend_from_slice(message_.as_bytes());
309
310- let message = AuthenticatedMessage::parse(&message).unwrap();
311+ let message = AuthenticatedMessage::parse_with_opts(&message, strict).unwrap();
312 let dkim = resolver.verify_dkim(&message).await;
313
314 match (dkim.last().unwrap().result(), &expect) {
315 @@ -517,4 +541,13 @@ pub mod test {
316 })
317 .collect()
318 }
319+
320+ pub async fn verify<'x>(
321+ resolver: &Resolver,
322+ signature: Signature,
323+ message_: &'x str,
324+ expect: Result<(), super::Error>,
325+ ) -> Vec<DkimOutput<'x>> {
326+ verify_with_opts(resolver, signature, message_, expect, true).await
327+ }
328 }
329 diff --git a/src/dkim/verify.rs b/src/dkim/verify.rs
330index 31690f9..1fcf246 100644
331--- a/src/dkim/verify.rs
332+++ b/src/dkim/verify.rs
333 @@ -221,6 +221,7 @@ impl Resolver {
334 | Error::ArcInvalidCV
335 | Error::ArcHasHeaderTag
336 | Error::ArcBrokenChain
337+ | Error::SignatureLength
338 | Error::NotAligned => (record.rr & RR_OTHER) != 0,
339 };
340
341 diff --git a/src/lib.rs b/src/lib.rs
342index f49feef..7bee922 100644
343--- a/src/lib.rs
344+++ b/src/lib.rs
345 @@ -461,6 +461,7 @@ pub enum Error {
346 RevokedPublicKey,
347 IncompatibleAlgorithms,
348 SignatureExpired,
349+ SignatureLength,
350 DnsError(String),
351 DnsRecordNotFound(ResponseCode),
352 ArcChainTooLong,
353 @@ -501,6 +502,7 @@ impl Display for Error {
354 ),
355 Error::FailedVerification => write!(f, "Signature verification failed"),
356 Error::SignatureExpired => write!(f, "Signature expired"),
357+ Error::SignatureLength => write!(f, "Insecure 'l=' tag found in Signature"),
358 Error::FailedAuidMatch => write!(f, "AUID does not match domain name"),
359 Error::ArcInvalidInstance(i) => {
360 write!(f, "Invalid 'i={i}' value found in ARC header")