Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/PRODUCTION_HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions smtp-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
117 changes: 117 additions & 0 deletions smtp-server/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<md5::Md5>::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,
Expand Down Expand Up @@ -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<bool, SmtpError> {
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,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading