Commit
+136 -77 +/-7 browse
1 | diff --git a/CHANGELOG.md b/CHANGELOG.md |
2 | index 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 |
15 | index 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 |
37 | index 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 |
49 | index 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 |
239 | index 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 |
330 | index 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 |
342 | index 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") |