From 17bbd8470d581ac8e30cb57b175d2a3429807c66 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 16:07:01 -0400 Subject: [PATCH 01/18] Reject a malformed version message instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server parsed the client's version message with version_bytes[..4], which panics on a body shorter than 4 bytes — a remotely triggerable panic for any TLS-authenticated peer, and process death under panic = "abort". Parse with a checked conversion and reject anything but exactly 4 bytes as Error::ProtocolVersion. This is stricter than the old code in one way: a version message longer than 4 bytes was previously accepted with the trailing bytes ignored. No client has ever sent one (both protocol versions send exactly CURRENT_PROTOCOL_VERSION.to_le_bytes()), so no interoperability change. --- tls/src/lib.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++ tls/src/server.rs | 10 +++++-- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/tls/src/lib.rs b/tls/src/lib.rs index 1717eff..eaeca47 100644 --- a/tls/src/lib.rs +++ b/tls/src/lib.rs @@ -606,6 +606,76 @@ mod tests { handle.await.unwrap(); } + // A version message whose body is shorter than the 4-byte version, sent + // by a TLS-authenticated client, is rejected as Error::ProtocolVersion — + // the server task must error, not panic on a short slice. + #[tokio::test] + async fn short_version_message_rejected() { + let log = logger(); + let mock_datadir = mock_datadir(); + let addr: SocketAddrV6 = SocketAddrV6::from_str("[::1]:46467").unwrap(); + + let server_config = + local_config(1, MeasurementConnectionPolicy::Enforced); + let corpus = vec![ + mock_datadir.join("corim-rot.cbor"), + mock_datadir.join("corim-sp.cbor"), + ]; + + let log2 = log.clone(); + let handle = tokio::spawn(async move { + let server = Server::new(server_config, addr, log2.clone()) + .await + .unwrap(); + + let result = server + .accept(corpus.clone()) + .await + .unwrap() + .handshake() + .await; + match result { + Err(Error::ProtocolVersion) => {} + Err(other) => { + panic!("expected ProtocolVersion, got {other:?}") + } + Ok(_) => { + panic!("a malformed version message must not complete") + } + } + }); + + let client_config = client::Client::new_tls_local_client_config( + mock_datadir.join("test-sprockets-auth-2.key.pem"), + mock_datadir.join("test-sprockets-auth-2.certlist.pem"), + vec![mock_datadir.join("test-root-a.cert.pem")], + log, + ) + .unwrap(); + + let dnsname = + rustls::pki_types::ServerName::try_from("unknown.com").unwrap(); + let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new( + client_config, + )); + let stream = loop { + if let Ok(s) = tokio::net::TcpStream::connect(addr).await { + break s; + }; + sleep(Duration::from_millis(1)).await; + }; + let mut stream = connector.connect(dnsname, stream).await.unwrap(); + + // A valid length prefix (2) followed by a 2-byte body: the server's + // recv_msg succeeds, but the body is shorter than the 4-byte version + // it must contain. + stream.write_all(&2u32.to_le_bytes()).await.unwrap(); + stream.write_all(b"xy").await.unwrap(); + stream.shutdown().await.unwrap(); + + handle.await.unwrap(); + } + #[tokio::test] async fn spawn_accept() { let log = logger(); diff --git a/tls/src/server.rs b/tls/src/server.rs index 669bf4a..1a0767a 100644 --- a/tls/src/server.rs +++ b/tls/src/server.rs @@ -138,8 +138,14 @@ impl SprocketsAcceptor { // get version from the client let version_bytes = recv_msg(&mut stream).await?; - let version = - u32::from_le_bytes(version_bytes[..4].try_into().unwrap()); + // Anything but exactly the 4-byte little-endian version is protocol + // garbage from the peer; reject it rather than index past the end of a + // short message. + let version_bytes: [u8; 4] = version_bytes + .as_slice() + .try_into() + .map_err(|_| Error::ProtocolVersion)?; + let version = u32::from_le_bytes(version_bytes); if version == CURRENT_PROTOCOL_VERSION { // we're good to go From 33ab77bd0809c150212be48ef3c9a068adaf87e5 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 16:09:43 -0400 Subject: [PATCH 02/18] Bound protocol message sizes before allocating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recv_msg allocated the full message buffer from the peer-controlled length prefix before reading any of the body, so a TLS-authenticated (but not yet attested) peer could demand a 4 GiB zeroed allocation with a 4-byte prefix, and hold it by leaving the connection open — repeatable across connections for memory exhaustion. Authenticated-but-unattested is exactly the adversary class the attestation layer exists to screen out, so the framing layer must not trust it with allocation sizes. Bound messages at a named MAX_MSG_SIZE (1 MiB), with compile-time asserts that the bound admits the largest legitimate messages (the hubpacked measurement log and attestation). Oversized lengths are rejected as a new Error::MessageTooLarge before any allocation. No interoperability change: every legitimate protocol message is far below the bound. --- tls/src/lib.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/tls/src/lib.rs b/tls/src/lib.rs index eaeca47..68a9c3a 100644 --- a/tls/src/lib.rs +++ b/tls/src/lib.rs @@ -6,6 +6,7 @@ use camino::Utf8PathBuf; use dice_mfg_msgs::PlatformId; +use hubpack::SerializedSize; use rustls::crypto::aws_lc_rs::cipher_suite::TLS13_CHACHA20_POLY1305_SHA256; use rustls::crypto::aws_lc_rs::kx_group::X25519; use rustls::crypto::CryptoProvider; @@ -104,6 +105,9 @@ pub enum Error { #[error("Hubpack error:")] Hubpack(#[from] hubpack::Error), + #[error("protocol message length {len} exceeds maximum {max}")] + MessageTooLarge { len: usize, max: usize }, + #[error("Failed to verify attestation")] AttestationVerifier(#[from] dice_verifier::VerifyAttestationError), @@ -280,6 +284,21 @@ type ProtocolRequestAck = Result; const CURRENT_PROTOCOL_VERSION: u32 = 2; const PREVIOUS_PROTOCOL_VERSION: u32 = CURRENT_PROTOCOL_VERSION - 1; +/// The largest message [`recv_msg`] will accept. +/// +/// The length prefix is peer-controlled and [`recv_msg`] allocates the full +/// message buffer before reading the body, so without a bound a +/// TLS-authenticated (but not yet attested) peer can demand a 4 GiB +/// allocation with a 4-byte prefix. 1 MiB comfortably exceeds every +/// legitimate protocol message: the largest are the hubpacked measurement +/// log and attestation, whose bounds are asserted below; cert chains and +/// nonces are far smaller. +const MAX_MSG_SIZE: usize = 1024 * 1024; + +// The bound must admit every legitimate protocol message. +const _: () = assert!(dice_verifier::Log::MAX_SIZE <= MAX_MSG_SIZE); +const _: () = assert!(dice_verifier::Attestation::MAX_SIZE <= MAX_MSG_SIZE); + async fn recv_msg( stream: &mut T, ) -> Result, Error> { @@ -287,7 +306,15 @@ async fn recv_msg( // a little endian byte array let mut msg_len = [0u8; 4]; stream.read_exact(&mut msg_len).await?; - let msg_len = u32::from_le_bytes(msg_len).try_into()?; + let msg_len: usize = u32::from_le_bytes(msg_len).try_into()?; + + // The length is peer-controlled: bound it before allocating. + if msg_len > MAX_MSG_SIZE { + return Err(Error::MessageTooLarge { + len: msg_len, + max: MAX_MSG_SIZE, + }); + } // with the length we can then get the message body let mut buf = vec![0u8; msg_len]; @@ -766,4 +793,73 @@ mod tests { sleep(Duration::from_millis(1)).await; } } + + // A message whose length prefix exceeds MAX_MSG_SIZE, sent by a + // TLS-authenticated client, is rejected as Error::MessageTooLarge before + // the message buffer is allocated — the peer-controlled prefix must not + // be able to demand a 4 GiB allocation. + #[tokio::test] + async fn oversized_message_rejected() { + let log = logger(); + let mock_datadir = mock_datadir(); + let addr: SocketAddrV6 = SocketAddrV6::from_str("[::1]:46468").unwrap(); + + let server_config = + local_config(1, MeasurementConnectionPolicy::Enforced); + let corpus = vec![ + mock_datadir.join("corim-rot.cbor"), + mock_datadir.join("corim-sp.cbor"), + ]; + + let log2 = log.clone(); + let handle = tokio::spawn(async move { + let server = Server::new(server_config, addr, log2.clone()) + .await + .unwrap(); + + let result = server + .accept(corpus.clone()) + .await + .unwrap() + .handshake() + .await; + match result { + Err(Error::MessageTooLarge { .. }) => {} + Err(other) => { + panic!("expected MessageTooLarge, got {other:?}") + } + Ok(_) => { + panic!("an oversized message must not complete") + } + } + }); + + let client_config = client::Client::new_tls_local_client_config( + mock_datadir.join("test-sprockets-auth-2.key.pem"), + mock_datadir.join("test-sprockets-auth-2.certlist.pem"), + vec![mock_datadir.join("test-root-a.cert.pem")], + log, + ) + .unwrap(); + + let dnsname = + rustls::pki_types::ServerName::try_from("unknown.com").unwrap(); + let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new( + client_config, + )); + let stream = loop { + if let Ok(s) = tokio::net::TcpStream::connect(addr).await { + break s; + }; + sleep(Duration::from_millis(1)).await; + }; + let mut stream = connector.connect(dnsname, stream).await.unwrap(); + + // A length prefix claiming a 4 GiB message; no body ever follows. The + // server must reject on the prefix alone. + stream.write_all(&u32::MAX.to_le_bytes()).await.unwrap(); + stream.shutdown().await.unwrap(); + + handle.await.unwrap(); + } } From 471e7934e409f1642f7cf6b140b55d5237e0b68a Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 11:50:13 -0400 Subject: [PATCH 03/18] Fix latent clippy lints surfaced by --all-targets on illumos cargo clippy --all-targets -- -D warnings fails on illumos (where the cfg(target_os) build-script lines and the unittest build path compile) on lints the ubuntu CI run never sees: uninlined format args in build.rs and a needless Ok(..?) wrapper, plus roots.len() < 1 in both examples. Inline the format args, drop the wrapper, and use is_empty(). No behavior change. --- tls/build.rs | 12 ++++++------ tls/examples/client.rs | 2 +- tls/examples/server.rs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tls/build.rs b/tls/build.rs index 5721741..501e365 100644 --- a/tls/build.rs +++ b/tls/build.rs @@ -24,9 +24,9 @@ where { let mock = R::load(input)?; let log = mock.to_bytes()?; - Ok(std::fs::write(output, &log).with_context(|| { - format!("write mock measurement log to file: {}", output) - })?) + std::fs::write(output, &log).with_context(|| { + format!("write mock measurement log to file: {output}") + }) } /// Execute one of the `attest-mock` commands to generate attestation @@ -34,8 +34,8 @@ where fn main() -> Result<()> { #[cfg(target_os = "illumos")] { - println!("cargo:rustc-link-arg=-Wl,-R{}", OXIDE_PLATFORM); - println!("cargo:rustc-link-search={}", OXIDE_PLATFORM); + println!("cargo:rustc-link-arg=-Wl,-R{OXIDE_PLATFORM}"); + println!("cargo:rustc-link-search={OXIDE_PLATFORM}"); } #[cfg(feature = "unittest")] @@ -51,7 +51,7 @@ fn main() -> Result<()> { let config_path = "test-keys/config.kdl"; let doc = config::load_and_validate(config_path.as_ref()).map_err(|e| { - anyhow!("Loading config from \"{}\" failed: {e:?}", config_path) + anyhow!("Loading config from \"{config_path}\" failed: {e:?}") })?; doc.write_key_pairs(outdir.clone(), OutputFileExistsBehavior::Skip) diff --git a/tls/examples/client.rs b/tls/examples/client.rs index bdd2567..e46e3dd 100644 --- a/tls/examples/client.rs +++ b/tls/examples/client.rs @@ -60,7 +60,7 @@ async fn main() { let args = Args::parse(); - if args.roots.len() < 1 { + if args.roots.is_empty() { panic!("Need at least one root"); } diff --git a/tls/examples/server.rs b/tls/examples/server.rs index a86e1e0..97ff147 100644 --- a/tls/examples/server.rs +++ b/tls/examples/server.rs @@ -59,7 +59,7 @@ async fn main() { let args = Args::parse(); - if args.roots.len() < 1 { + if args.roots.is_empty() { panic!("Must specify at least one root"); } From dff60bc21443537bf0ce0dcc1d2d50479859ac0e Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 11:07:05 -0400 Subject: [PATCH 04/18] Extract pure TLS-config construction into a config module Factor the four rustls config builders down to two transport-free functions and dedupe the peer-PlatformId extraction, in preparation for adding a second transport (QUIC) that will consume the same configuration and identity plumbing. - Add tls/src/config.rs with pub(crate) load_roots, new_tls_client_config, and new_tls_server_config. The old new_tls_{local,ipcc}_{client,server}_config builders differed only in the ResolveSetting passed to CertResolver::new, so they collapse to one client and one server builder parameterized by ResolveSetting. - Add platform_id_from_tls_certs in lib.rs, replacing the two hand-rolled peer-certificate-chain walks in client.rs and server.rs. It takes rustls' Option<&[CertificateDer]>, so it serves both TCP's peer_certificates() and (later) QUIC's peer_identity(). - Client::connect and Server::new no longer load the root PEMs twice. A missing or malformed root now surfaces as Error::FailedRead{path} via load_root_cert, rather than a bare Error::Io. - Correct the stale "use ring; aws_lc doesn't compile on illumos" comment on crypto_provider(): the crate uses aws-lc-rs, and it does build and run on illumos (verified natively). No change to TLS wire behavior, the crypto provider, the verifiers, or the attestation protocol. TCP tests pass on macOS and natively on illumos. --- tls/src/client.rs | 110 ++++---------------------------------------- tls/src/config.rs | 88 +++++++++++++++++++++++++++++++++++ tls/src/lib.rs | 85 +++++++++++++++++++++++++++------- tls/src/server.rs | 115 ++++++++-------------------------------------- 4 files changed, 184 insertions(+), 214 deletions(-) create mode 100644 tls/src/config.rs diff --git a/tls/src/client.rs b/tls/src/client.rs index 0b64f1d..96b3410 100644 --- a/tls/src/client.rs +++ b/tls/src/client.rs @@ -10,12 +10,13 @@ use std::sync::Arc; use tokio::net::TcpStream; use tokio_rustls::TlsConnector; -use crate::keys::{get_attest_data, AttestConfig, ResolveSetting}; +use crate::config::{load_roots, new_tls_client_config}; +use crate::keys::{get_attest_data, AttestConfig}; use crate::keys::{ CertResolver, MeasurementConnectionPolicy, RotCertVerifier, SprocketsConfig, }; use crate::{ - certs_from_der, certs_to_der, crypto_provider, load_root_cert, recv_msg, + certs_from_der, certs_to_der, platform_id_from_tls_certs, recv_msg, send_msg, ProtocolRequestAck, ProtocolResult, CURRENT_PROTOCOL_VERSION, PREVIOUS_PROTOCOL_VERSION, }; @@ -34,11 +35,10 @@ use rustls::{ ResolvesClientCert, }, sign::CertifiedKey, - version::TLS13, ClientConfig, SignatureScheme, }; use slog::{error, info, warn}; -use x509_cert::{der::Decode, Certificate}; +use x509_cert::Certificate; impl ResolvesClientCert for CertResolver { fn resolve( @@ -136,32 +136,12 @@ impl Client { corpus: Vec, log: slog::Logger, ) -> Result, Error> { - use x509_cert::der::DecodePem; - - let mut roots = Vec::new(); - for root in &config.roots { - let root = std::fs::read(root)?; - let root = Certificate::from_pem(&root)?; - roots.push(root); - } - - let c = match config.resolve { - ResolveSetting::Local { - priv_key, - cert_chain, - } => Client::new_tls_local_client_config( - priv_key, - cert_chain, - config.roots, - log.clone(), - )?, - ResolveSetting::Ipcc => { - Client::new_tls_ipcc_client_config(config.roots, log.clone())? - } - }; + let roots = load_roots(&config.roots)?; + let tls_config = + new_tls_client_config(config.resolve, roots.clone(), &log)?; Client::connect_with_config( - c, + tls_config, config.attest, roots, corpus, @@ -172,65 +152,6 @@ impl Client { .await } - // For some testing shenanigans - pub(crate) fn new_tls_local_client_config( - priv_key: Utf8PathBuf, - cert_chain: Utf8PathBuf, - roots: Vec, - log: slog::Logger, - ) -> Result { - let roots = roots - .into_iter() - .map(|x| load_root_cert(&x)) - .collect::, _>>()?; - - let verifier = Arc::new(RotCertVerifier::new(roots, log.clone())?) - as Arc; - - let client_resolver = Arc::new(CertResolver::new( - log.clone(), - ResolveSetting::Local { - priv_key, - cert_chain, - }, - )) as Arc; - - let config = - ClientConfig::builder_with_provider(Arc::new(crypto_provider())) - .with_protocol_versions(&[&TLS13])? - .dangerous() - .with_custom_certificate_verifier(verifier) - .with_client_cert_resolver(client_resolver); - - Ok(config) - } - - fn new_tls_ipcc_client_config( - roots: Vec, - log: slog::Logger, - ) -> Result { - let roots = roots - .into_iter() - .map(|x| load_root_cert(&x)) - .collect::, _>>()?; - - let verifier = Arc::new(RotCertVerifier::new(roots, log.clone())?) - as Arc; - - let client_resolver = - Arc::new(CertResolver::new(log.clone(), ResolveSetting::Ipcc)) - as Arc; - - let config = - ClientConfig::builder_with_provider(Arc::new(crypto_provider())) - .with_protocol_versions(&[&TLS13])? - .dangerous() - .with_custom_certificate_verifier(verifier) - .with_client_cert_resolver(client_resolver); - - Ok(config) - } - /// Connect to a remote peer async fn connect_with_config( tls_config: ClientConfig, @@ -262,19 +183,8 @@ impl Client { // get server cert chain from connection let (_, conn) = stream.get_ref(); - let tq_platform_id = if let Some(tls_certs) = conn.peer_certificates() { - let mut pki_path = Vec::new(); - for der in tls_certs.iter() { - pki_path.push(Certificate::from_der(der).map_err(|_| { - rustls::Error::InvalidCertificate( - rustls::CertificateError::BadEncoding, - ) - })?) - } - dice_mfg_msgs::PlatformId::try_from(&pki_path)? - } else { - return Err(Error::NoTQCerts); - }; + let tq_platform_id = + platform_id_from_tls_certs(conn.peer_certificates())?; // send version to the server send_msg(&mut stream, &CURRENT_PROTOCOL_VERSION.to_le_bytes()).await?; diff --git a/tls/src/config.rs b/tls/src/config.rs new file mode 100644 index 0000000..7a29127 --- /dev/null +++ b/tls/src/config.rs @@ -0,0 +1,88 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Construction of the rustls configurations underlying a sprockets +//! connection. +//! +//! The functions here are pure: they read the key and certificate material +//! named by a [`SprocketsConfig`](crate::keys::SprocketsConfig) and return a +//! rustls configuration. They open no sockets and perform no handshake, which +//! is what lets a single pair of builders serve every transport. + +use crate::keys::{CertResolver, ResolveSetting, RotCertVerifier}; +use crate::{crypto_provider, load_root_cert, Error}; +use camino::Utf8PathBuf; +use rustls::{ + client::{danger::ServerCertVerifier, ResolvesClientCert}, + server::{danger::ClientCertVerifier, ResolvesServerCert}, + version::TLS13, + ClientConfig, ServerConfig, +}; +use std::sync::Arc; +use x509_cert::Certificate; + +/// Loads the PEM-encoded root certificates at each of `paths`. +/// +/// # Errors +/// +/// Returns [`Error::FailedRead`] naming the offending path if any root cannot +/// be read, and [`Error::Der`] or [`Error::Pem`] if one cannot be parsed. A +/// single unusable root fails the whole load: sprockets has no notion of a +/// partially trusted set. +pub(crate) fn load_roots( + paths: &[Utf8PathBuf], +) -> Result, Error> { + paths.iter().map(load_root_cert).collect() +} + +/// Builds the client-side TLS configuration for a sprockets connection. +/// +/// The client authenticates the server with a [`RotCertVerifier`] over `roots` +/// and presents its own trust quorum credentials from `resolve`. The +/// configuration pins TLS 1.3 and the [`crypto_provider`] suite list. +pub(crate) fn new_tls_client_config( + resolve: ResolveSetting, + roots: Vec, + log: &slog::Logger, +) -> Result { + let verifier = Arc::new(RotCertVerifier::new(roots, log.clone())?) + as Arc; + + let client_resolver = Arc::new(CertResolver::new(log.clone(), resolve)) + as Arc; + + Ok( + ClientConfig::builder_with_provider(Arc::new(crypto_provider())) + .with_protocol_versions(&[&TLS13])? + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_client_cert_resolver(client_resolver), + ) +} + +/// Builds the server-side TLS configuration for a sprockets connection. +/// +/// Mutual authentication is mandatory: the [`RotCertVerifier`] installed over +/// `roots` is a [`ClientCertVerifier`] whose +/// [`client_auth_mandatory`](ClientCertVerifier::client_auth_mandatory) is +/// `true`, so a client that presents no certificate never completes the +/// handshake. +pub(crate) fn new_tls_server_config( + resolve: ResolveSetting, + roots: Vec, + log: &slog::Logger, +) -> Result { + let verifier = Arc::new(RotCertVerifier::new(roots, log.clone())?) + as Arc; + + let server_resolver = Arc::new(CertResolver::new(log.clone(), resolve)) + as Arc; + + Ok( + ServerConfig::builder_with_provider(Arc::new(crypto_provider())) + .with_protocol_versions(&[&TLS13])? + .with_client_cert_verifier(verifier) + .with_cert_resolver(server_resolver), + ) +} diff --git a/tls/src/lib.rs b/tls/src/lib.rs index 68a9c3a..737ac13 100644 --- a/tls/src/lib.rs +++ b/tls/src/lib.rs @@ -24,11 +24,12 @@ use std::{fs, io}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio_rustls::TlsStream; use x509_cert::{ - der::{self, DecodePem, Encode, Reader, SliceReader}, + der::{self, Decode, DecodePem, Encode, Reader, SliceReader}, Certificate, }; pub mod client; +mod config; pub mod ipcc; pub mod keys; pub mod server; @@ -252,6 +253,38 @@ pub fn load_root_cert(path: &Utf8PathBuf) -> Result { Ok(cert) } +/// Derives the peer's [`PlatformId`] from the trust quorum certificate chain +/// presented during the TLS handshake. +/// +/// `tls_certs` is the peer's chain as rustls reports it, end entity first. The +/// resulting identity is the one an attestation exchange must agree with: the +/// caller is expected to compare it against the `PlatformId` of the peer's +/// attestation cert chain and reject the connection on a mismatch. +/// +/// # Errors +/// +/// Returns [`Error::NoTQCerts`] if `tls_certs` is `None`, which is how both an +/// unauthenticated peer and a rustls handle that has not finished its handshake +/// present themselves. +pub(crate) fn platform_id_from_tls_certs( + tls_certs: Option<&[rustls::pki_types::CertificateDer<'_>]>, +) -> Result { + let Some(tls_certs) = tls_certs else { + return Err(Error::NoTQCerts); + }; + + let mut pki_path = Vec::new(); + for der in tls_certs.iter() { + pki_path.push(Certificate::from_der(der).map_err(|_| { + rustls::Error::InvalidCertificate( + rustls::CertificateError::BadEncoding, + ) + })?) + } + + Ok(PlatformId::try_from(&pki_path)?) +} + fn certs_to_der(certs: &[Certificate]) -> Result, Error> { let mut der = Vec::new(); @@ -337,7 +370,7 @@ async fn send_msg( /// Return a common [`CryptoProvider`] for use by both client and server. /// -/// Use `ring` as a crypto provider. `aws_lc` doesn't compile on illumos. +/// Uses `aws-lc-rs` as the crypto provider. /// /// Only allow X25519 for key exchange /// @@ -597,11 +630,17 @@ mod tests { }; }); - let client_config = client::Client::new_tls_local_client_config( - mock_datadir.join("test-sprockets-auth-2.key.pem"), - mock_datadir.join("test-sprockets-auth-2.certlist.pem"), - vec![mock_datadir.join("test-root-a.cert.pem")], - log, + let roots = + config::load_roots(&[mock_datadir.join("test-root-a.cert.pem")]) + .unwrap(); + let client_config = config::new_tls_client_config( + keys::ResolveSetting::Local { + priv_key: mock_datadir.join("test-sprockets-auth-2.key.pem"), + cert_chain: mock_datadir + .join("test-sprockets-auth-2.certlist.pem"), + }, + roots, + &log, ) .unwrap(); @@ -672,11 +711,17 @@ mod tests { } }); - let client_config = client::Client::new_tls_local_client_config( - mock_datadir.join("test-sprockets-auth-2.key.pem"), - mock_datadir.join("test-sprockets-auth-2.certlist.pem"), - vec![mock_datadir.join("test-root-a.cert.pem")], - log, + let roots = + config::load_roots(&[mock_datadir.join("test-root-a.cert.pem")]) + .unwrap(); + let client_config = config::new_tls_client_config( + keys::ResolveSetting::Local { + priv_key: mock_datadir.join("test-sprockets-auth-2.key.pem"), + cert_chain: mock_datadir + .join("test-sprockets-auth-2.certlist.pem"), + }, + roots, + &log, ) .unwrap(); @@ -834,11 +879,17 @@ mod tests { } }); - let client_config = client::Client::new_tls_local_client_config( - mock_datadir.join("test-sprockets-auth-2.key.pem"), - mock_datadir.join("test-sprockets-auth-2.certlist.pem"), - vec![mock_datadir.join("test-root-a.cert.pem")], - log, + let roots = + config::load_roots(&[mock_datadir.join("test-root-a.cert.pem")]) + .unwrap(); + let client_config = config::new_tls_client_config( + keys::ResolveSetting::Local { + priv_key: mock_datadir.join("test-sprockets-auth-2.key.pem"), + cert_chain: mock_datadir + .join("test-sprockets-auth-2.certlist.pem"), + }, + roots, + &log, ) .unwrap(); diff --git a/tls/src/server.rs b/tls/src/server.rs index 1a0767a..81649dd 100644 --- a/tls/src/server.rs +++ b/tls/src/server.rs @@ -4,12 +4,13 @@ //! A TLS based server +use crate::config::{load_roots, new_tls_server_config}; use crate::keys::{ get_attest_data, AttestConfig, CertResolver, MeasurementConnectionPolicy, - ResolveSetting, RotCertVerifier, SprocketsConfig, + RotCertVerifier, SprocketsConfig, }; use crate::{ - certs_from_der, certs_to_der, crypto_provider, load_root_cert, recv_msg, + certs_from_der, certs_to_der, platform_id_from_tls_certs, recv_msg, send_msg, ProtocolRequestAck, ProtocolResult, CURRENT_PROTOCOL_VERSION, PREVIOUS_PROTOCOL_VERSION, }; @@ -25,7 +26,6 @@ use rustls::{ danger::{ClientCertVerified, ClientCertVerifier}, ResolvesServerCert, }, - version::TLS13, CipherSuite, ServerConfig, SignatureScheme, }; use slog::{error, info, warn}; @@ -33,10 +33,7 @@ use std::net::{SocketAddr, SocketAddrV6}; use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; use tokio_rustls::TlsAcceptor; -use x509_cert::{ - der::{Decode, DecodePem}, - Certificate, -}; +use x509_cert::Certificate; impl ResolvesServerCert for CertResolver { fn resolve( @@ -122,19 +119,8 @@ impl SprocketsAcceptor { // get PlatformId from server TLS / Trust Quorum cert chain let (_, conn) = stream.get_ref(); - let tq_platform_id = if let Some(tls_certs) = conn.peer_certificates() { - let mut pki_path = Vec::new(); - for der in tls_certs.iter() { - pki_path.push(Certificate::from_der(der).map_err(|_| { - rustls::Error::InvalidCertificate( - rustls::CertificateError::BadEncoding, - ) - })?) - } - dice_mfg_msgs::PlatformId::try_from(&pki_path)? - } else { - return Err(Error::NoTQCerts); - }; + let tq_platform_id = + platform_id_from_tls_certs(conn.peer_certificates())?; // get version from the client let version_bytes = recv_msg(&mut stream).await?; @@ -373,89 +359,24 @@ impl Server { self.tcp_listener.local_addr() } - fn new_tls_local_server_config( - priv_key: Utf8PathBuf, - cert_chain: Utf8PathBuf, - roots: Vec, - log: slog::Logger, - ) -> Result { - let roots = roots - .into_iter() - .map(|x| load_root_cert(&x)) - .collect::, _>>()?; - - let verifier = Arc::new(RotCertVerifier::new(roots, log.clone())?) - as Arc; - - let server_resolver = Arc::new(CertResolver::new( - log.clone(), - ResolveSetting::Local { - priv_key, - cert_chain, - }, - )) as Arc; - - let config = - ServerConfig::builder_with_provider(Arc::new(crypto_provider())) - .with_protocol_versions(&[&TLS13])? - .with_client_cert_verifier(verifier) - .with_cert_resolver(server_resolver); - - Ok(config) - } - - fn new_tls_ipcc_server_config( - roots: Vec, - log: slog::Logger, - ) -> Result { - let roots = roots - .into_iter() - .map(|x| load_root_cert(&x)) - .collect::, _>>()?; - - let verifier = Arc::new(RotCertVerifier::new(roots, log.clone())?) - as Arc; - - let server_resolver = - Arc::new(CertResolver::new(log.clone(), ResolveSetting::Ipcc)) - as Arc; - - let config = - ServerConfig::builder_with_provider(Arc::new(crypto_provider())) - .with_protocol_versions(&[&TLS13])? - .with_client_cert_verifier(verifier) - .with_cert_resolver(server_resolver); - - Ok(config) - } - pub async fn new( config: SprocketsConfig, addr: SocketAddrV6, log: slog::Logger, ) -> Result { - let mut roots = Vec::new(); - for root in &config.roots { - let root = std::fs::read(root)?; - let root = Certificate::from_pem(&root)?; - roots.push(root); - } + let roots = load_roots(&config.roots)?; + let tls_config = + new_tls_server_config(config.resolve, roots.clone(), &log)?; - let c = match config.resolve { - ResolveSetting::Local { - priv_key, - cert_chain, - } => Server::new_tls_local_server_config( - priv_key, - cert_chain, - config.roots, - log.clone(), - )?, - ResolveSetting::Ipcc => { - Server::new_tls_ipcc_server_config(config.roots, log.clone())? - } - }; - Server::listen(c, config.attest, roots, addr, log, config.enforce).await + Server::listen( + tls_config, + config.attest, + roots, + addr, + log, + config.enforce, + ) + .await } async fn listen( From bd04c4d6a20958dd624d843520db71fac6175447 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 11:50:24 -0400 Subject: [PATCH 05/18] Extract the attestation protocol into a transport-generic module Factor the post-handshake attestation exchange out of client.rs and server.rs into a new attest module, in preparation for running the same protocol over a QUIC bidirectional stream as well as TCP. - Add tls/src/attest.rs holding the framing (send_msg/recv_msg with its MAX_MSG_SIZE bound), the DER cert-chain helpers (certs_to_der/certs_from_der), the protocol version constants and result aliases, and corims_from_paths, all moved from lib.rs and server.rs. - Extract client_exchange and server_exchange, each generic over T: AsyncRead + AsyncWrite + Unpin. They take the peer PlatformId already derived from the TLS handshake certificates and return the peer's attested PlatformId plus whether its measurements appraised. The bodies are the former inline exchanges verbatim, including the checked version-message parse and preserving the wire-visible asymmetries: the server sends its own attestation last (and never, under Enforced, after a failed appraisal), the client loads its corims inline at the end of the exchange, and the server loads its corpus before accepting the TLS connection so a malformed corpus fails early. - Client::connect_with_config and SprocketsAcceptor::handshake shrink to TLS setup, peer-PlatformId extraction, and a call into attest. No change to the attestation protocol bytes, the TLS configuration, or the crypto provider. Verified on illumos: clippy --all-targets clean and the TCP test suite passes. --- tls/src/attest.rs | 503 ++++++++++++++++++++++++++++++++++++++++++++++ tls/src/client.rs | 180 ++--------------- tls/src/lib.rs | 89 +------- tls/src/server.rs | 196 ++---------------- 4 files changed, 538 insertions(+), 430 deletions(-) create mode 100644 tls/src/attest.rs diff --git a/tls/src/attest.rs b/tls/src/attest.rs new file mode 100644 index 0000000..1b662f7 --- /dev/null +++ b/tls/src/attest.rs @@ -0,0 +1,503 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! The post-handshake sprockets attestation protocol. +//! +//! Once a mutually-authenticated TLS session exists, both peers run a +//! lock-step, client-first exchange over it: version negotiation, fresh +//! challenge nonces, attestation cert chains, measurement logs, and quotes, +//! followed by an appraisal of the peer's measurements against a reference +//! corpus. The two halves are [`client_exchange`] and [`server_exchange`]. +//! +//! Everything here is transport-generic. The exchange functions are generic +//! over any `AsyncRead + AsyncWrite` stream and use only length-prefixed +//! [`send_msg`]/[`recv_msg`] framing over a single reliable ordered +//! bidirectional byte stream, so the same protocol bytes run unchanged over a +//! TCP-backed [`TlsStream`](tokio_rustls::TlsStream) or a QUIC bidirectional +//! stream. The one input the exchange cannot derive for itself is the peer's +//! trust-quorum [`PlatformId`], which comes from the handshake certificates; +//! the caller extracts it (see +//! [`platform_id_from_tls_certs`](crate::platform_id_from_tls_certs)) and hands +//! it in so the exchange can enforce the identity binding. + +use crate::keys::{get_attest_data, AttestConfig, MeasurementConnectionPolicy}; +use crate::Error; +use camino::Utf8PathBuf; +use dice_mfg_msgs::PlatformId; +use dice_verifier::{ + Attestation, Corim, Log, MeasurementSet, Nonce, Nonce32, + ReferenceMeasurements, +}; +use hubpack::SerializedSize; +use slog::{info, warn}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use x509_cert::der::{Encode, Reader, SliceReader}; +use x509_cert::Certificate; + +// Response from the server, either the same version back, version - 1 +// or an error if there is no way the server can support this request +type ProtocolResult = Result; + +// Message from client acking the version or telling the server it's +// giving up +type ProtocolRequestAck = Result; + +const CURRENT_PROTOCOL_VERSION: u32 = 2; +const PREVIOUS_PROTOCOL_VERSION: u32 = CURRENT_PROTOCOL_VERSION - 1; + +/// The largest message [`recv_msg`] will accept. +/// +/// The length prefix is peer-controlled and [`recv_msg`] allocates the full +/// message buffer before reading the body, so without a bound a +/// TLS-authenticated (but not yet attested) peer can demand a 4 GiB +/// allocation with a 4-byte prefix. 1 MiB comfortably exceeds every +/// legitimate protocol message: the largest are the hubpacked measurement +/// log and attestation, whose bounds are asserted below; cert chains and +/// nonces are far smaller. +const MAX_MSG_SIZE: usize = 1024 * 1024; + +// The bound must admit every legitimate protocol message. +const _: () = assert!(dice_verifier::Log::MAX_SIZE <= MAX_MSG_SIZE); +const _: () = assert!(dice_verifier::Attestation::MAX_SIZE <= MAX_MSG_SIZE); + +async fn recv_msg( + stream: &mut T, +) -> Result, Error> { + // to receive a message we first get its length that is a u32 serialized as + // a little endian byte array + let mut msg_len = [0u8; 4]; + stream.read_exact(&mut msg_len).await?; + let msg_len: usize = u32::from_le_bytes(msg_len).try_into()?; + + // The length is peer-controlled: bound it before allocating. + if msg_len > MAX_MSG_SIZE { + return Err(Error::MessageTooLarge { + len: msg_len, + max: MAX_MSG_SIZE, + }); + } + + // with the length we can then get the message body + let mut buf = vec![0u8; msg_len]; + stream.read_exact(&mut buf).await?; + + Ok(buf) +} + +async fn send_msg( + stream: &mut T, + msg: &[u8], +) -> Result<(), Error> { + // to send a message we first send the receiver its length as a u32 + // serialized as a little endian byte array + let len: u32 = msg.len().try_into()?; + stream.write_all(&len.to_le_bytes()).await?; + // then we send the message + Ok(stream.write_all(msg).await?) +} + +fn certs_to_der(certs: &[Certificate]) -> Result, Error> { + let mut der = Vec::new(); + + for cert in certs { + der.append(&mut cert.to_der()?); + } + + Ok(der) +} + +fn certs_from_der(buf: &[u8]) -> Result, Error> { + let mut certs = Vec::new(); + let mut reader = SliceReader::new(buf)?; + + while !reader.is_finished() { + certs.push(reader.decode()?); + } + + Ok(certs) +} + +/// Loads and CBOR-decodes the CoRIM reference-measurement corpus at `paths`. +/// +/// The server loads its corpus *before* accepting the TLS connection, so a +/// malformed corpus aborts the handshake early rather than after attestation +/// work has begun; [`server_exchange`] then takes the loaded corpus by value. +/// [`client_exchange`], by contrast, loads its own corpus inline near the end +/// of the exchange — this asymmetry is wire-visible and deliberate. +pub(crate) fn corims_from_paths( + paths: &[Utf8PathBuf], + log: &slog::Logger, +) -> Result, Error> { + let mut corims = Vec::new(); + for c in paths { + info!(log, "Using file {:?}", c); + corims.push(Corim::from_file(c)?); + } + Ok(corims) +} + +/// Runs the client half of the attestation exchange over an established TLS +/// session. +/// +/// `stream` is the mutually-authenticated session; `tq_platform_id` is the +/// peer identity derived from its trust-quorum handshake certificates, against +/// which the peer's attestation cert chain must agree. On success returns the +/// peer's [`PlatformId`] and whether its measurements appraised successfully +/// against `corpus` (always `true` under +/// [`Enforced`](MeasurementConnectionPolicy::Enforced), since a failed +/// appraisal is an error there). +pub(crate) async fn client_exchange( + stream: &mut T, + tq_platform_id: PlatformId, + attest_config: &AttestConfig, + roots: &[Certificate], + corpus: Vec, + enforce: MeasurementConnectionPolicy, + log: &slog::Logger, +) -> Result<(PlatformId, bool), Error> +where + T: AsyncRead + AsyncWrite + Unpin, +{ + // send version to the server + send_msg(stream, &CURRENT_PROTOCOL_VERSION.to_le_bytes()).await?; + + // get version response from server, we expect it to be + // hubpacked + let version_response = recv_msg(stream).await?; + let (version, _): (ProtocolResult, _) = + hubpack::deserialize(&version_response)?; + + let version = match version { + Ok(v) => v, + // Not much we can do? + Err(_) => return Err(Error::ProtocolVersion), + }; + + if version == CURRENT_PROTOCOL_VERSION { + // we're good to go + let mut buf = vec![0u8; ProtocolRequestAck::MAX_SIZE]; + let resp: ProtocolRequestAck = Ok(version); + let resp_len = hubpack::serialize(&mut buf, &resp)?; + send_msg(stream, &buf[..resp_len]).await?; + } else if version == PREVIOUS_PROTOCOL_VERSION { + // Also good + let mut buf = vec![0u8; ProtocolRequestAck::MAX_SIZE]; + let resp: ProtocolRequestAck = Ok(version); + let resp_len = hubpack::serialize(&mut buf, &resp)?; + send_msg(stream, &buf[..resp_len]).await?; + } else { + // Farewell + let mut buf = vec![0u8; ProtocolRequestAck::MAX_SIZE]; + let resp: ProtocolRequestAck = Err(()); + let resp_len = hubpack::serialize(&mut buf, &resp)?; + send_msg(stream, &buf[..resp_len]).await?; + return Err(Error::ProtocolVersion); + } + + // Right now all protocols are the same + info!(log, "Running with protocol version {version}"); + + // send Nonce to server + let nonce = Nonce::from_platform_rng(Nonce32::LENGTH)?; + send_msg(stream, nonce.as_ref()).await?; + + // get Nonce from server + let server_nonce = recv_msg(stream).await?; + let server_nonce = Nonce::try_from(server_nonce)?; + + // get attestation & verify it before sending it + // The attesation protocol has an inherent race condition between + // getting the log and the attestation. We verify our own attestation + // before sending it to the challenger to fail as early as possible. + let attest_data = get_attest_data(attest_config, &server_nonce).await?; + dice_verifier::verify_attestation( + &attest_data.certs[0], + &attest_data.attestation, + &attest_data.log, + &server_nonce, + )?; + + // send client attestation cert chain to server + let cert_chain_der = certs_to_der(&attest_data.certs)?; + send_msg(stream, &cert_chain_der).await?; + + // get & verify server attestation cert chain + let server_cert_chain = recv_msg(stream).await?; + let server_cert_chain = certs_from_der(&server_cert_chain)?; + let root = + dice_verifier::verify_cert_chain(&server_cert_chain, Some(roots))?; + let server_platform_id = + dice_mfg_msgs::PlatformId::try_from(&server_cert_chain)?; + info!( + log, + "Cert chain from peer \"{}\" verified against root \"{}\"", + server_platform_id.as_str(), + root.tbs_certificate.subject, + ); + + if tq_platform_id != server_platform_id { + return Err(Error::PlatformIdMismatch); + } + info!(log, "TQ & attestation cert chains agree on platform id"); + + // send measurement log to server + let mut buf = vec![0u8; Log::MAX_SIZE]; + let log_len = hubpack::serialize(&mut buf, &attest_data.log)?; + send_msg(stream, &buf[..log_len]).await?; + + // get measurement log from server + let server_log = recv_msg(stream).await?; + let (server_log, _): (Log, _) = hubpack::deserialize(&server_log)?; + + // hubpack attestation and send to server + let mut buf = vec![0u8; Attestation::MAX_SIZE]; + let len = hubpack::serialize(&mut buf, &attest_data.attestation)?; + send_msg(stream, &buf[..len]).await?; + + // get attestation from server + let server_attestation = recv_msg(stream).await?; + let (server_attestation, _): (Attestation, _) = + hubpack::deserialize(&server_attestation)?; + + // verify server attestation + dice_verifier::verify_attestation( + &server_cert_chain[0], + &server_attestation, + &server_log, + &nonce, + )?; + info!(log, "Peer attestation verified"); + + // load corims into a set of ReferenceMeasurements + let mut corims = Vec::new(); + for c in corpus { + corims.push(Corim::from_file(c)?); + } + + for c in attest_data.test_corpus { + corims.push(Corim::from_file(c)?); + } + let reference_measurements = + ReferenceMeasurements::try_from(corims.as_slice())?; + + // appraise measurements from server attestation against reference + // measurements + let measurements = + MeasurementSet::from_artifacts(&server_cert_chain, &server_log)?; + let result = match dice_verifier::verify_measurements( + &measurements, + &reference_measurements, + ) { + Ok(()) => { + info!(log, "Peer measurements appraised successfully"); + true + } + Err(err) => { + warn!( + log, + "Peer ({}) measurements appraisal failed {} corpus {}", + server_platform_id.as_str(), + err, + reference_measurements + ); + match enforce { + MeasurementConnectionPolicy::Enforced => { + return Err(Error::AttestMeasurementsVerifier { + peer: server_platform_id, + err, + }); + } + MeasurementConnectionPolicy::Permissive => false, + } + } + }; + Ok((server_platform_id, result)) +} + +/// Runs the server half of the attestation exchange over an established TLS +/// session. +/// +/// `stream` is the mutually-authenticated session; `tq_platform_id` is the peer +/// identity derived from its trust-quorum handshake certificates; `corims` is +/// the reference-measurement corpus loaded before the handshake (see +/// [`corims_from_paths`]). On success returns the peer's [`PlatformId`] and +/// whether its measurements appraised successfully (always `true` under +/// [`Enforced`](MeasurementConnectionPolicy::Enforced)). +/// +/// The server's wire behavior is deliberately asymmetric to the client's: it +/// sends its own attestation *last*, only after appraising the client, and +/// under [`Enforced`](MeasurementConnectionPolicy::Enforced) a failed appraisal +/// returns before that final send ever happens. +pub(crate) async fn server_exchange( + stream: &mut T, + tq_platform_id: PlatformId, + mut corims: Vec, + attest_config: &AttestConfig, + roots: &[Certificate], + enforce: MeasurementConnectionPolicy, + log: &slog::Logger, +) -> Result<(PlatformId, bool), Error> +where + T: AsyncRead + AsyncWrite + Unpin, +{ + // get version from the client + let version_bytes = recv_msg(stream).await?; + // Anything but exactly the 4-byte little-endian version is protocol + // garbage from the peer; reject it rather than index past the end of a + // short message. + let version_bytes: [u8; 4] = version_bytes + .as_slice() + .try_into() + .map_err(|_| Error::ProtocolVersion)?; + let version = u32::from_le_bytes(version_bytes); + + if version == CURRENT_PROTOCOL_VERSION { + // we're good to go + let mut buf = vec![0u8; ProtocolResult::MAX_SIZE]; + let resp: ProtocolResult = Ok(version); + let resp_len = hubpack::serialize(&mut buf, &resp)?; + send_msg(stream, &buf[..resp_len]).await?; + } else if version == PREVIOUS_PROTOCOL_VERSION { + // We eventually want to support older protocol + let mut buf = vec![0u8; ProtocolResult::MAX_SIZE]; + let resp: ProtocolResult = Ok(version); + let resp_len = hubpack::serialize(&mut buf, &resp)?; + send_msg(stream, &buf[..resp_len]).await?; + } else { + // We can't deal with this + // We eventually want to support older protocol + let mut buf = vec![0u8; ProtocolResult::MAX_SIZE]; + let resp: ProtocolResult = Err(()); + let resp_len = hubpack::serialize(&mut buf, &resp)?; + send_msg(stream, &buf[..resp_len]).await?; + // Client has given us something bad, time to give up + return Err(Error::ProtocolVersion); + } + + // Wait for the protocol ACK + let protocol_ack_bytes = recv_msg(stream).await?; + let (protocol_ack, _): (ProtocolRequestAck, _) = + hubpack::deserialize(&protocol_ack_bytes)?; + + match protocol_ack { + Ok(v) => { + if v != version { + // this isn't right... + return Err(Error::ClientMismatch); + } + } + Err(_) => return Err(Error::ClientGaveUp), + } + + // Right now all protocols are the same + info!(log, "Running with protocol version {version}"); + + // get Nonce from client + let client_nonce = recv_msg(stream).await?; + let client_nonce = Nonce::try_from(client_nonce)?; + + // generate & send Nonce to client + let nonce = Nonce::from_platform_rng(Nonce32::LENGTH)?; + send_msg(stream, nonce.as_ref()).await?; + + // get attestation & verify it before sending it + // The attesation protocol has an inherent race condition between + // getting the log and the attestation. We verify our own attestation + // before sending it to the challenger to fail as early as possible. + let attest_data = get_attest_data(attest_config, &client_nonce).await?; + dice_verifier::verify_attestation( + &attest_data.certs[0], + &attest_data.attestation, + &attest_data.log, + &client_nonce, + )?; + + // get & verify client attestation cert chain + let client_cert_chain = recv_msg(stream).await?; + let client_cert_chain = certs_from_der(&client_cert_chain)?; + let root = + dice_verifier::verify_cert_chain(&client_cert_chain, Some(roots))?; + let client_platform_id = + dice_mfg_msgs::PlatformId::try_from(&client_cert_chain)?; + info!( + log, + "Cert chain from peer \"{}\" verified against root \"{}\"", + client_platform_id.as_str(), + root.tbs_certificate.subject, + ); + + if tq_platform_id != client_platform_id { + return Err(Error::PlatformIdMismatch); + } + info!(log, "TQ & attestation cert chains agree on platform id"); + + // send server attestation cert chain to client + let cert_chain_der = certs_to_der(&attest_data.certs)?; + send_msg(stream, &cert_chain_der).await?; + + // get measurement log from client + let client_log = recv_msg(stream).await?; + let (client_log, _): (Log, _) = hubpack::deserialize(&client_log)?; + + // send server measurement log to client + let mut buf = vec![0u8; Log::MAX_SIZE]; + let len = hubpack::serialize(&mut buf, &attest_data.log)?; + send_msg(stream, &buf[..len]).await?; + + // get attestation from client + let client_attestation = recv_msg(stream).await?; + let (client_attestation, _): (Attestation, _) = + hubpack::deserialize(&client_attestation)?; + + // verify client attestation + dice_verifier::verify_attestation( + &client_cert_chain[0], + &client_attestation, + &client_log, + &nonce, + )?; + info!(log, "Peer attestation verified"); + + for c in attest_data.test_corpus { + corims.push(Corim::from_file(c)?); + } + + let corpus = ReferenceMeasurements::try_from(corims.as_slice())?; + // appraise measurements from client attestation against reference + // measurements + let measurements = + MeasurementSet::from_artifacts(&client_cert_chain, &client_log)?; + let result = + match dice_verifier::verify_measurements(&measurements, &corpus) { + Ok(()) => { + info!(log, "Peer measurements appraised successfully"); + true + } + Err(err) => { + warn!( + log, + "Peer ({}) measurements appraisal failed: {} corpus {}", + client_platform_id.as_str(), + err, + corpus + ); + match enforce { + MeasurementConnectionPolicy::Enforced => { + return Err(Error::AttestMeasurementsVerifier { + peer: client_platform_id, + err, + }); + } + MeasurementConnectionPolicy::Permissive => false, + } + } + }; + + // hubpack the attestation and send to client + let mut buf = vec![0u8; Attestation::MAX_SIZE]; + let len = hubpack::serialize(&mut buf, &attest_data.attestation)?; + send_msg(stream, &buf[..len]).await?; + + Ok((client_platform_id, result)) +} diff --git a/tls/src/client.rs b/tls/src/client.rs index 96b3410..eac376c 100644 --- a/tls/src/client.rs +++ b/tls/src/client.rs @@ -10,23 +10,14 @@ use std::sync::Arc; use tokio::net::TcpStream; use tokio_rustls::TlsConnector; +use crate::attest; use crate::config::{load_roots, new_tls_client_config}; -use crate::keys::{get_attest_data, AttestConfig}; +use crate::keys::AttestConfig; use crate::keys::{ CertResolver, MeasurementConnectionPolicy, RotCertVerifier, SprocketsConfig, }; -use crate::{ - certs_from_der, certs_to_der, platform_id_from_tls_certs, recv_msg, - send_msg, ProtocolRequestAck, ProtocolResult, CURRENT_PROTOCOL_VERSION, - PREVIOUS_PROTOCOL_VERSION, -}; -use crate::{Error, Stream}; +use crate::{platform_id_from_tls_certs, Error, Stream}; use camino::Utf8PathBuf; -use dice_verifier::{ - Attestation, Corim, Log, MeasurementSet, Nonce, Nonce32, - ReferenceMeasurements, -}; -use hubpack::SerializedSize; use rustls::{ client::{ danger::{ @@ -37,7 +28,7 @@ use rustls::{ sign::CertifiedKey, ClientConfig, SignatureScheme, }; -use slog::{error, info, warn}; +use slog::{error, info}; use x509_cert::Certificate; impl ResolvesClientCert for CertResolver { @@ -186,160 +177,17 @@ impl Client { let tq_platform_id = platform_id_from_tls_certs(conn.peer_certificates())?; - // send version to the server - send_msg(&mut stream, &CURRENT_PROTOCOL_VERSION.to_le_bytes()).await?; - - // get version response from server, we expect it to be - // hubpacked - let version_response = recv_msg(&mut stream).await?; - let (version, _): (ProtocolResult, _) = - hubpack::deserialize(&version_response)?; - - let version = match version { - Ok(v) => v, - // Not much we can do? - Err(_) => return Err(Error::ProtocolVersion), - }; - - if version == CURRENT_PROTOCOL_VERSION { - // we're good to go - let mut buf = vec![0u8; ProtocolRequestAck::MAX_SIZE]; - let resp: ProtocolRequestAck = Ok(version); - let resp_len = hubpack::serialize(&mut buf, &resp)?; - send_msg(&mut stream, &buf[..resp_len]).await?; - } else if version == PREVIOUS_PROTOCOL_VERSION { - // Also good - let mut buf = vec![0u8; ProtocolRequestAck::MAX_SIZE]; - let resp: ProtocolRequestAck = Ok(version); - let resp_len = hubpack::serialize(&mut buf, &resp)?; - send_msg(&mut stream, &buf[..resp_len]).await?; - } else { - // Farewell - let mut buf = vec![0u8; ProtocolRequestAck::MAX_SIZE]; - let resp: ProtocolRequestAck = Err(()); - let resp_len = hubpack::serialize(&mut buf, &resp)?; - send_msg(&mut stream, &buf[..resp_len]).await?; - return Err(Error::ProtocolVersion); - } - - // Right now all protocols are the same - info!(log, "Running with protocol version {version}"); - - // send Nonce to server - let nonce = Nonce::from_platform_rng(Nonce32::LENGTH)?; - send_msg(&mut stream, nonce.as_ref()).await?; - - // get Nonce from server - let server_nonce = recv_msg(&mut stream).await?; - let server_nonce = Nonce::try_from(server_nonce)?; - - // get attestation & verify it before sending it - // The attesation protocol has an inherent race condition between - // getting the log and the attestation. We verify our own attestation - // before sending it to the challenger to fail as early as possible. - let attest_data = - get_attest_data(&attest_config, &server_nonce).await?; - dice_verifier::verify_attestation( - &attest_data.certs[0], - &attest_data.attestation, - &attest_data.log, - &server_nonce, - )?; - - // send client attestation cert chain to server - let cert_chain_der = certs_to_der(&attest_data.certs)?; - send_msg(&mut stream, &cert_chain_der).await?; - - // get & verify server attestation cert chain - let server_cert_chain = recv_msg(&mut stream).await?; - let server_cert_chain = certs_from_der(&server_cert_chain)?; - let root = - dice_verifier::verify_cert_chain(&server_cert_chain, Some(&roots))?; - let server_platform_id = - dice_mfg_msgs::PlatformId::try_from(&server_cert_chain)?; - info!( - log, - "Cert chain from peer \"{}\" verified against root \"{}\"", - server_platform_id.as_str(), - root.tbs_certificate.subject, - ); - - if tq_platform_id != server_platform_id { - return Err(Error::PlatformIdMismatch); - } - info!(log, "TQ & attestation cert chains agree on platform id"); - - // send measurement log to server - let mut buf = vec![0u8; Log::MAX_SIZE]; - let log_len = hubpack::serialize(&mut buf, &attest_data.log)?; - send_msg(&mut stream, &buf[..log_len]).await?; - - // get measurement log from server - let server_log = recv_msg(&mut stream).await?; - let (server_log, _): (Log, _) = hubpack::deserialize(&server_log)?; - - // hubpack attestation and send to server - let mut buf = vec![0u8; Attestation::MAX_SIZE]; - let len = hubpack::serialize(&mut buf, &attest_data.attestation)?; - send_msg(&mut stream, &buf[..len]).await?; - - // get attestation from server - let server_attestation = recv_msg(&mut stream).await?; - let (server_attestation, _): (Attestation, _) = - hubpack::deserialize(&server_attestation)?; - - // verify server attestation - dice_verifier::verify_attestation( - &server_cert_chain[0], - &server_attestation, - &server_log, - &nonce, - )?; - info!(log, "Peer attestation verified"); - - // load corims into a set of ReferenceMeasurements - let mut corims = Vec::new(); - for c in corpus { - corims.push(Corim::from_file(c)?); - } - - for c in attest_data.test_corpus { - corims.push(Corim::from_file(c)?); - } - let reference_measurements = - ReferenceMeasurements::try_from(corims.as_slice())?; + let (server_platform_id, result) = attest::client_exchange( + &mut stream, + tq_platform_id, + &attest_config, + &roots, + corpus, + enforce, + &log, + ) + .await?; - // appraise measurements from server attestation against reference - // measurements - let measurements = - MeasurementSet::from_artifacts(&server_cert_chain, &server_log)?; - let result = match dice_verifier::verify_measurements( - &measurements, - &reference_measurements, - ) { - Ok(()) => { - info!(log, "Peer measurements appraised successfully"); - true - } - Err(err) => { - warn!( - log, - "Peer ({}) measurements appraisal failed {} corpus {}", - server_platform_id.as_str(), - err, - reference_measurements - ); - match enforce { - MeasurementConnectionPolicy::Enforced => { - return Err(Error::AttestMeasurementsVerifier { - peer: server_platform_id, - err, - }); - } - MeasurementConnectionPolicy::Permissive => false, - } - } - }; Ok(Stream::new(stream.into(), server_platform_id, result)) } } diff --git a/tls/src/lib.rs b/tls/src/lib.rs index 737ac13..e5e2185 100644 --- a/tls/src/lib.rs +++ b/tls/src/lib.rs @@ -6,7 +6,6 @@ use camino::Utf8PathBuf; use dice_mfg_msgs::PlatformId; -use hubpack::SerializedSize; use rustls::crypto::aws_lc_rs::cipher_suite::TLS13_CHACHA20_POLY1305_SHA256; use rustls::crypto::aws_lc_rs::kx_group::X25519; use rustls::crypto::CryptoProvider; @@ -21,13 +20,14 @@ use std::os::fd::{AsRawFd, RawFd}; use std::pin::Pin; use std::task::{self, Poll}; use std::{fs, io}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio_rustls::TlsStream; use x509_cert::{ - der::{self, Decode, DecodePem, Encode, Reader, SliceReader}, + der::{self, Decode, DecodePem}, Certificate, }; +mod attest; pub mod client; mod config; pub mod ipcc; @@ -285,89 +285,6 @@ pub(crate) fn platform_id_from_tls_certs( Ok(PlatformId::try_from(&pki_path)?) } -fn certs_to_der(certs: &[Certificate]) -> Result, Error> { - let mut der = Vec::new(); - - for cert in certs { - der.append(&mut cert.to_der()?); - } - - Ok(der) -} - -fn certs_from_der(buf: &[u8]) -> Result, Error> { - let mut certs = Vec::new(); - let mut reader = SliceReader::new(buf)?; - - while !reader.is_finished() { - certs.push(reader.decode()?); - } - - Ok(certs) -} - -// Response from the server, either the same version back, version - 1 -// or an error if there is no way the server can support this request -type ProtocolResult = Result; - -// Message from client acking the version or telling the server it's -// giving up -type ProtocolRequestAck = Result; - -const CURRENT_PROTOCOL_VERSION: u32 = 2; -const PREVIOUS_PROTOCOL_VERSION: u32 = CURRENT_PROTOCOL_VERSION - 1; - -/// The largest message [`recv_msg`] will accept. -/// -/// The length prefix is peer-controlled and [`recv_msg`] allocates the full -/// message buffer before reading the body, so without a bound a -/// TLS-authenticated (but not yet attested) peer can demand a 4 GiB -/// allocation with a 4-byte prefix. 1 MiB comfortably exceeds every -/// legitimate protocol message: the largest are the hubpacked measurement -/// log and attestation, whose bounds are asserted below; cert chains and -/// nonces are far smaller. -const MAX_MSG_SIZE: usize = 1024 * 1024; - -// The bound must admit every legitimate protocol message. -const _: () = assert!(dice_verifier::Log::MAX_SIZE <= MAX_MSG_SIZE); -const _: () = assert!(dice_verifier::Attestation::MAX_SIZE <= MAX_MSG_SIZE); - -async fn recv_msg( - stream: &mut T, -) -> Result, Error> { - // to receive a message we first get its length that is a u32 serialized as - // a little endian byte array - let mut msg_len = [0u8; 4]; - stream.read_exact(&mut msg_len).await?; - let msg_len: usize = u32::from_le_bytes(msg_len).try_into()?; - - // The length is peer-controlled: bound it before allocating. - if msg_len > MAX_MSG_SIZE { - return Err(Error::MessageTooLarge { - len: msg_len, - max: MAX_MSG_SIZE, - }); - } - - // with the length we can then get the message body - let mut buf = vec![0u8; msg_len]; - stream.read_exact(&mut buf).await?; - - Ok(buf) -} - -async fn send_msg( - stream: &mut T, - msg: &[u8], -) -> Result<(), Error> { - // to send a message we first send the receiver its length as a u32 - // serialized as a little endian byte array - let len: u32 = msg.len().try_into()?; - stream.write_all(&len.to_le_bytes()).await?; - // then we send the message - Ok(stream.write_all(msg).await?) -} - /// Return a common [`CryptoProvider`] for use by both client and server. /// /// Uses `aws-lc-rs` as the crypto provider. diff --git a/tls/src/server.rs b/tls/src/server.rs index 81649dd..54efbe2 100644 --- a/tls/src/server.rs +++ b/tls/src/server.rs @@ -4,23 +4,14 @@ //! A TLS based server +use crate::attest; use crate::config::{load_roots, new_tls_server_config}; use crate::keys::{ - get_attest_data, AttestConfig, CertResolver, MeasurementConnectionPolicy, - RotCertVerifier, SprocketsConfig, + AttestConfig, CertResolver, MeasurementConnectionPolicy, RotCertVerifier, + SprocketsConfig, }; -use crate::{ - certs_from_der, certs_to_der, platform_id_from_tls_certs, recv_msg, - send_msg, ProtocolRequestAck, ProtocolResult, CURRENT_PROTOCOL_VERSION, - PREVIOUS_PROTOCOL_VERSION, -}; -use crate::{Error, Stream}; +use crate::{platform_id_from_tls_certs, Error, Stream}; use camino::Utf8PathBuf; -use dice_verifier::{ - Attestation, Corim, Log, MeasurementSet, Nonce, Nonce32, - ReferenceMeasurements, -}; -use hubpack::SerializedSize; use rustls::{ server::{ danger::{ClientCertVerified, ClientCertVerifier}, @@ -28,7 +19,7 @@ use rustls::{ }, CipherSuite, ServerConfig, SignatureScheme, }; -use slog::{error, info, warn}; +use slog::error; use std::net::{SocketAddr, SocketAddrV6}; use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; @@ -108,12 +99,9 @@ impl SprocketsAcceptor { enforce, } = self; - // load corims into a set of ReferenceMeasurements - let mut corims = Vec::new(); - for c in corpus { - info!(log, "Using file {:?}", c); - corims.push(Corim::from_file(c)?); - } + // Load the reference-measurement corpus before accepting the + // connection, so a malformed corpus aborts the handshake early. + let corims = attest::corims_from_paths(&corpus, &log)?; let mut stream = tls_acceptor.clone().accept(stream).await?; @@ -122,164 +110,16 @@ impl SprocketsAcceptor { let tq_platform_id = platform_id_from_tls_certs(conn.peer_certificates())?; - // get version from the client - let version_bytes = recv_msg(&mut stream).await?; - // Anything but exactly the 4-byte little-endian version is protocol - // garbage from the peer; reject it rather than index past the end of a - // short message. - let version_bytes: [u8; 4] = version_bytes - .as_slice() - .try_into() - .map_err(|_| Error::ProtocolVersion)?; - let version = u32::from_le_bytes(version_bytes); - - if version == CURRENT_PROTOCOL_VERSION { - // we're good to go - let mut buf = vec![0u8; ProtocolResult::MAX_SIZE]; - let resp: ProtocolResult = Ok(version); - let resp_len = hubpack::serialize(&mut buf, &resp)?; - send_msg(&mut stream, &buf[..resp_len]).await?; - } else if version == PREVIOUS_PROTOCOL_VERSION { - // We eventually want to support older protocol - let mut buf = vec![0u8; ProtocolResult::MAX_SIZE]; - let resp: ProtocolResult = Ok(version); - let resp_len = hubpack::serialize(&mut buf, &resp)?; - send_msg(&mut stream, &buf[..resp_len]).await?; - } else { - // We can't deal with this - // We eventually want to support older protocol - let mut buf = vec![0u8; ProtocolResult::MAX_SIZE]; - let resp: ProtocolResult = Err(()); - let resp_len = hubpack::serialize(&mut buf, &resp)?; - send_msg(&mut stream, &buf[..resp_len]).await?; - // Client has given us something bad, time to give up - return Err(Error::ProtocolVersion); - } - - // Wait for the protocol ACK - let protocol_ack_bytes = recv_msg(&mut stream).await?; - let (protocol_ack, _): (ProtocolRequestAck, _) = - hubpack::deserialize(&protocol_ack_bytes)?; - - match protocol_ack { - Ok(v) => { - if v != version { - // this isn't right... - return Err(Error::ClientMismatch); - } - } - Err(_) => return Err(Error::ClientGaveUp), - } - - // Right now all protocols are the same - info!(log, "Running with protocol version {version}"); - - // get Nonce from client - let client_nonce = recv_msg(&mut stream).await?; - let client_nonce = Nonce::try_from(client_nonce)?; - - // generate & send Nonce to client - let nonce = Nonce::from_platform_rng(Nonce32::LENGTH)?; - send_msg(&mut stream, nonce.as_ref()).await?; - - // get attestation & verify it before sending it - // The attesation protocol has an inherent race condition between - // getting the log and the attestation. We verify our own attestation - // before sending it to the challenger to fail as early as possible. - let attest_data = - get_attest_data(&attest_config, &client_nonce).await?; - dice_verifier::verify_attestation( - &attest_data.certs[0], - &attest_data.attestation, - &attest_data.log, - &client_nonce, - )?; - - // get & verify client attestation cert chain - let client_cert_chain = recv_msg(&mut stream).await?; - let client_cert_chain = certs_from_der(&client_cert_chain)?; - let root = - dice_verifier::verify_cert_chain(&client_cert_chain, Some(&roots))?; - let client_platform_id = - dice_mfg_msgs::PlatformId::try_from(&client_cert_chain)?; - info!( - log, - "Cert chain from peer \"{}\" verified against root \"{}\"", - client_platform_id.as_str(), - root.tbs_certificate.subject, - ); - - if tq_platform_id != client_platform_id { - return Err(Error::PlatformIdMismatch); - } - info!(log, "TQ & attestation cert chains agree on platform id"); - - // send server attestation cert chain to client - let cert_chain_der = certs_to_der(&attest_data.certs)?; - send_msg(&mut stream, &cert_chain_der).await?; - - // get measurement log from client - let client_log = recv_msg(&mut stream).await?; - let (client_log, _): (Log, _) = hubpack::deserialize(&client_log)?; - - // send server measurement log to client - let mut buf = vec![0u8; Log::MAX_SIZE]; - let len = hubpack::serialize(&mut buf, &attest_data.log)?; - send_msg(&mut stream, &buf[..len]).await?; - - // get attestation from client - let client_attestation = recv_msg(&mut stream).await?; - let (client_attestation, _): (Attestation, _) = - hubpack::deserialize(&client_attestation)?; - - // verify client attestation - dice_verifier::verify_attestation( - &client_cert_chain[0], - &client_attestation, - &client_log, - &nonce, - )?; - info!(log, "Peer attestation verified"); - - for c in attest_data.test_corpus { - corims.push(Corim::from_file(c)?); - } - - let corpus = ReferenceMeasurements::try_from(corims.as_slice())?; - // appraise measurements from client attestation against reference - // measurements - let measurements = - MeasurementSet::from_artifacts(&client_cert_chain, &client_log)?; - let result = - match dice_verifier::verify_measurements(&measurements, &corpus) { - Ok(()) => { - info!(log, "Peer measurements appraised successfully"); - true - } - Err(err) => { - warn!( - log, - "Peer ({}) measurements appraisal failed: {} corpus {}", - client_platform_id.as_str(), - err, - corpus - ); - match enforce { - MeasurementConnectionPolicy::Enforced => { - return Err(Error::AttestMeasurementsVerifier { - peer: client_platform_id, - err, - }); - } - MeasurementConnectionPolicy::Permissive => false, - } - } - }; - - // hubpack the attestation and send to client - let mut buf = vec![0u8; Attestation::MAX_SIZE]; - let len = hubpack::serialize(&mut buf, &attest_data.attestation)?; - send_msg(&mut stream, &buf[..len]).await?; + let (client_platform_id, result) = attest::server_exchange( + &mut stream, + tq_platform_id, + corims, + &attest_config, + &roots, + enforce, + &log, + ) + .await?; Ok((Stream::new(stream.into(), client_platform_id, result), addr)) } From 2b7b880be6f2c194b2f0d81670ba754996c648a6 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 13:34:31 -0400 Subject: [PATCH 06/18] Add an optional, default-off quic cargo feature Gate the forthcoming QUIC transport behind a quic feature that pulls in quinn as an optional dependency. Nothing uses quinn yet; this is the additive packaging step so the module can land next without touching the no-feature build. - Add quinn to the workspace dependencies with the tokio runtime and the aws-lc-rs rustls backend, matching the crypto provider sprockets already uses. - Add quinn as an optional dependency of sprockets-tls behind quic = ["dep:quinn"]. The Cargo.lock update is additions-only: no existing crate's resolved version changes, and `cargo tree -p sprockets-tls` without the feature still resolves zero quinn crates. Verified on illumos that both the no-feature build and the --features quic build compile natively with the aws-lc-rs backend (quinn, quinn-proto, and quinn-udp's solarish path). --- Cargo.lock | 204 ++++++++++++++++++++++++++++++++++++++++++++----- Cargo.toml | 1 + tls/Cargo.toml | 5 ++ 3 files changed, 190 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0609c7d..c1d7ecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,23 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.41" @@ -432,6 +449,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -460,7 +486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -482,7 +508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -703,7 +729,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "signature", @@ -726,7 +752,7 @@ dependencies = [ "hkdf", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -774,7 +800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -789,7 +815,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -829,8 +855,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -852,8 +880,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -869,7 +900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1037,7 +1068,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1084,7 +1115,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1204,6 +1235,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.7.5" @@ -1303,7 +1340,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -1444,7 +1481,7 @@ dependencies = [ "p384", "pem-rfc7468", "pkcs8", - "rand", + "rand 0.8.7", "rsa", "sha1", "sha2", @@ -1474,7 +1511,7 @@ dependencies = [ "p384", "pem-rfc7468", "pkcs8", - "rand", + "rand 0.8.7", "rsa", "sha1", "sha2", @@ -1574,6 +1611,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -1603,7 +1697,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1613,7 +1718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1625,6 +1730,21 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rats-corim" version = "0.1.0" @@ -1737,7 +1857,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2", "signature", "spki", @@ -1751,6 +1871,12 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1770,7 +1896,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1794,6 +1920,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -1978,7 +2105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1989,7 +2116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2026,9 +2153,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "slog" version = "2.8.2" @@ -2159,6 +2292,7 @@ dependencies = [ "libipcc 0.1.0 (git+https://github.com/oxidecomputer/ipcc-rs?rev=524eb8f125003dff50b9703900c6b323f00f9e1b)", "pem-rfc7468", "pki-playground 0.2.0 (git+https://github.com/oxidecomputer/pki-playground?rev=7600756029ce046a02c6234aa84ce230cc5eaa04)", + "quinn", "rustls", "secrecy", "serde", @@ -2312,7 +2446,7 @@ dependencies = [ "fastrand", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2560,6 +2694,26 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.18.0" @@ -2687,6 +2841,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 0e358ba..e930eb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ ed25519-dalek = { version = "2.1", default-features = false, features = ["digest libipcc = { git = "https://github.com/oxidecomputer/ipcc-rs", rev = "524eb8f125003dff50b9703900c6b323f00f9e1b" } pem-rfc7468 = { version = "0.7.0"} pki-playground = { git = "https://github.com/oxidecomputer/pki-playground", rev = "7600756029ce046a02c6234aa84ce230cc5eaa04" } +quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-aws-lc-rs", "log"] } rustls = { version = "0.23.10", default-features = false, features = ["std", "aws_lc_rs", "logging"] } secrecy = "0.8.0" serde = { version = "1", default-features = false } diff --git a/tls/Cargo.toml b/tls/Cargo.toml index 8b70c0f..f7522cf 100644 --- a/tls/Cargo.toml +++ b/tls/Cargo.toml @@ -14,6 +14,7 @@ libipcc.workspace = true ed25519-dalek.workspace = true hubpack = "0.1.2" pem-rfc7468 = { workspace = true, features = ["std"] } +quinn = { workspace = true, optional = true } rustls.workspace = true serde.workspace = true secrecy.workspace = true @@ -38,6 +39,10 @@ pki-playground = { workspace = true, optional = true } [features] unittest = ["attest-mock", "camino", "pki-playground"] +# QUIC transport. Additive and off by default: gates the `quic` module and its +# optional quinn dependency, leaving the TCP API and the no-feature build +# untouched. +quic = ["dep:quinn"] [dev-dependencies] sprockets-tls = { path = ".", features = ["unittest"] } From e806854a206e3bacf33613836cada018fb8622a8 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 14:15:41 -0400 Subject: [PATCH 07/18] Add the QUIC transport module Add a feature-gated quic module carrying the same mutually-authenticated, RoT-attested channel as the TCP API over QUIC. The transport reuses the crate's rustls config construction and the transport-generic attestation exchange unchanged; only the framing underneath differs. - quic.rs: the Endpoint primitive (one UDP socket that both connects and accepts), the Acceptor, the close_code table, and the quinn config assembly. The TLS config is the same one the TCP path builds, plus ALPN b"sprockets", disabled resumption/0-RTT, and a QuicClientConfig / QuicServerConfig wrapping it with the RFC 9001 AES-128-GCM Initial suite (the handshake still offers only ChaCha20-Poly1305). Transport policy (idle timeout, keep-alive) and server hardening (migration off, bounded pre-auth incoming, Retry-based address validation) are named constants. - quic/stream.rs: BiStream, which rejoins quinn's split send/recv halves into one AsyncRead + AsyncWrite duplex, and AttestedConnection, the QUIC analog of Stream whose attested identity covers every stream opened on the connection. - Four #[cfg(feature = "quic")] Error variants for the QUIC failure modes. The handshake awaits the full connection future, never quinn's into_0rtt path, so a connection is surfaced only after the client's certificate is verified. Verified on illumos: builds, clippy --all-targets, and rustdoc (public and private) are clean with the feature, and the TCP suite passes with and without it. --- tls/src/lib.rs | 20 ++ tls/src/quic.rs | 538 +++++++++++++++++++++++++++++++++++++++++ tls/src/quic/stream.rs | 250 +++++++++++++++++++ 3 files changed, 808 insertions(+) create mode 100644 tls/src/quic.rs create mode 100644 tls/src/quic/stream.rs diff --git a/tls/src/lib.rs b/tls/src/lib.rs index e5e2185..efd122e 100644 --- a/tls/src/lib.rs +++ b/tls/src/lib.rs @@ -32,6 +32,8 @@ pub mod client; mod config; pub mod ipcc; pub mod keys; +#[cfg(feature = "quic")] +pub mod quic; pub mod server; pub use client::Client; @@ -134,6 +136,24 @@ pub enum Error { #[error("Client gave up negotating the version")] ClientGaveUp, + + #[cfg(feature = "quic")] + #[error("QUIC connect error")] + QuicConnect(#[from] quinn::ConnectError), + + #[cfg(feature = "quic")] + #[error("QUIC connection error")] + QuicConnection(#[from] quinn::ConnectionError), + + #[cfg(feature = "quic")] + #[error("QUIC TLS config has no RFC 9001 Initial cipher suite")] + QuicNoInitialCipherSuite( + #[from] quinn::crypto::rustls::NoInitialCipherSuite, + ), + + #[cfg(feature = "quic")] + #[error("QUIC endpoint is closed")] + QuicEndpointClosed, } /// A type representing an established sprockets connection. diff --git a/tls/src/quic.rs b/tls/src/quic.rs new file mode 100644 index 0000000..dd1a4bf --- /dev/null +++ b/tls/src/quic.rs @@ -0,0 +1,538 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, +// v. 2.0. If a copy of the MPL was not distributed with this file, You can +// obtain one at https://mozilla.org/MPL/2.0/. + +//! QUIC transport for sprockets connections. +//! +//! This module carries the exact same mutually-authenticated, RoT-attested +//! channel that the TCP API ([`Client`](crate::Client) / +//! [`Server`](crate::Server)) provides, over QUIC instead of TCP. It is +//! feature-gated (`quic`). +//! +//! The core primitive is [`Endpoint`]: one UDP socket that can both +//! [`connect`](Endpoint::connect) and [`accept`](Endpoint::accept). Each +//! direction yields an [`AttestedConnection`], the QUIC analog of +//! [`Stream`](crate::Stream). +//! +//! # Security model: identical to TCP +//! +//! QUIC uses TLS 1.3 for its handshake by construction (RFC 9001), and this +//! module drives it with the very same rustls configuration the TCP transport +//! uses: the same [`crypto_provider`](crate::crypto_provider) pins (X25519, +//! ChaCha20-Poly1305, TLS 1.3 only), the same [`RotCertVerifier`] and +//! [`CertResolver`] enforcing mandatory mutual authentication over the same +//! roots, and the same Ed25519-only, time-and-name-ignoring DICE chain +//! verification. After the handshake, the *same* attestation protocol bytes run +//! over a QUIC bidirectional stream, and the same PlatformId binding (equality +//! between the TLS/trust-quorum chain and the attestation chain) is enforced. +//! No cryptographic property changes. +//! +//! [`RotCertVerifier`]: crate::keys::RotCertVerifier +//! [`CertResolver`]: crate::keys::CertResolver +//! +//! # QUIC-specific surfaces +//! +//! - **Initial-packet AES-128-GCM.** RFC 9001 §5 mandates AES-128-GCM for the +//! protection of Initial packets, whose keys are derived from the (public) +//! Destination Connection ID. This carries no confidentiality or +//! authentication claim — it is obfuscation over public values — so it does +//! not weaken the ChaCha20-Poly1305-only session guarantee. The handshake and +//! all application data continue to offer and use only ChaCha20-Poly1305; the +//! Initial suite is supplied separately via +//! [`QuicClientConfig::with_initial`](quinn::crypto::rustls::QuicClientConfig::with_initial). +//! - **ALPN.** Connections negotiate the ALPN token `b"sprockets"`. rustls +//! enforces strict ALPN matching in QUIC mode, and the token is authenticated +//! by the handshake transcript. This is a frozen wire constant: it must never +//! encode a protocol version — version negotiation stays in-band so a mixed +//! fleet can roll forward. +//! - **Resumption and 0-RTT are disabled.** The client sets +//! [`Resumption::disabled`](rustls::client::Resumption::disabled) and the +//! server sets `send_tls13_tickets = 0`; `max_early_data_size` stays 0. This +//! makes the de-facto no-resumption behavior of the TCP path an explicit +//! guarantee. +//! - **Migration is disabled.** The server sets +//! [`migration(false)`](quinn::ServerConfig::migration): bootstrap addresses +//! are stable, and disabling migration keeps a peer from silently changing +//! address mid-connection. +//! - **Client-auth gating.** The server obtains a connection only by awaiting +//! the full connection future, never quinn's `into_0rtt` path, so a +//! connection becomes usable only after the client's certificate has been +//! verified. +//! +//! ## Close codes +//! +//! On a failed handshake the connection is closed with an application error +//! code from [`close_code`], letting the peer distinguish the failure class: +//! +//! | Code | Meaning | +//! |------|---------| +//! | [`PROTOCOL`](close_code::PROTOCOL) | Version negotiation failed | +//! | [`ATTESTATION`](close_code::ATTESTATION) | Peer attestation did not verify | +//! | [`APPRAISAL`](close_code::APPRAISAL) | Measurements failed appraisal under `Enforced` | +//! | [`PLATFORM_ID_MISMATCH`](close_code::PLATFORM_ID_MISMATCH) | TLS and attestation chains disagreed on PlatformId | +//! | [`LOCAL_ERROR`](close_code::LOCAL_ERROR) | A local (I/O, encoding, transport) failure | +//! +//! # Liveness and shutdown +//! +//! quinn closes an idle connection after `MAX_IDLE_TIMEOUT` (30 s). To keep a +//! quiet connection alive (akin to how TCP lives indefinitely when silent) the +//! transport automatically sends keep-alives every `KEEP_ALIVE_INTERVAL` (10 +//! s), covering even a stalled application writer. Dropping the last handle to +//! a connection closes it with code 0 and may discard undelivered in-flight +//! data; the FIN queued by +//! [`AsyncWrite::poll_shutdown`](tokio::io::AsyncWrite::poll_shutdown) is not +//! waited on. Delivery assurance therefore comes from application-level +//! acknowledgment or an explicit [`Endpoint::close`] followed by +//! [`Endpoint::wait_idle`]. +//! +//! # Version compatibility +//! +//! quinn 0.11 types appear in this module's public API (via the [`quinn`] +//! re-export, so consumers get version-matched types). sprockets and its +//! consumers must therefore bump quinn in lockstep. + +use crate::keys::{AttestConfig, MeasurementConnectionPolicy, SprocketsConfig}; +use crate::{attest, config, platform_id_from_tls_certs, Error}; +use camino::Utf8PathBuf; +use dice_mfg_msgs::PlatformId; +use dice_verifier::Corim; +use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; +use quinn::{IdleTimeout, TransportConfig, VarInt}; +use rustls::crypto::aws_lc_rs::cipher_suite::TLS13_AES_128_GCM_SHA256; +use std::any::Any; +use std::io; +use std::net::{SocketAddr, SocketAddrV6}; +use std::sync::Arc; +use std::time::Duration; +use x509_cert::Certificate; + +mod stream; + +pub use stream::{AttestedConnection, BiStream}; + +/// Re-export of the exact `quinn` this crate is built against, so consumers can +/// name version-matched quinn types (`VarInt`, `Connection`, `SendStream`, …). +pub use quinn; + +/// The ALPN protocol identifier negotiated on every sprockets QUIC connection. +/// +/// A frozen wire constant: it names the sprockets protocol family, never a +/// version. Version negotiation happens in-band after the handshake. +const ALPN_SPROCKETS: &[u8] = b"sprockets"; + +/// The dummy server name offered on connect. Bootstrap-network nodes have no +/// DNS names; the [`RotCertVerifier`](crate::keys::RotCertVerifier) ignores it, +/// exactly as on the TCP path. +const SERVER_NAME: &str = "unknown.com"; + +/// Idle timeout: a connection with no traffic for this long is closed. quinn's +/// default, made explicit so it reads next to [`KEEP_ALIVE_INTERVAL`]. +const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Keep-alive interval. quinn sends no keep-alives by default; without this a +/// quiet connection would hit [`MAX_IDLE_TIMEOUT`] and drop, a liveness +/// regression against TCP. Kept well under the idle timeout. +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10); + +/// Maximum concurrent peer-initiated bidirectional streams. quinn's default, +/// made explicit. +const MAX_CONCURRENT_BIDI_STREAMS: u32 = 100; + +/// Maximum concurrent peer-initiated unidirectional streams. sprockets uses +/// only bidirectional streams, so none are permitted. +const MAX_CONCURRENT_UNI_STREAMS: u32 = 0; + +/// Maximum half-open incoming connections the server buffers before +/// authentication. Replaces quinn's permissive 65,536 default; a rack is ~32 +/// sleds. +const MAX_INCOMING: usize = 256; + +/// Total bytes buffered across all pre-authentication incoming connections. +/// Replaces quinn's permissive 100 MiB default. +const INCOMING_BUFFER_SIZE_TOTAL: u64 = 10 << 20; // 10 MiB + +/// Application error codes used to close a connection on a failed handshake. +/// +/// These are QUIC application close codes, observable by the peer as the reason +/// a connection was refused. See the [module documentation](self#close-codes). +pub mod close_code { + use quinn::VarInt; + + /// Protocol-version negotiation failed. + pub const PROTOCOL: VarInt = VarInt::from_u32(1); + /// The peer's attestation did not verify. + pub const ATTESTATION: VarInt = VarInt::from_u32(2); + /// The peer's measurements failed appraisal under `Enforced`. + pub const APPRAISAL: VarInt = VarInt::from_u32(3); + /// The TLS and attestation cert chains disagreed on the peer PlatformId. + pub const PLATFORM_ID_MISMATCH: VarInt = VarInt::from_u32(4); + /// A local failure (I/O, encoding, or transport) aborted the handshake. + pub const LOCAL_ERROR: VarInt = VarInt::from_u32(5); +} + +/// The RFC 9001 Initial-packet cipher suite: AES-128-GCM. +/// +/// Supplied to quinn separately from the handshake suite list, which continues +/// to offer only ChaCha20-Poly1305. This is infallible by construction (since +/// aws-lc-rs always provides AES-128-GCM with QUIC support) and the panics +/// below can only fire if that provider is swapped for one that lacks it. +fn initial_suite() -> rustls::quic::Suite { + TLS13_AES_128_GCM_SHA256 + .tls13() + .expect("TLS13_AES_128_GCM_SHA256 is a TLS 1.3 suite") + .quic_suite() + .expect("aws-lc-rs provides QUIC support for AES-128-GCM") +} + +/// The shared transport policy applied to both directions of an endpoint. +fn transport_config() -> TransportConfig { + let mut transport = TransportConfig::default(); + transport + .max_idle_timeout(Some( + IdleTimeout::try_from(MAX_IDLE_TIMEOUT) + .expect("30s is a valid idle timeout"), + )) + .keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)) + .max_concurrent_bidi_streams(VarInt::from_u32( + MAX_CONCURRENT_BIDI_STREAMS, + )) + .max_concurrent_uni_streams(VarInt::from_u32( + MAX_CONCURRENT_UNI_STREAMS, + )); + transport +} + +/// Builds the quinn client configuration for a sprockets QUIC endpoint. +fn new_quic_client_config( + resolve: crate::keys::ResolveSetting, + roots: Vec, + log: &slog::Logger, + transport: Arc, +) -> Result { + let mut tls = config::new_tls_client_config(resolve, roots, log)?; + tls.alpn_protocols = vec![ALPN_SPROCKETS.to_vec()]; + tls.resumption = rustls::client::Resumption::disabled(); + + let quic = QuicClientConfig::with_initial(Arc::new(tls), initial_suite())?; + let mut client_config = quinn::ClientConfig::new(Arc::new(quic)); + client_config.transport_config(transport); + Ok(client_config) +} + +/// Builds the quinn server configuration for a sprockets QUIC endpoint. +fn new_quic_server_config( + resolve: crate::keys::ResolveSetting, + roots: Vec, + log: &slog::Logger, + transport: Arc, +) -> Result { + let mut tls = config::new_tls_server_config(resolve, roots, log)?; + tls.alpn_protocols = vec![ALPN_SPROCKETS.to_vec()]; + tls.send_tls13_tickets = 0; + + let quic = QuicServerConfig::with_initial(Arc::new(tls), initial_suite())?; + let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic)); + server_config + .transport_config(transport) + .migration(false) + .max_incoming(MAX_INCOMING) + .incoming_buffer_size_total(INCOMING_BUFFER_SIZE_TOTAL); + Ok(server_config) +} + +/// Derives the peer [`PlatformId`] from a quinn connection's peer identity. +/// +/// quinn returns the peer's TLS certificate chain as an `Option>` +/// that, for the rustls backend, downcasts to a +/// `Vec>`. A missing identity or a failed downcast — the +/// latter only possible if two incompatible rustls versions coexist in the +/// dependency graph — is reported as [`Error::NoTQCerts`], the same error the +/// TCP path raises for an unauthenticated peer. +fn platform_id_from_peer_identity( + identity: Option>, +) -> Result { + let certs = identity + .and_then(|id| { + id.downcast::>>() + .ok() + }) + .map(|boxed| *boxed); + platform_id_from_tls_certs(certs.as_deref()) +} + +/// Maps a handshake error to the QUIC close code the peer will observe. +fn close_code_for_error(err: &Error) -> VarInt { + match err { + Error::ProtocolVersion + | Error::ClientMismatch + | Error::ClientGaveUp => close_code::PROTOCOL, + Error::PlatformIdMismatch => close_code::PLATFORM_ID_MISMATCH, + Error::AttestMeasurementsVerifier { .. } => close_code::APPRAISAL, + Error::AttestationVerifier(_) + | Error::AttestCertVerifier(_) + | Error::MeasurementSet(_) + | Error::ReferenceMeasurements(_) + | Error::Attest(_) + | Error::AttestData(_) + | Error::NonceError(_) + | Error::PlatformIdPkiPath(_) + | Error::PlatformId(_) + | Error::CorimError(_) + | Error::AttestMock(_) + | Error::AttestIpcc(_) + | Error::RotRequest(_) + | Error::NoTQCerts => close_code::ATTESTATION, + _ => close_code::LOCAL_ERROR, + } +} + +/// Closes `connection` with the code and reason derived from `err`. +fn close_for_error(connection: &quinn::Connection, err: &Error) { + connection.close(close_code_for_error(err), err.to_string().as_bytes()); +} + +/// A sprockets QUIC endpoint: one UDP socket that both dials and listens. +/// +/// Construct with [`new`](Endpoint::new), then [`connect`](Endpoint::connect) +/// to a peer or [`accept`](Endpoint::accept) an inbound connection. Both +/// directions run the same attestation exchange and yield an +/// [`AttestedConnection`]. +pub struct Endpoint { + inner: quinn::Endpoint, + attest_config: AttestConfig, + roots: Vec, + enforce: MeasurementConnectionPolicy, + log: slog::Logger, +} + +impl Endpoint { + /// Binds a UDP socket at `bind` and prepares it to both dial and listen + /// using `config`. + /// + /// # Errors + /// + /// Returns [`Error::FailedRead`] if a root or key file cannot be read, + /// [`Error::QuicNoInitialCipherSuite`] if the crypto provider lacks the + /// Initial suite (unreachable with the default aws-lc-rs provider), or + /// [`Error::Io`] if the socket cannot be bound. + pub fn new( + config: SprocketsConfig, + bind: SocketAddrV6, + log: slog::Logger, + ) -> Result { + let roots = config::load_roots(&config.roots)?; + let transport = Arc::new(transport_config()); + let client_config = new_quic_client_config( + config.resolve.clone(), + roots.clone(), + &log, + transport.clone(), + )?; + let server_config = new_quic_server_config( + config.resolve.clone(), + roots.clone(), + &log, + transport, + )?; + + let mut inner = quinn::Endpoint::server(server_config, bind.into())?; + inner.set_default_client_config(client_config); + + Ok(Endpoint { + inner, + attest_config: config.attest, + roots, + enforce: config.enforce, + log, + }) + } + + /// Returns the local address the endpoint is bound to. + pub fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } + + /// Returns the underlying quinn endpoint, an escape hatch for configuration + /// this API does not surface. + pub fn inner(&self) -> &quinn::Endpoint { + &self.inner + } + + /// Dials `addr`, completes the QUIC/TLS handshake, and runs the attestation + /// exchange, returning the attested connection. + /// + /// `corpus` is the reference-measurement corpus the peer's measurements are + /// appraised against. Not cancel safe: run in a dedicated task. + pub async fn connect( + &self, + addr: SocketAddrV6, + corpus: Vec, + ) -> Result { + let connection = self.inner.connect(addr.into(), SERVER_NAME)?.await?; + + // The peer identity is the server's TLS/trust-quorum chain, the QUIC + // analog of `peer_certificates()` on the TCP path. + let tq_platform_id = + platform_id_from_peer_identity(connection.peer_identity())?; + + let (send, recv) = connection.open_bi().await?; + let mut stream = BiStream::new(send, recv); + + let (peer_platform_id, appraisal) = match attest::client_exchange( + &mut stream, + tq_platform_id, + &self.attest_config, + &self.roots, + corpus, + self.enforce, + &self.log, + ) + .await + { + Ok(result) => result, + Err(err) => { + close_for_error(&connection, &err); + return Err(err); + } + }; + + Ok(AttestedConnection::new( + connection, + stream, + peer_platform_id, + appraisal, + )) + } + + /// Awaits the next inbound connection, returning an [`Acceptor`] whose + /// [`handshake`](Acceptor::handshake) completes the exchange. + /// + /// `corpus` is loaded before any connection is awaited, so a malformed + /// corpus fails here rather than mid-handshake — mirroring the TCP + /// acceptor. Unvalidated incoming connections are forced through a Retry + /// (address validation) and the validated re-dial is the one returned. + /// + /// # Errors + /// + /// Returns [`Error::QuicEndpointClosed`] if the endpoint has been closed. + pub async fn accept( + &self, + corpus: Vec, + ) -> Result { + let corims = attest::corims_from_paths(&corpus, &self.log)?; + + loop { + let incoming = + self.inner.accept().await.ok_or(Error::QuicEndpointClosed)?; + + // Require address validation. An unvalidated peer is sent a Retry; + // it re-dials, and that attempt arrives already validated. This + // bounds the pre-authentication amplification surface. + if !incoming.remote_address_validated() { + let _ = incoming.retry(); + continue; + } + + let addr = incoming.remote_address(); + return Ok(Acceptor { + incoming, + corims, + attest_config: self.attest_config.clone(), + roots: self.roots.clone(), + enforce: self.enforce, + log: self.log.clone(), + addr, + }); + } + } + + /// Closes the endpoint and all its connections with the given code and + /// reason. + pub fn close(&self, error_code: VarInt, reason: &[u8]) { + self.inner.close(error_code, reason); + } + + /// Waits until all connections are cleanly shut down. + pub async fn wait_idle(&self) { + self.inner.wait_idle().await; + } +} + +/// A pending inbound QUIC connection whose attestation exchange has not yet run. +/// +/// Mirrors the TCP [`SprocketsAcceptor`](crate::server::SprocketsAcceptor): +/// [`handshake`](Acceptor::handshake) awaits the fully-authenticated connection +/// and runs the server side of the attestation exchange. +pub struct Acceptor { + incoming: quinn::Incoming, + corims: Vec, + attest_config: AttestConfig, + roots: Vec, + enforce: MeasurementConnectionPolicy, + log: slog::Logger, + addr: SocketAddr, +} + +impl Acceptor { + /// The address of the peer that initiated this connection. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Completes the QUIC/TLS handshake and runs the server attestation + /// exchange, returning the attested connection and the peer address. + /// + /// The connection future is awaited in full — never quinn's `into_0rtt` + /// path — so it resolves only after the client's certificate has been + /// verified. The exchange runs over the first bidirectional stream the + /// client opens. Not cancel safe: run in a dedicated task. + pub async fn handshake( + self, + ) -> Result<(AttestedConnection, SocketAddr), Error> { + let Acceptor { + incoming, + corims, + attest_config, + roots, + enforce, + log, + addr, + } = self; + + let connection = incoming.await?; + + let tq_platform_id = + platform_id_from_peer_identity(connection.peer_identity())?; + + let (send, recv) = connection.accept_bi().await?; + let mut stream = BiStream::new(send, recv); + + let (peer_platform_id, appraisal) = match attest::server_exchange( + &mut stream, + tq_platform_id, + corims, + &attest_config, + &roots, + enforce, + &log, + ) + .await + { + Ok(result) => result, + Err(err) => { + close_for_error(&connection, &err); + return Err(err); + } + }; + + Ok(( + AttestedConnection::new( + connection, + stream, + peer_platform_id, + appraisal, + ), + addr, + )) + } +} diff --git a/tls/src/quic/stream.rs b/tls/src/quic/stream.rs new file mode 100644 index 0000000..435356d --- /dev/null +++ b/tls/src/quic/stream.rs @@ -0,0 +1,250 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Stream and connection types for the sprockets QUIC transport. +//! +//! [`BiStream`] rejoins quinn's split send/receive halves into one duplex byte +//! stream; [`AttestedConnection`] is the attested connection handle returned by +//! the [`quic`](crate::quic) API, the QUIC analog of [`Stream`](crate::Stream). + +use crate::Error; +use dice_mfg_msgs::PlatformId; +use std::io::IoSlice; +use std::net::SocketAddr; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +/// A QUIC bidirectional stream presented as a single duplex byte stream. +/// +/// quinn splits a bidirectional stream into a [`quinn::SendStream`] and a +/// [`quinn::RecvStream`]; `BiStream` rejoins them so the stream satisfies both +/// [`AsyncRead`] and [`AsyncWrite`], the shape the attestation exchange and +/// application code expect from a TCP-backed [`Stream`](crate::Stream). Reads +/// are served by the receive half, writes by the send half. +/// +/// [`AsyncWrite::poll_shutdown`] maps to [`quinn::SendStream::finish`], which +/// queues a FIN but does not wait for the peer to acknowledge delivery; see the +/// [module documentation](crate::quic#liveness-and-shutdown) for what that +/// implies about in-flight data. +pub struct BiStream { + send: quinn::SendStream, + recv: quinn::RecvStream, +} + +impl BiStream { + pub(crate) fn new( + send: quinn::SendStream, + recv: quinn::RecvStream, + ) -> Self { + BiStream { send, recv } + } + + /// Returns the send and receive halves, consuming the stream. + /// + /// An escape hatch for code that needs the raw quinn streams (for example + /// to call [`quinn::RecvStream::read_chunk`] or set stream priorities). + pub fn into_inner(self) -> (quinn::SendStream, quinn::RecvStream) { + (self.send, self.recv) + } +} + +// quinn's `SendStream`/`RecvStream` carry inherent `poll_write`/`poll_read` +// methods (returning quinn's own error types) that would shadow the tokio trait +// methods under plain method-call syntax, so every delegation names the trait +// explicitly. +impl AsyncRead for BiStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + AsyncRead::poll_read(Pin::new(&mut self.get_mut().recv), cx, buf) + } +} + +impl AsyncWrite for BiStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + AsyncWrite::poll_write(Pin::new(&mut self.get_mut().send), cx, buf) + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[IoSlice<'_>], + ) -> Poll> { + AsyncWrite::poll_write_vectored( + Pin::new(&mut self.get_mut().send), + cx, + bufs, + ) + } + + fn is_write_vectored(&self) -> bool { + AsyncWrite::is_write_vectored(&self.send) + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + AsyncWrite::poll_flush(Pin::new(&mut self.get_mut().send), cx) + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + AsyncWrite::poll_shutdown(Pin::new(&mut self.get_mut().send), cx) + } +} + +/// An authenticated, attested QUIC connection: the QUIC analog of +/// [`Stream`](crate::Stream). +/// +/// By the time an `AttestedConnection` exists, the QUIC/TLS 1.3 handshake has +/// completed with mutual authentication and both peers have run the sprockets +/// attestation exchange over the primary bidirectional stream. The attested +/// identity — [`peer_platform_id`](Self::peer_platform_id) — covers the whole +/// connection: further streams from [`open_bi`](Self::open_bi) / +/// [`accept_bi`](Self::accept_bi) inherit it without a new exchange. +/// +/// `AttestedConnection` implements [`AsyncRead`] and [`AsyncWrite`] by +/// delegating to the primary stream, so code written against a +/// [`Stream`](crate::Stream) ports over directly. +pub struct AttestedConnection { + connection: quinn::Connection, + stream: BiStream, + platform_id: PlatformId, + corpus_appraisal_success: bool, +} + +impl AttestedConnection { + pub(crate) fn new( + connection: quinn::Connection, + stream: BiStream, + platform_id: PlatformId, + corpus_appraisal_success: bool, + ) -> Self { + AttestedConnection { + connection, + stream, + platform_id, + corpus_appraisal_success, + } + } + + /// The attested [`PlatformId`] of the peer. + pub fn peer_platform_id(&self) -> &PlatformId { + &self.platform_id + } + + /// Whether the peer's measurements appraised successfully against the + /// reference corpus. + /// + /// Always `true` under + /// [`Enforced`](crate::keys::MeasurementConnectionPolicy::Enforced), where a + /// failed appraisal aborts the handshake rather than yielding a connection. + pub fn appraisal_success(&self) -> bool { + self.corpus_appraisal_success + } + + /// The underlying quinn connection, for opening further streams or + /// inspecting connection state. + pub fn connection(&self) -> &quinn::Connection { + &self.connection + } + + /// The peer's current socket address. + pub fn remote_address(&self) -> SocketAddr { + self.connection.remote_address() + } + + /// Opens a new outbound bidirectional stream. + /// + /// The stream inherits the connection's attested identity; no further + /// attestation exchange is performed. + pub async fn open_bi(&self) -> Result { + let (send, recv) = self.connection.open_bi().await?; + Ok(BiStream::new(send, recv)) + } + + /// Accepts the next inbound bidirectional stream opened by the peer. + /// + /// The stream inherits the connection's attested identity; no further + /// attestation exchange is performed. + pub async fn accept_bi(&self) -> Result { + let (send, recv) = self.connection.accept_bi().await?; + Ok(BiStream::new(send, recv)) + } + + /// Closes the connection immediately with the given application error code + /// and reason. + /// + /// See [`quinn::Connection::close`] for the delivery semantics. + pub fn close(&self, error_code: quinn::VarInt, reason: &[u8]) { + self.connection.close(error_code, reason); + } + + /// Decomposes into the quinn connection, the primary stream, the attested + /// peer identity, and the appraisal result. + pub fn into_parts(self) -> (quinn::Connection, BiStream, PlatformId, bool) { + ( + self.connection, + self.stream, + self.platform_id, + self.corpus_appraisal_success, + ) + } +} + +impl AsyncRead for AttestedConnection { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_read(cx, buf) + } +} + +impl AsyncWrite for AttestedConnection { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_write(cx, buf) + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_write_vectored(cx, bufs) + } + + fn is_write_vectored(&self) -> bool { + self.stream.is_write_vectored() + } + + fn poll_flush( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_flush(cx) + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) + } +} From 2c9071ee410ef3ba8de563d49ca074ebc26cfa83 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 14:16:28 -0400 Subject: [PATCH 08/18] Add quic::Client and quic::Server facades Thin facades over the quic Endpoint that mirror the TCP Client and Server APIs, for callers that only dial or only listen. - quic::Client::connect binds an ephemeral endpoint on [::]:0, runs the attested handshake, and returns the connection. The endpoint handle is then dropped; quinn keeps its I/O driver alive while the returned connection lives (the EndpointDriver only terminates once no endpoint handles and no connections remain), so the connection stays usable. Callers that dial repeatedly or also listen should share one Endpoint. - quic::Server wraps a listening Endpoint with new / listen_addr / accept, plus an endpoint() accessor for callers that also need to dial or close. Verified on illumos: clippy --all-targets, rustdoc (public and private), and the TCP suite are clean with the feature. --- tls/src/quic.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/tls/src/quic.rs b/tls/src/quic.rs index dd1a4bf..359cf4d 100644 --- a/tls/src/quic.rs +++ b/tls/src/quic.rs @@ -101,7 +101,7 @@ use quinn::{IdleTimeout, TransportConfig, VarInt}; use rustls::crypto::aws_lc_rs::cipher_suite::TLS13_AES_128_GCM_SHA256; use std::any::Any; use std::io; -use std::net::{SocketAddr, SocketAddrV6}; +use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6}; use std::sync::Arc; use std::time::Duration; use x509_cert::Certificate; @@ -536,3 +536,81 @@ impl Acceptor { )) } } + +/// A one-shot QUIC client, mirroring the TCP [`Client`](crate::Client). +/// +/// For callers that only dial and do not need to hold an [`Endpoint`]. Each +/// [`connect`](Client::connect) binds a fresh UDP socket on an OS-assigned port +/// (`[::]:0`), performs the attested handshake, and returns the connection. The +/// endpoint handle is then dropped, but quinn keeps its I/O driver alive as long +/// as the returned connection lives, so the connection stays usable. Callers +/// that dial repeatedly, or that also listen, should share one [`Endpoint`] +/// instead. +pub struct Client {} + +impl Client { + /// Binds an ephemeral endpoint, connects to `addr`, and runs the attested + /// handshake, returning the connection. + /// + /// Behaves like [`Client::connect`](crate::Client::connect) on the TCP path. + /// Not cancel safe: run in a dedicated task. + pub async fn connect( + config: SprocketsConfig, + addr: SocketAddrV6, + corpus: Vec, + log: slog::Logger, + ) -> Result { + let bind = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0); + let endpoint = Endpoint::new(config, bind, log)?; + endpoint.connect(addr, corpus).await + } +} + +/// A QUIC server, mirroring the TCP [`Server`](crate::Server). +/// +/// A thin wrapper over a listening [`Endpoint`] for callers that only accept +/// connections. Callers that also dial can use the underlying [`Endpoint`] +/// directly. +pub struct Server { + endpoint: Endpoint, +} + +impl Server { + /// Binds a UDP socket at `addr` and prepares it to accept sprockets QUIC + /// connections. + /// + /// Behaves like [`Server::new`](crate::Server::new) on the TCP path. + pub fn new( + config: SprocketsConfig, + addr: SocketAddrV6, + log: slog::Logger, + ) -> Result { + Ok(Server { + endpoint: Endpoint::new(config, addr, log)?, + }) + } + + /// Returns the local address the server is bound to. + /// + /// As with the TCP [`Server::listen_addr`](crate::Server::listen_addr), + /// binding port 0 lets the OS assign a port; this reports the real one. + pub fn listen_addr(&self) -> io::Result { + self.endpoint.local_addr() + } + + /// Awaits the next inbound connection, returning an [`Acceptor`]. + /// + /// Behaves like [`Server::accept`](crate::Server::accept) on the TCP path. + pub async fn accept( + &self, + corpus: Vec, + ) -> Result { + self.endpoint.accept(corpus).await + } + + /// Returns the underlying endpoint, for callers that also need to dial or + /// to close the endpoint explicitly. + pub fn endpoint(&self) -> &Endpoint { + &self.endpoint + } +} From 35ee915309a0f7098969437d02990b533992b2c2 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 15:11:27 -0400 Subject: [PATCH 09/18] Add QUIC integration tests Twelve tests for the quic module, reusing the crate's test PKI and the shared helpers in crate::tests (local_config is made pub for this). Transport and exchange wiring: - basic, no_corpus: enforcing and permissive handshakes with a message echo - unattested_client: valid TLS but no attestation exchange is rejected - spawn_accept: several concurrent clients on one endpoint - multi_stream: a second stream inherits the connection's attested identity - appraisal_failure_close_code: the client observes the APPRAISAL close code - client_auth_required: a client with no certificate is rejected Security pins: - platform_id_mismatch: a peer whose TLS and attestation chains disagree on PlatformId is rejected, and the client observes PLATFORM_ID_MISMATCH - session_cipher_pinned_to_chacha20: a client offering AES-GCM session suites is rejected (the RFC 9001 AES-128-GCM Initial suite does not satisfy the ChaCha20-only session pin) - wrong_alpn_rejected: a client offering the wrong ALPN token is rejected Robustness: - large_payload: 1 MiB round-trips intact both directions, exercising QUIC stream flow control and the BiStream duplex - initial_suite_is_aes_128_gcm: unit test pinning the Initial suite Two behaviors surfaced while writing these: dropping a connection handle before the peer has read closes it with code 0 and truncates undelivered data (so the tests keep connections alive until reads complete, matching the module's delivery-assurance note), and the two test configs provision distinct platforms. Verified on illumos: clippy --all-targets and the full suite pass with and without the feature. --- tls/src/lib.rs | 2 +- tls/src/quic.rs | 3 + tls/src/quic/tests.rs | 671 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 tls/src/quic/tests.rs diff --git a/tls/src/lib.rs b/tls/src/lib.rs index efd122e..245ba29 100644 --- a/tls/src/lib.rs +++ b/tls/src/lib.rs @@ -344,7 +344,7 @@ mod tests { Utf8PathBuf::from(env!("OUT_DIR")) } - fn local_config( + pub fn local_config( n: usize, enforce: MeasurementConnectionPolicy, ) -> keys::SprocketsConfig { diff --git a/tls/src/quic.rs b/tls/src/quic.rs index 359cf4d..60a5ac6 100644 --- a/tls/src/quic.rs +++ b/tls/src/quic.rs @@ -108,6 +108,9 @@ use x509_cert::Certificate; mod stream; +#[cfg(test)] +mod tests; + pub use stream::{AttestedConnection, BiStream}; /// Re-export of the exact `quinn` this crate is built against, so consumers can diff --git a/tls/src/quic/tests.rs b/tls/src/quic/tests.rs new file mode 100644 index 0000000..8f0901e --- /dev/null +++ b/tls/src/quic/tests.rs @@ -0,0 +1,671 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Integration tests for the QUIC transport. +//! +//! These reuse the crate's test PKI (built by `build.rs` under the `unittest` +//! feature) and the shared helpers in [`crate::tests`]. Each server binds +//! `[::1]:0` and the real bound port is read back via +//! [`Server::listen_addr`](super::Server::listen_addr), so tests never race on a +//! fixed port and can run concurrently. + +use super::*; +use crate::tests::{local_config, logger, mock_datadir}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use MeasurementConnectionPolicy::{Enforced, Permissive}; + +/// A loopback bind address on an OS-assigned port. +fn localhost() -> SocketAddrV6 { + SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0) +} + +/// The bound address of a server, as a `SocketAddrV6` for `connect`. +fn as_v6(addr: SocketAddr) -> SocketAddrV6 { + match addr { + SocketAddr::V6(addr) => addr, + SocketAddr::V4(addr) => panic!("expected an IPv6 address, got {addr}"), + } +} + +/// The standard two-CoRIM reference corpus used by the enforcing tests. +fn corpus(dir: &Utf8PathBuf) -> Vec { + vec![dir.join("corim-rot.cbor"), dir.join("corim-sp.cbor")] +} + +/// A bare quinn client endpoint using the real sprockets TLS client config +/// (valid credentials), for tests that drive the connection manually without +/// running the attestation exchange. +fn raw_client_endpoint( + config: SprocketsConfig, + log: &slog::Logger, +) -> quinn::Endpoint { + let roots = crate::config::load_roots(&config.roots).unwrap(); + let transport = Arc::new(transport_config()); + let client_config = + new_quic_client_config(config.resolve, roots, log, transport).unwrap(); + let mut endpoint = + quinn::Endpoint::client(SocketAddr::from((Ipv6Addr::LOCALHOST, 0))) + .unwrap(); + endpoint.set_default_client_config(client_config); + endpoint +} + +/// A quinn client endpoint that authenticates the server but presents *no* +/// client certificate, for the mandatory-client-auth test. +fn no_client_auth_endpoint( + config: SprocketsConfig, + log: &slog::Logger, +) -> quinn::Endpoint { + use rustls::client::danger::ServerCertVerifier; + use rustls::version::TLS13; + + let roots = crate::config::load_roots(&config.roots).unwrap(); + let verifier = Arc::new( + crate::keys::RotCertVerifier::new(roots, log.clone()).unwrap(), + ) as Arc; + let mut tls = rustls::ClientConfig::builder_with_provider(Arc::new( + crate::crypto_provider(), + )) + .with_protocol_versions(&[&TLS13]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + tls.alpn_protocols = vec![ALPN_SPROCKETS.to_vec()]; + + let quic = quinn::crypto::rustls::QuicClientConfig::with_initial( + Arc::new(tls), + initial_suite(), + ) + .unwrap(); + let mut endpoint = + quinn::Endpoint::client(SocketAddr::from((Ipv6Addr::LOCALHOST, 0))) + .unwrap(); + endpoint + .set_default_client_config(quinn::ClientConfig::new(Arc::new(quic))); + endpoint +} + +/// A quinn client endpoint identical to the real one except that it offers the +/// wrong ALPN token, for the ALPN-enforcement test. +fn wrong_alpn_endpoint( + config: SprocketsConfig, + log: &slog::Logger, +) -> quinn::Endpoint { + let roots = crate::config::load_roots(&config.roots).unwrap(); + let mut tls = + crate::config::new_tls_client_config(config.resolve, roots, log) + .unwrap(); + tls.alpn_protocols = vec![b"h3".to_vec()]; + + let quic = quinn::crypto::rustls::QuicClientConfig::with_initial( + Arc::new(tls), + initial_suite(), + ) + .unwrap(); + let mut endpoint = + quinn::Endpoint::client(SocketAddr::from((Ipv6Addr::LOCALHOST, 0))) + .unwrap(); + endpoint + .set_default_client_config(quinn::ClientConfig::new(Arc::new(quic))); + endpoint +} + +/// A quinn client endpoint with valid credentials and correct ALPN, but whose +/// TLS config offers *all* of aws-lc-rs's default cipher suites (including +/// AES-GCM) as session suites, for the cipher-suite-pin test. +fn all_suites_endpoint( + config: SprocketsConfig, + log: &slog::Logger, +) -> quinn::Endpoint { + use rustls::client::danger::ServerCertVerifier; + use rustls::client::ResolvesClientCert; + use rustls::version::TLS13; + + let roots = crate::config::load_roots(&config.roots).unwrap(); + let verifier = Arc::new( + crate::keys::RotCertVerifier::new(roots, log.clone()).unwrap(), + ) as Arc; + let resolver = + Arc::new(crate::keys::CertResolver::new(log.clone(), config.resolve)) + as Arc; + let mut tls = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_protocol_versions(&[&TLS13]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_client_cert_resolver(resolver); + tls.alpn_protocols = vec![ALPN_SPROCKETS.to_vec()]; + + let quic = quinn::crypto::rustls::QuicClientConfig::with_initial( + Arc::new(tls), + initial_suite(), + ) + .unwrap(); + let mut endpoint = + quinn::Endpoint::client(SocketAddr::from((Ipv6Addr::LOCALHOST, 0))) + .unwrap(); + endpoint + .set_default_client_config(quinn::ClientConfig::new(Arc::new(quic))); + endpoint +} + +/// A config whose TLS/trust-quorum identity and attestation identity come from +/// *different* key sets, so the two chains disagree on `PlatformId`. +fn mismatched_config() -> SprocketsConfig { + let tls = local_config(1, Enforced); + let attest = local_config(2, Enforced); + SprocketsConfig { + resolve: tls.resolve, + attest: attest.attest, + roots: tls.roots, + enforce: Enforced, + } +} + +/// Walks an error's source chain (peeking inside `io::Error` wrappers, which +/// `source()` would otherwise skip) for a QUIC application close code the peer +/// sent. +fn application_close_code(err: &Error) -> Option { + fn code_of(e: &(dyn std::error::Error + 'static)) -> Option { + if let Some(quinn::ConnectionError::ApplicationClosed(close)) = + e.downcast_ref::() + { + return Some(close.error_code); + } + if let Some(quinn::ReadError::ConnectionLost( + quinn::ConnectionError::ApplicationClosed(close), + )) = e.downcast_ref::() + { + return Some(close.error_code); + } + None + } + + let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err); + while let Some(e) = current { + if let Some(code) = code_of(e) { + return Some(code); + } + if let Some(io_err) = e.downcast_ref::() { + if let Some(code) = + io_err.get_ref().and_then(|inner| code_of(inner)) + { + return Some(code); + } + } + current = e.source(); + } + None +} + +/// A full enforcing handshake succeeds and both peers see the other's attested +/// identity, and application data flows over the primary stream. +#[tokio::test] +async fn basic() { + let log = logger(); + let dir = mock_datadir(); + const MSG: &str = "Hello Joe"; + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let (mut conn, _peer_addr) = server + .accept(server_corpus) + .await + .unwrap() + .handshake() + .await + .unwrap(); + assert!(conn.appraisal_success()); + let mut buf = String::new(); + conn.read_to_string(&mut buf).await.unwrap(); + assert_eq!(buf, MSG); + *conn.peer_platform_id() + }); + + let mut client = + Client::connect(local_config(2, Enforced), addr, corpus(&dir), log) + .await + .unwrap(); + assert!(client.appraisal_success()); + let server_id = *client.peer_platform_id(); + client.write_all(MSG.as_bytes()).await.unwrap(); + client.shutdown().await.unwrap(); + + let client_id = server_task.await.unwrap(); + + // Each peer derived the other's attested identity, and the two test configs + // provision distinct platforms, so the identities differ. + assert_ne!(server_id, client_id); +} + +/// Under `Permissive` with an empty corpus, the handshake completes but +/// `appraisal_success()` reports false on both sides. +#[tokio::test] +async fn no_corpus() { + let log = logger(); + const MSG: &str = "Hello Joe"; + + let server = + Server::new(local_config(1, Permissive), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_task = tokio::spawn(async move { + let (mut conn, _) = server + .accept(vec![]) + .await + .unwrap() + .handshake() + .await + .unwrap(); + assert!(!conn.appraisal_success()); + let mut buf = String::new(); + conn.read_to_string(&mut buf).await.unwrap(); + assert_eq!(buf, MSG); + }); + + let mut client = + Client::connect(local_config(2, Permissive), addr, vec![], log) + .await + .unwrap(); + assert!(!client.appraisal_success()); + client.write_all(MSG.as_bytes()).await.unwrap(); + client.shutdown().await.unwrap(); + + server_task.await.unwrap(); +} + +/// A client with valid TLS credentials that never runs the attestation +/// exchange (it just writes application bytes) must not complete a handshake. +#[tokio::test] +async fn unattested_client() { + let log = logger(); + let dir = mock_datadir(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let acceptor = server.accept(server_corpus).await.unwrap(); + let result = acceptor.handshake().await; + assert!( + result.is_err(), + "a client that skips attestation must not complete the handshake" + ); + }); + + let endpoint = raw_client_endpoint(local_config(2, Enforced), &log); + let conn = endpoint + .connect(addr.into(), SERVER_NAME) + .unwrap() + .await + .unwrap(); + let (mut send, _recv) = conn.open_bi().await.unwrap(); + // Fewer bytes than the exchange's leading length prefix, then EOF: the + // server's first `recv_msg` fails rather than reading a valid message. + send.write_all(b"xy").await.unwrap(); + let _ = send.finish(); + + server_task.await.unwrap(); + drop(conn); + drop(endpoint); +} + +/// One server endpoint handshakes with several concurrent clients. +#[tokio::test] +async fn spawn_accept() { + let log = logger(); + let dir = mock_datadir(); + const MSG: &str = "Hello Joe"; + const CLIENTS: usize = 3; + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let mut reads = Vec::new(); + for _ in 0..CLIENTS { + let acceptor = server.accept(server_corpus.clone()).await.unwrap(); + reads.push(tokio::spawn(async move { + let (mut conn, _) = acceptor.handshake().await.unwrap(); + let mut buf = String::new(); + conn.read_to_string(&mut buf).await.unwrap(); + assert_eq!(buf, MSG); + })); + } + for read in reads { + read.await.unwrap(); + } + }); + + // Keep each client connection alive (held in its JoinHandle) until every + // server-side read has finished. Dropping a connection early would close it + // with code 0 and truncate the still-unread message. + let mut clients = Vec::new(); + for _ in 0..CLIENTS { + let log = log.clone(); + let dir = dir.clone(); + clients.push(tokio::spawn(async move { + let mut client = Client::connect( + local_config(2, Enforced), + addr, + corpus(&dir), + log, + ) + .await + .unwrap(); + client.write_all(MSG.as_bytes()).await.unwrap(); + client.shutdown().await.unwrap(); + client + })); + } + + tokio::time::timeout(Duration::from_secs(30), server_task) + .await + .expect("server handshakes did not complete in time") + .unwrap(); + + for client in clients { + let _client = client.await.unwrap(); + } +} + +/// A second stream opened after the handshake inherits the connection's +/// attested identity and carries data independently of the primary stream. +#[tokio::test] +async fn multi_stream() { + let log = logger(); + let dir = mock_datadir(); + const PRIMARY: &str = "primary stream"; + const SECOND: &str = "second stream"; + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let (mut conn, _) = server + .accept(server_corpus) + .await + .unwrap() + .handshake() + .await + .unwrap(); + + let mut primary = String::new(); + conn.read_to_string(&mut primary).await.unwrap(); + assert_eq!(primary, PRIMARY); + + let mut second = conn.accept_bi().await.unwrap(); + let mut secondary = String::new(); + second.read_to_string(&mut secondary).await.unwrap(); + assert_eq!(secondary, SECOND); + }); + + let mut client = + Client::connect(local_config(2, Enforced), addr, corpus(&dir), log) + .await + .unwrap(); + client.write_all(PRIMARY.as_bytes()).await.unwrap(); + client.shutdown().await.unwrap(); + + let mut second = client.open_bi().await.unwrap(); + second.write_all(SECOND.as_bytes()).await.unwrap(); + second.shutdown().await.unwrap(); + + server_task.await.unwrap(); +} + +/// When the server rejects measurements under `Enforced`, it closes the +/// connection with [`close_code::APPRAISAL`], which the client observes. +#[tokio::test] +async fn appraisal_failure_close_code() { + let log = logger(); + let dir = mock_datadir(); + + // Enforcing, but with an empty corpus, so appraisal necessarily fails. + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_task = tokio::spawn(async move { + let result = server.accept(vec![]).await.unwrap().handshake().await; + match result { + Err(Error::AttestMeasurementsVerifier { .. }) => {} + Err(other) => { + panic!("expected an appraisal failure, got {other:?}") + } + Ok(_) => panic!("server must not complete a failing handshake"), + } + }); + + let result = + Client::connect(local_config(2, Enforced), addr, corpus(&dir), log) + .await; + let err = match result { + Ok(_) => { + panic!("client must not complete a handshake the server rejects") + } + Err(err) => err, + }; + assert_eq!( + application_close_code(&err), + Some(close_code::APPRAISAL), + "client should observe the server's APPRAISAL close code; got: {err:?}" + ); + + server_task.await.unwrap(); +} + +/// A client presenting no certificate cannot complete the handshake: the +/// server's connection future errors before any stream or peer identity is +/// reachable. +#[tokio::test] +async fn client_auth_required() { + let log = logger(); + let dir = mock_datadir(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let acceptor = server.accept(server_corpus).await.unwrap(); + let result = acceptor.handshake().await; + assert!( + result.is_err(), + "server must reject a client that presents no certificate" + ); + }); + + let endpoint = no_client_auth_endpoint(local_config(2, Enforced), &log); + // The client-side connection also fails once the server aborts; we only + // assert on the server's rejection. + let _ = endpoint.connect(addr.into(), SERVER_NAME).unwrap().await; + + server_task.await.unwrap(); + drop(endpoint); +} + +/// A peer whose TLS/trust-quorum chain and attestation chain disagree on +/// `PlatformId` is rejected: the server errors with +/// [`Error::PlatformIdMismatch`] and closes with +/// [`close_code::PLATFORM_ID_MISMATCH`], which the client observes. This pins +/// the load-bearing TLS-to-attestation identity binding. +#[tokio::test] +async fn platform_id_mismatch() { + let log = logger(); + let dir = mock_datadir(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let result = server + .accept(server_corpus) + .await + .unwrap() + .handshake() + .await; + match result { + Err(Error::PlatformIdMismatch) => {} + Err(other) => panic!("expected PlatformIdMismatch, got {other:?}"), + Ok(_) => panic!("a mismatched client must not complete"), + } + }); + + let result = + Client::connect(mismatched_config(), addr, corpus(&dir), log).await; + let err = match result { + Ok(_) => panic!("client with mismatched identities must not connect"), + Err(err) => err, + }; + assert_eq!( + application_close_code(&err), + Some(close_code::PLATFORM_ID_MISMATCH), + "client should observe the PLATFORM_ID_MISMATCH close code; got: {err:?}" + ); + + server_task.await.unwrap(); +} + +/// A client offering the wrong ALPN token cannot complete the handshake, even +/// with otherwise-valid credentials: rustls enforces strict ALPN matching in +/// QUIC mode. +#[tokio::test] +async fn wrong_alpn_rejected() { + let log = logger(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_task = tokio::spawn(async move { + let acceptor = server.accept(vec![]).await.unwrap(); + assert!( + acceptor.handshake().await.is_err(), + "server must reject a client offering the wrong ALPN" + ); + }); + + let endpoint = wrong_alpn_endpoint(local_config(2, Enforced), &log); + let _ = endpoint.connect(addr.into(), SERVER_NAME).unwrap().await; + + server_task.await.unwrap(); + drop(endpoint); +} + +/// A client offering AES-GCM session cipher suites is rejected: the server's +/// resolver pins ChaCha20-Poly1305 as the only permitted session suite. The +/// AES-128-GCM used for RFC 9001 Initial packets is separate and does not +/// satisfy this. +#[tokio::test] +async fn session_cipher_pinned_to_chacha20() { + let log = logger(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_task = tokio::spawn(async move { + let acceptor = server.accept(vec![]).await.unwrap(); + assert!( + acceptor.handshake().await.is_err(), + "server must reject a client offering non-ChaCha20 session suites" + ); + }); + + let endpoint = all_suites_endpoint(local_config(2, Enforced), &log); + let _ = endpoint.connect(addr.into(), SERVER_NAME).unwrap().await; + + server_task.await.unwrap(); + drop(endpoint); +} + +/// A payload far larger than the path MTU and a single packet round-trips +/// intact over the attested stream, exercising QUIC stream flow control and +/// the `BiStream` duplex under load in both directions. +#[tokio::test] +async fn large_payload() { + let log = logger(); + let dir = mock_datadir(); + + // 1 MiB: hundreds of packets, near quinn's default stream receive window. + let payload: Vec = (0..1024 * 1024).map(|i| i as u8).collect(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let expected = payload.clone(); + let server_task = tokio::spawn(async move { + let (mut conn, _) = server + .accept(server_corpus) + .await + .unwrap() + .handshake() + .await + .unwrap(); + let mut buf = vec![0u8; expected.len()]; + conn.read_exact(&mut buf).await.unwrap(); + assert_eq!(buf, expected); + // Echo it back, then keep the connection alive (returned to the caller) + // until the client has read the echo. + conn.write_all(&buf).await.unwrap(); + conn.shutdown().await.unwrap(); + conn + }); + + let mut client = + Client::connect(local_config(2, Enforced), addr, corpus(&dir), log) + .await + .unwrap(); + client.write_all(&payload).await.unwrap(); + + let mut echoed = vec![0u8; payload.len()]; + client.read_exact(&mut echoed).await.unwrap(); + assert_eq!(echoed, payload); + + let _server_conn = server_task.await.unwrap(); +} + +/// The RFC 9001 Initial suite is AES-128-GCM. Guards against a future +/// "simplification" to quinn's `TryFrom` path, which would fail at runtime +/// because the sprockets provider deliberately lacks AES-128-GCM as a +/// session suite. +#[test] +fn initial_suite_is_aes_128_gcm() { + let suite = initial_suite(); + assert_eq!( + suite.suite.common.suite, + rustls::CipherSuite::TLS13_AES_128_GCM_SHA256 + ); +} From ed9203a8c64667a39f368f8ade1133163d872032 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 15:17:32 -0400 Subject: [PATCH 10/18] Add QUIC client and server examples The QUIC analogs of the client and server examples: same CLI and stdin/echo behavior, over a sprockets QUIC connection instead of TCP. AttestedConnection's AsyncRead/AsyncWrite impls let tokio::io::split and copy work as with Stream; quic::Server::new is synchronous. The shutdown flow deliberately differs from the TCP examples, demonstrating the drop semantics from the quic module docs: the client drains the echo to stream EOF after stdin ends (exiting on stdin close would drop the connection and could truncate the in-flight echo), and the server finishes its stream then holds the connection until the client closes, treating a client that departs by connection close as the end of that connection rather than a fatal error. Both examples are gated behind required-features = ["quic"], so a no-feature --all-targets build skips them and Cargo.lock is unchanged. Verified on illumos: clippy --all-targets clean with the feature, and the example pair run against each other over loopback: two sequential clients, full echoes with no timing dependence, server alive throughout. --- tls/Cargo.toml | 8 ++ tls/examples/client_quic.rs | 127 ++++++++++++++++++++++++++++++++ tls/examples/server_quic.rs | 142 ++++++++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 tls/examples/client_quic.rs create mode 100644 tls/examples/server_quic.rs diff --git a/tls/Cargo.toml b/tls/Cargo.toml index f7522cf..2a8a9ee 100644 --- a/tls/Cargo.toml +++ b/tls/Cargo.toml @@ -46,3 +46,11 @@ quic = ["dep:quinn"] [dev-dependencies] sprockets-tls = { path = ".", features = ["unittest"] } + +[[example]] +name = "client_quic" +required-features = ["quic"] + +[[example]] +name = "server_quic" +required-features = ["quic"] diff --git a/tls/examples/client_quic.rs b/tls/examples/client_quic.rs new file mode 100644 index 0000000..caf9dc1 --- /dev/null +++ b/tls/examples/client_quic.rs @@ -0,0 +1,127 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Example QUIC client that echoes out whatever it gets back. +//! +//! The QUIC analog of the `client` example: identical CLI, but the transport +//! is a sprockets QUIC connection rather than TCP. +use camino::Utf8PathBuf; +use clap::Parser; +use slog::{info, Drain}; +use sprockets_tls::keys::{ + AttestConfig, MeasurementConnectionPolicy, ResolveSetting, SprocketsConfig, +}; +use sprockets_tls::quic::Client; +use std::net::SocketAddrV6; +use std::str::FromStr; +use tokio::io::{copy, split, AsyncWriteExt}; +use tokio::io::{stdin as tokio_stdin, stdout as tokio_stdout}; + +#[derive(Debug, Parser)] +enum Setting { + Ipcc, + Local { + /// TLS signing key used in Trust Quorum + tq_priv_key: Utf8PathBuf, + /// Cert chain for TLS signing key + tq_cert_chain: Utf8PathBuf, + /// Key used to sign the attestations produced by AttestMock + attest_priv_key: Utf8PathBuf, + /// Cert chain for attestation signing key + attest_cert_chain: Utf8PathBuf, + /// Measurement log produced by AttestMock + log: Utf8PathBuf, + }, +} + +#[derive(Debug, Parser)] +struct Args { + /// Root Certificates + #[clap(long)] + roots: Vec, + #[clap(subcommand)] + config: Setting, + /// CBOR encoded CoRIM documents used as reference measurements in the + /// attestation appraisal process + #[clap(long)] + corpus: Vec, + /// Address and port to connect to + #[clap(long)] + addr: String, + #[clap(long)] + enforce: bool, +} + +#[tokio::main] +async fn main() { + let decorator = slog_term::TermDecorator::new().build(); + let drain = slog_term::FullFormat::new(decorator).build().fuse(); + let drain = slog_async::Async::new(drain).build().fuse(); + + let log = slog::Logger::root(drain, slog::o!("component" => "sprockets")); + + let args = Args::parse(); + + if args.roots.is_empty() { + panic!("Need at least one root"); + } + + let (attest, resolve) = match args.config { + Setting::Ipcc => (AttestConfig::Ipcc, ResolveSetting::Ipcc), + Setting::Local { + tq_priv_key, + tq_cert_chain, + attest_priv_key, + attest_cert_chain, + log, + } => ( + AttestConfig::Local { + priv_key: attest_priv_key, + cert_chain: attest_cert_chain, + log, + test_corpus: vec![], + }, + ResolveSetting::Local { + priv_key: tq_priv_key, + cert_chain: tq_cert_chain, + }, + ), + }; + + let client_config = SprocketsConfig { + attest, + roots: args.roots, + resolve, + enforce: if args.enforce { + MeasurementConnectionPolicy::Enforced + } else { + MeasurementConnectionPolicy::Permissive + }, + }; + + let addr = SocketAddrV6::from_str(&args.addr).unwrap(); + + let conn = Client::connect(client_config, addr, args.corpus, log.clone()) + .await + .unwrap(); + let platform_id = conn.peer_platform_id().as_str(); + info!(log, "connected to attested peer: {platform_id}"); + + let mut stdin = tokio_stdin(); + let (mut reader, mut writer) = split(conn); + + // Unlike the TCP client example's select!, the echo is drained to EOF + // after stdin ends: exiting as soon as stdin closes would drop the + // connection (close code 0) and could truncate the in-flight echo — see + // the drop semantics in the `quic` module docs. Reading the server's + // stream FIN is the application-level delivery acknowledgment. + let stdout_task = tokio::spawn(async move { + let mut stdout = tokio_stdout(); + let _ = copy(&mut reader, &mut stdout).await; + }); + + copy(&mut stdin, &mut writer).await.unwrap(); + writer.shutdown().await.unwrap(); + stdout_task.await.unwrap(); +} diff --git a/tls/examples/server_quic.rs b/tls/examples/server_quic.rs new file mode 100644 index 0000000..301bae5 --- /dev/null +++ b/tls/examples/server_quic.rs @@ -0,0 +1,142 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Example QUIC server that echoes back whatever was sent. +//! +//! The QUIC analog of the `server` example: identical CLI, but the transport +//! is a sprockets QUIC endpoint rather than a TCP listener. +use camino::Utf8PathBuf; +use clap::Parser; +use slog::{info, Drain}; +use sprockets_tls::keys::{ + AttestConfig, MeasurementConnectionPolicy, ResolveSetting, SprocketsConfig, +}; +use sprockets_tls::quic::Server; +use std::net::SocketAddrV6; +use std::str::FromStr; +use tokio::io::{copy, split, AsyncWriteExt}; + +#[derive(Debug, Parser)] +enum Setting { + Ipcc, + Local { + /// TLS signing key used in Trust Quorum + tq_priv_key: Utf8PathBuf, + /// Cert chain for TLS signing key + tq_cert_chain: Utf8PathBuf, + /// Key used to sign the attestations produced by AttestMock + attest_priv_key: Utf8PathBuf, + /// Cert chain for attestation signing key + attest_cert_chain: Utf8PathBuf, + /// Measurement log produced by AttestMock + log: Utf8PathBuf, + }, +} + +#[derive(Debug, Parser)] +struct Args { + /// Root Certificates + #[clap(long)] + roots: Vec, + #[clap(subcommand)] + config: Setting, + /// CBOR encoded CoRIM documents used as reference measurements in the + /// attestation appraisal process + #[clap(long)] + corpus: Vec, + /// Address and port to bind + #[clap(long)] + addr: String, + #[clap(long)] + enforce: bool, +} + +#[tokio::main] +async fn main() { + let decorator = slog_term::TermDecorator::new().build(); + let drain = slog_term::FullFormat::new(decorator).build().fuse(); + let drain = slog_async::Async::new(drain).build().fuse(); + + let log = slog::Logger::root(drain, slog::o!("component" => "sprockets")); + + let args = Args::parse(); + + if args.roots.is_empty() { + panic!("Must specify at least one root"); + } + + let listen_addr = SocketAddrV6::from_str(&args.addr).unwrap(); + + let (attest, resolve) = match args.config { + Setting::Ipcc => (AttestConfig::Ipcc, ResolveSetting::Ipcc), + Setting::Local { + tq_priv_key, + tq_cert_chain, + attest_priv_key, + attest_cert_chain, + log, + } => ( + AttestConfig::Local { + priv_key: attest_priv_key, + cert_chain: attest_cert_chain, + log, + test_corpus: vec![], + }, + ResolveSetting::Local { + priv_key: tq_priv_key, + cert_chain: tq_cert_chain, + }, + ), + }; + + let server_config = SprocketsConfig { + attest, + roots: args.roots, + resolve, + enforce: if args.enforce { + MeasurementConnectionPolicy::Enforced + } else { + MeasurementConnectionPolicy::Permissive + }, + }; + + // Unlike the TCP `Server::new`, the QUIC constructor is synchronous; it + // binds the UDP socket and spawns quinn's endpoint driver on the current + // runtime. + let server = Server::new(server_config, listen_addr, log.clone()).unwrap(); + + loop { + let (conn, _) = server + .accept(args.corpus.clone()) + .await + .unwrap() + .handshake() + .await + .unwrap(); + let platform_id = conn.peer_platform_id().as_str(); + info!(log, "connected to attested peer: {platform_id}"); + + // A handle on the quinn connection, kept to await the client's + // departure below after `split` consumes the sprockets connection. + let quinn_conn = conn.connection().clone(); + let (mut reader, mut writer) = split(conn); + + // A client that departs by closing the connection (rather than + // finishing its stream) surfaces here as an error: that ends this + // connection, not the server. + match copy(&mut reader, &mut writer).await { + Ok(n) => { + // Finish the echo stream so the client reads EOF after the + // last echoed byte, then hold the connection open until the + // client has read everything and closed: dropping our handle + // first could discard the in-flight echo tail (see the drop + // semantics in the `quic` module docs). + let _ = writer.shutdown().await; + quinn_conn.closed().await; + info!(log, "Echo: {}", n); + } + Err(e) => info!(log, "connection ended: {e}"), + } + } +} From a1a6359186b5c052063747efc232dba384f31895 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 15:20:53 -0400 Subject: [PATCH 11/18] Run CI with the quic feature off and on Add a features matrix to the clippy and build-and-test jobs so both the default (quic off) and --features quic configurations are linted, built, and tested on every run. check-style is left single because rustfmt is feature-independent, and clippy keeps its existing target scope: --all-targets would enable the unittest feature whose build.rs needs attest-mock, which only the build-and-test job installs (and which already builds all targets and runs the tests in both configurations). --- .github/workflows/rust.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 2679f96..d13af27 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -23,6 +23,11 @@ jobs: clippy-lint: runs-on: ubuntu-latest + strategy: + matrix: + # Lint with the quic feature off (default) and on, so neither + # configuration regresses. + features: ["", "--features quic"] steps: - uses: actions/checkout@v4 - name: Report cargo version @@ -30,13 +35,16 @@ jobs: - name: Report Clippy version run: cargo clippy -- --version - name: Run Clippy Lints - run: cargo clippy -- -D warnings + run: cargo clippy ${{ matrix.features }} -- -D warnings build-and-test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] + # Build every target and run the tests with the quic feature off + # (default) and on. + features: ["", "--features quic"] steps: - uses: actions/checkout@v4 - name: Report cargo version @@ -44,6 +52,6 @@ jobs: - name: Report rustc version run: rustc --version - name: Build - run: cargo build --all-targets --verbose + run: cargo build --all-targets ${{ matrix.features }} --verbose - name: Run tests - run: cargo test --verbose + run: cargo test ${{ matrix.features }} --verbose From 5456133860993906c92ecab58fd91aecda45f031 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 15:50:49 -0400 Subject: [PATCH 12/18] Close with a mapped code on every post-establishment handshake failure Previously only attestation-exchange failures closed the connection with a close code from close_code; failures between connection establishment and the exchange (peer-identity extraction, opening or accepting the primary stream) returned early and let the dropped connection close with code 0, indistinguishable from a graceful close at the peer. Split the post-establishment work into client_handshake/server_handshake helpers so Endpoint::connect and Acceptor::handshake close with the mapped code on any failure past establishment. --- tls/src/quic.rs | 137 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 94 insertions(+), 43 deletions(-) diff --git a/tls/src/quic.rs b/tls/src/quic.rs index 60a5ac6..d4439cd 100644 --- a/tls/src/quic.rs +++ b/tls/src/quic.rs @@ -373,17 +373,8 @@ impl Endpoint { ) -> Result { let connection = self.inner.connect(addr.into(), SERVER_NAME)?.await?; - // The peer identity is the server's TLS/trust-quorum chain, the QUIC - // analog of `peer_certificates()` on the TCP path. - let tq_platform_id = - platform_id_from_peer_identity(connection.peer_identity())?; - - let (send, recv) = connection.open_bi().await?; - let mut stream = BiStream::new(send, recv); - - let (peer_platform_id, appraisal) = match attest::client_exchange( - &mut stream, - tq_platform_id, + match client_handshake( + &connection, &self.attest_config, &self.roots, corpus, @@ -392,19 +383,19 @@ impl Endpoint { ) .await { - Ok(result) => result, + Ok((stream, peer_platform_id, appraisal)) => { + Ok(AttestedConnection::new( + connection, + stream, + peer_platform_id, + appraisal, + )) + } Err(err) => { close_for_error(&connection, &err); - return Err(err); + Err(err) } - }; - - Ok(AttestedConnection::new( - connection, - stream, - peer_platform_id, - appraisal, - )) + } } /// Awaits the next inbound connection, returning an [`Acceptor`] whose @@ -504,15 +495,8 @@ impl Acceptor { let connection = incoming.await?; - let tq_platform_id = - platform_id_from_peer_identity(connection.peer_identity())?; - - let (send, recv) = connection.accept_bi().await?; - let mut stream = BiStream::new(send, recv); - - let (peer_platform_id, appraisal) = match attest::server_exchange( - &mut stream, - tq_platform_id, + match server_handshake( + &connection, corims, &attest_config, &roots, @@ -521,25 +505,92 @@ impl Acceptor { ) .await { - Ok(result) => result, + Ok((stream, peer_platform_id, appraisal)) => Ok(( + AttestedConnection::new( + connection, + stream, + peer_platform_id, + appraisal, + ), + addr, + )), Err(err) => { close_for_error(&connection, &err); - return Err(err); + Err(err) } - }; - - Ok(( - AttestedConnection::new( - connection, - stream, - peer_platform_id, - appraisal, - ), - addr, - )) + } } } +/// The client side of the post-establishment handshake: derives the peer +/// identity, opens the primary stream, and runs the attestation exchange. +/// +/// Split out from [`Endpoint::connect`] so that *any* failure after the +/// connection is established — not only an exchange failure — closes the +/// connection with the close code mapped from the error. +async fn client_handshake( + connection: &quinn::Connection, + attest_config: &AttestConfig, + roots: &[Certificate], + corpus: Vec, + enforce: MeasurementConnectionPolicy, + log: &slog::Logger, +) -> Result<(BiStream, PlatformId, bool), Error> { + // The peer identity is the server's TLS/trust-quorum chain, the QUIC + // analog of `peer_certificates()` on the TCP path. + let tq_platform_id = + platform_id_from_peer_identity(connection.peer_identity())?; + + let (send, recv) = connection.open_bi().await?; + let mut stream = BiStream::new(send, recv); + + let (peer_platform_id, appraisal) = attest::client_exchange( + &mut stream, + tq_platform_id, + attest_config, + roots, + corpus, + enforce, + log, + ) + .await?; + + Ok((stream, peer_platform_id, appraisal)) +} + +/// The server side of the post-establishment handshake: derives the peer +/// identity, accepts the primary stream, and runs the attestation exchange. +/// +/// Split out from [`Acceptor::handshake`] for the same reason as +/// [`client_handshake`]. +async fn server_handshake( + connection: &quinn::Connection, + corims: Vec, + attest_config: &AttestConfig, + roots: &[Certificate], + enforce: MeasurementConnectionPolicy, + log: &slog::Logger, +) -> Result<(BiStream, PlatformId, bool), Error> { + let tq_platform_id = + platform_id_from_peer_identity(connection.peer_identity())?; + + let (send, recv) = connection.accept_bi().await?; + let mut stream = BiStream::new(send, recv); + + let (peer_platform_id, appraisal) = attest::server_exchange( + &mut stream, + tq_platform_id, + corims, + attest_config, + roots, + enforce, + log, + ) + .await?; + + Ok((stream, peer_platform_id, appraisal)) +} + /// A one-shot QUIC client, mirroring the TCP [`Client`](crate::Client). /// /// For callers that only dial and do not need to hold an [`Endpoint`]. Each From 20b0dc87ce4cb12a15447ae79ee72b1bf10c8897 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 15:51:40 -0400 Subject: [PATCH 13/18] Send static close reasons and classify peer-sent garbage as peer faults The close reason previously carried the rendered error message, which for local failures can include detail the peer has no need for (file paths, configuration specifics). Close with a short static reason naming only the failure class. Hubpack decode failures, DER parse failures, and oversized length prefixes during the exchange arise from peer-sent bytes (the encode directions cannot fail on our own, already-validated data), so map them to PROTOCOL / ATTESTATION / PROTOCOL respectively instead of letting them fall through to LOCAL_ERROR. --- tls/src/quic.rs | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/tls/src/quic.rs b/tls/src/quic.rs index d4439cd..d933a61 100644 --- a/tls/src/quic.rs +++ b/tls/src/quic.rs @@ -66,7 +66,7 @@ //! //! | Code | Meaning | //! |------|---------| -//! | [`PROTOCOL`](close_code::PROTOCOL) | Version negotiation failed | +//! | [`PROTOCOL`](close_code::PROTOCOL) | Version negotiation failed or unparseable protocol bytes | //! | [`ATTESTATION`](close_code::ATTESTATION) | Peer attestation did not verify | //! | [`APPRAISAL`](close_code::APPRAISAL) | Measurements failed appraisal under `Enforced` | //! | [`PLATFORM_ID_MISMATCH`](close_code::PLATFORM_ID_MISMATCH) | TLS and attestation chains disagreed on PlatformId | @@ -161,7 +161,8 @@ const INCOMING_BUFFER_SIZE_TOTAL: u64 = 10 << 20; // 10 MiB pub mod close_code { use quinn::VarInt; - /// Protocol-version negotiation failed. + /// Version negotiation failed, or the peer sent bytes that do not parse + /// as the sprockets protocol. pub const PROTOCOL: VarInt = VarInt::from_u32(1); /// The peer's attestation did not verify. pub const ATTESTATION: VarInt = VarInt::from_u32(2); @@ -263,14 +264,36 @@ fn platform_id_from_peer_identity( platform_id_from_tls_certs(certs.as_deref()) } -/// Maps a handshake error to the QUIC close code the peer will observe. -fn close_code_for_error(err: &Error) -> VarInt { +/// Maps a handshake error to the QUIC close code the peer will observe, and a +/// short static reason naming the failure class. +/// +/// The reason deliberately names only the class, never the error's rendered +/// message: error strings can carry local detail (file paths, configuration +/// specifics) that the peer has no need for. +fn close_code_for_error(err: &Error) -> (VarInt, &'static str) { match err { + // Hubpack failures during the exchange are decode failures on + // peer-sent bytes (the encode direction into MAX_SIZE-d buffers + // cannot fail), and an oversized length prefix is likewise + // peer-sent, so they classify as protocol garbage, not as a local + // error. Error::ProtocolVersion | Error::ClientMismatch - | Error::ClientGaveUp => close_code::PROTOCOL, - Error::PlatformIdMismatch => close_code::PLATFORM_ID_MISMATCH, - Error::AttestMeasurementsVerifier { .. } => close_code::APPRAISAL, + | Error::ClientGaveUp + | Error::Hubpack(_) + | Error::MessageTooLarge { .. } => { + (close_code::PROTOCOL, "protocol error") + } + Error::PlatformIdMismatch => { + (close_code::PLATFORM_ID_MISMATCH, "platform id mismatch") + } + Error::AttestMeasurementsVerifier { .. } => { + (close_code::APPRAISAL, "measurement appraisal failed") + } + // DER failures during the exchange are parse failures on the + // peer-sent attestation cert chain (our own chain re-encodes + // infallibly, having already been parsed), so they belong with the + // attestation failures. Error::AttestationVerifier(_) | Error::AttestCertVerifier(_) | Error::MeasurementSet(_) @@ -284,14 +307,16 @@ fn close_code_for_error(err: &Error) -> VarInt { | Error::AttestMock(_) | Error::AttestIpcc(_) | Error::RotRequest(_) - | Error::NoTQCerts => close_code::ATTESTATION, - _ => close_code::LOCAL_ERROR, + | Error::Der(_) + | Error::NoTQCerts => (close_code::ATTESTATION, "attestation failed"), + _ => (close_code::LOCAL_ERROR, "local error"), } } /// Closes `connection` with the code and reason derived from `err`. fn close_for_error(connection: &quinn::Connection, err: &Error) { - connection.close(close_code_for_error(err), err.to_string().as_bytes()); + let (code, reason) = close_code_for_error(err); + connection.close(code, reason.as_bytes()); } /// A sprockets QUIC endpoint: one UDP socket that both dials and listens. From 81be71bade9602a68716b8adb29cdf44960e5a2d Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 15:52:35 -0400 Subject: [PATCH 14/18] Make the one-shot quic::Client bind a dial-only endpoint Client::connect went through Endpoint::new, which always installs a server configuration, so the facade's ephemeral socket would field and buffer inbound QUIC connection attempts it could never accept. Bind via quinn::Endpoint::client instead: no server configuration, no listening surface. Endpoint::new is unchanged for callers that want the dual-role socket. --- tls/src/quic.rs | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/tls/src/quic.rs b/tls/src/quic.rs index d933a61..aea4a4a 100644 --- a/tls/src/quic.rs +++ b/tls/src/quic.rs @@ -347,6 +347,28 @@ impl Endpoint { config: SprocketsConfig, bind: SocketAddrV6, log: slog::Logger, + ) -> Result { + Endpoint::new_inner(config, bind, log, true) + } + + /// Binds a dial-only endpoint at `bind`: no server configuration is + /// installed, so the socket never answers inbound connection attempts. + /// + /// Used by the one-shot [`Client`] facade; [`accept`](Endpoint::accept) + /// on such an endpoint never yields a connection. + fn new_dial_only( + config: SprocketsConfig, + bind: SocketAddrV6, + log: slog::Logger, + ) -> Result { + Endpoint::new_inner(config, bind, log, false) + } + + fn new_inner( + config: SprocketsConfig, + bind: SocketAddrV6, + log: slog::Logger, + listen: bool, ) -> Result { let roots = config::load_roots(&config.roots)?; let transport = Arc::new(transport_config()); @@ -356,14 +378,18 @@ impl Endpoint { &log, transport.clone(), )?; - let server_config = new_quic_server_config( - config.resolve.clone(), - roots.clone(), - &log, - transport, - )?; - let mut inner = quinn::Endpoint::server(server_config, bind.into())?; + let mut inner = if listen { + let server_config = new_quic_server_config( + config.resolve, + roots.clone(), + &log, + transport, + )?; + quinn::Endpoint::server(server_config, bind.into())? + } else { + quinn::Endpoint::client(bind.into())? + }; inner.set_default_client_config(client_config); Ok(Endpoint { @@ -621,6 +647,8 @@ async fn server_handshake( /// For callers that only dial and do not need to hold an [`Endpoint`]. Each /// [`connect`](Client::connect) binds a fresh UDP socket on an OS-assigned port /// (`[::]:0`), performs the attested handshake, and returns the connection. The +/// socket is dial-only: it carries no server configuration and never answers +/// inbound connection attempts. The /// endpoint handle is then dropped, but quinn keeps its I/O driver alive as long /// as the returned connection lives, so the connection stays usable. Callers /// that dial repeatedly, or that also listen, should share one [`Endpoint`] @@ -640,7 +668,7 @@ impl Client { log: slog::Logger, ) -> Result { let bind = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0); - let endpoint = Endpoint::new(config, bind, log)?; + let endpoint = Endpoint::new_dial_only(config, bind, log)?; endpoint.connect(addr, corpus).await } } From 4b6745ea3097f788b3d90e2a9f19c51826f14e7e Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 15:52:59 -0400 Subject: [PATCH 15/18] Document double-shutdown and retry-error semantics A second shutdown().await on a BiStream errors (quinn's finish rejects an already-finished stream), unlike the TCP-backed Stream; state this on the type since BiStream is pitched as a drop-in duplex. Also note why the retry() result in Endpoint::accept is safe to discard. --- tls/src/quic.rs | 4 +++- tls/src/quic/stream.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tls/src/quic.rs b/tls/src/quic.rs index aea4a4a..72a146c 100644 --- a/tls/src/quic.rs +++ b/tls/src/quic.rs @@ -472,7 +472,9 @@ impl Endpoint { // Require address validation. An unvalidated peer is sent a Retry; // it re-dials, and that attempt arrives already validated. This - // bounds the pre-authentication amplification surface. + // bounds the pre-authentication amplification surface. retry() + // errors only for an already-validated incoming, which the guard + // above excludes. if !incoming.remote_address_validated() { let _ = incoming.retry(); continue; diff --git a/tls/src/quic/stream.rs b/tls/src/quic/stream.rs index 435356d..6b9e1b3 100644 --- a/tls/src/quic/stream.rs +++ b/tls/src/quic/stream.rs @@ -27,7 +27,9 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; /// [`AsyncWrite::poll_shutdown`] maps to [`quinn::SendStream::finish`], which /// queues a FIN but does not wait for the peer to acknowledge delivery; see the /// [module documentation](crate::quic#liveness-and-shutdown) for what that -/// implies about in-flight data. +/// implies about in-flight data. Unlike shutting down a TCP-backed +/// [`Stream`](crate::Stream), a *second* `shutdown().await` returns an error: +/// `finish` rejects a stream that is already finished. pub struct BiStream { send: quinn::SendStream, recv: quinn::RecvStream, From c46bc0d3d36e8f7fb6f5bdecdf824a0db5314d50 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 16:13:29 -0400 Subject: [PATCH 16/18] Add QUIC regression tests for the protocol hardening fixes QUIC-side pins for the two attest-protocol hardening fixes, mirroring the TCP tests: a raw quinn client with valid credentials sends a version message shorter than 4 bytes (must be rejected as ProtocolVersion, not a panic) and a length prefix claiming a 4 GiB message (must be rejected as MessageTooLarge on the prefix alone, with the client observing the PROTOCOL close code). --- tls/src/quic/tests.rs | 107 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tls/src/quic/tests.rs b/tls/src/quic/tests.rs index 8f0901e..0171b6d 100644 --- a/tls/src/quic/tests.rs +++ b/tls/src/quic/tests.rs @@ -324,6 +324,113 @@ async fn unattested_client() { drop(endpoint); } +/// A version message whose body is shorter than the 4-byte version is +/// rejected as [`Error::ProtocolVersion`] — the server task must error, not +/// panic on a short slice. +#[tokio::test] +async fn short_version_message_rejected() { + let log = logger(); + let dir = mock_datadir(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let result = server + .accept(server_corpus) + .await + .unwrap() + .handshake() + .await; + match result { + Err(Error::ProtocolVersion) => {} + Err(other) => panic!("expected ProtocolVersion, got {other:?}"), + Ok(_) => { + panic!("a malformed version message must not complete") + } + } + }); + + let endpoint = raw_client_endpoint(local_config(2, Enforced), &log); + let conn = endpoint + .connect(addr.into(), SERVER_NAME) + .unwrap() + .await + .unwrap(); + let (mut send, _recv) = conn.open_bi().await.unwrap(); + // A valid length prefix (2) followed by a 2-byte body: the server's + // recv_msg succeeds, but the body is shorter than the 4-byte version it + // must contain. + send.write_all(&2u32.to_le_bytes()).await.unwrap(); + send.write_all(b"xy").await.unwrap(); + let _ = send.finish(); + + server_task.await.unwrap(); + drop(conn); + drop(endpoint); +} + +/// A message whose length prefix exceeds `MAX_MSG_SIZE` is rejected as +/// [`Error::MessageTooLarge`] before the message buffer is allocated, and +/// the client observes the [`close_code::PROTOCOL`] close code. +#[tokio::test] +async fn oversized_message_rejected() { + let log = logger(); + let dir = mock_datadir(); + + let server = + Server::new(local_config(1, Enforced), localhost(), log.clone()) + .unwrap(); + let addr = as_v6(server.listen_addr().unwrap()); + + let server_corpus = corpus(&dir); + let server_task = tokio::spawn(async move { + let result = server + .accept(server_corpus) + .await + .unwrap() + .handshake() + .await; + match result { + Err(Error::MessageTooLarge { .. }) => {} + Err(other) => panic!("expected MessageTooLarge, got {other:?}"), + Ok(_) => panic!("an oversized message must not complete"), + } + }); + + let endpoint = raw_client_endpoint(local_config(2, Enforced), &log); + let conn = endpoint + .connect(addr.into(), SERVER_NAME) + .unwrap() + .await + .unwrap(); + let (mut send, mut recv) = conn.open_bi().await.unwrap(); + // A length prefix claiming a 4 GiB message; no body ever follows. The + // server must reject on the prefix alone. + send.write_all(&u32::MAX.to_le_bytes()).await.unwrap(); + + // The server closes with PROTOCOL; observe it from the read half. + let read_err = recv + .read_to_end(usize::MAX) + .await + .expect_err("server must close the connection"); + match read_err { + quinn::ReadToEndError::Read(quinn::ReadError::ConnectionLost( + quinn::ConnectionError::ApplicationClosed(close), + )) => { + assert_eq!(close.error_code, close_code::PROTOCOL); + } + other => panic!("expected an application close, got {other:?}"), + } + + server_task.await.unwrap(); + drop(conn); + drop(endpoint); +} + /// One server endpoint handshakes with several concurrent clients. #[tokio::test] async fn spawn_accept() { From 13971db821e7f2dd75f3a102f7aebb1261b31e98 Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 16:59:31 -0400 Subject: [PATCH 17/18] Mention the QUIC transport in the crate docs A reader landing on the crate root had no way to discover the feature-gated quic module. Plain code span rather than an intra-doc link: the module is conditionally compiled, so a link would break the no-feature doc build. --- tls/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tls/src/lib.rs b/tls/src/lib.rs index 245ba29..b1105bc 100644 --- a/tls/src/lib.rs +++ b/tls/src/lib.rs @@ -3,6 +3,10 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! TLS based connections +//! +//! The default transport is TCP ([`Client`] / [`Server`]). With the `quic` +//! cargo feature enabled, the `quic` module provides the same +//! mutually-authenticated, attested channel over QUIC. use camino::Utf8PathBuf; use dice_mfg_msgs::PlatformId; From 45636f2e0036869c74b08472d641cee8c3e203ee Mon Sep 17 00:00:00 2001 From: finch Date: Fri, 17 Jul 2026 17:09:06 -0400 Subject: [PATCH 18/18] Run the QUIC example pair as an integration test The examples are part of the crate's contract, and running them caught a real bug (the server panicking when a client departed), so pin them with an end-to-end integration test: spawn server_quic on [::1]:0, read the announced address, run client_quic twice, and assert each client's stdin comes back complete on stdout and that the server outlives its clients. server_quic now announces its bound address on stdout: with port 0 that is the only way any caller, interactive or test, can learn the OS-assigned port. Cargo provides no CARGO_BIN_EXE_ for examples, so the test locates the binaries relative to the test executable; every wait is bounded so a wedged process fails the suite rather than hanging it. --- tls/examples/server_quic.rs | 5 + tls/tests/quic_examples.rs | 189 ++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tls/tests/quic_examples.rs diff --git a/tls/examples/server_quic.rs b/tls/examples/server_quic.rs index 301bae5..e9c28af 100644 --- a/tls/examples/server_quic.rs +++ b/tls/examples/server_quic.rs @@ -106,6 +106,11 @@ async fn main() { // runtime. let server = Server::new(server_config, listen_addr, log.clone()).unwrap(); + // Announce the actual bound address on stdout (the logs go to stderr): + // with port 0 this is how a caller — interactive or the example-pair + // integration test — learns the OS-assigned port. + println!("listening on {}", server.listen_addr().unwrap()); + loop { let (conn, _) = server .accept(args.corpus.clone()) diff --git a/tls/tests/quic_examples.rs b/tls/tests/quic_examples.rs new file mode 100644 index 0000000..a096bb9 --- /dev/null +++ b/tls/tests/quic_examples.rs @@ -0,0 +1,189 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Runs the QUIC example pair (`server_quic` / `client_quic`) against each +//! other end to end, as separate processes over loopback — the examples are +//! themselves part of the crate's contract, and this pins them working. +//! +//! Cargo builds examples as part of `cargo test`, but provides no +//! `CARGO_BIN_EXE_` for them (that exists only for `[[bin]]` targets), +//! so the binaries are located relative to the test executable: +//! `target//examples/`. The test PKI comes from `OUT_DIR`, where +//! `build.rs` generates it (the `unittest` feature is always enabled for test +//! builds via the crate's self-dev-dependency). + +#![cfg(feature = "quic")] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::SocketAddrV6; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +/// Bound on every wait in this test, so a wedged process fails the test +/// instead of hanging the suite. +const TIMEOUT: Duration = Duration::from_secs(60); + +fn example_bin(name: &str) -> PathBuf { + // The test executable lives in target//deps/; examples are + // siblings of deps/ in target//examples/. + let mut path = std::env::current_exe().unwrap(); + path.pop(); + path.pop(); + path.push("examples"); + path.push(name); + assert!( + path.exists(), + "example binary {path:?} not found; \ + it is built by `cargo test --features quic`" + ); + path +} + +fn pki(file: &str) -> PathBuf { + PathBuf::from(env!("OUT_DIR")).join(file) +} + +/// The five positional paths of the examples' `local` subcommand, for test +/// identity `n`. +fn identity_args(n: usize) -> Vec { + vec![ + pki(&format!("test-sprockets-auth-{n}.key.pem")), + pki(&format!("test-sprockets-auth-{n}.certlist.pem")), + pki(&format!("test-alias-{n}.key.pem")), + pki(&format!("test-alias-{n}.certlist.pem")), + pki("log.bin"), + ] +} + +fn base_cmd(bin: &Path) -> Command { + let mut cmd = Command::new(bin); + cmd.arg("--roots") + .arg(pki("test-root-a.cert.pem")) + .arg("--corpus") + .arg(pki("corim-rot.cbor")) + .arg("--corpus") + .arg(pki("corim-sp.cbor")) + .arg("--enforce"); + cmd +} + +/// Kills the wrapped child on scope exit, so a failing assertion never leaks +/// a listening server process. +struct KillOnDrop(Child); + +impl Drop for KillOnDrop { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn wait_bounded(child: &mut Child, what: &str) -> ExitStatus { + let deadline = Instant::now() + TIMEOUT; + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + assert!( + Instant::now() < deadline, + "{what} did not exit within {TIMEOUT:?}" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// Runs `client_quic` against `addr`, feeding `msg` on stdin, and returns +/// what the client wrote to stdout (the echo; the client logs to stderr). +fn run_client(bin: &Path, addr: &str, msg: &str) -> String { + let mut child = base_cmd(bin) + .arg("--addr") + .arg(addr) + .arg("local") + .args(identity_args(2)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + // Write the message, then drop the handle: the resulting stdin EOF is + // what moves the client to shutdown-and-drain. + child + .stdin + .take() + .unwrap() + .write_all(msg.as_bytes()) + .unwrap(); + + let status = wait_bounded(&mut child, "client_quic"); + assert!(status.success(), "client_quic exited with {status}"); + + // The echo is far smaller than the pipe buffer, so reading after exit + // cannot have blocked the child. + let mut echoed = String::new(); + child + .stdout + .take() + .unwrap() + .read_to_string(&mut echoed) + .unwrap(); + echoed +} + +/// The example pair round-trips messages over an attested QUIC connection: +/// the server started on port 0 announces its real address, each client's +/// stdin comes back complete on its stdout, and the server survives clients +/// departing (it used to panic when a finished client closed its +/// connection). +#[test] +fn example_pair_round_trips() { + let mut server = KillOnDrop( + base_cmd(&example_bin("server_quic")) + .arg("--addr") + .arg("[::1]:0") + .arg("local") + .args(identity_args(1)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + + // The server's first stdout line announces the OS-assigned address. Read + // it via a thread + channel so a silent server trips TIMEOUT rather than + // blocking the test forever; the thread then keeps draining stdout so + // the server can never block on a full pipe. + let stdout = server.0.stdout.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut lines = BufReader::new(stdout).lines(); + if let Some(Ok(line)) = lines.next() { + let _ = tx.send(line); + } + for _ in lines {} + }); + let line = rx + .recv_timeout(TIMEOUT) + .expect("server_quic announced its listen address"); + let addr = line + .strip_prefix("listening on ") + .unwrap_or_else(|| panic!("unexpected announce line: {line:?}")); + let addr: SocketAddrV6 = addr.parse().unwrap(); + let addr = addr.to_string(); + + let client_bin = example_bin("client_quic"); + + const FIRST: &str = "hello over quic\n"; + assert_eq!(run_client(&client_bin, &addr, FIRST), FIRST); + + const SECOND: &str = "second client\n"; + assert_eq!(run_client(&client_bin, &addr, SECOND), SECOND); + + assert!( + server.0.try_wait().unwrap().is_none(), + "server_quic must outlive its clients" + ); +}