From 328f02eda943b07cf95a71e07ef4704c64d1b9de Mon Sep 17 00:00:00 2001 From: Simhadri Saratchandra Date: Thu, 16 Jul 2026 12:25:52 +0530 Subject: [PATCH] feat: support CRAM-MD5 authentication (RFC 2195) Advertise and accept AUTH CRAM-MD5 alongside PLAIN and LOGIN. The existing [[server.auth]] username/password credentials serve all three mechanisms, so no config changes are needed. - smtp: new AuthenticatingCramMd5 session state, challenge generation (, unique per session to prevent digest replay), and an on_auth_cram_md5 callback with a supports_cram_md5 capability gate so implementations that don't support the mechanism neither advertise it nor accept it (504 instead). - hedwig: verify the client digest by recomputing HMAC-MD5(password, challenge) from the stored password, compared in constant time. hmac and md-5 were already in the tree as transitive deps. - Harden the AUTH exchange for all mechanisms: cancellation with '*' (RFC 4954) and malformed base64/response data now get a 501 and return the session to Greeted instead of tearing down the connection. Verified end-to-end with Python smtplib forcing CRAM-MD5 against a live server: 235 on correct password, 535 on wrong, mail accepted after auth. --- Cargo.lock | 2 + README.md | 2 +- docs/PRODUCTION_HARDENING.md | 4 +- smtp-server/Cargo.toml | 3 + smtp-server/src/callbacks.rs | 117 ++++++++++ smtp/src/lib.rs | 407 +++++++++++++++++++++++++++++++++-- smtp/src/parser.rs | 38 ++++ 7 files changed, 558 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73eb795..87e2ed0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,6 +1123,7 @@ dependencies = [ "email-address-parser", "futures", "hickory-resolver 0.24.2", + "hmac", "hostname 0.4.0", "humantime-serde", "huml-rs", @@ -1131,6 +1132,7 @@ dependencies = [ "mail-auth", "mail-parser 0.9.4", "mailparse", + "md-5", "memchr", "miette", "moka", diff --git a/README.md b/README.md index 24c3639..4a67729 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - Async SMTP relay with persistent filesystem queue - DKIM signing (RSA and Ed25519) - MTA-STS (RFC 8461) — automatic TLS policy enforcement for outbound delivery -- SMTP authentication (multiple users) +- SMTP authentication (PLAIN, LOGIN, CRAM-MD5; multiple users) - TLS/STARTTLS support with multiple listeners - Per-domain rate limiting - Prometheus metrics and health checks diff --git a/docs/PRODUCTION_HARDENING.md b/docs/PRODUCTION_HARDENING.md index 698f17e..c098137 100644 --- a/docs/PRODUCTION_HARDENING.md +++ b/docs/PRODUCTION_HARDENING.md @@ -162,9 +162,9 @@ Bounce immediately on 5xx — these are permanent failures. ### 12. AUTH advertised without TLS -**Problem:** When auth is enabled, `AUTH PLAIN LOGIN` is advertised even on plaintext listeners. Clients may send credentials in cleartext over the wire. +**Problem:** When auth is enabled, `AUTH PLAIN LOGIN CRAM-MD5` is advertised even on plaintext listeners. Clients using PLAIN or LOGIN may send credentials in cleartext over the wire (CRAM-MD5 sends only an HMAC digest, not the password itself). -**Location:** `smtp/src/lib.rs` — EHLO response includes `AUTH PLAIN LOGIN` when `auth_enabled` is true, regardless of TLS state. +**Location:** `smtp/src/lib.rs` — EHLO response includes `AUTH PLAIN LOGIN CRAM-MD5` when `auth_enabled` is true, regardless of TLS state. **Fix:** Only advertise `AUTH` after TLS is established (either implicit TLS listener or post-STARTTLS). This is required by RFC 4954 §4: > A server MUST NOT advertise the AUTH extension on a non-TLS connection if the server requires TLS for authentication. diff --git a/smtp-server/Cargo.toml b/smtp-server/Cargo.toml index 36b51ae..848de7f 100644 --- a/smtp-server/Cargo.toml +++ b/smtp-server/Cargo.toml @@ -31,6 +31,9 @@ tokio-rustls = { version = "0.26.0", features = [ "ring", ], default-features = false } subtle = "2.6.1" +# For CRAM-MD5 verification; both already in the tree as transitive deps. +hmac = "0.12" +md-5 = "0.10" tempfile = "3.13.0" pkcs8 = "0.10.0" tracing = "0.1" diff --git a/smtp-server/src/callbacks.rs b/smtp-server/src/callbacks.rs index 7150ac4..49160a6 100644 --- a/smtp-server/src/callbacks.rs +++ b/smtp-server/src/callbacks.rs @@ -25,6 +25,21 @@ use crate::{ worker::{self, Job, Worker}, }; +/// Computes the lowercase hex HMAC-MD5 digest a client must send for the +/// given password and challenge (RFC 2195). +fn cram_md5_digest(password: &str, challenge: &str) -> String { + use hmac::{Hmac, Mac}; + // HMAC accepts keys of any length, so new_from_slice cannot fail. + let mut mac = + Hmac::::new_from_slice(password.as_bytes()).expect("HMAC accepts any key length"); + mac.update(challenge.as_bytes()); + mac.finalize() + .into_bytes() + .iter() + .map(|b| format!("{:02x}", b)) + .collect() +} + /// The Callbacks struct holds the configuration, storage, and sender channel. pub struct Callbacks { cfg: Cfg, @@ -312,6 +327,33 @@ impl SmtpCallbacks for Callbacks { Ok(false) } + fn supports_cram_md5(&self) -> bool { + true + } + + // Handles a CRAM-MD5 challenge response by recomputing the digest from + // the stored password (RFC 2195). + async fn on_auth_cram_md5( + &self, + username: &str, + challenge: &str, + digest: &str, + ) -> Result { + if self.cfg.server.auth.is_none() { + return Ok(false); + } + + let auth_mapping = self.auth_mapping.lock().await; + if let Some(password) = auth_mapping.get(username) { + let expected = cram_md5_digest(password, challenge); + // RFC 2195 mandates lowercase hex, but accept uppercase too. + let is_valid = + constant_time_eq(expected.as_bytes(), digest.to_ascii_lowercase().as_bytes()); + return Ok(is_valid); + } + Ok(false) + } + // Handles the MAIL FROM command. async fn on_mail_from( &self, @@ -1237,6 +1279,81 @@ mod tests { } } + #[test] + fn test_cram_md5_digest_rfc2195_vector() { + // Known-answer test straight from RFC 2195 section 2. + assert_eq!( + cram_md5_digest( + "tanstaaftanstaaf", + "<1896.697170952@postoffice.reston.mci.net>" + ), + "b913a602c7eda7a495b4e6e7334d3890" + ); + } + + #[tokio::test] + async fn test_on_auth_cram_md5() { + let mut cfg = create_test_config(); + cfg.server.auth = Some(vec![CfgAuth { + username: "testuser".to_string(), + password: "testpass".to_string(), + }]); + + let storage = Arc::new(MockStorage {}); + let (sender, receiver) = async_channel::bounded(100); + let (callbacks, worker_handles, _mta_sts_resolver) = + Callbacks::new(storage, sender, receiver, cfg) + .await + .expect("callbacks should initialize"); + + let challenge = "<42.1234567890@test.local>"; + let digest = cram_md5_digest("testpass", challenge); + + // Correct digest authenticates. + assert!(callbacks + .on_auth_cram_md5("testuser", challenge, &digest) + .await + .unwrap()); + // Uppercase hex is tolerated. + assert!(callbacks + .on_auth_cram_md5("testuser", challenge, &digest.to_uppercase()) + .await + .unwrap()); + // Digest computed from the wrong password is rejected. + let wrong = cram_md5_digest("wrongpass", challenge); + assert!(!callbacks + .on_auth_cram_md5("testuser", challenge, &wrong) + .await + .unwrap()); + // A digest for a different challenge (replay) is rejected. + let replayed = cram_md5_digest("testpass", "<1.1@test.local>"); + assert!(!callbacks + .on_auth_cram_md5("testuser", challenge, &replayed) + .await + .unwrap()); + // Unknown user is rejected. + assert!(!callbacks + .on_auth_cram_md5("nouser", challenge, &digest) + .await + .unwrap()); + + for handle in worker_handles { + // Abort the spawned worker to avoid leaking background work between tests. + handle.abort(); + } + } + + #[tokio::test] + async fn test_on_auth_cram_md5_no_config() { + let callbacks = create_test_callbacks(None).await; + let digest = cram_md5_digest("pass", "<1.1@test.local>"); + let result = callbacks + .on_auth_cram_md5("user", "<1.1@test.local>", &digest) + .await; + assert!(result.is_ok()); + assert!(!result.unwrap()); + } + #[tokio::test] async fn test_on_mail_from_no_domain_filters() { // Test on_mail_from when no domain filters exist (line 194) diff --git a/smtp/src/lib.rs b/smtp/src/lib.rs index ff9de2e..43a2347 100644 --- a/smtp/src/lib.rs +++ b/smtp/src/lib.rs @@ -178,6 +178,8 @@ pub enum SessionState { Greeted, AuthenticatingUsername, AuthenticatingPassword(String), + /// Waiting for the client's CRAM-MD5 response; holds the issued challenge. + AuthenticatingCramMd5(String), Authenticated, ReceivingMailFrom, ReceivingRcptTo, @@ -206,6 +208,37 @@ pub trait SmtpCallbacks: Send + Sync { /// `Ok(true)` if authentication is successful, `Ok(false)` or `Err` otherwise. async fn on_auth(&self, username: &str, password: &str) -> Result; + /// Whether this implementation supports CRAM-MD5. The mechanism is + /// advertised in EHLO and accepted only when this returns true, so + /// implementations overriding `on_auth_cram_md5` must also override + /// this to return true. + fn supports_cram_md5(&self) -> bool { + false + } + + /// Called when a client responds to a CRAM-MD5 challenge (RFC 2195). + /// + /// The implementation must recompute `HMAC-MD5(password, challenge)` for + /// the user's stored password and compare it against `digest`. The + /// default implementation rejects every attempt; override it (together + /// with `supports_cram_md5`) to support CRAM-MD5. + /// + /// # Arguments + /// * `username` - The username from the client's response. + /// * `challenge` - The exact challenge string previously sent to the client. + /// * `digest` - The hex-encoded HMAC-MD5 digest from the client's response. + /// + /// # Returns + /// `Ok(true)` if authentication is successful, `Ok(false)` or `Err` otherwise. + async fn on_auth_cram_md5( + &self, + _username: &str, + _challenge: &str, + _digest: &str, + ) -> Result { + Ok(false) + } + /// Called when a client sends a MAIL FROM command. /// /// # Arguments @@ -539,7 +572,11 @@ impl SmtpServer { } // Add AUTH support if enabled. if self.auth_enabled { - response.push_str("250-AUTH PLAIN LOGIN\r\n"); + if self.callbacks.supports_cram_md5() { + response.push_str("250-AUTH PLAIN LOGIN CRAM-MD5\r\n"); + } else { + response.push_str("250-AUTH PLAIN LOGIN\r\n"); + } } response.push_str("250 OK\r\n"); stream.write_line(response.as_bytes()).await?; @@ -556,10 +593,51 @@ impl SmtpServer { session.state = SessionState::AuthenticatingUsername; stream.write_line(b"334 VXNlcm5hbWU6\r\n").await?; } + (SessionState::Greeted, SmtpCommand::AuthCramMd5) => { + if !self.callbacks.supports_cram_md5() { + stream + .write_line(b"504 Unrecognized authentication type\r\n") + .await?; + return Ok(false); + } + let challenge = self.generate_cram_md5_challenge(); + let encoded = BASE64_STANDARD.encode(&challenge); + session.state = SessionState::AuthenticatingCramMd5(challenge); + stream + .write_line(format!("334 {}\r\n", encoded).as_bytes()) + .await?; + } + // RFC 4954 §4: a client may cancel an in-progress AUTH exchange + // by sending "*"; the server must answer 501 and keep the + // session usable. + ( + SessionState::AuthenticatingUsername + | SessionState::AuthenticatingPassword(_) + | SessionState::AuthenticatingCramMd5(_), + SmtpCommand::AuthUsername(ref line) + | SmtpCommand::AuthPassword(ref line) + | SmtpCommand::AuthCramMd5Response(ref line), + ) if line == "*" => { + session.state = SessionState::Greeted; + stream + .write_line(b"501 Authentication cancelled\r\n") + .await?; + } + ( + SessionState::AuthenticatingCramMd5(challenge), + SmtpCommand::AuthCramMd5Response(response), + ) => { + self.handle_auth_cram_md5(session, challenge.clone(), response, stream) + .await?; + } (SessionState::AuthenticatingUsername, SmtpCommand::AuthUsername(username)) => { - let decoded_username = decode_base64(&username)?; - session.state = SessionState::AuthenticatingPassword(decoded_username); - stream.write_line(b"334 UGFzc3dvcmQ6\r\n").await?; + match decode_base64(&username) { + Ok(decoded_username) => { + session.state = SessionState::AuthenticatingPassword(decoded_username); + stream.write_line(b"334 UGFzc3dvcmQ6\r\n").await?; + } + Err(_) => self.reject_malformed_auth(session, stream).await?, + } } ( SessionState::AuthenticatingPassword(username), @@ -624,18 +702,35 @@ impl SmtpServer { Ok(false) } + /// Rejects a malformed AUTH exchange with 501 and returns the session to + /// `Greeted`, keeping the connection usable for another attempt instead + /// of tearing it down. + async fn reject_malformed_auth( + &self, + session: &mut SmtpSession, + stream: &mut Box, + ) -> Result<()> { + session.state = SessionState::Greeted; + stream + .write_line(b"501 Invalid authentication data\r\n") + .await?; + Ok(()) + } + async fn handle_auth_plain( &self, session: &mut SmtpSession, auth_data: String, stream: &mut Box, ) -> Result<()> { - let decoded = decode_base64(&auth_data)?; - let parts: Vec<&str> = decoded.split('\0').collect(); - if parts.len() != 3 { - bail!("Invalid AUTH PLAIN data"); - } - self.handle_authentication(session, parts[1], parts[2], stream) + let credentials = decode_base64(&auth_data).ok().and_then(|decoded| { + let parts: Vec<&str> = decoded.split('\0').collect(); + (parts.len() == 3).then(|| (parts[1].to_string(), parts[2].to_string())) + }); + let Some((username, password)) = credentials else { + return self.reject_malformed_auth(session, stream).await; + }; + self.handle_authentication(session, &username, &password, stream) .await } @@ -646,11 +741,53 @@ impl SmtpServer { password: String, stream: &mut Box, ) -> Result<()> { - let decoded_password = decode_base64(&password)?; + let Ok(decoded_password) = decode_base64(&password) else { + return self.reject_malformed_auth(session, stream).await; + }; self.handle_authentication(session, &username, &decoded_password, stream) .await } + async fn handle_auth_cram_md5( + &self, + session: &mut SmtpSession, + challenge: String, + response: String, + stream: &mut Box, + ) -> Result<()> { + // RFC 2195: the response is " ". The digest + // never contains spaces, so split on the last one to tolerate + // usernames that do. + let credentials = decode_base64(&response).ok().and_then(|decoded| { + decoded + .rsplit_once(' ') + .map(|(u, d)| (u.to_string(), d.to_string())) + }); + let Some((username, digest)) = credentials else { + return self.reject_malformed_auth(session, stream).await; + }; + let result = self + .callbacks + .on_auth_cram_md5(&username, &challenge, &digest) + .await; + self.finish_authentication(session, result, stream).await + } + + /// Generates a unique RFC 2195 challenge (``). + /// Uniqueness per session is what prevents replaying a captured digest; + /// the counter plus wall-clock nanoseconds guarantees it without a + /// dependency on a randomness crate. + fn generate_cram_md5_challenge(&self) -> String { + static CHALLENGE_COUNTER: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + let seq = CHALLENGE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("<{}.{}@{}>", seq, nanos, self.hostname) + } + async fn handle_authentication( &self, session: &mut SmtpSession, @@ -658,7 +795,17 @@ impl SmtpServer { password: &str, stream: &mut Box, ) -> Result<()> { - match self.callbacks.on_auth(username, password).await { + let result = self.callbacks.on_auth(username, password).await; + self.finish_authentication(session, result, stream).await + } + + async fn finish_authentication( + &self, + session: &mut SmtpSession, + result: Result, + stream: &mut Box, + ) -> Result<()> { + match result { Ok(true) => { session.state = SessionState::Authenticated; stream @@ -975,4 +1122,240 @@ mod tests { let received = run_chunked_data_session(sent, 7).await; assert_eq!(received, expected); } + + /// Accepts CRAM-MD5 attempts whose digest equals `accept_digest`, and + /// records every attempt so the test can assert what reached the callback. + struct CramCallbacks { + attempts: StdMutex>, + accept_digest: String, + } + + #[async_trait] + impl SmtpCallbacks for CramCallbacks { + async fn on_ehlo(&self, _domain: &str) -> Result<(), SmtpError> { + Ok(()) + } + async fn on_auth(&self, _username: &str, _password: &str) -> Result { + Ok(false) + } + fn supports_cram_md5(&self) -> bool { + true + } + async fn on_auth_cram_md5( + &self, + username: &str, + challenge: &str, + digest: &str, + ) -> Result { + self.attempts.lock().unwrap().push(( + username.to_string(), + challenge.to_string(), + digest.to_string(), + )); + Ok(digest == self.accept_digest) + } + async fn on_mail_from( + &self, + _from_command: &parser::MailFromCommand, + ) -> Result<(), SmtpError> { + Ok(()) + } + async fn on_rcpt_to(&self, _to: &str) -> Result<(), SmtpError> { + Ok(()) + } + async fn on_data(&self, _email: Email) -> Result<(), SmtpError> { + Ok(()) + } + } + + #[tokio::test] + async fn test_auth_cram_md5_flow() { + let good_digest = "64b2a43c1f6ed6806a980914e23e75f0"; + let callbacks = Arc::new(CramCallbacks { + attempts: StdMutex::new(Vec::new()), + accept_digest: good_digest.to_string(), + }); + let server = SmtpServer { + callbacks: callbacks.clone(), + auth_enabled: true, + max_message_size: DEFAULT_MAX_MESSAGE_SIZE, + cmd_timeout: Duration::from_secs(5), + data_timeout: Duration::from_secs(5), + hostname: "test.local".to_string(), + }; + + let (client, server_side) = tokio::io::duplex(4096); + let mut server_stream: Box = Box::new(server_side); + let server_task = + tokio::spawn(async move { server.handle_client(&mut server_stream).await }); + + let (mut reader, mut writer) = tokio::io::split(client); + + async fn read_reply( + reader: &mut R, + expected: &str, + ) -> String { + let mut buf = vec![0u8; 512]; + let n = reader.read(&mut buf).await.unwrap(); + let reply = String::from_utf8_lossy(&buf[..n]).to_string(); + assert!( + reply.contains(expected), + "expected {expected:?}, got {reply:?}" + ); + reply + } + + /// Requests a CRAM-MD5 challenge and returns it decoded. + async fn request_challenge( + reader: &mut R, + writer: &mut W, + ) -> String { + writer.write_all(b"AUTH CRAM-MD5\r\n").await.unwrap(); + let mut buf = vec![0u8; 512]; + let n = reader.read(&mut buf).await.unwrap(); + let reply = String::from_utf8_lossy(&buf[..n]).to_string(); + let encoded = reply + .strip_prefix("334 ") + .unwrap_or_else(|| panic!("expected 334 challenge, got {reply:?}")) + .trim(); + String::from_utf8(BASE64_STANDARD.decode(encoded).unwrap()).unwrap() + } + + read_reply(&mut reader, "220").await; + writer.write_all(b"EHLO client.test\r\n").await.unwrap(); + read_reply(&mut reader, "250-AUTH PLAIN LOGIN CRAM-MD5").await; + + // A wrong digest is rejected and the session returns to Greeted. + let challenge1 = request_challenge(&mut reader, &mut writer).await; + assert!( + challenge1.starts_with('<') && challenge1.ends_with("@test.local>"), + "malformed challenge: {challenge1:?}" + ); + let bad = BASE64_STANDARD.encode(format!("alice {}", "0".repeat(32))); + writer + .write_all(format!("{}\r\n", bad).as_bytes()) + .await + .unwrap(); + read_reply(&mut reader, "535").await; + + // Cancelling the exchange with "*" (RFC 4954) gets a 501 and keeps + // the session usable. + let _ = request_challenge(&mut reader, &mut writer).await; + writer.write_all(b"*\r\n").await.unwrap(); + read_reply(&mut reader, "501").await; + + // Malformed responses — invalid base64, and valid base64 missing the + // "username digest" separator — also get 501 without dropping the + // connection. + let _ = request_challenge(&mut reader, &mut writer).await; + writer.write_all(b"!!!not-base64!!!\r\n").await.unwrap(); + read_reply(&mut reader, "501").await; + let _ = request_challenge(&mut reader, &mut writer).await; + let no_separator = BASE64_STANDARD.encode("nospace"); + writer + .write_all(format!("{}\r\n", no_separator).as_bytes()) + .await + .unwrap(); + read_reply(&mut reader, "501").await; + + // Cancelling AUTH LOGIN mid-exchange behaves the same way. + writer.write_all(b"AUTH LOGIN\r\n").await.unwrap(); + read_reply(&mut reader, "334").await; + writer.write_all(b"*\r\n").await.unwrap(); + read_reply(&mut reader, "501").await; + + // A second attempt gets a fresh challenge; the right digest succeeds. + let challenge2 = request_challenge(&mut reader, &mut writer).await; + assert_ne!(challenge1, challenge2, "challenges must be unique"); + let good = BASE64_STANDARD.encode(format!("alice {}", good_digest)); + writer + .write_all(format!("{}\r\n", good).as_bytes()) + .await + .unwrap(); + read_reply(&mut reader, "235").await; + + // The session is authenticated: mail commands are accepted. + writer + .write_all(b"MAIL FROM:\r\n") + .await + .unwrap(); + read_reply(&mut reader, "250").await; + + writer.write_all(b"QUIT\r\n").await.unwrap(); + read_reply(&mut reader, "221").await; + drop(writer); + server_task.await.unwrap().unwrap(); + + // The callback saw exactly the username, wire challenges, and digests. + let attempts = callbacks.attempts.lock().unwrap(); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0].0, "alice"); + assert_eq!(attempts[0].1, challenge1); + assert_eq!(attempts[0].2, "0".repeat(32)); + assert_eq!( + attempts[1], + ("alice".to_string(), challenge2, good_digest.to_string()) + ); + } + + #[tokio::test] + async fn test_auth_cram_md5_not_advertised_without_callback_support() { + // RecordingCallbacks keeps the default supports_cram_md5() == false. + let callbacks = Arc::new(RecordingCallbacks { + emails: StdMutex::new(Vec::new()), + }); + let server = SmtpServer { + callbacks, + auth_enabled: true, + max_message_size: DEFAULT_MAX_MESSAGE_SIZE, + cmd_timeout: Duration::from_secs(5), + data_timeout: Duration::from_secs(5), + hostname: "test.local".to_string(), + }; + + let (client, server_side) = tokio::io::duplex(4096); + let mut server_stream: Box = Box::new(server_side); + let server_task = + tokio::spawn(async move { server.handle_client(&mut server_stream).await }); + + let (mut reader, mut writer) = tokio::io::split(client); + let mut buf = vec![0u8; 512]; + + let n = reader.read(&mut buf).await.unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).contains("220")); + + writer.write_all(b"EHLO client.test\r\n").await.unwrap(); + let n = reader.read(&mut buf).await.unwrap(); + let ehlo_reply = String::from_utf8_lossy(&buf[..n]).to_string(); + assert!(ehlo_reply.contains("250-AUTH PLAIN LOGIN\r\n")); + assert!(!ehlo_reply.contains("CRAM-MD5")); + + // Trying it anyway is rejected without breaking the session. + writer.write_all(b"AUTH CRAM-MD5\r\n").await.unwrap(); + let n = reader.read(&mut buf).await.unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).contains("504")); + + // Malformed AUTH PLAIN data gets a 501, and the session stays usable: + // a well-formed attempt afterwards succeeds (RecordingCallbacks + // accepts all credentials). + writer + .write_all(b"AUTH PLAIN !!!not-base64!!!\r\n") + .await + .unwrap(); + let n = reader.read(&mut buf).await.unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).contains("501")); + let plain = BASE64_STANDARD.encode("\0user\0pass"); + writer + .write_all(format!("AUTH PLAIN {}\r\n", plain).as_bytes()) + .await + .unwrap(); + let n = reader.read(&mut buf).await.unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).contains("235")); + + writer.write_all(b"QUIT\r\n").await.unwrap(); + let n = reader.read(&mut buf).await.unwrap(); + assert!(String::from_utf8_lossy(&buf[..n]).contains("221")); + drop(writer); + server_task.await.unwrap().unwrap(); + } } diff --git a/smtp/src/parser.rs b/smtp/src/parser.rs index 0c6f512..0262d17 100644 --- a/smtp/src/parser.rs +++ b/smtp/src/parser.rs @@ -36,6 +36,10 @@ pub(crate) enum SmtpCommand { AuthUsername(String), /// Password input during AUTH LOGIN AuthPassword(String), + /// Initial AUTH CRAM-MD5 command + AuthCramMd5, + /// Client response to a CRAM-MD5 challenge (base64 of "username digest") + AuthCramMd5Response(String), /// MAIL FROM command with email address and ESMTP parameters MailFrom(MailFromCommand), /// RCPT TO command with email address @@ -56,6 +60,7 @@ pub(crate) fn parse_command(input: &str, state: &SessionState) -> Result = match state { SessionState::AuthenticatingUsername => parse_auth_username(input), SessionState::AuthenticatingPassword(_) => parse_auth_password(input), + SessionState::AuthenticatingCramMd5(_) => parse_auth_cram_md5_response(input), _ => parse_normal_command(input), }; @@ -81,10 +86,18 @@ fn parse_auth_password(input: &str) -> IResult<&str, SmtpCommand> { .parse(input) } +fn parse_auth_cram_md5_response(input: &str) -> IResult<&str, SmtpCommand> { + map(take_while1(|c: char| c.is_ascii()), |s: &str| { + SmtpCommand::AuthCramMd5Response(s.to_string()) + }) + .parse(input) +} + fn parse_normal_command(input: &str) -> IResult<&str, SmtpCommand> { alt(( parse_ehlo, parse_auth_plain, + parse_auth_cram_md5, parse_auth_login, parse_mail_from, parse_rcpt_to, @@ -120,6 +133,10 @@ fn parse_auth_login(input: &str) -> IResult<&str, SmtpCommand> { map(tag_no_case("AUTH LOGIN"), |_| SmtpCommand::AuthLogin).parse(input) } +fn parse_auth_cram_md5(input: &str) -> IResult<&str, SmtpCommand> { + map(tag_no_case("AUTH CRAM-MD5"), |_| SmtpCommand::AuthCramMd5).parse(input) +} + fn parse_mail_from(input: &str) -> IResult<&str, SmtpCommand> { map( preceded( @@ -295,6 +312,27 @@ mod tests { .unwrap(), SmtpCommand::AuthPassword("cGFzc3dvcmQ=".to_string()) ); + + // Test AUTH CRAM-MD5, case-insensitively + assert_eq!( + parse_command("AUTH CRAM-MD5", &SessionState::Greeted).unwrap(), + SmtpCommand::AuthCramMd5 + ); + assert_eq!( + parse_command("auth cram-md5", &SessionState::Greeted).unwrap(), + SmtpCommand::AuthCramMd5 + ); + + // The challenge response is only parsed while awaiting it + assert_eq!( + parse_command( + "dXNlciBhYmMxMjM=", + &SessionState::AuthenticatingCramMd5("<1.2@host>".to_string()) + ) + .unwrap(), + SmtpCommand::AuthCramMd5Response("dXNlciBhYmMxMjM=".to_string()) + ); + assert!(parse_command("dXNlciBhYmMxMjM=", &SessionState::Greeted).is_err()); } #[test]