49. TLS and mutual TLS with rustls
Implements: Security from Zero Ch 28–29 and Ch 53 (your own CA plus mTLS), Go §50.
rustls is a TLS library written in Rust. It supports only TLS 1.2 and
1.3 with modern cipher suites, so there is no SSLv3, RC4, or renegotiation
to disable. Its cryptography comes from a provider: aws-lc-rs by default,
or ring. The program below builds a whole PKI in memory: a CA, a server
certificate, and a client certificate. It then runs an mTLS server and
connects to it twice, once with a client certificate and once without.
// src/bin/mtls.rs
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
use rustls::server::WebPkiClientVerifier;
use rustls::{ClientConfig, RootCertStore, ServerConfig};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::{TlsAcceptor, TlsConnector};
struct Pki { ca: CertificateDer<'static>, server: (CertificateDer<'static>, PrivateKeyDer<'static>), client: (CertificateDer<'static>, PrivateKeyDer<'static>) }
fn leaf(name: &str, san: Vec<String>, issuer: &Issuer<'_, KeyPair>) -> anyhow::Result<(CertificateDer<'static>, PrivateKeyDer<'static>)> {
let key = KeyPair::generate()?; // ECDSA P-256 by default
let mut params = CertificateParams::new(san)?;
params.distinguished_name.push(DnType::CommonName, name);
let cert = params.signed_by(&key, issuer)?;
Ok((cert.der().clone(), PrivatePkcs8KeyDer::from(key.serialize_der()).into()))
}
fn make_pki() -> anyhow::Result<Pki> {
let ca_key = KeyPair::generate()?;
let mut ca_params = CertificateParams::new(Vec::<String>::new())?;
ca_params.distinguished_name.push(DnType::CommonName, "Rust Guide Lab CA");
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let ca_cert = ca_params.self_signed(&ca_key)?;
let issuer = Issuer::new(ca_params, ca_key);
Ok(Pki {
ca: ca_cert.der().clone(),
server: leaf("localhost", vec!["localhost".into()], &issuer)?,
client: leaf("billing-service", vec!["billing.internal".into()], &issuer)?,
})
}
async fn connect(addr: std::net::SocketAddr, roots: Arc<RootCertStore>, identity: Option<(CertificateDer<'static>, PrivateKeyDer<'static>)>) -> anyhow::Result<String> {
let builder = ClientConfig::builder().with_root_certificates(roots);
let config = match identity {
Some((cert, key)) => builder.with_client_auth_cert(vec![cert], key)?,
None => builder.with_no_client_auth(),
};
let tcp = TcpStream::connect(addr).await?;
let mut tls = TlsConnector::from(Arc::new(config)).connect(ServerName::try_from("localhost")?, tcp).await?;
tls.write_all(b"ping").await?;
let mut buf = Vec::new();
tls.read_to_end(&mut buf).await?; // in TLS 1.3, a rejected client cert surfaces here, after the handshake
Ok(String::from_utf8(buf)?)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pki = make_pki()?;
let mut roots = RootCertStore::empty();
roots.add(pki.ca.clone())?; // trust ONLY our lab CA, not the system store
let roots = Arc::new(roots);
let verifier = WebPkiClientVerifier::builder(Arc::clone(&roots)).build()?; // client certs REQUIRED
let server_cfg = ServerConfig::builder()
.with_client_cert_verifier(verifier)
.with_single_cert(vec![pki.server.0.clone()], pki.server.1.clone_key())?;
let acceptor = TlsAcceptor::from(Arc::new(server_cfg));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
loop {
let (tcp, _) = listener.accept().await.unwrap();
let acceptor = acceptor.clone();
tokio::spawn(async move {
match acceptor.accept(tcp).await {
Ok(mut tls) => {
let (_, conn) = tls.get_ref();
let info = format!("pong over {:?} / {:?}, client chain of {} cert(s)",
conn.protocol_version().unwrap(),
conn.negotiated_cipher_suite().unwrap().suite(),
conn.peer_certificates().map_or(0, |c| c.len()));
let mut buf = [0u8; 4];
let _ = tls.read_exact(&mut buf).await;
let _ = tls.write_all(info.as_bytes()).await;
let _ = tls.shutdown().await;
}
Err(e) => eprintln!("server: handshake rejected: {e}"),
}
});
}
});
println!("with client cert: {:?}", connect(addr, Arc::clone(&roots), Some(pki.client)).await);
println!("without client cert: {:?}", connect(addr, roots, None).await.map_err(|e| e.to_string()));
Ok(())
}
with client cert: Ok("pong over TLSv1_3 / TLS13_AES_256_GCM_SHA384, client chain of 1 cert(s)")
server: handshake rejected: peer sent no certificates
without client cert: Err("received fatal alert: CertificateRequired")
What to take from this:
- The client fails after the handshake, not during it. In TLS 1.3 the client sends its (empty) certificate in its final flight and considers the handshake complete. The server's rejection arrives as an alert on the first read. This is the same TLS 1.3 behavior as in Go and OpenSSL, and it confuses everyone the first time they debug mTLS.
with_root_certificates(roots)trusts only your CA. Service-to-service mTLS should never trust the public web PKI. This is the model behind SPIFFE/SPIRE (Security in Depth Ch 23).- In production, load PEM files with
rustls::pki_types::pem::PemObject(CertificateDer::pem_file_iter,PrivateKeyDer::from_pem_file), and reload certificates without a restart by implementingResolvesServerCert. Short-lived certificates need reloading (HTTPS Ch 16).