From df265a3671ab055b3938682d943bf21a942cd2b0 Mon Sep 17 00:00:00 2001 From: abidedavana Date: Sat, 18 Jul 2026 12:15:38 +0400 Subject: [PATCH 1/2] feat(rust-examples): add mutual TLS example Add a mutual TLS (client authentication) example to the rust examples, modeled on the tokio-server-client example. The server requires client certificates (ClientAuthType::Required), validates them against the example CA only, and pins the expected client identity with a VerifyHostNameCallback. The README demonstrates a successful mTLS handshake plus two rejection cases: a client with no certificate and a client with a trusted-CA certificate for the wrong identity. --- bindings/rust-examples/Cargo.toml | 1 + bindings/rust-examples/mutual-tls/Cargo.toml | 13 ++ bindings/rust-examples/mutual-tls/README.md | 49 +++++++ .../mutual-tls/src/bin/client.rs | 79 +++++++++++ .../mutual-tls/src/bin/server.rs | 123 ++++++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 bindings/rust-examples/mutual-tls/Cargo.toml create mode 100644 bindings/rust-examples/mutual-tls/README.md create mode 100644 bindings/rust-examples/mutual-tls/src/bin/client.rs create mode 100644 bindings/rust-examples/mutual-tls/src/bin/server.rs diff --git a/bindings/rust-examples/Cargo.toml b/bindings/rust-examples/Cargo.toml index ee3b3d7beef..30ae0bc7bc3 100644 --- a/bindings/rust-examples/Cargo.toml +++ b/bindings/rust-examples/Cargo.toml @@ -2,6 +2,7 @@ members = [ "client-hello-config-resolution", "hyper-server-client", "key-logging", + "mutual-tls", "tokio-server-client", ] resolver = "2" diff --git a/bindings/rust-examples/mutual-tls/Cargo.toml b/bindings/rust-examples/mutual-tls/Cargo.toml new file mode 100644 index 00000000000..2c3275d766d --- /dev/null +++ b/bindings/rust-examples/mutual-tls/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "mutual-tls" +version.workspace = true +authors.workspace = true +publish.workspace = true +license.workspace = true +edition.workspace = true + +[dependencies] +s2n-tls = { path = "../../rust/extended/s2n-tls" } +s2n-tls-tokio = { path = "../../rust/extended/s2n-tls-tokio" } +tokio = { version = "1", features = ["full"] } +clap = { version = "4", features = ["derive"] } diff --git a/bindings/rust-examples/mutual-tls/README.md b/bindings/rust-examples/mutual-tls/README.md new file mode 100644 index 00000000000..19e33df5e72 --- /dev/null +++ b/bindings/rust-examples/mutual-tls/README.md @@ -0,0 +1,49 @@ +This example shows how to configure mutual TLS (client authentication), where the server requires clients to prove their identity with a client certificate. The [server](src/bin/server.rs) sets `ClientAuthType::Required` and validates client certificates against its trust store. The [client](src/bin/client.rs) loads a certificate and private key, just like a server would, and presents them when the server requests a certificate. + +Note that when client authentication is used, the server MUST implement a host name verification callback to validate the identity on the client certificate: the default behavior will likely reject all client certificates. See [Client / Mutual Authentication](../../../docs/usage-guide/topics/ch09-certificates.md#client--mutual-authentication). In this example the server only accepts client certificates issued to `www.wombat.com`. + +To run this example, first start the server in one terminal +``` +cargo run --bin server +``` +The server prints the address it is listening on, e.g. `Listening on 127.0.0.1:9443`. Then run the client in another terminal, using that address. + +### Authenticated client +``` +cargo run --bin client 127.0.0.1:9443 +``` +``` +TlsStream { + connection: Connection { + handshake_type: "NEGOTIATED|FULL_HANDSHAKE|CLIENT_AUTH|MIDDLEBOX_COMPAT", + cipher_suite: "TLS_AES_128_GCM_SHA256", + actual_protocol_version: TLS13, + selected_key_exchange_group: "secp256r1", + .. + }, +} +``` +The `CLIENT_AUTH` flag in the handshake type shows that the client proved its identity with its `www.wombat.com` certificate, and anything typed into the client is now sent to the server over the mutually authenticated connection. + +### Client without a certificate +A client that doesn't present a certificate is rejected. For example, the client from the [tokio-server-client](../tokio-server-client) example doesn't load a client certificate: +``` +cargo run -p tokio-server-client --bin client -- 127.0.0.1:9443 +``` +The server rejects the connection: +``` +Rejected connection from 127.0.0.1:52652: Server requires client certificate +``` + +### Client with an untrusted identity +A client certificate that chains to a trusted CA is still rejected if the host name verification callback doesn't accept its identity. The `www.kangaroo.com` certificate is issued by the same example CA, but the server only trusts `www.wombat.com`: +``` +cargo run --bin client -- --cert ../certs/kangaroo-chain.pem --key ../certs/kangaroo-key.pem 127.0.0.1:9443 +``` +``` +Rejected connection from 127.0.0.1:52666: Certificate is not valid for the supplied hostname +``` + +Two behaviors to be aware of when experimenting with the rejected clients: +* The server's rejection errors only surface after a delay: s2n-tls "blinds" handshake failures by 10-30 seconds to protect against timing side-channels. +* The rejected TLS1.3 clients still print a successful handshake. With TLS1.3, the client considers the handshake complete before the server has validated the client certificate, and the rejection only arrives as an alert on a later read. This protocol quirk is described in [Client / Mutual Authentication](../../../docs/usage-guide/topics/ch09-certificates.md#client--mutual-authentication). diff --git a/bindings/rust-examples/mutual-tls/src/bin/client.rs b/bindings/rust-examples/mutual-tls/src/bin/client.rs new file mode 100644 index 00000000000..a43dd8b43b5 --- /dev/null +++ b/bindings/rust-examples/mutual-tls/src/bin/client.rs @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +use clap::Parser; +use s2n_tls::{config::Config, enums::ClientAuthType, security::DEFAULT_TLS13}; +use s2n_tls_tokio::TlsConnector; +use std::{error::Error, fs}; +use tokio::{io::AsyncWriteExt, net::TcpStream}; + +/// NOTE: this certificate, key, and ca are to be used for demonstration purposes only! +const DEFAULT_CA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/ca-cert.pem"); +const DEFAULT_CERT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/wombat-chain.pem"); +const DEFAULT_KEY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/wombat-key.pem"); + +#[derive(Parser, Debug)] +struct Args { + #[clap(short, long, default_value_t = String::from(DEFAULT_CA))] + trust: String, + /// Certificate presented to the server to prove the client's identity. + #[clap(short, long, requires = "key", default_value_t = String::from(DEFAULT_CERT))] + cert: String, + #[clap(short, long, requires = "cert", default_value_t = String::from(DEFAULT_KEY))] + key: String, + addr: String, +} + +async fn run_client( + trust_pem: &[u8], + cert_pem: &[u8], + key_pem: &[u8], + addr: &str, +) -> Result<(), Box> { + // Set up the configuration for new connections. + // As with a normal TLS client, you will need a trust store. + let mut config = Config::builder(); + config.set_security_policy(&DEFAULT_TLS13)?; + config.trust_pem(trust_pem)?; + + // Load a certificate and private key for the client. The server + // authenticates the client, so the client needs its own certificate, + // just like a server does. + config.set_client_auth_type(ClientAuthType::Required)?; + config.load_pem(cert_pem, key_pem)?; + + // Create the TlsConnector based on the configuration. + let client = TlsConnector::new(config.build()?); + + // Connect to the server. + let stream = TcpStream::connect(addr).await?; + let tls = client.connect("www.kangaroo.com", stream).await?; + println!("{:#?}", tls); + + // Split the stream. + // This allows us to call read and write from different tasks. + let (mut reader, mut writer) = tokio::io::split(tls); + + // Copy data from the server to stdout + tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + tokio::io::copy(&mut reader, &mut stdout).await + }); + + // Send data from stdin to the server + let mut stdin = tokio::io::stdin(); + tokio::io::copy(&mut stdin, &mut writer).await?; + writer.shutdown().await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + let trust_pem = fs::read(args.trust)?; + let cert_pem = fs::read(args.cert)?; + let key_pem = fs::read(args.key)?; + run_client(&trust_pem, &cert_pem, &key_pem, &args.addr).await?; + Ok(()) +} diff --git a/bindings/rust-examples/mutual-tls/src/bin/server.rs b/bindings/rust-examples/mutual-tls/src/bin/server.rs new file mode 100644 index 00000000000..e8fd73956cf --- /dev/null +++ b/bindings/rust-examples/mutual-tls/src/bin/server.rs @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +use clap::Parser; +use s2n_tls::{ + callbacks::VerifyHostNameCallback, config::Config, enums::ClientAuthType, + security::DEFAULT_TLS13, +}; +use s2n_tls_tokio::TlsAcceptor; +use std::{error::Error, fs}; +use tokio::{io::AsyncWriteExt, net::TcpListener}; + +/// NOTE: this certificate, key, and ca are to be used for demonstration purposes only! +const DEFAULT_CERT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/kangaroo-chain.pem"); +const DEFAULT_KEY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/kangaroo-key.pem"); +const DEFAULT_CA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/ca-cert.pem"); + +/// The name that the server expects to find on trusted client certificates. +const TRUSTED_CLIENT_NAME: &str = "www.wombat.com"; + +#[derive(Parser, Debug)] +struct Args { + #[clap(short, long, requires = "key", default_value_t = String::from(DEFAULT_CERT))] + cert: String, + #[clap(short, long, requires = "cert", default_value_t = String::from(DEFAULT_KEY))] + key: String, + /// CA used to validate client certificates. + #[clap(short, long, default_value_t = String::from(DEFAULT_CA))] + trust: String, + #[clap(short, long, default_value_t = String::from("127.0.0.1:0"))] + addr: String, +} + +/// Verifies the identity on client certificates. +/// +/// When client authentication is used, the server MUST implement a host name +/// verification callback: the default behavior will likely reject all client +/// certificates. +struct TrustedClientName; +impl VerifyHostNameCallback for TrustedClientName { + fn verify_host_name(&self, host_name: &str) -> bool { + host_name == TRUSTED_CLIENT_NAME + } +} + +async fn run_server( + cert_pem: &[u8], + key_pem: &[u8], + trust_pem: &[u8], + addr: &str, +) -> Result<(), Box> { + // Set up the configuration for new connections. + // As with a normal TLS server, you will need a certificate and private key. + let mut builder = Config::builder(); + builder.set_security_policy(&DEFAULT_TLS13)?; + builder.load_pem(cert_pem, key_pem)?; + + // Require clients to prove their identity with a client certificate. + builder.set_client_auth_type(ClientAuthType::Required)?; + + // Client certificates are validated against the server's trust store. + // Only trust the example CA, not the default system certificates: + // any certificate signed by a system CA should not be treated as + // a valid client identity. + builder.trust_pem(trust_pem)?; + builder.with_system_certs(false)?; + builder.set_verify_host_callback(TrustedClientName)?; + + let config = builder.build()?; + + // Create the TlsAcceptor based on the configuration. + let server = TlsAcceptor::new(config); + + // Bind to an address and listen for connections. + // ":0" can be used to automatically assign a port. + let listener = TcpListener::bind(&addr).await?; + let addr = listener + .local_addr() + .map(|x| x.to_string()) + .unwrap_or_else(|_| "UNKNOWN".to_owned()); + println!("Listening on {}", addr); + + loop { + // Wait for a client to connect. + let (stream, peer_addr) = listener.accept().await?; + println!("Connection from {:?}", peer_addr); + + // Spawn a new task to handle the connection. + // We probably want to spawn the task BEFORE calling TcpAcceptor::accept, + // because the TLS handshake can be slow. + let server = server.clone(); + tokio::spawn(async move { + // The handshake fails if the client can't prove its identity, + // so unauthorized clients are rejected here. + let mut tls = match server.accept(stream).await { + Ok(tls) => tls, + Err(error) => { + println!("Rejected connection from {:?}: {}", peer_addr, error); + return Ok(()); + } + }; + println!("{:#?}", tls); + + // Copy data from the client to stdout + let mut stdout = tokio::io::stdout(); + tokio::io::copy(&mut tls, &mut stdout).await?; + tls.shutdown().await?; + println!("Connection from {:?} closed", peer_addr); + + Ok::<(), Box>(()) + }); + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + let cert_pem = fs::read(args.cert)?; + let key_pem = fs::read(args.key)?; + let trust_pem = fs::read(args.trust)?; + run_server(&cert_pem, &key_pem, &trust_pem, &args.addr).await?; + Ok(()) +} From 055fd1404bdcaef8b32970a8a6165201c1d5bdcb Mon Sep 17 00:00:00 2001 From: abidedavana Date: Thu, 23 Jul 2026 09:06:26 +0400 Subject: [PATCH 2/2] address review feedback: replace io::split with a request/response exchange and graceful shutdown --- bindings/rust-examples/mutual-tls/README.md | 15 ++++++++-- .../mutual-tls/src/bin/client.rs | 29 ++++++++++--------- .../mutual-tls/src/bin/server.rs | 24 ++++++++++++--- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/bindings/rust-examples/mutual-tls/README.md b/bindings/rust-examples/mutual-tls/README.md index 19e33df5e72..8091365e112 100644 --- a/bindings/rust-examples/mutual-tls/README.md +++ b/bindings/rust-examples/mutual-tls/README.md @@ -1,5 +1,7 @@ This example shows how to configure mutual TLS (client authentication), where the server requires clients to prove their identity with a client certificate. The [server](src/bin/server.rs) sets `ClientAuthType::Required` and validates client certificates against its trust store. The [client](src/bin/client.rs) loads a certificate and private key, just like a server would, and presents them when the server requests a certificate. +After the handshake the client sends a greeting, the server responds, and the two sides perform a graceful shutdown: the server closes its side of the connection once it is done writing, the client reads the response up to end-of-stream and then closes its own side, and the server reads the client's end-of-stream. Skipping this shutdown sequence means a peer can see an unexpected end-of-stream error instead of a clean close. + Note that when client authentication is used, the server MUST implement a host name verification callback to validate the identity on the client certificate: the default behavior will likely reject all client certificates. See [Client / Mutual Authentication](../../../docs/usage-guide/topics/ch09-certificates.md#client--mutual-authentication). In this example the server only accepts client certificates issued to `www.wombat.com`. To run this example, first start the server in one terminal @@ -22,8 +24,13 @@ TlsStream { .. }, } +The server says: good byte from the server +``` +The `CLIENT_AUTH` flag in the handshake type shows that the client proved its identity with its `www.wombat.com` certificate. The server receives the greeting over the mutually authenticated connection and both sides shut down cleanly: +``` +The client says: hello from the client +Connection from 127.0.0.1:55104 closed ``` -The `CLIENT_AUTH` flag in the handshake type shows that the client proved its identity with its `www.wombat.com` certificate, and anything typed into the client is now sent to the server over the mutually authenticated connection. ### Client without a certificate A client that doesn't present a certificate is rejected. For example, the client from the [tokio-server-client](../tokio-server-client) example doesn't load a client certificate: @@ -32,7 +39,7 @@ cargo run -p tokio-server-client --bin client -- 127.0.0.1:9443 ``` The server rejects the connection: ``` -Rejected connection from 127.0.0.1:52652: Server requires client certificate +Rejected connection from 127.0.0.1:55110: Server requires client certificate ``` ### Client with an untrusted identity @@ -40,9 +47,11 @@ A client certificate that chains to a trusted CA is still rejected if the host n ``` cargo run --bin client -- --cert ../certs/kangaroo-chain.pem --key ../certs/kangaroo-key.pem 127.0.0.1:9443 ``` +The server rejects the connection: ``` -Rejected connection from 127.0.0.1:52666: Certificate is not valid for the supplied hostname +Rejected connection from 127.0.0.1:35188: Certificate is not valid for the supplied hostname ``` +and the rejected client receives no response and fails with an error instead of shutting down cleanly. Two behaviors to be aware of when experimenting with the rejected clients: * The server's rejection errors only surface after a delay: s2n-tls "blinds" handshake failures by 10-30 seconds to protect against timing side-channels. diff --git a/bindings/rust-examples/mutual-tls/src/bin/client.rs b/bindings/rust-examples/mutual-tls/src/bin/client.rs index a43dd8b43b5..ecb66e43596 100644 --- a/bindings/rust-examples/mutual-tls/src/bin/client.rs +++ b/bindings/rust-examples/mutual-tls/src/bin/client.rs @@ -5,7 +5,10 @@ use clap::Parser; use s2n_tls::{config::Config, enums::ClientAuthType, security::DEFAULT_TLS13}; use s2n_tls_tokio::TlsConnector; use std::{error::Error, fs}; -use tokio::{io::AsyncWriteExt, net::TcpStream}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, +}; /// NOTE: this certificate, key, and ca are to be used for demonstration purposes only! const DEFAULT_CA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/ca-cert.pem"); @@ -47,23 +50,21 @@ async fn run_client( // Connect to the server. let stream = TcpStream::connect(addr).await?; - let tls = client.connect("www.kangaroo.com", stream).await?; + let mut tls = client.connect("www.kangaroo.com", stream).await?; println!("{:#?}", tls); - // Split the stream. - // This allows us to call read and write from different tasks. - let (mut reader, mut writer) = tokio::io::split(tls); + // Send a greeting to the server. + tls.write_all(b"hello from the client").await?; - // Copy data from the server to stdout - tokio::spawn(async move { - let mut stdout = tokio::io::stdout(); - tokio::io::copy(&mut reader, &mut stdout).await - }); + // Receive the server's response. The server closes its side of the + // connection when it is done writing, so read until end-of-stream. + let mut response = Vec::new(); + tls.read_to_end(&mut response).await?; + println!("The server says: {}", String::from_utf8_lossy(&response)); - // Send data from stdin to the server - let mut stdin = tokio::io::stdin(); - tokio::io::copy(&mut stdin, &mut writer).await?; - writer.shutdown().await?; + // The server already closed its side of the connection, so close + // ours to complete the graceful two-way shutdown. + tls.shutdown().await?; Ok(()) } diff --git a/bindings/rust-examples/mutual-tls/src/bin/server.rs b/bindings/rust-examples/mutual-tls/src/bin/server.rs index e8fd73956cf..7bce09b5951 100644 --- a/bindings/rust-examples/mutual-tls/src/bin/server.rs +++ b/bindings/rust-examples/mutual-tls/src/bin/server.rs @@ -8,7 +8,10 @@ use s2n_tls::{ }; use s2n_tls_tokio::TlsAcceptor; use std::{error::Error, fs}; -use tokio::{io::AsyncWriteExt, net::TcpListener}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; /// NOTE: this certificate, key, and ca are to be used for demonstration purposes only! const DEFAULT_CERT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/kangaroo-chain.pem"); @@ -101,10 +104,23 @@ async fn run_server( }; println!("{:#?}", tls); - // Copy data from the client to stdout - let mut stdout = tokio::io::stdout(); - tokio::io::copy(&mut tls, &mut stdout).await?; + // Receive the client's greeting. + let mut buffer = [0; 1024]; + let bytes_read = tls.read(&mut buffer).await?; + println!( + "The client says: {}", + String::from_utf8_lossy(&buffer[..bytes_read]) + ); + + // Respond, then close our side of the connection to signal + // that we are done writing. + tls.write_all(b"good byte from the server").await?; tls.shutdown().await?; + + // The connection isn't fully closed until the client also + // closes its side by sending its own close_notify alert. + let bytes_read = tls.read(&mut buffer).await?; + assert_eq!(bytes_read, 0, "expected the client to close the connection"); println!("Connection from {:?} closed", peer_addr); Ok::<(), Box>(())