secured bridge communication

This commit is contained in:
Shautvast 2026-03-02 16:27:46 +01:00
parent 9375b5b96f
commit 5a3ac2eed9
11 changed files with 274 additions and 49 deletions

151
Cargo.lock generated
View file

@ -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"

View file

@ -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:

View file

@ -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"] }

View file

@ -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<dyn std::error::Error>> {
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<S>(
socket: S,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = socket.into_split();
let mut lines = BufReader::new(reader).lines();
) -> Result<(), Box<dyn std::error::Error>>
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

View file

@ -33,14 +33,15 @@ pub type SharedState = Arc<Mutex<BridgeState>>;
// ── 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::<Result<(), String>>();
pub fn start(config: config::Config) -> Result<Vec<u8>, String> {
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Vec<u8>, 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<Vec<u8>, 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::<String>::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<u8> = cert.der().to_vec();
let key_der: Vec<u8> = 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 ────────────────────────────────────────────────────────────

View file

@ -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<dyn std::error::Error>> {
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<S>(
socket: S,
state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = socket.into_split();
let mut lines = BufReader::new(reader).lines();
) -> Result<(), Box<dyn std::error::Error>>
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();

View file

@ -46,6 +46,8 @@ pub struct SmtpConfig {
pub password: Option<String>,
pub tls_mode: TlsMode,
pub from: String,
#[serde(skip)]
pub tls_cert_der: Option<Vec<u8>>,
}
#[derive(Debug, Deserialize, Clone)]
@ -56,6 +58,8 @@ pub struct ImapConfig {
#[serde(default)]
pub password: Option<String>,
pub use_tls: bool,
#[serde(skip)]
pub tls_cert_der: Option<Vec<u8>>,
}
/// 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()
})
}
}

View file

@ -42,9 +42,13 @@ pub(crate) fn connect(config: &Config) -> Result<ImapSession, String> {
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()
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("");

View file

@ -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),

View file

@ -229,6 +229,7 @@ fn setup_imap(existing: Option<&Config>) -> Result<Config, String> {
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<Config, String> {
password: None, // stored in keychain
tls_mode: smtp_tls,
from: smtp_from,
tls_cert_der: None,
}),
proton: None,
bridge: None,

View file

@ -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)