From 5a3ac2eed9a396282e4cfd5a5e7e264bfa907e51 Mon Sep 17 00:00:00 2001 From: Shautvast Date: Mon, 2 Mar 2026 16:27:46 +0100 Subject: [PATCH] secured bridge communication --- Cargo.lock | 151 ++++++++++++++++++++++++++++++- README.md | 6 +- proton-bridge/Cargo.toml | 3 + proton-bridge/src/imap_server.rs | 30 ++++-- proton-bridge/src/lib.rs | 48 ++++++++-- proton-bridge/src/smtp_server.rs | 28 ++++-- src/config.rs | 10 +- src/connect.rs | 10 +- src/main.rs | 23 +++-- src/setup.rs | 2 + src/smtp.rs | 12 ++- 11 files changed, 274 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac544c0..959171b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,6 +336,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -587,6 +609,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -676,6 +700,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1226,6 +1259,12 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "eax" version = "0.5.0" @@ -1553,6 +1592,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -2188,6 +2233,16 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -2939,6 +2994,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3362,11 +3427,14 @@ dependencies = [ "pgp", "pwhash", "rand 0.8.5", + "rcgen", "reqwest", + "rustls", "serde", "serde_json", "sha2 0.10.9", "tokio", + "tokio-rustls", "toml", "tracing", ] @@ -3601,6 +3669,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3702,6 +3783,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ripemd" version = "0.1.3" @@ -3788,6 +3883,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3797,6 +3907,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4237,9 +4359,9 @@ dependencies = [ [[package]] name = "subtle" -version = "2.4.1" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -4500,6 +4622,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "1.0.2+spec-1.1.0" @@ -4765,6 +4897,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5441,6 +5579,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/README.md b/README.md index 92df014..4f99c94 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# tuimail — User Guide +# Tuimail Tuimail is a terminal email client. It shows your inbox in a split-pane view: the email list on top, the message preview on the bottom. -#### Safety features +### Safety features - no html rendering (conversion to plain text) - no tracking pixels - no javascript @@ -12,7 +12,7 @@ the email list on top, the message preview on the bottom. - safe communication to remote hosts (standard TLS, PGP for Proton) --- -### Configuring +# Configuring Sorry, there is no binary release for now. This setup requires that you have rust cargo installed. Run the setup wizard: diff --git a/proton-bridge/Cargo.toml b/proton-bridge/Cargo.toml index aff55dd..d7c915c 100644 --- a/proton-bridge/Cargo.toml +++ b/proton-bridge/Cargo.toml @@ -19,6 +19,9 @@ pgp = { version = "0.15", default-features = false } # rpgp — OpenPGP decrypt chrono = "0.4" env_logger = "0.11" tracing = "0.1.44" +rcgen = "0.13" +tokio-rustls = "0.26" +rustls = "0.23" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/proton-bridge/src/imap_server.rs b/proton-bridge/src/imap_server.rs index 6b95465..cb8549f 100644 --- a/proton-bridge/src/imap_server.rs +++ b/proton-bridge/src/imap_server.rs @@ -5,9 +5,10 @@ /// Each accepted connection is handled in its own tokio task. use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{TcpListener, TcpStream}; -use tracing::{debug, error, info, warn}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; +use tracing::{debug, error, warn}; use crate::api::{ApiClient, LABEL_INBOX}; use crate::{crypto, SharedState}; @@ -18,12 +19,20 @@ use crate::{crypto, SharedState}; pub async fn run_with_listener( state: SharedState, listener: TcpListener, + acceptor: TlsAcceptor, ) -> Result<(), Box> { loop { let (socket, _addr) = listener.accept().await?; + let tls_stream = match acceptor.accept(socket).await { + Ok(s) => s, + Err(e) => { + error!("IMAP TLS handshake failed: {e}"); + continue; + } + }; let state = Arc::clone(&state); tokio::spawn(async move { - if let Err(e) = handle_connection(socket, state).await { + if let Err(e) = handle_connection(tls_stream, state).await { error!("IMAP connection error: {e}"); } }); @@ -32,12 +41,15 @@ pub async fn run_with_listener( // ── Per-connection handler ──────────────────────────────────────────────────── -async fn handle_connection( - socket: TcpStream, +async fn handle_connection( + socket: S, state: SharedState, -) -> Result<(), Box> { - let (reader, mut writer) = socket.into_split(); - let mut lines = BufReader::new(reader).lines(); +) -> Result<(), Box> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let (rd, mut writer) = tokio::io::split(socket); + let mut lines = BufReader::new(rd).lines(); let mut authenticated = false; writer diff --git a/proton-bridge/src/lib.rs b/proton-bridge/src/lib.rs index 03e52e6..194c9c9 100644 --- a/proton-bridge/src/lib.rs +++ b/proton-bridge/src/lib.rs @@ -33,14 +33,15 @@ pub type SharedState = Arc>; // ── Public entry point ──────────────────────────────────────────────────────── -/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return `Ok(())`. +/// Authenticate with ProtonMail, bind IMAP and SMTP ports, then return the +/// self-signed TLS certificate (DER bytes) used for local bridge communication. /// The servers keep running in background Tokio tasks on a dedicated OS thread. -/// Returns `Err` if authentication or port binding fails. +/// Returns `Err` if authentication, cert generation, or port binding fails. /// /// This is the integration entry point for tuimail. It is synchronous so the /// caller (tuimail's main thread) needs no Tokio runtime of its own. -pub fn start(config: config::Config) -> Result<(), String> { - let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); +pub fn start(config: config::Config) -> Result, String> { + let (ready_tx, ready_rx) = std::sync::mpsc::channel::, String>>(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -48,8 +49,8 @@ pub fn start(config: config::Config) -> Result<(), String> { .expect("tokio runtime"); rt.block_on(async move { match run(config).await { - Ok(()) => { - let _ = ready_tx.send(Ok(())); + Ok(cert_der) => { + let _ = ready_tx.send(Ok(cert_der)); // Keep the runtime alive so spawned server tasks keep running. std::future::pending::<()>().await; } @@ -66,7 +67,7 @@ pub fn start(config: config::Config) -> Result<(), String> { // ── Async startup ───────────────────────────────────────────────────────────── -async fn run(config: config::Config) -> Result<(), String> { +async fn run(config: config::Config) -> Result, String> { let client = auth::build_client().map_err(|e| format!("HTTP client: {e}"))?; let session = auth::authenticate(&client, &config.proton) .await @@ -88,6 +89,32 @@ async fn run(config: config::Config) -> Result<(), String> { store.load_all(messages); info!("Inbox: {} messages ({total} total)", store.count()); + // Generate a self-signed TLS cert for local bridge communication. + // Use an IP SAN for 127.0.0.1 so TLS hostname validation works when + // connecting by IP address (which is what native_tls/lettre require). + let key_pair = rcgen::KeyPair::generate() + .map_err(|e| format!("key gen: {e}"))?; + let mut params = rcgen::CertificateParams::new(Vec::::new()) + .map_err(|e| format!("cert params: {e}"))?; + params.subject_alt_names.push(rcgen::SanType::IpAddress( + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + )); + let cert = params + .self_signed(&key_pair) + .map_err(|e| format!("cert gen: {e}"))?; + let cert_der: Vec = cert.der().to_vec(); + let key_der: Vec = key_pair.serialize_der(); + + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + let server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(cert_der.clone())], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)), + ) + .map_err(|e| format!("rustls config: {e}"))?; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg)); + // Bind ports before spawning tasks — ports are occupied when start() returns. let imap_listener = TcpListener::bind(("127.0.0.1", config.bridge.imap_port)) .await @@ -108,18 +135,19 @@ async fn run(config: config::Config) -> Result<(), String> { })); let imap_state = Arc::clone(&state); + let imap_acceptor = acceptor.clone(); tokio::spawn(async move { - if let Err(e) = imap_server::run_with_listener(imap_state, imap_listener).await { + if let Err(e) = imap_server::run_with_listener(imap_state, imap_listener, imap_acceptor).await { eprintln!("IMAP server error: {e}"); } }); tokio::spawn(async move { - if let Err(e) = smtp_server::run_with_listener(state, smtp_listener).await { + if let Err(e) = smtp_server::run_with_listener(state, smtp_listener, acceptor).await { error!("SMTP server error: {e}"); } }); - Ok(()) // ← ports are bound; start() unblocks here + Ok(cert_der) // ← ports are bound; start() unblocks here } // ── Key pool setup ──────────────────────────────────────────────────────────── diff --git a/proton-bridge/src/smtp_server.rs b/proton-bridge/src/smtp_server.rs index 4704708..99be468 100644 --- a/proton-bridge/src/smtp_server.rs +++ b/proton-bridge/src/smtp_server.rs @@ -9,8 +9,9 @@ use crate::api::ApiClient; use crate::{SharedState, crypto}; use base64::Engine; use base64::engine::general_purpose::STANDARD as B64; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; use tracing::error; // ── Public entry point ──────────────────────────────────────────────────────── @@ -20,12 +21,20 @@ use tracing::error; pub async fn run_with_listener( state: SharedState, listener: TcpListener, + acceptor: TlsAcceptor, ) -> Result<(), Box> { loop { let (socket, _addr) = listener.accept().await?; + let tls_stream = match acceptor.accept(socket).await { + Ok(s) => s, + Err(e) => { + error!("SMTP TLS handshake failed: {e}"); + continue; + } + }; let state = Arc::clone(&state); tokio::spawn(async move { - if let Err(e) = handle_connection(socket, state).await { + if let Err(e) = handle_connection(tls_stream, state).await { error!("SMTP connection error: {e}"); } }); @@ -34,12 +43,15 @@ pub async fn run_with_listener( // ── Per-connection handler ──────────────────────────────────────────────────── -async fn handle_connection( - socket: TcpStream, +async fn handle_connection( + socket: S, state: SharedState, -) -> Result<(), Box> { - let (reader, mut writer) = socket.into_split(); - let mut lines = BufReader::new(reader).lines(); +) -> Result<(), Box> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let (rd, mut writer) = tokio::io::split(socket); + let mut lines = BufReader::new(rd).lines(); let mut authenticated = false; let mut mail_from = String::new(); diff --git a/src/config.rs b/src/config.rs index a5f1af4..6f16e3d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,6 +46,8 @@ pub struct SmtpConfig { pub password: Option, pub tls_mode: TlsMode, pub from: String, + #[serde(skip)] + pub tls_cert_der: Option>, } #[derive(Debug, Deserialize, Clone)] @@ -56,6 +58,8 @@ pub struct ImapConfig { #[serde(default)] pub password: Option, pub use_tls: bool, + #[serde(skip)] + pub tls_cert_der: Option>, } /// Mirror of `proton_bridge::config::ProtonConfig` — used for config parsing @@ -150,7 +154,8 @@ impl Config { port: b.imap_port, username: "bridge".into(), password: b.local_password.clone(), - use_tls: false, + use_tls: true, + tls_cert_der: None, // injected by main.rs after start() }) } } @@ -178,8 +183,9 @@ impl Config { port: b.smtp_port, username: "bridge".into(), password: b.local_password.clone(), - tls_mode: TlsMode::None, + tls_mode: TlsMode::Smtps, from, + tls_cert_der: None, // injected by main.rs after start() }) } } diff --git a/src/connect.rs b/src/connect.rs index f7e2151..8f938d3 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -42,9 +42,13 @@ pub(crate) fn connect(config: &Config) -> Result { tcp.set_write_timeout(Some(IO_TIMEOUT)).map_err(|e| e.to_string())?; if imap_cfg.use_tls { - let tls = native_tls::TlsConnector::builder() - .build() - .map_err(|e| e.to_string())?; + let mut builder = native_tls::TlsConnector::builder(); + if let Some(ref der) = imap_cfg.tls_cert_der { + let cert = native_tls::Certificate::from_der(der) + .map_err(|e| e.to_string())?; + builder.add_root_certificate(cert); + } + let tls = builder.build().map_err(|e| e.to_string())?; let tls_stream = tls.connect(&imap_cfg.host, tcp) .map_err(|e| e.to_string())?; let password = imap_cfg.password.as_deref().unwrap_or(""); diff --git a/src/main.rs b/src/main.rs index adddb9b..a598e6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,16 @@ fn main() -> io::Result<()> { exit(1); }); + // ── Derive effective IMAP/SMTP configs for the chosen provider ── + let mut imap_cfg = config.effective_imap().unwrap_or_else(|e| { + eprintln!("{e}"); + exit(1); + }); + let mut smtp_cfg = config.effective_smtp().unwrap_or_else(|e| { + eprintln!("{e}"); + exit(1); + }); + // ── Start bridge if needed (before entering raw mode so output is clean) ── if config.provider == Provider::Proton { let bridge_cfg = config.as_bridge_config().unwrap_or_else(|e| { @@ -53,22 +63,15 @@ fn main() -> io::Result<()> { exit(1); }); eprint!("Starting ProtonMail bridge..."); - proton_bridge::start(bridge_cfg).unwrap_or_else(|e| { + let cert_der = proton_bridge::start(bridge_cfg).unwrap_or_else(|e| { eprintln!("\nBridge failed to start: {e}"); exit(1); }); eprintln!(" ready."); + imap_cfg.tls_cert_der = Some(cert_der.clone()); + smtp_cfg.tls_cert_der = Some(cert_der); } - // ── Derive effective IMAP/SMTP configs for the chosen provider ── - let imap_cfg = config.effective_imap().unwrap_or_else(|e| { - eprintln!("{e}"); - exit(1); - }); - let smtp_cfg = config.effective_smtp().unwrap_or_else(|e| { - eprintln!("{e}"); - exit(1); - }); let runtime_config = Config { imap: Some(imap_cfg), smtp: Some(smtp_cfg), diff --git a/src/setup.rs b/src/setup.rs index 10f0ef0..415b08b 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -229,6 +229,7 @@ fn setup_imap(existing: Option<&Config>) -> Result { username: imap_user, password: None, // stored in keychain use_tls: imap_tls, + tls_cert_der: None, }), smtp: Some(SmtpConfig { host: smtp_host, @@ -237,6 +238,7 @@ fn setup_imap(existing: Option<&Config>) -> Result { password: None, // stored in keychain tls_mode: smtp_tls, from: smtp_from, + tls_cert_der: None, }), proton: None, bridge: None, diff --git a/src/smtp.rs b/src/smtp.rs index 8dfd428..a294036 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -2,7 +2,7 @@ use std::sync::mpsc; use std::thread; use std::time::Duration; use lettre::transport::smtp::authentication::Credentials; -use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::transport::smtp::client::{Certificate, Tls, TlsParameters}; use lettre::{Message, SmtpTransport, Transport}; use crate::config::{SmtpConfig, TlsMode}; @@ -39,7 +39,15 @@ pub(crate) fn send_email( } TlsMode::Smtps => { // SMTPS: TLS from the first byte (port 465) - let tls = TlsParameters::new(cfg.host.clone()).map_err(|e| e.to_string())?; + let tls = if let Some(ref der) = cfg.tls_cert_der { + let cert = Certificate::from_der(der.clone()).map_err(|e| e.to_string())?; + TlsParameters::builder(cfg.host.clone()) + .add_root_certificate(cert) + .build_native() + .map_err(|e| e.to_string())? + } else { + TlsParameters::new(cfg.host.clone()).map_err(|e| e.to_string())? + }; SmtpTransport::relay(&cfg.host) .map_err(|e| e.to_string())? .port(cfg.port)