Author: Kevin Schoon [me@kevinschoon.com]
Hash: 7cdf752ffcd2d5f5ae04a9e507f9301d276ce34f
Timestamp: Sun, 01 Sep 2024 18:56:19 +0000 (1 month ago)

+285 -11 +/-7 browse
add support for AUTH PLAIN
1diff --git a/Cargo.lock b/Cargo.lock
2index 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
66index 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
105index 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
125new file mode 100644
126index 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
312index 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
332index 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
465new file mode 100755
466index 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 -