Commit
+285 -11 +/-7 browse
1 | diff --git a/Cargo.lock b/Cargo.lock |
2 | index 5bb94a1..a525bcc 100644 |
3 | --- a/Cargo.lock |
4 | +++ b/Cargo.lock |
5 | @@ -68,6 +68,12 @@ dependencies = [ |
6 | ] |
7 | |
8 | [[package]] |
9 | + name = "base64" |
10 | + version = "0.22.1" |
11 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
12 | + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
13 | + |
14 | + [[package]] |
15 | name = "bitflags" |
16 | version = "2.6.0" |
17 | source = "registry+https://github.com/rust-lang/crates.io-index" |
18 | @@ -307,6 +313,7 @@ name = "maitred" |
19 | version = "0.1.0" |
20 | dependencies = [ |
21 | "async-trait", |
22 | + "base64", |
23 | "bytes", |
24 | "crossbeam-deque", |
25 | "email_address", |
26 | @@ -314,6 +321,7 @@ dependencies = [ |
27 | "mail-parser", |
28 | "md5", |
29 | "smtp-proto", |
30 | + "stringprep", |
31 | "thiserror", |
32 | "tokio", |
33 | "tokio-stream", |
34 | @@ -551,6 +559,17 @@ dependencies = [ |
35 | ] |
36 | |
37 | [[package]] |
38 | + name = "stringprep" |
39 | + version = "0.1.5" |
40 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
41 | + checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" |
42 | + dependencies = [ |
43 | + "unicode-bidi", |
44 | + "unicode-normalization", |
45 | + "unicode-properties", |
46 | + ] |
47 | + |
48 | + [[package]] |
49 | name = "syn" |
50 | version = "2.0.72" |
51 | source = "registry+https://github.com/rust-lang/crates.io-index" |
52 | @@ -744,6 +763,12 @@ dependencies = [ |
53 | ] |
54 | |
55 | [[package]] |
56 | + name = "unicode-properties" |
57 | + version = "0.1.2" |
58 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
59 | + checksum = "52ea75f83c0137a9b98608359a5f1af8144876eb67bcb1ce837368e906a9f524" |
60 | + |
61 | + [[package]] |
62 | name = "url" |
63 | version = "2.5.2" |
64 | source = "registry+https://github.com/rust-lang/crates.io-index" |
65 | diff --git a/cmd/maitred-debug/src/main.rs b/cmd/maitred-debug/src/main.rs |
66 | index ec5c708..97457be 100644 |
67 | --- a/cmd/maitred-debug/src/main.rs |
68 | +++ b/cmd/maitred-debug/src/main.rs |
69 | @@ -1,7 +1,8 @@ |
70 | use tracing::Level; |
71 | |
72 | use maitred::{ |
73 | - mail_parser::Message, DeliveryError, DeliveryFunc, Error, MilterFunc, Server, SessionOptions, |
74 | + mail_parser::Message, DeliveryError, DeliveryFunc, Error, MilterFunc, PlainAuthFunc, Server, |
75 | + SessionOptions, |
76 | }; |
77 | |
78 | async fn print_message(message: Message<'static>) -> Result<(), DeliveryError> { |
79 | @@ -31,13 +32,21 @@ async fn main() -> Result<(), Error> { |
80 | .address("127.0.0.1:2525") |
81 | .with_milter(MilterFunc(|message: &Message<'static>| { |
82 | let message = message.clone(); |
83 | - Box::pin(async move { Ok(message.to_owned()) }) |
84 | + async move { Ok(message.to_owned()) } |
85 | })) |
86 | .with_delivery(DeliveryFunc(|message: &Message<'static>| { |
87 | let message = message.clone(); |
88 | - Box::pin(async move { print_message(message.to_owned()).await }) |
89 | + async move { print_message(message.to_owned()).await } |
90 | })) |
91 | - .with_session_opts(SessionOptions::default()); |
92 | + .with_session_opts(SessionOptions::default().plain_auth(PlainAuthFunc( |
93 | + |authcid: &str, authzid: &str, passwd: &str| { |
94 | + println!( |
95 | + "AUTHCID: {}, AUTHZID: {}, PASSWD: {}", |
96 | + authcid, authzid, passwd |
97 | + ); |
98 | + async move { Ok(()) } |
99 | + }, |
100 | + ))); |
101 | mail_server.listen().await?; |
102 | Ok(()) |
103 | } |
104 | diff --git a/maitred/Cargo.toml b/maitred/Cargo.toml |
105 | index 4ca527d..d91b51e 100644 |
106 | --- a/maitred/Cargo.toml |
107 | +++ b/maitred/Cargo.toml |
108 | @@ -5,6 +5,7 @@ edition = "2021" |
109 | |
110 | [dependencies] |
111 | async-trait = "0.1.81" |
112 | + base64 = "0.22.1" |
113 | bytes = "1.6.1" |
114 | crossbeam-deque = "0.8.5" |
115 | email_address = "0.2.9" |
116 | @@ -12,6 +13,7 @@ futures = "0.3.30" |
117 | mail-parser = { version = "0.9.3", features = ["serde", "serde_support"] } |
118 | md5 = "0.7.0" |
119 | smtp-proto = { version = "0.1.5", features = ["serde", "serde_support"] } |
120 | + stringprep = "0.1.5" |
121 | thiserror = "1.0.63" |
122 | tokio = { version = "1.39.2", features = ["full"] } |
123 | tokio-stream = { version = "0.1.15", features = ["full"] } |
124 | diff --git a/maitred/src/auth.rs b/maitred/src/auth.rs |
125 | new file mode 100644 |
126 | index 0000000..cea772e |
127 | --- /dev/null |
128 | +++ b/maitred/src/auth.rs |
129 | @@ -0,0 +1,181 @@ |
130 | + use std::{future::Future, string::FromUtf8Error}; |
131 | + |
132 | + use async_trait::async_trait; |
133 | + use base64::{prelude::*, DecodeError}; |
134 | + use stringprep::{saslprep, Error as SaslPrepError}; |
135 | + |
136 | + use crate::{smtp_response, Response}; |
137 | + use smtp_proto::Response as SmtpResponse; |
138 | + |
139 | + #[derive(Debug, thiserror::Error)] |
140 | + pub enum AuthError { |
141 | + #[error("Unauthorized")] |
142 | + Unauthorized, |
143 | + #[error("Input too long, maximum 255 characters")] |
144 | + InputTooLong, |
145 | + #[error("Not enough fields")] |
146 | + NotEnoughFields, |
147 | + #[error("Failed to decode authentication data: {0}")] |
148 | + Base64Decoding(#[from] DecodeError), |
149 | + #[error("Bad input: {0}")] |
150 | + SaslPrep(#[from] SaslPrepError), |
151 | + #[error("Not valid UTF8: {0}")] |
152 | + Utf8(#[from] FromUtf8Error), |
153 | + } |
154 | + |
155 | + #[allow(clippy::from_over_into)] |
156 | + impl Into<Response<String>> for AuthError { |
157 | + fn into(self) -> Response<String> { |
158 | + let message = self.to_string(); |
159 | + match self { |
160 | + AuthError::Unauthorized => { |
161 | + smtp_response!(400, 0, 0, 0, message) |
162 | + } |
163 | + AuthError::InputTooLong => { |
164 | + smtp_response!(500, 0, 0, 0, message) |
165 | + } |
166 | + AuthError::NotEnoughFields => { |
167 | + smtp_response!(500, 0, 0, 0, message) |
168 | + } |
169 | + AuthError::Base64Decoding(err) => { |
170 | + smtp_response!(500, 0, 0, 0, err.to_string()) |
171 | + } |
172 | + AuthError::SaslPrep(err) => { |
173 | + smtp_response!(500, 0, 0, 0, err.to_string()) |
174 | + } |
175 | + AuthError::Utf8(err) => { |
176 | + smtp_response!(500, 0, 0, 0, err.to_string()) |
177 | + } |
178 | + } |
179 | + } |
180 | + } |
181 | + |
182 | + #[async_trait] |
183 | + pub trait PlainAuth: Sync + Send { |
184 | + /// authenticate is passed the plaintext authcid, authzid, and passwd |
185 | + /// for the user. The implementer should return AuthError::Unauthorized |
186 | + /// if the credentials are invalid. |
187 | + async fn authenticate( |
188 | + &self, |
189 | + authcid: &str, |
190 | + authzid: &str, |
191 | + passwd: &str, |
192 | + ) -> Result<(), AuthError>; |
193 | + } |
194 | + |
195 | + pub struct PlainAuthFunc<F, T>(pub F) |
196 | + where |
197 | + F: Fn(&str, &str, &str) -> T + Sync + Send, |
198 | + T: Future<Output = Result<(), AuthError>> + Send; |
199 | + |
200 | + #[async_trait] |
201 | + impl<F, T> PlainAuth for PlainAuthFunc<F, T> |
202 | + where |
203 | + F: Fn(&str, &str, &str) -> T + Sync + Send, |
204 | + T: Future<Output = Result<(), AuthError>> + Send, |
205 | + { |
206 | + async fn authenticate( |
207 | + &self, |
208 | + authcid: &str, |
209 | + authzid: &str, |
210 | + passwd: &str, |
211 | + ) -> Result<(), AuthError> { |
212 | + let f = (self.0)(authcid, authzid, passwd); |
213 | + f.await |
214 | + } |
215 | + } |
216 | + |
217 | + /// Read a PLAIN SASL mechanism per RFC4616 |
218 | + /// The mechanism consists of a single message, a string of [UTF-8] |
219 | + /// encoded [Unicode] characters, from the client to the server. The |
220 | + /// client presents the authorization identity (identity to act as), |
221 | + /// followed by a NUL (U+0000) character, followed by the authentication |
222 | + /// identity (identity whose password will be used), followed by a NUL |
223 | + /// (U+0000) character, followed by the clear-text password. |
224 | + #[derive(Default)] |
225 | + pub(crate) struct AuthData { |
226 | + values: [String; 3], |
227 | + } |
228 | + |
229 | + impl AuthData { |
230 | + pub fn authcid(&self) -> String { |
231 | + self.values[0].clone() |
232 | + } |
233 | + |
234 | + pub fn authzid(&self) -> String { |
235 | + self.values[1].clone() |
236 | + } |
237 | + |
238 | + pub fn passwd(&self) -> String { |
239 | + self.values[2].clone() |
240 | + } |
241 | + } |
242 | + |
243 | + impl TryFrom<&str> for AuthData { |
244 | + type Error = AuthError; |
245 | + |
246 | + fn try_from(value: &str) -> Result<Self, Self::Error> { |
247 | + let decoded = BASE64_STANDARD.decode(value)?; |
248 | + let mut n = 0; |
249 | + let mut raw_data: [Vec<u8>; 3] = [ |
250 | + Vec::with_capacity(255), |
251 | + Vec::with_capacity(255), |
252 | + Vec::with_capacity(255), |
253 | + ]; |
254 | + for (i, ch) in decoded.iter().enumerate() { |
255 | + if *ch == b'\0' { |
256 | + if i > 0 { |
257 | + n += 1; |
258 | + } |
259 | + continue; |
260 | + } |
261 | + if raw_data[n].len() + 1 > 255 { |
262 | + return Err(AuthError::InputTooLong); |
263 | + } |
264 | + raw_data[n].push(*ch); |
265 | + } |
266 | + println!("N: {}", n); |
267 | + if n == 0 { |
268 | + return Err(AuthError::NotEnoughFields); |
269 | + } |
270 | + if raw_data[2].is_empty() { |
271 | + // if only an athcid and passwd were specified shift the value |
272 | + // from authzid. |
273 | + raw_data[2] = raw_data[1].clone(); |
274 | + raw_data[1] = raw_data[0].clone(); |
275 | + } |
276 | + // RFC4013 |
277 | + let sasl_authcid = String::from_utf8(raw_data[0].to_vec())?; |
278 | + let sasl_authcid = saslprep(&sasl_authcid)?; |
279 | + let sasl_authzid = String::from_utf8(raw_data[1].to_vec())?; |
280 | + let sasl_authzid = saslprep(&sasl_authzid)?; |
281 | + let sasl_passwd = String::from_utf8(raw_data[2].to_vec())?; |
282 | + let sasl_passwd = saslprep(&sasl_passwd)?; |
283 | + Ok(AuthData { |
284 | + values: [ |
285 | + sasl_authcid.to_string(), |
286 | + sasl_authzid.to_string(), |
287 | + sasl_passwd.to_string(), |
288 | + ], |
289 | + }) |
290 | + } |
291 | + } |
292 | + |
293 | + #[cfg(test)] |
294 | + mod tests { |
295 | + |
296 | + use super::*; |
297 | + use base64::engine::general_purpose::STANDARD; |
298 | + |
299 | + #[test] |
300 | + pub fn test_auth_data() { |
301 | + let data = AuthData::try_from(STANDARD.encode(b"\0hello\0world").as_str()).unwrap(); |
302 | + assert!(data.authcid() == "hello"); |
303 | + assert!(data.authzid() == "hello"); |
304 | + assert!(data.passwd() == "world"); |
305 | + let data = AuthData::try_from(STANDARD.encode(b"\0fuu\0bar\0baz").as_str()).unwrap(); |
306 | + assert!(data.authcid() == "fuu"); |
307 | + assert!(data.authzid() == "bar"); |
308 | + assert!(data.passwd() == "baz"); |
309 | + } |
310 | + } |
311 | diff --git a/maitred/src/lib.rs b/maitred/src/lib.rs |
312 | index d95857e..189649b 100644 |
313 | --- a/maitred/src/lib.rs |
314 | +++ b/maitred/src/lib.rs |
315 | @@ -47,6 +47,7 @@ |
316 | //! } |
317 | //! ``` |
318 | |
319 | + pub mod auth; |
320 | pub mod delivery; |
321 | pub mod expand; |
322 | pub mod milter; |
323 | @@ -65,6 +66,7 @@ mod worker; |
324 | use smtp_proto::Response as SmtpResponse; |
325 | use transport::Response; |
326 | |
327 | + pub use auth::{AuthError, PlainAuth, PlainAuthFunc}; |
328 | pub use delivery::{Delivery, DeliveryError, DeliveryFunc}; |
329 | pub use expand::{Expansion, ExpansionError, ExpansionFunc}; |
330 | pub use milter::{Milter, MilterError, MilterFunc}; |
331 | diff --git a/maitred/src/session.rs b/maitred/src/session.rs |
332 | index 75b48c8..00e2d53 100644 |
333 | --- a/maitred/src/session.rs |
334 | +++ b/maitred/src/session.rs |
335 | @@ -10,6 +10,7 @@ use mail_parser::{Message, MessageParser}; |
336 | use smtp_proto::{EhloResponse, Request, Response as SmtpResponse}; |
337 | use url::Host; |
338 | |
339 | + use crate::auth::{AuthData, PlainAuth}; |
340 | use crate::expand::Expansion; |
341 | use crate::smtp_response; |
342 | use crate::transport::Response; |
343 | @@ -68,6 +69,7 @@ pub struct SessionOptions { |
344 | pub greeting: String, |
345 | pub list_expansion: Option<Arc<dyn Expansion>>, |
346 | pub verification: Option<Arc<dyn Verify>>, |
347 | + pub plain_auth: Option<Arc<dyn PlainAuth>>, |
348 | } |
349 | |
350 | impl Default for SessionOptions { |
351 | @@ -80,6 +82,7 @@ impl Default for SessionOptions { |
352 | greeting: DEFAULT_GREETING.to_string(), |
353 | list_expansion: None, |
354 | verification: None, |
355 | + plain_auth: None, |
356 | } |
357 | } |
358 | } |
359 | @@ -120,6 +123,15 @@ impl SessionOptions { |
360 | self.verification = Some(Arc::new(verification)); |
361 | self |
362 | } |
363 | + |
364 | + pub fn plain_auth<T>(mut self, plain_auth: T) -> Self |
365 | + where |
366 | + T: crate::auth::PlainAuth + 'static, |
367 | + { |
368 | + self.capabilities |= smtp_proto::EXT_AUTH; |
369 | + self.plain_auth = Some(Arc::new(plain_auth)); |
370 | + self |
371 | + } |
372 | } |
373 | |
374 | /// Stateful connection that coresponds to a single SMTP session. |
375 | @@ -132,10 +144,10 @@ pub(crate) struct Session { |
376 | /// rcpt address |
377 | pub rcpt_to: Option<Vec<EmailAddress>>, |
378 | pub hostname: Option<Host>, |
379 | - /// If an active data transfer is taking place |
380 | + // If an active data transfer is taking place |
381 | data_transfer: Option<DataTransfer>, |
382 | initialized: Option<Mode>, |
383 | - |
384 | + auth_initialized: bool, |
385 | // session options |
386 | opts: Rc<SessionOptions>, |
387 | } |
388 | @@ -278,6 +290,9 @@ impl Session { |
389 | let mut resp = EhloResponse::new(format!("Hello {}", host)); |
390 | resp.capabilities = self.opts.capabilities; |
391 | resp.size = self.opts.maximum_size as usize; |
392 | + if self.opts.plain_auth.is_some() { |
393 | + resp.auth_mechanisms = smtp_proto::AUTH_PLAIN; |
394 | + } |
395 | Ok(vec![Response::Ehlo(resp)]) |
396 | } |
397 | Request::Lhlo { host } => { |
398 | @@ -348,10 +363,36 @@ impl Session { |
399 | "Starting BDAT data transfer".to_string() |
400 | )]) |
401 | } |
402 | + // After an AUTH command has been successfully completed, no more |
403 | + // AUTH commands may be issued in the same session. After a |
404 | + // successful AUTH command completes, a server MUST reject any |
405 | + // further AUTH commands with a 503 reply. |
406 | Request::Auth { |
407 | mechanism, |
408 | initial_response, |
409 | - } => todo!(), |
410 | + } => { |
411 | + if let Some(auth_fn) = &self.opts.plain_auth { |
412 | + if *mechanism != smtp_proto::AUTH_PLAIN { |
413 | + // only plain auth is supported |
414 | + return Err(smtp_response!(504, 5, 5, 4, "Auth Not Supported")); |
415 | + } |
416 | + let auth_data = |
417 | + AuthData::try_from(initial_response.as_str()).map_err(|e| e.into())?; |
418 | + |
419 | + auth_fn |
420 | + .authenticate(&auth_data.authcid(), &auth_data.authzid(), &auth_data.passwd()) |
421 | + .await |
422 | + .map_err(|e| e.into())?; |
423 | + |
424 | + tracing::info!("Successfully authenticated"); |
425 | + |
426 | + self.auth_initialized = true; |
427 | + |
428 | + Ok(vec![smtp_response!(250, 0, 0, 0, "OK")]) |
429 | + } else { |
430 | + Err(smtp_response!(504, 5, 5, 4, "Auth Not Supported")) |
431 | + } |
432 | + } |
433 | Request::Noop { value: _ } => { |
434 | self.check_initialized()?; |
435 | Ok(vec![smtp_response!(250, 0, 0, 0, "OK".to_string())]) |
436 | @@ -552,8 +593,11 @@ mod test { |
437 | )), |
438 | }]; |
439 | let session = Mutex::new( |
440 | - Session::default() |
441 | - .with_options(SessionOptions::default().our_hostname(EXAMPLE_HOSTNAME).into()), |
442 | + Session::default().with_options( |
443 | + SessionOptions::default() |
444 | + .our_hostname(EXAMPLE_HOSTNAME) |
445 | + .into(), |
446 | + ), |
447 | ); |
448 | process_all(&session, requests).await; |
449 | } |
450 | @@ -812,8 +856,11 @@ transport rather than the session. |
451 | }, |
452 | ]; |
453 | let session = Mutex::new( |
454 | - Session::default() |
455 | - .with_options(SessionOptions::default().our_hostname(EXAMPLE_HOSTNAME).into()), |
456 | + Session::default().with_options( |
457 | + SessionOptions::default() |
458 | + .our_hostname(EXAMPLE_HOSTNAME) |
459 | + .into(), |
460 | + ), |
461 | ); |
462 | process_all(&session, requests).await; |
463 | let session = session.lock().await; |
464 | diff --git a/scripts/swaks_test_auth.sh b/scripts/swaks_test_auth.sh |
465 | new file mode 100755 |
466 | index 0000000..a58b874 |
467 | --- /dev/null |
468 | +++ b/scripts/swaks_test_auth.sh |
469 | @@ -0,0 +1,8 @@ |
470 | + #!/bin/sh |
471 | + |
472 | + # Uses swaks: https://www.jetmore.org/john/code/swaks/ to do some basic SMTP |
473 | + # verification. Make sure you install the tool first! |
474 | + |
475 | + printf "Subject: Hello\nWorld\n" | swaks --to hello@example.com --auth PLAIN \ |
476 | + --auth-user hello --auth-password world --auth-plaintext --server localhost:2525 \ |
477 | + --pipeline --data - |