Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08c5145d20 | |||
| 0515d2ae46 | |||
| 96b4ccb7d3 | |||
| 7c0c327913 | |||
| 9e722874b4 | |||
| 873af43ef2 | |||
| 76d898b648 | |||
| b422639c34 | |||
| c45ba2a7b4 | |||
| b10597fd5e |
41
changelog.md
41
changelog.md
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-11 - 5.2.0 - feat(packaging)
|
||||
add package exports entry, include ts/dist_ts in package files, and add TS barrel index re-exports
|
||||
|
||||
- package.json: add "exports" mapping "." -> "./dist_ts/index.js" to provide a module entry point
|
||||
- package.json: add "ts/**/*" and "dist_ts/**/*" to "files" so TypeScript sources and built output are published
|
||||
- ts/index.ts: new barrel that re-exports './00_commitinfo_data.js', './mail/index.js', and './security/index.js'
|
||||
|
||||
## 2026-02-11 - 5.1.3 - fix(docs)
|
||||
clarify sendEmail default behavior and document automatic MX discovery and delivery modes
|
||||
|
||||
- Updated README to describe automatic MX record discovery and grouping behavior when using sendEmail() (MTA mode)
|
||||
- Added a Delivery Modes section and API signature for sendEmail(mode) describing mta, forward, and process options
|
||||
- Expanded examples to show multi-recipient delivery, explicit mode usage, and retained low-level sendOutboundEmail example
|
||||
|
||||
## 2026-02-11 - 5.1.2 - fix(readme)
|
||||
adjust ASCII architecture diagram alignment in README
|
||||
|
||||
- Whitespace and alignment tweaks to the ASCII architecture diagram in readme.md
|
||||
- No code or behavior changes; documentation-only edit
|
||||
|
||||
## 2026-02-11 - 5.1.1 - fix(release)
|
||||
no changes
|
||||
|
||||
- No files changed in this commit.
|
||||
- Current package version remains 5.1.0 (from package.json).
|
||||
|
||||
## 2026-02-11 - 5.1.0 - feat(mailer-smtp)
|
||||
add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements
|
||||
|
||||
- Add server-side SCRAM-SHA-256 implementation in Rust (scram.rs) and wire up SCRAM credential request/response between Rust and TypeScript bridge (ScramCredentialRequest / scramCredentialResult).
|
||||
- Support SCRAM-SHA-256 auth mechanism in SMTP command parsing and advertise AUTH PLAIN LOGIN SCRAM-SHA-256 capability.
|
||||
- Add opportunistic TLS mode for MTA-to-MTA delivery: configurable tls_opportunistic flag, an OpportunisticVerifier that skips cert verification per RFC 7435, and plumbing into connect/upgrade TLS paths.
|
||||
- Add pipelined envelope support for MAIL FROM + multiple RCPT TO (send_pipelined_envelope) and use pipelining when server advertises PIPELINING to improve outbound performance.
|
||||
- Add Ed25519 DKIM signing support and auto-dispatch: sign_dkim_ed25519, sign_dkim_auto, dkim_dns_record_value_typed, and TS changes to detect key type and call the auto signing API.
|
||||
- Expose additional per-domain TLS certs (additionalTlsCerts) and implement SNI-based certificate resolver on the server to select certs by hostname; parsing helpers and fallback default cert handling included.
|
||||
- Install ring crypto provider early in mailer-bin main for rustls operations and add related rust dependencies (sha2, hmac, pbkdf2) and workspace entries.
|
||||
- TypeScript delivery and server bridge changes: group recipients by domain, MX resolution fallback to A record, MTA delivery loop over MX hosts, DKIM options propagation, TLS opportunistic option passed to outbound client, SCRAM credential computation in TS using PBKDF2/HMAC/SHA256 and sending results back to Rust.
|
||||
- Add new tests and utilities: IPv6 DNSBL support and tests, SCRAM unit tests, DKIM Ed25519 tests, node-level MTA delivery integration test, and various test updates.
|
||||
- Public API additions on the Rust <-> TS bridge: signDkim accepts keyType, new scram credential result command, onScramCredentialRequest/onScramCredentialResult helpers and sendScramCredentialResult.
|
||||
- Various refactors and safety/feature improvements across mailer-core/smtp/security: envelope handling, stream buffering detection, and error handling for auth flows.
|
||||
|
||||
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail)
|
||||
remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartmta",
|
||||
"version": "5.0.0",
|
||||
"version": "5.2.0",
|
||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||
"keywords": [
|
||||
"mta",
|
||||
@@ -27,6 +27,9 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js"
|
||||
},
|
||||
"bin": {
|
||||
"mailer": "./bin/mailer-wrapper.js"
|
||||
},
|
||||
@@ -56,6 +59,8 @@
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"dist_ts/**/*",
|
||||
"bin/",
|
||||
"scripts/install-binary.js",
|
||||
"dist_rust/**/*",
|
||||
|
||||
93
readme.md
93
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartmta
|
||||
|
||||
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. 🚀
|
||||
A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with a Rust-powered SMTP engine — no nodemailer, no shortcuts. Automatic MX record discovery means you just call `sendEmail()` and smartmta figures out where to deliver. 🚀
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -43,28 +43,28 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ UnifiedEmailServer │
|
||||
│ (orchestrates all components, emits events) │
|
||||
│ UnifiedEmailServer │
|
||||
│ (orchestrates all components, emits events) │
|
||||
├───────────┬───────────┬──────────────┬───────────────────────┤
|
||||
│ Email │ Security │ Delivery │ Configuration │
|
||||
│ Router │ Stack │ System │ │
|
||||
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||
│ │ Act │ │ │ DMARC │ │ │ Retry │ │ │ DKIMCreator │ │
|
||||
│ └──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │ │
|
||||
│ │ │ Scan │ │ │ └────────────────┘ │
|
||||
│ ┌──────┐ │ ┌───────┐ │ ┌──────────┐ │ ┌────────────────┐ │
|
||||
│ │Match │ │ │ DKIM │ │ │ Queue │ │ │ DomainRegistry │ │
|
||||
│ │Route │ │ │ SPF │ │ │ Rate Lim │ │ │ DnsManager │ │
|
||||
│ │ Act │ │ │ DMARC │ │ │ Retry │ │ │ DKIMCreator │ │
|
||||
│ └──────┘ │ │ IPRep │ │ └──────────┘ │ │ Templates │ │
|
||||
│ │ │ Scan │ │ │ └────────────────┘ │
|
||||
│ │ └───────┘ │ │ │
|
||||
├───────────┴───────────┴──────────────┴───────────────────────┤
|
||||
│ Rust Security Bridge (smartrust IPC) │
|
||||
│ Rust Security Bridge (smartrust IPC) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Rust Acceleration Layer │
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||
│ │ SMTP Client │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||
│ │ TLS/AUTH │ │ Scanning │ │ Detection │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||
│ Rust Acceleration Layer │
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
|
||||
│ │ mailer-smtp │ │mailer-security│ │ mailer-core │ │
|
||||
│ │ SMTP Server │ │DKIM/SPF/DMARC │ │ Types/Validation │ │
|
||||
│ │ SMTP Client │ │IP Rep/Content │ │ MIME/Bounce │ │
|
||||
│ │ TLS/AUTH │ │ Scanning │ │ Detection │ │
|
||||
│ └──────────────┘ └───────────────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -78,9 +78,10 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
|
||||
|
||||
**Data flow for outbound mail:**
|
||||
|
||||
1. 📝 TypeScript constructs the email and resolves DKIM keys for the sender domain
|
||||
2. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
|
||||
3. 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
|
||||
1. 📝 TypeScript constructs the email and calls `sendEmail()` (defaults to MTA mode)
|
||||
2. 🔍 MTA mode automatically resolves MX records for each recipient domain, sorts by priority, and groups recipients for efficient delivery
|
||||
3. 🦀 Sends to Rust via IPC — Rust builds the RFC 2822 message, signs with DKIM, and delivers via its SMTP client with connection pooling
|
||||
4. 📬 Result (accepted/rejected recipients, server response) returned to TypeScript
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -169,9 +170,9 @@ await emailServer.start();
|
||||
|
||||
> 🔒 **Note:** `start()` will throw if the Rust binary is not compiled. Run `pnpm build` first.
|
||||
|
||||
### 📧 Sending Outbound Emails
|
||||
### 📧 Sending Emails (Automatic MX Discovery)
|
||||
|
||||
All outbound email delivery goes through the Rust SMTP client, accessed via `UnifiedEmailServer.sendOutboundEmail()`. The Rust client handles connection pooling, TLS negotiation, and DKIM signing automatically:
|
||||
The recommended way to send email is `sendEmail()`. It defaults to **MTA mode**, which automatically resolves MX records for each recipient domain via DNS — you don't need to know the destination mail server:
|
||||
|
||||
```typescript
|
||||
import { Email, UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
@@ -179,8 +180,7 @@ import { Email, UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
// Build an email
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
cc: ['cc@example.com'],
|
||||
to: ['alice@gmail.com', 'bob@company.org'],
|
||||
subject: 'Hello from smartmta! 🚀',
|
||||
text: 'Plain text body',
|
||||
html: '<h1>Hello!</h1><p>HTML body with <strong>formatting</strong></p>',
|
||||
@@ -194,7 +194,50 @@ const email = new Email({
|
||||
],
|
||||
});
|
||||
|
||||
// Send via the Rust SMTP client (connection pooling, TLS, DKIM signing)
|
||||
// Send — MTA mode auto-discovers MX servers for gmail.com and company.org
|
||||
const emailId = await emailServer.sendEmail(email);
|
||||
|
||||
// Optionally specify a delivery mode explicitly
|
||||
const emailId2 = await emailServer.sendEmail(email, 'mta');
|
||||
```
|
||||
|
||||
In MTA mode, smartmta:
|
||||
- 🔍 Resolves MX records for each recipient domain (e.g. `gmail.com`, `company.org`)
|
||||
- 📊 Sorts MX hosts by priority (lowest = highest priority per RFC 5321)
|
||||
- 🔄 Tries each MX host in order until delivery succeeds
|
||||
- 🌐 Falls back to the domain's A record if no MX records exist
|
||||
- 📦 Groups recipients by domain for efficient batch delivery
|
||||
- 🔑 Signs outbound mail with DKIM automatically
|
||||
|
||||
### 📮 Delivery Modes
|
||||
|
||||
`sendEmail()` accepts a mode parameter that controls how the email is delivered:
|
||||
|
||||
```typescript
|
||||
public async sendEmail(
|
||||
email: Email,
|
||||
mode: EmailProcessingMode = 'mta', // 'mta' | 'forward' | 'process'
|
||||
route?: IEmailRoute,
|
||||
options?: {
|
||||
skipSuppressionCheck?: boolean;
|
||||
ipAddress?: string;
|
||||
isTransactional?: boolean;
|
||||
}
|
||||
): Promise<string>
|
||||
```
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `mta` (default) | **Auto MX discovery** — resolves MX records via DNS, delivers directly to the recipient's mail server. No relay configuration needed. |
|
||||
| `forward` | **Relay delivery** — forwards the email to a configured SMTP host (e.g. an internal mail gateway or third-party relay). |
|
||||
| `process` | **Scan + deliver** — runs the content scanning / security pipeline first, then delivers via auto MX resolution. |
|
||||
|
||||
### 📬 Direct SMTP Delivery (Low-Level)
|
||||
|
||||
For cases where you know the exact target SMTP server (e.g. relaying to a specific host), use the lower-level `sendOutboundEmail()`:
|
||||
|
||||
```typescript
|
||||
// Send directly to a known SMTP server (bypasses MX resolution)
|
||||
const result = await emailServer.sendOutboundEmail('smtp.example.com', 587, email, {
|
||||
auth: { user: 'sender@example.com', pass: 'your-password' },
|
||||
dkimDomain: 'example.com',
|
||||
|
||||
4
rust/Cargo.lock
generated
4
rust/Cargo.lock
generated
@@ -982,6 +982,7 @@ dependencies = [
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailer-smtp",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1025,15 +1026,18 @@ dependencies = [
|
||||
"base64",
|
||||
"dashmap",
|
||||
"hickory-resolver 0.25.2",
|
||||
"hmac",
|
||||
"mailer-core",
|
||||
"mailer-security",
|
||||
"mailparse",
|
||||
"pbkdf2",
|
||||
"regex",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
|
||||
@@ -29,3 +29,6 @@ uuid = { version = "1", features = ["v4"] }
|
||||
rustls-pki-types = "1"
|
||||
psl = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
pbkdf2 = { version = "0.12", default-features = false }
|
||||
|
||||
@@ -21,3 +21,4 @@ hickory-resolver.workspace = true
|
||||
dashmap.workspace = true
|
||||
base64.workspace = true
|
||||
uuid.workspace = true
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use mailer_smtp::connection::{
|
||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
|
||||
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, ScramCredentialResult,
|
||||
};
|
||||
|
||||
/// mailer-bin: Rust-powered email security tools
|
||||
@@ -114,10 +114,11 @@ struct IpcEvent {
|
||||
|
||||
// --- Pending callbacks for correlation-ID based reverse calls ---
|
||||
|
||||
/// Stores oneshot senders for pending email processing and auth callbacks.
|
||||
/// Stores oneshot senders for pending email processing, auth, and SCRAM callbacks.
|
||||
struct PendingCallbacks {
|
||||
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
|
||||
auth: DashMap<String, oneshot::Sender<AuthResult>>,
|
||||
scram: DashMap<String, oneshot::Sender<ScramCredentialResult>>,
|
||||
}
|
||||
|
||||
impl PendingCallbacks {
|
||||
@@ -125,6 +126,7 @@ impl PendingCallbacks {
|
||||
Self {
|
||||
email: DashMap::new(),
|
||||
auth: DashMap::new(),
|
||||
scram: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,9 +149,22 @@ impl CallbackRegistry for PendingCallbacks {
|
||||
self.auth.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
fn register_scram_callback(
|
||||
&self,
|
||||
correlation_id: &str,
|
||||
) -> oneshot::Receiver<ScramCredentialResult> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.scram.insert(correlation_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Install the ring CryptoProvider for rustls TLS operations (STARTTLS, implicit TLS).
|
||||
// This must happen before any TLS connection is attempted.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.management {
|
||||
@@ -494,6 +509,22 @@ fn handle_smtp_event(event: ConnectionEvent) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
ConnectionEvent::ScramCredentialRequest {
|
||||
correlation_id,
|
||||
session_id,
|
||||
username,
|
||||
remote_addr,
|
||||
} => {
|
||||
emit_event(
|
||||
"scramCredentialRequest",
|
||||
serde_json::json!({
|
||||
"correlationId": correlation_id,
|
||||
"sessionId": session_id,
|
||||
"username": username,
|
||||
"remoteAddr": remote_addr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,8 +673,13 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
.get("privateKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let key_type = req
|
||||
.params
|
||||
.get("keyType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("rsa");
|
||||
|
||||
match mailer_security::sign_dkim(raw_message.as_bytes(), domain, selector, private_key) {
|
||||
match mailer_security::sign_dkim_auto(raw_message.as_bytes(), domain, selector, private_key, key_type) {
|
||||
Ok(header) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
@@ -825,6 +861,10 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
||||
handle_auth_result(req, state)
|
||||
}
|
||||
|
||||
"scramCredentialResult" => {
|
||||
handle_scram_credential_result(req, state)
|
||||
}
|
||||
|
||||
"configureRateLimits" => {
|
||||
// Rate limit configuration is set at startSmtpServer time.
|
||||
// This command allows runtime updates, but for now we acknowledge it.
|
||||
@@ -1010,6 +1050,56 @@ fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle scramCredentialResult IPC command — resolves a pending SCRAM credential callback.
|
||||
fn handle_scram_credential_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
let correlation_id = req
|
||||
.params
|
||||
.get("correlationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let found = req.params.get("found").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let result = ScramCredentialResult {
|
||||
found,
|
||||
salt: req.params.get("salt")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
iterations: req.params.get("iterations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32),
|
||||
stored_key: req.params.get("storedKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
server_key: req.params.get("serverKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
|
||||
};
|
||||
|
||||
if let Some((_, tx)) = state.callbacks.scram.remove(correlation_id) {
|
||||
let _ = tx.send(result);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"resolved": true})),
|
||||
error: None,
|
||||
}
|
||||
} else {
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!(
|
||||
"No pending SCRAM credential callback for correlationId: {}",
|
||||
correlation_id
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SmtpServerConfig from IPC params JSON.
|
||||
fn parse_smtp_config(
|
||||
params: &serde_json::Value,
|
||||
@@ -1075,6 +1165,27 @@ fn parse_smtp_config(
|
||||
config.processing_timeout_secs = timeout;
|
||||
}
|
||||
|
||||
// Parse additional TLS certs for SNI
|
||||
if let Some(certs_arr) = params.get("additionalTlsCerts").and_then(|v| v.as_array()) {
|
||||
for cert_val in certs_arr {
|
||||
if let (Some(domains_arr), Some(cert_pem), Some(key_pem)) = (
|
||||
cert_val.get("domains").and_then(|v| v.as_array()),
|
||||
cert_val.get("certPem").and_then(|v| v.as_str()),
|
||||
cert_val.get("keyPem").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let domains: Vec<String> = domains_arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect();
|
||||
config.additional_tls_certs.push(mailer_smtp::config::TlsDomainCert {
|
||||
domains,
|
||||
cert_pem: cert_pem.to_string(),
|
||||
key_pem: key_pem.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -1187,11 +1298,12 @@ async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResp
|
||||
// Optional DKIM signing
|
||||
if let Some(dkim_val) = req.params.get("dkim") {
|
||||
if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
|
||||
match mailer_security::sign_dkim(
|
||||
match mailer_security::sign_dkim_auto(
|
||||
&raw_message,
|
||||
&dkim_config.domain,
|
||||
&dkim_config.selector,
|
||||
&dkim_config.private_key,
|
||||
&dkim_config.key_type,
|
||||
) {
|
||||
Ok(header) => {
|
||||
// Prepend DKIM header to the message
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use mail_auth::common::crypto::{RsaKey, Sha256};
|
||||
use mail_auth::common::crypto::{Ed25519Key, RsaKey, Sha256};
|
||||
use mail_auth::common::headers::HeaderWriter;
|
||||
use mail_auth::dkim::{Canonicalization, DkimSigner};
|
||||
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
|
||||
@@ -118,9 +118,62 @@ pub fn sign_dkim(
|
||||
Ok(signature.to_header())
|
||||
}
|
||||
|
||||
/// Sign a raw email message with DKIM using Ed25519-SHA256 (RFC 8463).
|
||||
///
|
||||
/// * `raw_message` - The raw RFC 5322 message bytes
|
||||
/// * `domain` - The signing domain (d= tag)
|
||||
/// * `selector` - The DKIM selector (s= tag)
|
||||
/// * `private_key_pkcs8_der` - Ed25519 private key in PKCS#8 DER format
|
||||
///
|
||||
/// Returns the DKIM-Signature header string to prepend to the message.
|
||||
pub fn sign_dkim_ed25519(
|
||||
raw_message: &[u8],
|
||||
domain: &str,
|
||||
selector: &str,
|
||||
private_key_pkcs8_der: &[u8],
|
||||
) -> Result<String> {
|
||||
let ed_key = Ed25519Key::from_pkcs8_maybe_unchecked_der(private_key_pkcs8_der)
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to load Ed25519 key: {}", e)))?;
|
||||
|
||||
let signature = DkimSigner::from_key(ed_key)
|
||||
.domain(domain)
|
||||
.selector(selector)
|
||||
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
|
||||
.header_canonicalization(Canonicalization::Relaxed)
|
||||
.body_canonicalization(Canonicalization::Relaxed)
|
||||
.sign(raw_message)
|
||||
.map_err(|e| SecurityError::Dkim(format!("Ed25519 DKIM signing failed: {}", e)))?;
|
||||
|
||||
Ok(signature.to_header())
|
||||
}
|
||||
|
||||
/// Sign a raw email message with DKIM, auto-selecting RSA or Ed25519 based on `key_type`.
|
||||
///
|
||||
/// * `key_type` - `"rsa"` (default) or `"ed25519"`
|
||||
/// * For RSA: `private_key_pem` is a PEM-encoded RSA key
|
||||
/// * For Ed25519: `private_key_pem` is a PEM-encoded PKCS#8 Ed25519 key
|
||||
pub fn sign_dkim_auto(
|
||||
raw_message: &[u8],
|
||||
domain: &str,
|
||||
selector: &str,
|
||||
private_key_pem: &str,
|
||||
key_type: &str,
|
||||
) -> Result<String> {
|
||||
match key_type.to_lowercase().as_str() {
|
||||
"ed25519" => {
|
||||
// Parse PEM to DER for Ed25519
|
||||
let der = rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
|
||||
.map_err(|e| SecurityError::Key(format!("Failed to parse Ed25519 PEM: {}", e)))?;
|
||||
sign_dkim_ed25519(raw_message, domain, selector, der.secret_pkcs8_der())
|
||||
}
|
||||
_ => sign_dkim(raw_message, domain, selector, private_key_pem),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a DKIM DNS TXT record value for a given public key.
|
||||
///
|
||||
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
|
||||
/// `key_type` should be `"rsa"` or `"ed25519"`.
|
||||
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||
// Extract the base64 content from PEM
|
||||
let key_b64: String = public_key_pem
|
||||
@@ -132,6 +185,24 @@ pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
|
||||
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
|
||||
}
|
||||
|
||||
/// Generate a DKIM DNS TXT record value with explicit key type.
|
||||
///
|
||||
/// * `key_type` - `"rsa"` or `"ed25519"`
|
||||
pub fn dkim_dns_record_value_typed(public_key_pem: &str, key_type: &str) -> String {
|
||||
let key_b64: String = public_key_pem
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("-----"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
let k = match key_type.to_lowercase().as_str() {
|
||||
"ed25519" => "ed25519",
|
||||
_ => "rsa",
|
||||
};
|
||||
|
||||
format!("v=DKIM1; h=sha256; k={}; p={}", k, key_b64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -149,4 +220,42 @@ mod tests {
|
||||
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_dkim_ed25519() {
|
||||
// Generate an Ed25519 key pair using mail-auth
|
||||
let pkcs8_der = Ed25519Key::generate_pkcs8().expect("generate ed25519 key");
|
||||
let ed_key = Ed25519Key::from_pkcs8_der(&pkcs8_der).expect("parse ed25519 key");
|
||||
let _pub_key = ed_key.public_key();
|
||||
|
||||
let msg = b"From: test@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nBody";
|
||||
let result = sign_dkim_ed25519(msg, "example.com", "ed25519sel", &pkcs8_der);
|
||||
assert!(result.is_ok());
|
||||
let header = result.unwrap();
|
||||
assert!(header.contains("a=ed25519-sha256"));
|
||||
assert!(header.contains("d=example.com"));
|
||||
assert!(header.contains("s=ed25519sel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_dkim_auto_dispatches() {
|
||||
// RSA with invalid key should error
|
||||
let msg = b"From: test@example.com\r\n\r\nBody";
|
||||
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "rsa");
|
||||
assert!(result.is_err());
|
||||
|
||||
// Ed25519 with invalid PEM should error
|
||||
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "ed25519");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dkim_dns_record_value_typed() {
|
||||
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
|
||||
let rsa_record = dkim_dns_record_value_typed(pem, "rsa");
|
||||
assert!(rsa_record.contains("k=rsa"));
|
||||
|
||||
let ed_record = dkim_dns_record_value_typed(pem, "ed25519");
|
||||
assert!(ed_record.contains("k=ed25519"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use hickory_resolver::TokioResolver;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn risk_level(score: u8) -> RiskLevel {
|
||||
|
||||
/// Check an IP against DNSBL servers.
|
||||
///
|
||||
/// * `ip` - The IP address to check (must be IPv4)
|
||||
/// * `ip` - The IP address to check (IPv4 or IPv6)
|
||||
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
|
||||
/// * `resolver` - DNS resolver to use
|
||||
pub async fn check_dnsbl(
|
||||
@@ -91,20 +91,10 @@ pub async fn check_dnsbl(
|
||||
dnsbl_servers: &[&str],
|
||||
resolver: &TokioResolver,
|
||||
) -> Result<DnsblResult> {
|
||||
let ipv4 = match ip {
|
||||
IpAddr::V4(v4) => v4,
|
||||
IpAddr::V6(_) => {
|
||||
// IPv6 DNSBL is less common; return clean result
|
||||
return Ok(DnsblResult {
|
||||
ip: ip.to_string(),
|
||||
listed_count: 0,
|
||||
listed_on: Vec::new(),
|
||||
total_checked: 0,
|
||||
});
|
||||
}
|
||||
let reversed = match ip {
|
||||
IpAddr::V4(v4) => reverse_ipv4(v4),
|
||||
IpAddr::V6(v6) => reverse_ipv6(v6),
|
||||
};
|
||||
|
||||
let reversed = reverse_ipv4(ipv4);
|
||||
let total = dnsbl_servers.len();
|
||||
|
||||
// Query all DNSBL servers in parallel
|
||||
@@ -178,6 +168,21 @@ fn reverse_ipv4(ip: Ipv4Addr) -> String {
|
||||
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
|
||||
}
|
||||
|
||||
/// Reverse IPv6 address to nibble format for DNSBL queries.
|
||||
///
|
||||
/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble.
|
||||
/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2`
|
||||
fn reverse_ipv6(ip: Ipv6Addr) -> String {
|
||||
let segments = ip.segments();
|
||||
let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect();
|
||||
full_hex
|
||||
.chars()
|
||||
.rev()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
/// Heuristic IP type classification based on well-known prefix ranges.
|
||||
/// Same heuristics as the TypeScript IPReputationChecker.
|
||||
fn classify_ip(ip: IpAddr) -> IpType {
|
||||
@@ -272,6 +277,38 @@ mod tests {
|
||||
assert!(!is_valid_ipv4("not-an-ip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_ipv6() {
|
||||
let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap();
|
||||
assert_eq!(
|
||||
reverse_ipv6(ip),
|
||||
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_ipv6_loopback() {
|
||||
let ip: Ipv6Addr = "::1".parse().unwrap();
|
||||
assert_eq!(
|
||||
reverse_ipv6(ip),
|
||||
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_dnsbl_ipv6_runs() {
|
||||
// Verify IPv6 actually goes through DNSBL queries (not skipped)
|
||||
let resolver = hickory_resolver::TokioResolver::builder_tokio()
|
||||
.map(|b| b.build())
|
||||
.unwrap();
|
||||
let ip: IpAddr = "::1".parse().unwrap();
|
||||
let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap();
|
||||
// Loopback should not be listed on any DNSBL
|
||||
assert_eq!(result.listed_count, 0);
|
||||
// But total_checked should be > 0 — proving IPv6 was actually queried
|
||||
assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_dnsbl_servers() {
|
||||
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
|
||||
|
||||
@@ -9,7 +9,7 @@ pub mod spf;
|
||||
pub mod verify;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult};
|
||||
pub use dkim::{dkim_dns_record_value, dkim_dns_record_value_typed, dkim_outputs_to_results, sign_dkim, sign_dkim_auto, sign_dkim_ed25519, verify_dkim, DkimVerificationResult};
|
||||
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
|
||||
pub use verify::{verify_email_security, EmailSecurityResult};
|
||||
pub use error::{Result, SecurityError};
|
||||
|
||||
@@ -23,3 +23,6 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
|
||||
rustls-pemfile = "2"
|
||||
mailparse.workspace = true
|
||||
webpki-roots = "0.26"
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
pbkdf2.workspace = true
|
||||
|
||||
@@ -37,6 +37,12 @@ pub struct SmtpClientConfig {
|
||||
/// Maximum connections per pool. Default: 10.
|
||||
#[serde(default = "default_max_pool_connections")]
|
||||
pub max_pool_connections: usize,
|
||||
|
||||
/// Accept invalid TLS certificates (expired, self-signed, wrong hostname).
|
||||
/// Standard for MTA-to-MTA opportunistic TLS per RFC 7435.
|
||||
/// Default: false.
|
||||
#[serde(default)]
|
||||
pub tls_opportunistic: bool,
|
||||
}
|
||||
|
||||
/// Authentication configuration.
|
||||
@@ -60,8 +66,15 @@ pub struct DkimSignConfig {
|
||||
pub domain: String,
|
||||
/// DKIM selector (e.g. "default" or "mta").
|
||||
pub selector: String,
|
||||
/// PEM-encoded RSA private key.
|
||||
/// PEM-encoded private key (RSA or Ed25519 PKCS#8).
|
||||
pub private_key: String,
|
||||
/// Key type: "rsa" (default) or "ed25519".
|
||||
#[serde(default = "default_key_type")]
|
||||
pub key_type: String,
|
||||
}
|
||||
|
||||
fn default_key_type() -> String {
|
||||
"rsa".to_string()
|
||||
}
|
||||
|
||||
impl SmtpClientConfig {
|
||||
|
||||
@@ -117,6 +117,7 @@ pub async fn connect_tls(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout_secs: u64,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Connecting to {}:{} (implicit TLS)", host, port);
|
||||
let addr = format!("{host}:{port}");
|
||||
@@ -130,7 +131,7 @@ pub async fn connect_tls(
|
||||
message: format!("Failed to connect to {addr}: {e}"),
|
||||
})?;
|
||||
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, host).await?;
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, host, tls_opportunistic).await?;
|
||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||
}
|
||||
|
||||
@@ -138,24 +139,77 @@ pub async fn connect_tls(
|
||||
pub async fn upgrade_to_tls(
|
||||
stream: ClientSmtpStream,
|
||||
hostname: &str,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
|
||||
let tcp_stream = stream.into_tcp_stream()?;
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, hostname).await?;
|
||||
let tls_stream = perform_tls_handshake(tcp_stream, hostname, tls_opportunistic).await?;
|
||||
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||
}
|
||||
|
||||
/// A TLS certificate verifier that accepts any certificate.
|
||||
/// Used for MTA-to-MTA opportunistic TLS per RFC 7435.
|
||||
#[derive(Debug)]
|
||||
struct OpportunisticVerifier;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for OpportunisticVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls_pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls_pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls_pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls_pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls_pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform the TLS handshake on a TCP stream using webpki-roots.
|
||||
/// When `tls_opportunistic` is true, certificate verification is skipped
|
||||
/// (standard for MTA-to-MTA delivery per RFC 7435).
|
||||
async fn perform_tls_handshake(
|
||||
tcp_stream: TcpStream,
|
||||
hostname: &str,
|
||||
tls_opportunistic: bool,
|
||||
) -> Result<TlsStream<TcpStream>, SmtpClientError> {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let tls_config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
let tls_config = if tls_opportunistic {
|
||||
debug!("Using opportunistic TLS (no cert verification) for {}", hostname);
|
||||
rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(OpportunisticVerifier))
|
||||
.with_no_client_auth()
|
||||
} else {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth()
|
||||
};
|
||||
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||
let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
|
||||
@@ -190,7 +244,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connect_tls_refused() {
|
||||
let result = connect_tls("127.0.0.1", 19998, 2).await;
|
||||
let result = connect_tls("127.0.0.1", 19998, 2, false).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ impl ConnectionPool {
|
||||
&self.config.host,
|
||||
self.config.port,
|
||||
self.config.connection_timeout_secs,
|
||||
self.config.tls_opportunistic,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
@@ -139,7 +140,7 @@ impl ConnectionPool {
|
||||
if !self.config.secure && capabilities.starttls {
|
||||
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
|
||||
stream =
|
||||
super::connection::upgrade_to_tls(stream, &self.config.host).await?;
|
||||
super::connection::upgrade_to_tls(stream, &self.config.host, self.config.tls_opportunistic).await?;
|
||||
|
||||
// Re-EHLO after STARTTLS — use updated capabilities for auth
|
||||
capabilities = protocol::send_ehlo(
|
||||
@@ -244,9 +245,10 @@ impl SmtpClientManager {
|
||||
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
|
||||
}
|
||||
|
||||
// Perform the SMTP transaction
|
||||
// Perform the SMTP transaction (use pipelining if server supports it)
|
||||
let pipelining = conn.capabilities.pipelining;
|
||||
let result =
|
||||
Self::perform_send(&mut conn.stream, sender, recipients, message, config).await;
|
||||
Self::perform_send(&mut conn.stream, sender, recipients, message, config, pipelining).await;
|
||||
|
||||
// Re-acquire the pool lock and release the connection
|
||||
let mut pool = pool_arc.lock().await;
|
||||
@@ -268,30 +270,39 @@ impl SmtpClientManager {
|
||||
recipients: &[String],
|
||||
message: &[u8],
|
||||
config: &SmtpClientConfig,
|
||||
pipelining: bool,
|
||||
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||
let timeout_secs = config.socket_timeout_secs;
|
||||
|
||||
// MAIL FROM
|
||||
protocol::send_mail_from(stream, sender, timeout_secs).await?;
|
||||
let (accepted, rejected) = if pipelining {
|
||||
// Use pipelined envelope: MAIL FROM + all RCPT TO in one batch
|
||||
let (_mail_ok, acc, rej) = protocol::send_pipelined_envelope(
|
||||
stream, sender, recipients, timeout_secs,
|
||||
).await?;
|
||||
(acc, rej)
|
||||
} else {
|
||||
// Sequential: MAIL FROM, then each RCPT TO
|
||||
protocol::send_mail_from(stream, sender, timeout_secs).await?;
|
||||
|
||||
// RCPT TO for each recipient
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
|
||||
for rcpt in recipients {
|
||||
match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await {
|
||||
Ok(resp) => {
|
||||
if resp.is_success() {
|
||||
accepted.push(rcpt.clone());
|
||||
} else {
|
||||
for rcpt in recipients {
|
||||
match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await {
|
||||
Ok(resp) => {
|
||||
if resp.is_success() {
|
||||
accepted.push(rcpt.clone());
|
||||
} else {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(accepted, rejected)
|
||||
};
|
||||
|
||||
// If no recipients were accepted, fail
|
||||
if accepted.is_empty() {
|
||||
@@ -339,6 +350,7 @@ impl SmtpClientManager {
|
||||
&config.host,
|
||||
config.port,
|
||||
config.connection_timeout_secs,
|
||||
config.tls_opportunistic,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
|
||||
@@ -318,6 +318,54 @@ pub async fn send_rcpt_to(
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Send MAIL FROM + RCPT TO commands in a single pipelined batch.
|
||||
///
|
||||
/// Writes all envelope commands at once, then reads responses in order.
|
||||
/// Returns `(mail_from_ok, accepted_recipients, rejected_recipients)`.
|
||||
pub async fn send_pipelined_envelope(
|
||||
stream: &mut ClientSmtpStream,
|
||||
sender: &str,
|
||||
recipients: &[String],
|
||||
timeout_secs: u64,
|
||||
) -> Result<(bool, Vec<String>, Vec<String>), SmtpClientError> {
|
||||
// Build the full pipelined command batch
|
||||
let mut batch = format!("MAIL FROM:<{sender}>\r\n");
|
||||
for rcpt in recipients {
|
||||
batch.push_str(&format!("RCPT TO:<{rcpt}>\r\n"));
|
||||
}
|
||||
|
||||
// Send all commands at once
|
||||
debug!("SMTP C (pipelined): MAIL FROM + {} RCPT TO", recipients.len());
|
||||
stream.write_all(batch.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
// Read MAIL FROM response
|
||||
let mail_resp = read_response(stream, timeout_secs).await?;
|
||||
if !mail_resp.is_success() {
|
||||
return Err(mail_resp.to_error());
|
||||
}
|
||||
|
||||
// Read RCPT TO responses
|
||||
let mut accepted = Vec::new();
|
||||
let mut rejected = Vec::new();
|
||||
for rcpt in recipients {
|
||||
match read_response(stream, timeout_secs).await {
|
||||
Ok(resp) => {
|
||||
if resp.is_success() {
|
||||
accepted.push(rcpt.clone());
|
||||
} else {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
rejected.push(rcpt.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((true, accepted, rejected))
|
||||
}
|
||||
|
||||
/// Send DATA command, followed by the message body with dot-stuffing.
|
||||
pub async fn send_data(
|
||||
stream: &mut ClientSmtpStream,
|
||||
|
||||
@@ -50,6 +50,7 @@ pub enum SmtpCommand {
|
||||
pub enum AuthMechanism {
|
||||
Plain,
|
||||
Login,
|
||||
ScramSha256,
|
||||
}
|
||||
|
||||
/// Errors that can occur during command parsing.
|
||||
@@ -218,6 +219,7 @@ fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
|
||||
let mechanism = match mech_str.to_ascii_uppercase().as_str() {
|
||||
"PLAIN" => AuthMechanism::Plain,
|
||||
"LOGIN" => AuthMechanism::Login,
|
||||
"SCRAM-SHA-256" => AuthMechanism::ScramSha256,
|
||||
other => {
|
||||
return Err(ParseError::SyntaxError(format!(
|
||||
"unsupported AUTH mechanism: {other}"
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Per-domain TLS certificate for SNI-based cert selection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TlsDomainCert {
|
||||
/// Domain names this certificate covers (matched against SNI hostname).
|
||||
pub domains: Vec<String>,
|
||||
/// Certificate chain in PEM format.
|
||||
pub cert_pem: String,
|
||||
/// Private key in PEM format.
|
||||
pub key_pem: String,
|
||||
}
|
||||
|
||||
/// Configuration for an SMTP server instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SmtpServerConfig {
|
||||
@@ -11,10 +22,13 @@ pub struct SmtpServerConfig {
|
||||
pub ports: Vec<u16>,
|
||||
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
|
||||
pub secure_port: Option<u16>,
|
||||
/// TLS certificate chain in PEM format.
|
||||
/// TLS certificate chain in PEM format (default cert).
|
||||
pub tls_cert_pem: Option<String>,
|
||||
/// TLS private key in PEM format.
|
||||
/// TLS private key in PEM format (default key).
|
||||
pub tls_key_pem: Option<String>,
|
||||
/// Additional per-domain TLS certificates for SNI-based selection.
|
||||
#[serde(default)]
|
||||
pub additional_tls_certs: Vec<TlsDomainCert>,
|
||||
/// Maximum message size in bytes.
|
||||
pub max_message_size: u64,
|
||||
/// Maximum number of concurrent connections.
|
||||
@@ -43,6 +57,7 @@ impl Default for SmtpServerConfig {
|
||||
secure_port: None,
|
||||
tls_cert_pem: None,
|
||||
tls_key_pem: None,
|
||||
additional_tls_certs: Vec::new(),
|
||||
max_message_size: 10 * 1024 * 1024, // 10 MB
|
||||
max_connections: 100,
|
||||
max_recipients: 100,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod connection;
|
||||
pub mod data;
|
||||
pub mod rate_limiter;
|
||||
pub mod response;
|
||||
pub mod scram;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod state;
|
||||
|
||||
@@ -196,7 +196,7 @@ pub fn build_capabilities(
|
||||
caps.push("STARTTLS".to_string());
|
||||
}
|
||||
if auth_available {
|
||||
caps.push("AUTH PLAIN LOGIN".to_string());
|
||||
caps.push("AUTH PLAIN LOGIN SCRAM-SHA-256".to_string());
|
||||
}
|
||||
caps
|
||||
}
|
||||
@@ -253,7 +253,7 @@ mod tests {
|
||||
let caps = build_capabilities(10485760, true, false, true);
|
||||
assert!(caps.contains(&"SIZE 10485760".to_string()));
|
||||
assert!(caps.contains(&"STARTTLS".to_string()));
|
||||
assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||
assert!(caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
|
||||
assert!(caps.contains(&"PIPELINING".to_string()));
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ mod tests {
|
||||
// When already secure, STARTTLS should NOT be advertised
|
||||
let caps = build_capabilities(10485760, true, true, false);
|
||||
assert!(!caps.contains(&"STARTTLS".to_string()));
|
||||
assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string()));
|
||||
assert!(!caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
342
rust/crates/mailer-smtp/src/scram.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
//! SCRAM-SHA-256 server-side implementation (RFC 5802 + RFC 7677).
|
||||
//!
|
||||
//! Implements the server side of the SCRAM-SHA-256 SASL mechanism,
|
||||
//! a challenge-response protocol that avoids transmitting cleartext passwords.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Pre-computed SCRAM credentials for a user (derived from password).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScramCredentials {
|
||||
pub salt: Vec<u8>,
|
||||
pub iterations: u32,
|
||||
pub stored_key: Vec<u8>,
|
||||
pub server_key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Server-side SCRAM state machine.
|
||||
pub struct ScramServer {
|
||||
/// Username extracted from client-first-message.
|
||||
pub username: String,
|
||||
/// Full combined nonce (client + server).
|
||||
combined_nonce: String,
|
||||
/// Server nonce portion (used in tests for verification).
|
||||
#[allow(dead_code)]
|
||||
server_nonce: String,
|
||||
/// Stored credentials (set after TS responds).
|
||||
credentials: Option<ScramCredentials>,
|
||||
/// The server-first-message (for auth message construction).
|
||||
server_first: String,
|
||||
/// The client-first-message-bare (for auth message construction).
|
||||
client_first_bare: String,
|
||||
}
|
||||
|
||||
impl ScramServer {
|
||||
/// Process the client-first-message.
|
||||
///
|
||||
/// Parses the client nonce and username, generates a server nonce,
|
||||
/// and returns a partial state that needs credentials to produce the
|
||||
/// server-first-message.
|
||||
pub fn from_client_first(client_first: &str) -> Result<Self, String> {
|
||||
// client-first-message = gs2-header client-first-message-bare
|
||||
// gs2-header = "n,," (no channel binding)
|
||||
// client-first-message-bare = "n=username,r=nonce"
|
||||
let bare = if let Some(rest) = client_first.strip_prefix("n,,") {
|
||||
rest
|
||||
} else if let Some(rest) = client_first.strip_prefix("y,,") {
|
||||
rest
|
||||
} else {
|
||||
return Err("Invalid SCRAM gs2-header".into());
|
||||
};
|
||||
|
||||
let mut username = String::new();
|
||||
let mut client_nonce = String::new();
|
||||
|
||||
for part in bare.split(',') {
|
||||
if let Some(val) = part.strip_prefix("n=") {
|
||||
username = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("r=") {
|
||||
client_nonce = val.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if username.is_empty() || client_nonce.is_empty() {
|
||||
return Err("Missing username or nonce in client-first-message".into());
|
||||
}
|
||||
|
||||
// Generate server nonce
|
||||
let server_nonce: String = (0..24)
|
||||
.map(|_| {
|
||||
let idx = (rand_byte() as usize) % 62;
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let combined_nonce = format!("{}{}", client_nonce, server_nonce);
|
||||
|
||||
Ok(ScramServer {
|
||||
username,
|
||||
combined_nonce,
|
||||
server_nonce,
|
||||
credentials: None,
|
||||
server_first: String::new(),
|
||||
client_first_bare: bare.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the credentials and produce the server-first-message.
|
||||
pub fn server_first_message(&mut self, creds: ScramCredentials) -> String {
|
||||
let salt_b64 = BASE64.encode(&creds.salt);
|
||||
let server_first = format!(
|
||||
"r={},s={},i={}",
|
||||
self.combined_nonce, salt_b64, creds.iterations
|
||||
);
|
||||
|
||||
self.server_first = server_first.clone();
|
||||
self.credentials = Some(creds);
|
||||
server_first
|
||||
}
|
||||
|
||||
/// Process the client-final-message and verify the proof.
|
||||
///
|
||||
/// Returns the server-final-message (containing ServerSignature) on success,
|
||||
/// or an error string on failure.
|
||||
pub fn process_client_final(&mut self, client_final: &str) -> Result<String, String> {
|
||||
let creds = self.credentials.as_ref().ok_or("No credentials set")?;
|
||||
|
||||
// Parse client-final-message
|
||||
// Format: c=biws,r=<combined_nonce>,p=<client_proof>
|
||||
let mut channel_binding = String::new();
|
||||
let mut nonce = String::new();
|
||||
let mut proof_b64 = String::new();
|
||||
|
||||
for part in client_final.split(',') {
|
||||
if let Some(val) = part.strip_prefix("c=") {
|
||||
channel_binding = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("r=") {
|
||||
nonce = val.to_string();
|
||||
} else if let Some(val) = part.strip_prefix("p=") {
|
||||
proof_b64 = val.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Verify nonce matches
|
||||
if nonce != self.combined_nonce {
|
||||
return Err("Nonce mismatch".into());
|
||||
}
|
||||
|
||||
// Build the client-final-message-without-proof
|
||||
let client_final_without_proof = format!("c={},r={}", channel_binding, nonce);
|
||||
|
||||
// Complete the auth message
|
||||
let auth_message = format!(
|
||||
"{},{},{}",
|
||||
self.client_first_bare, self.server_first, client_final_without_proof
|
||||
);
|
||||
|
||||
// Verify client proof
|
||||
let client_proof = BASE64.decode(proof_b64.as_bytes())
|
||||
.map_err(|_| "Invalid base64 in client proof")?;
|
||||
|
||||
// ClientSignature = HMAC(StoredKey, AuthMessage)
|
||||
let client_signature = hmac_sha256(&creds.stored_key, auth_message.as_bytes());
|
||||
|
||||
// ClientKey = ClientProof XOR ClientSignature
|
||||
if client_proof.len() != client_signature.len() {
|
||||
return Err("Client proof length mismatch".into());
|
||||
}
|
||||
let client_key: Vec<u8> = client_proof
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
|
||||
// StoredKey = H(ClientKey)
|
||||
let computed_stored_key = sha256(&client_key);
|
||||
|
||||
// Verify: computed StoredKey must match the stored StoredKey
|
||||
if computed_stored_key != creds.stored_key {
|
||||
return Err("Authentication failed".into());
|
||||
}
|
||||
|
||||
// Generate ServerSignature for mutual authentication
|
||||
let server_signature = hmac_sha256(&creds.server_key, auth_message.as_bytes());
|
||||
let server_sig_b64 = BASE64.encode(&server_signature);
|
||||
|
||||
Ok(format!("v={}", server_sig_b64))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute SCRAM credentials from a plaintext password (for TS to pre-compute).
|
||||
pub fn compute_scram_credentials(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
|
||||
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations)
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
// ClientKey = HMAC(SaltedPassword, "Client Key")
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
|
||||
// StoredKey = H(ClientKey)
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
// ServerKey = HMAC(SaltedPassword, "Server Key")
|
||||
let server_key = hmac_sha256(&salted_password, b"Server Key");
|
||||
|
||||
ScramCredentials {
|
||||
salt: salt.to_vec(),
|
||||
iterations,
|
||||
stored_key,
|
||||
server_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn sha256(data: &[u8]) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Simple random byte using system randomness.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let state = RandomState::new();
|
||||
let mut hasher = state.build_hasher();
|
||||
hasher.write_u64(std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64);
|
||||
hasher.finish() as u8
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scram_full_exchange() {
|
||||
let password = "pencil";
|
||||
let salt = b"test-salt-1234";
|
||||
let iterations = 4096;
|
||||
|
||||
// Pre-compute server-side credentials from password
|
||||
let creds = compute_scram_credentials(password, salt, iterations);
|
||||
|
||||
// 1. Client sends client-first-message
|
||||
let client_first = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||
assert_eq!(server.username, "user");
|
||||
|
||||
// 2. Server responds with server-first-message
|
||||
let server_first = server.server_first_message(creds.clone());
|
||||
assert!(server_first.starts_with(&format!("r=rOprNGfwEbeRWgbNEkqO{}", server.server_nonce)));
|
||||
assert!(server_first.contains("s="));
|
||||
assert!(server_first.contains("i=4096"));
|
||||
|
||||
// 3. Client computes proof
|
||||
// SaltedPassword
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
let client_first_bare = "n=user,r=rOprNGfwEbeRWgbNEkqO";
|
||||
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||
|
||||
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||
let client_proof: Vec<u8> = client_key
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
let proof_b64 = BASE64.encode(&client_proof);
|
||||
|
||||
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||
|
||||
// 4. Server verifies proof
|
||||
let result = server.process_client_final(&client_final);
|
||||
assert!(result.is_ok(), "SCRAM verification failed: {:?}", result.err());
|
||||
let server_final = result.unwrap();
|
||||
assert!(server_final.starts_with("v="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scram_wrong_password() {
|
||||
let password = "pencil";
|
||||
let wrong_password = "wrong";
|
||||
let salt = b"test-salt";
|
||||
let iterations = 4096;
|
||||
|
||||
let creds = compute_scram_credentials(password, salt, iterations);
|
||||
|
||||
let client_first = "n,,n=user,r=clientnonce123";
|
||||
let mut server = ScramServer::from_client_first(client_first).unwrap();
|
||||
let server_first = server.server_first_message(creds);
|
||||
|
||||
// Client computes proof with wrong password
|
||||
let mut salted_password = [0u8; 32];
|
||||
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||
wrong_password.as_bytes(),
|
||||
salt,
|
||||
iterations,
|
||||
&mut salted_password,
|
||||
);
|
||||
|
||||
let client_key = hmac_sha256(&salted_password, b"Client Key");
|
||||
let stored_key = sha256(&client_key);
|
||||
|
||||
let client_first_bare = "n=user,r=clientnonce123";
|
||||
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
|
||||
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
|
||||
|
||||
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
|
||||
let client_proof: Vec<u8> = client_key
|
||||
.iter()
|
||||
.zip(client_signature.iter())
|
||||
.map(|(a, b)| a ^ b)
|
||||
.collect();
|
||||
let proof_b64 = BASE64.encode(&client_proof);
|
||||
|
||||
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
|
||||
let result = server.process_client_final(&client_final);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_scram_credentials() {
|
||||
let creds = compute_scram_credentials("password", b"salt", 4096);
|
||||
assert_eq!(creds.salt, b"salt");
|
||||
assert_eq!(creds.iterations, 4096);
|
||||
assert_eq!(creds.stored_key.len(), 32);
|
||||
assert_eq!(creds.server_key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_client_first() {
|
||||
assert!(ScramServer::from_client_first("invalid").is_err());
|
||||
assert!(ScramServer::from_client_first("n,,").is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use crate::rate_limiter::{RateLimitConfig, RateLimiter};
|
||||
use hickory_resolver::TokioResolver;
|
||||
use mailer_security::MessageAuthenticator;
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufReader;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -263,6 +264,69 @@ async fn accept_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// SNI-based certificate resolver that selects the appropriate TLS certificate
|
||||
/// based on the client's requested hostname.
|
||||
struct SniCertResolver {
|
||||
/// Domain -> certified key mapping.
|
||||
certs: HashMap<String, Arc<rustls::sign::CertifiedKey>>,
|
||||
/// Default certificate for non-matching SNI or missing SNI.
|
||||
default: Arc<rustls::sign::CertifiedKey>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SniCertResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SniCertResolver")
|
||||
.field("domains", &self.certs.keys().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl rustls::server::ResolvesServerCert for SniCertResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
client_hello: rustls::server::ClientHello<'_>,
|
||||
) -> Option<Arc<rustls::sign::CertifiedKey>> {
|
||||
if let Some(sni) = client_hello.server_name() {
|
||||
let sni_lower = sni.to_lowercase();
|
||||
if let Some(key) = self.certs.get(&sni_lower) {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
Some(self.default.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PEM cert+key pair into a `CertifiedKey`.
|
||||
fn parse_certified_key(
|
||||
cert_pem: &str,
|
||||
key_pem: &str,
|
||||
) -> Result<rustls::sign::CertifiedKey, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let certs: Vec<CertificateDer<'static>> = {
|
||||
let mut reader = BufReader::new(cert_pem.as_bytes());
|
||||
rustls_pemfile::certs(&mut reader).collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
if certs.is_empty() {
|
||||
return Err("No certificates found in PEM".into());
|
||||
}
|
||||
|
||||
let key: PrivateKeyDer<'static> = {
|
||||
let mut reader = BufReader::new(key_pem.as_bytes());
|
||||
let mut keys = Vec::new();
|
||||
for item in rustls_pemfile::read_all(&mut reader) {
|
||||
match item? {
|
||||
rustls_pemfile::Item::Pkcs8Key(key) => keys.push(PrivateKeyDer::Pkcs8(key)),
|
||||
rustls_pemfile::Item::Pkcs1Key(key) => keys.push(PrivateKeyDer::Pkcs1(key)),
|
||||
rustls_pemfile::Item::Sec1Key(key) => keys.push(PrivateKeyDer::Sec1(key)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
keys.into_iter().next().ok_or("No private key found in PEM")?
|
||||
};
|
||||
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
|
||||
Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
|
||||
}
|
||||
|
||||
/// Build a TLS acceptor from PEM cert/key strings.
|
||||
fn build_tls_acceptor(
|
||||
config: &SmtpServerConfig,
|
||||
@@ -311,9 +375,42 @@ fn build_tls_acceptor(
|
||||
.ok_or("No private key found in PEM")?
|
||||
};
|
||||
|
||||
let tls_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
// If additional TLS certs are configured, use SNI-based resolution
|
||||
let tls_config = if config.additional_tls_certs.is_empty() {
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?
|
||||
} else {
|
||||
// Build default certified key
|
||||
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
|
||||
let default_ck = Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key));
|
||||
|
||||
// Build per-domain certs
|
||||
let mut domain_certs = HashMap::new();
|
||||
for domain_cert in &config.additional_tls_certs {
|
||||
match parse_certified_key(&domain_cert.cert_pem, &domain_cert.key_pem) {
|
||||
Ok(ck) => {
|
||||
let ck = Arc::new(ck);
|
||||
for domain in &domain_cert.domains {
|
||||
domain_certs.insert(domain.to_lowercase(), ck.clone());
|
||||
}
|
||||
info!("SNI cert loaded for domains: {:?}", domain_cert.domains);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load SNI cert for domains {:?}: {}", domain_cert.domains, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resolver = SniCertResolver {
|
||||
certs: domain_certs,
|
||||
default: default_ck,
|
||||
};
|
||||
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(resolver))
|
||||
};
|
||||
|
||||
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
|
||||
}
|
||||
|
||||
295
test/test.mta.delivery.node.ts
Normal file
295
test/test.mta.delivery.node.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||
import { Email } from '../ts/mail/core/classes.email.js';
|
||||
import * as net from 'net';
|
||||
import * as dns from 'dns';
|
||||
|
||||
const storageMap = new Map<string, string>();
|
||||
const mockDcRouter = {
|
||||
storageManager: {
|
||||
get: async (key: string) => storageMap.get(key) || null,
|
||||
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||
},
|
||||
};
|
||||
|
||||
let server: UnifiedEmailServer;
|
||||
let bridgeAvailable = false;
|
||||
let mockSmtpServer: net.Server;
|
||||
|
||||
/**
|
||||
* Create a minimal mock SMTP server that accepts any email.
|
||||
*/
|
||||
function createMockSmtpServer(port: number): Promise<net.Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer((socket) => {
|
||||
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
|
||||
|
||||
let inData = false;
|
||||
let dataBuffer = '';
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
const input = chunk.toString();
|
||||
|
||||
if (inData) {
|
||||
dataBuffer += input;
|
||||
if (dataBuffer.includes('\r\n.\r\n')) {
|
||||
inData = false;
|
||||
dataBuffer = '';
|
||||
socket.write('250 2.0.0 Ok: queued\r\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
|
||||
for (const line of lines) {
|
||||
const cmd = line.toUpperCase();
|
||||
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||||
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
|
||||
} else if (cmd.startsWith('MAIL FROM')) {
|
||||
socket.write('250 2.1.0 Ok\r\n');
|
||||
} else if (cmd.startsWith('RCPT TO')) {
|
||||
socket.write('250 2.1.5 Ok\r\n');
|
||||
} else if (cmd === 'DATA') {
|
||||
inData = true;
|
||||
dataBuffer = '';
|
||||
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||
} else if (cmd === 'QUIT') {
|
||||
socket.write('221 2.0.0 Bye\r\n');
|
||||
socket.end();
|
||||
} else if (cmd === 'RSET') {
|
||||
socket.write('250 2.0.0 Ok\r\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
srv.listen(port, '127.0.0.1', () => {
|
||||
resolve(srv);
|
||||
});
|
||||
|
||||
srv.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Store original resolveMx so we can restore it
|
||||
const originalResolveMx = dns.promises.Resolver.prototype.resolveMx;
|
||||
|
||||
tap.test('setup - start server and mock SMTP', async () => {
|
||||
RustSecurityBridge.resetInstance();
|
||||
|
||||
server = new UnifiedEmailServer(mockDcRouter, {
|
||||
ports: [10425],
|
||||
hostname: 'test.mta.local',
|
||||
domains: [
|
||||
{ domain: 'mta-test.com', dnsMode: 'forward' },
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'mta-route',
|
||||
priority: 10,
|
||||
match: { recipients: '*@mta-test.com' },
|
||||
action: { type: 'deliver' },
|
||||
},
|
||||
{
|
||||
name: 'process-route',
|
||||
priority: 20,
|
||||
match: { recipients: '*@process-test.com' },
|
||||
action: {
|
||||
type: 'process',
|
||||
options: {
|
||||
contentScanning: true,
|
||||
scanners: [{ type: 'spam' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
bridgeAvailable = true;
|
||||
} catch (err) {
|
||||
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||
return;
|
||||
}
|
||||
|
||||
mockSmtpServer = await createMockSmtpServer(10426);
|
||||
});
|
||||
|
||||
tap.test('MX resolution for a public domain', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Use the delivery system's resolveMxForDomain via a quick DNS lookup
|
||||
const resolver = new dns.promises.Resolver();
|
||||
try {
|
||||
const records = await resolver.resolveMx('gmail.com');
|
||||
expect(records).toBeTruthy();
|
||||
expect(records.length).toBeGreaterThan(0);
|
||||
// Each record should have exchange and priority
|
||||
for (const rec of records) {
|
||||
expect(typeof rec.exchange).toEqual('string');
|
||||
expect(typeof rec.priority).toEqual('number');
|
||||
}
|
||||
console.log(`Resolved ${records.length} MX records for gmail.com`);
|
||||
} catch (err) {
|
||||
console.log(`SKIP: DNS resolution failed (network may be unavailable): ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('group recipients by domain', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Test the grouping logic directly
|
||||
const recipients = [
|
||||
'alice@example.com',
|
||||
'bob@example.com',
|
||||
'carol@other.org',
|
||||
'dave@example.com',
|
||||
'eve@other.org',
|
||||
];
|
||||
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const rcpt of recipients) {
|
||||
const domain = rcpt.split('@')[1]?.toLowerCase();
|
||||
if (!domain) continue;
|
||||
const list = groups.get(domain) || [];
|
||||
list.push(rcpt);
|
||||
groups.set(domain, list);
|
||||
}
|
||||
|
||||
expect(groups.size).toEqual(2);
|
||||
expect(groups.get('example.com')!.length).toEqual(3);
|
||||
expect(groups.get('other.org')!.length).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('MTA delivery to mock SMTP server via mocked MX', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Mock dns.promises.Resolver.resolveMx to return 127.0.0.1
|
||||
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||||
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||||
};
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@mta-test.com',
|
||||
to: 'recipient@target-domain.com',
|
||||
subject: 'MTA Delivery Test',
|
||||
text: 'Testing MTA delivery with MX resolution.',
|
||||
});
|
||||
|
||||
// Use sendOutboundEmail at the resolved MX host (which is mocked to 127.0.0.1)
|
||||
// But the real test is the delivery system's handleMtaDelivery, which we test
|
||||
// by sending through the server's outbound path with the mock MX.
|
||||
|
||||
// Direct test: resolve MX then send
|
||||
const resolver = new dns.promises.Resolver();
|
||||
const mxRecords = await resolver.resolveMx('target-domain.com');
|
||||
expect(mxRecords[0].exchange).toEqual('127.0.0.1');
|
||||
|
||||
// Send via the resolved MX host to the mock SMTP server on port 10425
|
||||
// Note: MTA delivery uses port 25 by default, but our mock is on 10425.
|
||||
// We test the sendOutboundEmail path directly with the mock MX host.
|
||||
const result = await server.sendOutboundEmail('127.0.0.1', 10426, email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.accepted.length).toBeGreaterThan(0);
|
||||
expect(result.response).toInclude('2.0.0');
|
||||
|
||||
// Restore original resolveMx
|
||||
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||
});
|
||||
|
||||
tap.test('MTA delivery - connection refused to unreachable MX', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@mta-test.com',
|
||||
to: 'recipient@unreachable-domain.com',
|
||||
subject: 'Connection Refused MX Test',
|
||||
text: 'This should fail — no server at the target.',
|
||||
});
|
||||
|
||||
// Send to a port that nothing is listening on
|
||||
try {
|
||||
await server.sendOutboundEmail('127.0.0.1', 59789, email);
|
||||
throw new Error('Expected sendOutboundEmail to fail');
|
||||
} catch (err: any) {
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.message.length).toBeGreaterThan(0);
|
||||
console.log(`Got expected error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('MTA delivery with multiple recipients across domains', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Mock MX to return 127.0.0.1 for all domains
|
||||
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
|
||||
return [{ exchange: '127.0.0.1', priority: 10 }];
|
||||
};
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@mta-test.com',
|
||||
to: ['alice@domain-a.com', 'bob@domain-b.com'],
|
||||
subject: 'Multi-Domain MTA Test',
|
||||
text: 'Testing delivery to multiple domains.',
|
||||
});
|
||||
|
||||
// Send to each recipient's domain individually (simulating MTA behavior)
|
||||
for (const recipient of email.to) {
|
||||
const singleEmail = new Email({
|
||||
from: email.from,
|
||||
to: recipient,
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
});
|
||||
const result = await server.sendOutboundEmail('127.0.0.1', 10426, singleEmail);
|
||||
expect(result.accepted.length).toEqual(1);
|
||||
}
|
||||
|
||||
// Restore original resolveMx
|
||||
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||
});
|
||||
|
||||
tap.test('E2E: send real email to hello@task.vc via MX resolution', async () => {
|
||||
if (!bridgeAvailable) { console.log('SKIP'); return; }
|
||||
|
||||
// Resolve real MX records for task.vc
|
||||
const resolver = new dns.promises.Resolver();
|
||||
const mxRecords = await resolver.resolveMx('task.vc');
|
||||
expect(mxRecords.length).toBeGreaterThan(0);
|
||||
const mxHost = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange;
|
||||
console.log(`Resolved MX for task.vc: ${mxHost} (priority ${mxRecords[0].priority})`);
|
||||
|
||||
const email = new Email({
|
||||
from: 'test@mta-test.com',
|
||||
to: 'hello@task.vc',
|
||||
subject: `MTA E2E Test — ${new Date().toISOString()}`,
|
||||
text: 'This is an automated E2E test from @serve.zone/mailer verifying real MX resolution and outbound SMTP delivery.',
|
||||
});
|
||||
|
||||
const result = await server.sendOutboundEmail(mxHost, 25, email);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.accepted).toBeTruthy();
|
||||
expect(result.accepted.length).toEqual(1);
|
||||
expect(result.accepted[0]).toEqual('hello@task.vc');
|
||||
expect(result.response).toInclude('2.0.0');
|
||||
console.log(`Email delivered to hello@task.vc via ${mxHost}: ${result.response}`);
|
||||
});
|
||||
|
||||
tap.test('cleanup - stop server and mock SMTP', async () => {
|
||||
// Restore MX resolver in case it wasn't restored
|
||||
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
|
||||
|
||||
// Force-close mock server (destroy all open sockets)
|
||||
if (mockSmtpServer) {
|
||||
mockSmtpServer.close();
|
||||
}
|
||||
if (bridgeAvailable) {
|
||||
await server.stop();
|
||||
}
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartmta',
|
||||
version: '5.0.0',
|
||||
version: '5.2.0',
|
||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||
}
|
||||
|
||||
3
ts/index.ts
Normal file
3
ts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './00_commitinfo_data.js';
|
||||
export * from './mail/index.js';
|
||||
export * from './security/index.js';
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
SecurityEventType
|
||||
} from '../../security/index.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||
|
||||
const dns = plugins.dns;
|
||||
|
||||
/**
|
||||
* Delivery status enumeration
|
||||
*/
|
||||
@@ -480,39 +482,119 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for a domain, sorted by priority (lowest first).
|
||||
* Falls back to the domain itself as an A record per RFC 5321.
|
||||
*/
|
||||
private async resolveMxForDomain(domain: string): Promise<Array<{ exchange: string; priority: number }>> {
|
||||
const resolver = new dns.promises.Resolver();
|
||||
try {
|
||||
const mxRecords = await resolver.resolveMx(domain);
|
||||
return mxRecords.sort((a, b) => a.priority - b.priority);
|
||||
} catch (err) {
|
||||
logger.log('warn', `No MX records for ${domain}, falling back to A record`);
|
||||
return [{ exchange: domain, priority: 0 }];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group recipient addresses by their domain part.
|
||||
*/
|
||||
private groupRecipientsByDomain(recipients: string[]): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const rcpt of recipients) {
|
||||
const domain = rcpt.split('@')[1]?.toLowerCase();
|
||||
if (!domain) continue;
|
||||
const list = groups.get(domain) || [];
|
||||
list.push(rcpt);
|
||||
groups.set(domain, list);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for MTA mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleMtaDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `MTA delivery for item ${item.id}`);
|
||||
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const route = item.route;
|
||||
|
||||
try {
|
||||
// Apply DKIM signing if configured in the route
|
||||
if (item.route?.action.options?.mtaOptions?.dkimSign) {
|
||||
await this.applyDkimSigning(email, item.route.action.options.mtaOptions);
|
||||
}
|
||||
|
||||
// In a full implementation, this would use the MTA service
|
||||
// For now, we'll simulate a successful delivery
|
||||
|
||||
logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`);
|
||||
|
||||
// Note: The MTA implementation would handle actual local delivery
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
dkimSigned: !!item.route?.action.options?.mtaOptions?.dkimSign
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
|
||||
throw error;
|
||||
|
||||
if (!this.emailServer) {
|
||||
throw new Error('No email server available for MTA delivery');
|
||||
}
|
||||
|
||||
// Build DKIM options from route config
|
||||
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
|
||||
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
|
||||
: undefined;
|
||||
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
|
||||
|
||||
const allRecipients = email.getAllRecipients();
|
||||
if (allRecipients.length === 0) {
|
||||
throw new Error('No recipients specified for MTA delivery');
|
||||
}
|
||||
|
||||
const domainGroups = this.groupRecipientsByDomain(allRecipients);
|
||||
const results: Array<{ domain: string; success: boolean; error?: string; accepted?: string[]; rejected?: string[] }> = [];
|
||||
|
||||
for (const [domain, recipients] of domainGroups) {
|
||||
const mxHosts = await this.resolveMxForDomain(domain);
|
||||
let delivered = false;
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (const mx of mxHosts) {
|
||||
try {
|
||||
logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
|
||||
|
||||
// Create a temporary Email scoped to this domain's recipients
|
||||
const domainEmail = new Email({
|
||||
from: email.from,
|
||||
to: recipients.filter(r => email.to.includes(r)),
|
||||
cc: recipients.filter(r => (email.cc || []).includes(r)),
|
||||
bcc: recipients.filter(r => (email.bcc || []).includes(r)),
|
||||
subject: email.subject,
|
||||
text: email.text,
|
||||
html: email.html,
|
||||
});
|
||||
|
||||
const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
|
||||
dkimDomain,
|
||||
dkimSelector,
|
||||
});
|
||||
|
||||
results.push({
|
||||
domain,
|
||||
success: true,
|
||||
accepted: result.accepted,
|
||||
rejected: result.rejected,
|
||||
});
|
||||
delivered = true;
|
||||
logger.log('info', `MTA: delivered to ${domain} via ${mx.exchange}`);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
lastError = err.message;
|
||||
logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!delivered) {
|
||||
results.push({ domain, success: false, error: lastError });
|
||||
logger.log('error', `MTA: all MX hosts failed for ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
const allFailed = results.every(r => !r.success);
|
||||
if (allFailed) {
|
||||
const summary = results.map(r => `${r.domain}: ${r.error}`).join('; ');
|
||||
throw new Error(`MTA delivery failed for all domains: ${summary}`);
|
||||
}
|
||||
|
||||
return {
|
||||
recipients: allRecipients.length,
|
||||
domainResults: results,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,16 +666,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
||||
await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
|
||||
}
|
||||
|
||||
logger.log('info', `Email successfully processed in store-and-forward mode`);
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
scanned: !!route?.action.options?.contentScanning,
|
||||
transformed: !!(route?.action.options?.transformations && route?.action.options?.transformations.length > 0),
|
||||
dkimSigned: !!(item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim)
|
||||
};
|
||||
logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`);
|
||||
|
||||
// After scanning + transformations, deliver via MTA
|
||||
return await this.handleMtaDelivery(item);
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to process email: ${error.message}`);
|
||||
throw error;
|
||||
|
||||
@@ -131,11 +131,15 @@ export class DkimManager {
|
||||
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
|
||||
const rawEmail = email.toRFC822String();
|
||||
|
||||
// Detect key type from PEM header
|
||||
const keyType = privateKey.includes('ED25519') ? 'ed25519' : 'rsa';
|
||||
|
||||
const signResult = await this.rustBridge.signDkim({
|
||||
rawMessage: rawEmail,
|
||||
domain,
|
||||
selector,
|
||||
privateKey,
|
||||
keyType,
|
||||
});
|
||||
|
||||
if (signResult.header) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface IActionExecutorDeps {
|
||||
auth?: { user: string; pass: string };
|
||||
dkimDomain?: string;
|
||||
dkimSelector?: string;
|
||||
tlsOpportunistic?: boolean;
|
||||
}) => Promise<ISmtpSendResult>;
|
||||
bounceManager: BounceManager;
|
||||
deliveryQueue: UnifiedDeliveryQueue;
|
||||
|
||||
@@ -285,6 +285,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
auth?: { user: string; pass: string };
|
||||
dkimDomain?: string;
|
||||
dkimSelector?: string;
|
||||
tlsOpportunistic?: boolean;
|
||||
}): Promise<ISmtpSendResult> {
|
||||
// Build DKIM config if domain has keys
|
||||
let dkim: { domain: string; selector: string; privateKey: string } | undefined;
|
||||
@@ -321,6 +322,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
|
||||
poolKey: `${host}:${port}`,
|
||||
maxPoolConnections: this.options.outbound?.maxConnections || 10,
|
||||
tlsOpportunistic: options?.tlsOpportunistic ?? (port === 25),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -416,6 +418,22 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.rustBridge.onScramCredentialRequest(async (data) => {
|
||||
try {
|
||||
await this.handleScramCredentialRequest(data);
|
||||
} catch (err) {
|
||||
logger.log('error', `Error handling SCRAM credential request: ${(err as Error).message}`);
|
||||
try {
|
||||
await this.rustBridge.sendScramCredentialResult({
|
||||
correlationId: data.correlationId,
|
||||
found: false,
|
||||
});
|
||||
} catch (sendErr) {
|
||||
logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async startSmtpServer(): Promise<void> {
|
||||
@@ -622,6 +640,53 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a SCRAM credential request from the Rust SMTP server.
|
||||
* Computes SCRAM-SHA-256 credentials from the stored password for the given user.
|
||||
*/
|
||||
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
|
||||
const { correlationId, username, remoteAddr } = data;
|
||||
const crypto = await import('crypto');
|
||||
|
||||
logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
|
||||
|
||||
const users = this.options.auth?.users || [];
|
||||
const matched = users.find(u => u.username === username);
|
||||
|
||||
if (!matched) {
|
||||
await this.rustBridge.sendScramCredentialResult({
|
||||
correlationId,
|
||||
found: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute SCRAM-SHA-256 credentials from plaintext password
|
||||
const salt = crypto.randomBytes(16);
|
||||
const iterations = 4096;
|
||||
|
||||
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
|
||||
const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
|
||||
|
||||
// ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
|
||||
const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
|
||||
|
||||
// StoredKey = SHA256(ClientKey)
|
||||
const storedKey = crypto.createHash('sha256').update(clientKey).digest();
|
||||
|
||||
// ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
|
||||
const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
|
||||
|
||||
await this.rustBridge.sendScramCredentialResult({
|
||||
correlationId,
|
||||
found: true,
|
||||
salt: salt.toString('base64'),
|
||||
iterations,
|
||||
storedKey: storedKey.toString('base64'),
|
||||
serverKey: serverKey.toString('base64'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
|
||||
* or falling back to IPC call if no pre-computed results are available.
|
||||
|
||||
@@ -115,7 +115,7 @@ export class DKIMCreator {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a DKIM key pair - changed to public for API access
|
||||
// Create an RSA DKIM key pair - changed to public for API access
|
||||
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||||
modulusLength: 2048,
|
||||
@@ -126,6 +126,16 @@ export class DKIMCreator {
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Create an Ed25519 DKIM key pair (RFC 8463)
|
||||
public async createEd25519Keys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('ed25519', {
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Store a DKIM key pair - uses storage manager if available, else disk
|
||||
public async storeDKIMKeys(
|
||||
privateKey: string,
|
||||
@@ -176,8 +186,11 @@ export class DKIMCreator {
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
// Detect key type from PEM header
|
||||
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
|
||||
|
||||
// Now generate the DKIM DNS TXT record
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
|
||||
|
||||
return {
|
||||
name: `mta._domainkey.${domainArg}`,
|
||||
@@ -375,8 +388,11 @@ export class DKIMCreator {
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
// Detect key type from PEM header
|
||||
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
|
||||
|
||||
// Generate the DKIM DNS TXT record
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
|
||||
|
||||
return {
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
|
||||
@@ -95,11 +95,12 @@ interface ISmtpSendOptions {
|
||||
domain?: string;
|
||||
auth?: { user: string; pass: string; method?: string };
|
||||
email: IOutboundEmail;
|
||||
dkim?: { domain: string; selector: string; privateKey: string };
|
||||
dkim?: { domain: string; selector: string; privateKey: string; keyType?: string };
|
||||
connectionTimeoutSecs?: number;
|
||||
socketTimeoutSecs?: number;
|
||||
poolKey?: string;
|
||||
maxPoolConnections?: number;
|
||||
tlsOpportunistic?: boolean;
|
||||
}
|
||||
|
||||
interface ISmtpSendRawOptions {
|
||||
@@ -147,6 +148,7 @@ interface ISmtpServerConfig {
|
||||
securePort?: number;
|
||||
tlsCertPem?: string;
|
||||
tlsKeyPem?: string;
|
||||
additionalTlsCerts?: Array<{ domains: string[]; certPem: string; keyPem: string }>;
|
||||
maxMessageSize?: number;
|
||||
maxConnections?: number;
|
||||
maxRecipients?: number;
|
||||
@@ -193,6 +195,13 @@ interface IAuthRequestEvent {
|
||||
remoteAddr: string;
|
||||
}
|
||||
|
||||
interface IScramCredentialRequestEvent {
|
||||
correlationId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
remoteAddr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe command map for the mailer-bin IPC bridge.
|
||||
*/
|
||||
@@ -222,7 +231,7 @@ type TMailerCommands = {
|
||||
result: IDkimVerificationResult[];
|
||||
};
|
||||
signDkim: {
|
||||
params: { rawMessage: string; domain: string; selector?: string; privateKey: string };
|
||||
params: { rawMessage: string; domain: string; selector?: string; privateKey: string; keyType?: string };
|
||||
result: { header: string; signedMessage: string };
|
||||
};
|
||||
checkSpf: {
|
||||
@@ -273,6 +282,17 @@ type TMailerCommands = {
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
scramCredentialResult: {
|
||||
params: {
|
||||
correlationId: string;
|
||||
found: boolean;
|
||||
salt?: string;
|
||||
iterations?: number;
|
||||
storedKey?: string;
|
||||
serverKey?: string;
|
||||
};
|
||||
result: { resolved: boolean };
|
||||
};
|
||||
configureRateLimits: {
|
||||
params: IRateLimitConfig;
|
||||
result: { configured: boolean };
|
||||
@@ -706,12 +726,13 @@ export class RustSecurityBridge extends EventEmitter {
|
||||
return this.bridge.sendCommand('verifyDkim', { rawMessage });
|
||||
}
|
||||
|
||||
/** Sign an email with DKIM. */
|
||||
/** Sign an email with DKIM (RSA or Ed25519). */
|
||||
public async signDkim(opts: {
|
||||
rawMessage: string;
|
||||
domain: string;
|
||||
selector?: string;
|
||||
privateKey: string;
|
||||
keyType?: string;
|
||||
}): Promise<{ header: string; signedMessage: string }> {
|
||||
this.ensureRunning();
|
||||
return this.bridge.sendCommand('signDkim', opts);
|
||||
@@ -829,6 +850,22 @@ export class RustSecurityBridge extends EventEmitter {
|
||||
await this.bridge.sendCommand('authResult', opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SCRAM credentials back to the Rust SMTP server.
|
||||
* Values (salt, storedKey, serverKey) must be base64-encoded.
|
||||
*/
|
||||
public async sendScramCredentialResult(opts: {
|
||||
correlationId: string;
|
||||
found: boolean;
|
||||
salt?: string;
|
||||
iterations?: number;
|
||||
storedKey?: string;
|
||||
serverKey?: string;
|
||||
}): Promise<void> {
|
||||
this.ensureRunning();
|
||||
await this.bridge.sendCommand('scramCredentialResult', opts);
|
||||
}
|
||||
|
||||
/** Update rate limit configuration at runtime. */
|
||||
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
||||
this.ensureRunning();
|
||||
@@ -855,6 +892,14 @@ export class RustSecurityBridge extends EventEmitter {
|
||||
this.bridge.on('management:authRequest', handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for scramCredentialRequest events from the Rust SMTP server.
|
||||
* The handler must call sendScramCredentialResult() with the correlationId.
|
||||
*/
|
||||
public onScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
|
||||
this.bridge.on('management:scramCredentialRequest', handler);
|
||||
}
|
||||
|
||||
/** Remove an emailReceived event handler. */
|
||||
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
|
||||
this.bridge.off('management:emailReceived', handler);
|
||||
@@ -864,6 +909,11 @@ export class RustSecurityBridge extends EventEmitter {
|
||||
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
|
||||
this.bridge.off('management:authRequest', handler);
|
||||
}
|
||||
|
||||
/** Remove a scramCredentialRequest event handler. */
|
||||
public offScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
|
||||
this.bridge.off('management:scramCredentialRequest', handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export interfaces for consumers
|
||||
@@ -882,6 +932,7 @@ export type {
|
||||
IEmailData,
|
||||
IEmailReceivedEvent,
|
||||
IAuthRequestEvent,
|
||||
IScramCredentialRequestEvent,
|
||||
IOutboundEmail,
|
||||
ISmtpSendResult,
|
||||
ISmtpSendOptions,
|
||||
|
||||
Reference in New Issue
Block a user