feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements

This commit is contained in:
2026-02-11 10:11:43 +00:00
parent 7908cbaefa
commit b10597fd5e
28 changed files with 1849 additions and 153 deletions

View File

@@ -9,6 +9,7 @@ use crate::config::SmtpServerConfig;
use crate::data::{DataAccumulator, DataAction};
use crate::rate_limiter::RateLimiter;
use crate::response::{build_capabilities, SmtpResponse};
use crate::scram::{ScramCredentials, ScramServer};
use crate::session::{AuthState, SmtpSession};
use crate::validation;
@@ -52,6 +53,13 @@ pub enum ConnectionEvent {
password: String,
remote_addr: String,
},
/// A SCRAM credential request — Rust needs stored credentials from TS.
ScramCredentialRequest {
correlation_id: String,
session_id: String,
username: String,
remote_addr: String,
},
}
/// How email data is transported from Rust to TS.
@@ -81,6 +89,16 @@ pub struct AuthResult {
pub message: Option<String>,
}
/// Result of TS returning SCRAM credentials for a user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScramCredentialResult {
pub found: bool,
pub salt: Option<Vec<u8>>,
pub iterations: Option<u32>,
pub stored_key: Option<Vec<u8>>,
pub server_key: Option<Vec<u8>>,
}
/// Abstraction over plain and TLS streams.
pub enum SmtpStream {
Plain(BufReader<TcpStream>),
@@ -133,6 +151,14 @@ impl SmtpStream {
}
}
/// Check if the internal buffer has unread data (pipelined commands).
pub fn has_buffered_data(&self) -> bool {
match self {
SmtpStream::Plain(reader) => !reader.buffer().is_empty(),
SmtpStream::Tls(reader) => !reader.buffer().is_empty(),
}
}
/// Unwrap to get the raw TcpStream for STARTTLS upgrade.
/// Only works on Plain streams.
pub fn into_tcp_stream(self) -> Option<TcpStream> {
@@ -212,7 +238,7 @@ pub async fn handle_connection(
break;
}
Ok(Ok(_)) => {
// Process command
// Process the first command
let response = process_line(
&line,
&mut session,
@@ -227,59 +253,123 @@ pub async fn handle_connection(
)
.await;
// Check for pipelined commands in the buffer.
// Collect pipelinable responses into a batch for single write.
let mut response_batch: Vec<u8> = Vec::new();
let mut should_break = false;
let mut starttls_signal = false;
match response {
LineResult::Response(resp) => {
if stream.write_all(&resp.to_bytes()).await.is_err() {
break;
}
if stream.flush().await.is_err() {
break;
}
response_batch.extend_from_slice(&resp.to_bytes());
}
LineResult::Quit(resp) => {
let _ = stream.write_all(&resp.to_bytes()).await;
let _ = stream.flush().await;
break;
should_break = true;
}
LineResult::StartTlsSignal => {
// Send 220 Ready response
let resp = SmtpResponse::new(220, "Ready to start TLS");
if stream.write_all(&resp.to_bytes()).await.is_err() {
break;
}
if stream.flush().await.is_err() {
break;
}
// Extract TCP stream and upgrade
if let Some(tcp_stream) = stream.into_tcp_stream() {
if let Some(acceptor) = &tls_acceptor {
match acceptor.accept(tcp_stream).await {
Ok(tls_stream) => {
stream = SmtpStream::Tls(BufReader::new(tls_stream));
session.secure = true;
// Client must re-EHLO after STARTTLS
session.state = crate::state::SmtpState::Connected;
session.client_hostname = None;
session.esmtp = false;
session.auth_state = AuthState::None;
session.envelope = Default::default();
debug!(session_id = %session.id, "TLS upgrade successful");
}
Err(e) => {
warn!(session_id = %session.id, error = %e, "TLS handshake failed");
break;
}
}
} else {
break;
}
} else {
// Already TLS — shouldn't happen
break;
}
starttls_signal = true;
}
LineResult::NoResponse => {}
LineResult::Disconnect => {
should_break = true;
}
}
if should_break {
break;
}
// Process additional pipelined commands from the buffer
if !starttls_signal {
while stream.has_buffered_data() {
let mut next_line = String::new();
match stream.read_line(&mut next_line, 4096).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let next_response = process_line(
&next_line,
&mut session,
&mut stream,
&config,
&rate_limiter,
&event_tx,
callback_register.as_ref(),
&tls_acceptor,
&authenticator,
&resolver,
)
.await;
match next_response {
LineResult::Response(resp) => {
response_batch.extend_from_slice(&resp.to_bytes());
}
LineResult::Quit(resp) => {
response_batch.extend_from_slice(&resp.to_bytes());
should_break = true;
break;
}
LineResult::StartTlsSignal | LineResult::Disconnect => {
// Non-pipelinable: flush batch and handle
starttls_signal = matches!(next_response, LineResult::StartTlsSignal);
should_break = matches!(next_response, LineResult::Disconnect);
break;
}
LineResult::NoResponse => {}
}
}
}
}
}
// Flush the accumulated response batch in one write
if !response_batch.is_empty() {
if stream.write_all(&response_batch).await.is_err() {
break;
}
if stream.flush().await.is_err() {
break;
}
}
if should_break {
break;
}
if starttls_signal {
// Send 220 Ready response
let resp = SmtpResponse::new(220, "Ready to start TLS");
if stream.write_all(&resp.to_bytes()).await.is_err() {
break;
}
if stream.flush().await.is_err() {
break;
}
// Extract TCP stream and upgrade
if let Some(tcp_stream) = stream.into_tcp_stream() {
if let Some(acceptor) = &tls_acceptor {
match acceptor.accept(tcp_stream).await {
Ok(tls_stream) => {
stream = SmtpStream::Tls(BufReader::new(tls_stream));
session.secure = true;
session.state = crate::state::SmtpState::Connected;
session.client_hostname = None;
session.esmtp = false;
session.auth_state = AuthState::None;
session.envelope = Default::default();
debug!(session_id = %session.id, "TLS upgrade successful");
}
Err(e) => {
warn!(session_id = %session.id, error = %e, "TLS handshake failed");
break;
}
}
} else {
break;
}
} else {
break;
}
}
@@ -322,6 +412,12 @@ pub trait CallbackRegistry: Send + Sync {
&self,
correlation_id: &str,
) -> oneshot::Receiver<AuthResult>;
/// Register a callback for SCRAM credential lookup and return a receiver.
fn register_scram_callback(
&self,
correlation_id: &str,
) -> oneshot::Receiver<ScramCredentialResult>;
}
/// Process a single input line from the client.
@@ -406,16 +502,29 @@ async fn process_line(
mechanism,
initial_response,
} => {
handle_auth(
mechanism,
initial_response,
session,
config,
rate_limiter,
event_tx,
callback_registry,
)
.await
if matches!(mechanism, AuthMechanism::ScramSha256) {
handle_auth_scram(
initial_response,
session,
stream,
config,
rate_limiter,
event_tx,
callback_registry,
)
.await
} else {
handle_auth(
mechanism,
initial_response,
session,
config,
rate_limiter,
event_tx,
callback_registry,
)
.await
}
}
SmtpCommand::Help(_) => {
@@ -832,6 +941,217 @@ async fn handle_auth(
))
}
}
AuthMechanism::ScramSha256 => {
// SCRAM is handled separately in process_line; this should not be reached.
LineResult::Response(SmtpResponse::not_implemented())
}
}
}
/// Handle AUTH SCRAM-SHA-256 — full exchange in a single async function.
///
/// SCRAM is a multi-step challenge-response protocol:
/// 1. Client sends client-first-message (in initial_response or after 334)
/// 2. Server requests SCRAM credentials from TS
/// 3. Server sends server-first-message (334 challenge)
/// 4. Client sends client-final-message (proof)
/// 5. Server verifies proof and responds with 235 or 535
async fn handle_auth_scram(
initial_response: Option<String>,
session: &mut SmtpSession,
stream: &mut SmtpStream,
config: &SmtpServerConfig,
rate_limiter: &RateLimiter,
event_tx: &mpsc::Sender<ConnectionEvent>,
callback_registry: &dyn CallbackRegistry,
) -> LineResult {
if !config.auth_enabled {
return LineResult::Response(SmtpResponse::not_implemented());
}
if session.is_authenticated() {
return LineResult::Response(SmtpResponse::bad_sequence("Already authenticated"));
}
if !session.state.can_auth() {
return LineResult::Response(SmtpResponse::bad_sequence("Send EHLO first"));
}
// Step 1: Get client-first-message
let client_first_b64 = match initial_response {
Some(s) if !s.is_empty() => s,
_ => {
// No initial response — send empty 334 challenge
let resp = SmtpResponse::auth_challenge("");
if stream.write_all(&resp.to_bytes()).await.is_err() {
return LineResult::Disconnect;
}
if stream.flush().await.is_err() {
return LineResult::Disconnect;
}
// Read client-first-message
let mut line = String::new();
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
match timeout(socket_timeout, stream.read_line(&mut line, 4096)).await {
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
Ok(Ok(_)) => {}
}
let trimmed = line.trim().to_string();
if trimmed == "*" {
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
}
trimmed
}
};
// Decode base64 client-first-message
let client_first_bytes = match BASE64.decode(client_first_b64.as_bytes()) {
Ok(b) => b,
Err(_) => {
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
}
};
let client_first = match String::from_utf8(client_first_bytes) {
Ok(s) => s,
Err(_) => {
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
}
};
// Parse client-first-message
let mut scram = match ScramServer::from_client_first(&client_first) {
Ok(s) => s,
Err(e) => {
debug!(error = %e, "SCRAM client-first-message parse error");
return LineResult::Response(SmtpResponse::param_error(
"Invalid SCRAM client-first-message",
));
}
};
// Step 2: Request SCRAM credentials from TS
let correlation_id = uuid::Uuid::new_v4().to_string();
let rx = callback_registry.register_scram_callback(&correlation_id);
let event = ConnectionEvent::ScramCredentialRequest {
correlation_id: correlation_id.clone(),
session_id: session.id.clone(),
username: scram.username.clone(),
remote_addr: session.remote_addr.clone(),
};
if event_tx.send(event).await.is_err() {
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
}
// Wait for credentials from TS
let cred_timeout = Duration::from_secs(5);
let cred_result = match timeout(cred_timeout, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => {
warn!(correlation_id = %correlation_id, "SCRAM credential callback dropped");
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
}
Err(_) => {
warn!(correlation_id = %correlation_id, "SCRAM credential request timed out");
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
}
};
if !cred_result.found {
// User not found — fail auth (don't reveal that user doesn't exist)
session.auth_state = AuthState::None;
let exceeded = session.record_auth_failure(config.max_auth_failures);
if exceeded {
return LineResult::Quit(SmtpResponse::service_unavailable(
&config.hostname,
"Too many authentication failures",
));
}
return LineResult::Response(SmtpResponse::auth_failed());
}
let creds = ScramCredentials {
salt: cred_result.salt.unwrap_or_default(),
iterations: cred_result.iterations.unwrap_or(4096),
stored_key: cred_result.stored_key.unwrap_or_default(),
server_key: cred_result.server_key.unwrap_or_default(),
};
// Step 3: Generate and send server-first-message
let server_first = scram.server_first_message(creds);
let server_first_b64 = BASE64.encode(server_first.as_bytes());
let challenge = SmtpResponse::auth_challenge(&server_first_b64);
if stream.write_all(&challenge.to_bytes()).await.is_err() {
return LineResult::Disconnect;
}
if stream.flush().await.is_err() {
return LineResult::Disconnect;
}
// Step 4: Read client-final-message
let mut client_final_line = String::new();
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
match timeout(socket_timeout, stream.read_line(&mut client_final_line, 4096)).await {
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
Ok(Ok(_)) => {}
}
let client_final_b64 = client_final_line.trim();
// Cancel if *
if client_final_b64 == "*" {
session.auth_state = AuthState::None;
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
}
// Decode base64 client-final-message
let client_final_bytes = match BASE64.decode(client_final_b64.as_bytes()) {
Ok(b) => b,
Err(_) => {
session.auth_state = AuthState::None;
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
}
};
let client_final = match String::from_utf8(client_final_bytes) {
Ok(s) => s,
Err(_) => {
session.auth_state = AuthState::None;
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
}
};
// Step 5: Verify proof
match scram.process_client_final(&client_final) {
Ok(server_final) => {
let server_final_b64 = BASE64.encode(server_final.as_bytes());
session.auth_state = AuthState::Authenticated {
username: scram.username.clone(),
};
LineResult::Response(SmtpResponse::new(
235,
format!("2.7.0 Authentication successful {}", server_final_b64),
))
}
Err(e) => {
debug!(error = %e, "SCRAM proof verification failed");
session.auth_state = AuthState::None;
let exceeded = session.record_auth_failure(config.max_auth_failures);
if exceeded {
if !rate_limiter.check_auth_failure(&session.remote_addr) {
return LineResult::Quit(SmtpResponse::service_unavailable(
&config.hostname,
"Too many authentication failures",
));
}
return LineResult::Quit(SmtpResponse::service_unavailable(
&config.hostname,
"Too many authentication failures",
));
}
LineResult::Response(SmtpResponse::auth_failed())
}
}
}