Files
figcraft/crates/collab-net/src/tls.rs
T
BFlat 76df2d4136 M9: hosting figcraft-server: wss in the desktop app, server releases, deployment guide (#125)
The desktop app joins wss:// sessions (rustls with webpki roots, certificates always checked, clear errors; FIGCRAFT_CA_FILE for private authorities), with one TLS session shared by the reader and writer threads. figcraft-server stops gracefully on SIGTERM/SIGINT/SIGHUP/Ctrl+C, ships in Linux x86_64/aarch64 release archives, and docs/server.md gains a Deploy on a Linux server guide with a hardened systemd unit, Caddyfile, nginx config and Dockerfile in packaging/server.
2026-10-11 15:52:27 +07:00

135 lines
6.4 KiB
Rust

//! TLS for `wss://` addresses (native): rustls with ring, trusting Mozilla's root certificates
//! (compiled in) plus any in the PEM file `FIGCRAFT_CA_FILE` names (a private authority or a
//! self-signed server). Certificates are always verified.
//!
//! A connection's reader and writer threads share one TLS session ([`Tls`]), each holding it only
//! to decrypt or encrypt: the reader waits for bytes on its own socket handle without it, and a
//! sender takes the socket's write lock first (so records go out in the order they were made),
//! encrypts under the session's lock, then writes with only the socket's. Neither waits on the
//! network while holding the session, so a send stuck behind a full socket never stops the reader
//! from draining what arrives.
use std::io::{self, Read, Write};
use std::net::TcpStream;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{CertificateError, ClientConfig, ClientConnection, RootCertStore};
/// The variable naming a PEM file of extra certificate authorities to trust.
pub const CA_FILE: &str = "FIGCRAFT_CA_FILE";
/// One connection's TLS session, shared by its reader and writer.
pub type Tls = Arc<Mutex<ClientConnection>>;
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Set up TLS on `stream`, connected to `host` (`name:port`), verifying its certificate.
pub fn handshake(stream: &TcpStream, host: &str) -> Result<Tls, String> {
let name = host.rsplit_once(':').map_or(host, |(n, _)| n).trim_start_matches('[').trim_end_matches(']');
let name = ServerName::try_from(name.to_string()).map_err(|_| format!("{name} isn't a name a certificate can be for"))?;
let mut conn = ClientConnection::new(config()?, name).map_err(|e| e.to_string())?;
// Writes are flushed to the socket at once, so the queue never grows.
conn.set_buffer_limit(None);
while conn.is_handshaking() {
conn.complete_io(&mut &*stream).map_err(|e| explain(host, &e))?;
}
Ok(Arc::new(Mutex::new(conn)))
}
/// Why the TLS handshake with `host` failed, in words.
fn explain(host: &str, e: &io::Error) -> String {
let why = |c: &CertificateError| match c {
CertificateError::UnknownIssuer => format!(
"isn't signed by an authority this app trusts (for a private or self-signed certificate, set {CA_FILE} to its authority's PEM file)"
),
CertificateError::NotValidForName | CertificateError::NotValidForNameContext { .. } => "is for another name".into(),
CertificateError::Expired | CertificateError::ExpiredContext { .. } => "has expired".into(),
CertificateError::NotValidYet | CertificateError::NotValidYetContext { .. } => "isn't valid yet (is this computer's clock right?)".into(),
CertificateError::Revoked => "has been revoked".into(),
other => format!("isn't valid ({other:?})"),
};
match e.get_ref().and_then(|i| i.downcast_ref::<rustls::Error>()) {
Some(rustls::Error::InvalidCertificate(c)) => format!("the certificate of {host} {}", why(c)),
Some(other) => format!("no secure connection to {host} ({other}); is a TLS server answering there?"),
None => format!("no secure connection to {host} ({e})"),
}
}
/// The client settings, made once.
fn config() -> Result<Arc<ClientConfig>, String> {
static CONFIG: OnceLock<Result<Arc<ClientConfig>, String>> = OnceLock::new();
CONFIG.get_or_init(make_config).clone()
}
fn make_config() -> Result<Arc<ClientConfig>, String> {
let mut roots = RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Some(path) = std::env::var_os(CA_FILE) {
let shown = std::path::Path::new(&path).display().to_string();
let certs: Vec<CertificateDer> = CertificateDer::pem_file_iter(&path)
.and_then(Iterator::collect)
.map_err(|e| format!("can't read the certificates in {CA_FILE} ({shown}): {e}"))?;
if roots.add_parsable_certificates(certs).0 == 0 {
return Err(format!("{CA_FILE} ({shown}) has no certificate this app can use"));
}
}
#[cfg(test)]
roots.add(CertificateDer::from(&include_bytes!("testdata/test-ca.der")[..])).map_err(|e| e.to_string())?;
let config = ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(Arc::new(config))
}
/// Read plaintext into `buf`: what the session has, else wait for bytes on `sock` (outside the
/// session's lock), take them in and try again. Anything the session must answer (alerts, key
/// updates) goes out through `out`.
pub fn read(tls: &Mutex<ClientConnection>, sock: &mut TcpStream, out: &Mutex<TcpStream>, buf: &mut [u8]) -> io::Result<usize> {
loop {
match lock(tls).reader().read(buf) {
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
done => return done,
}
// Wait for ciphertext without taking it off the socket, then let the session read only
// what it takes: its plaintext buffer is empty here, so it never overflows (feeding it a
// whole chunk could, when a record completes on top of plaintext not yet read).
sock.peek(&mut [0u8; 1])?;
let answer = {
let mut c = lock(tls);
// An empty read (the socket ended) tells the session so: the next read ends too.
c.read_tls(sock)?;
c.process_new_packets().map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
c.wants_write()
};
if answer {
send(tls, out, &[])?;
}
}
}
/// Encrypt and send all of `buf`.
pub fn write(tls: &Mutex<ClientConnection>, out: &Mutex<TcpStream>, buf: &[u8]) -> io::Result<usize> {
send(tls, out, buf)?;
Ok(buf.len())
}
/// Encrypt `plain` and send it after whatever the session has waiting (see the module's docs for
/// the locking).
fn send(tls: &Mutex<ClientConnection>, out: &Mutex<TcpStream>, plain: &[u8]) -> io::Result<()> {
let mut w = lock(out);
let mut records = Vec::new();
{
let mut c = lock(tls);
c.writer().write_all(plain)?;
while c.wants_write() {
c.write_tls(&mut records)?;
}
}
w.write_all(&records)
}