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 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..2a8a9ee 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,18 @@ 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"] } + +[[example]] +name = "client_quic" +required-features = ["quic"] + +[[example]] +name = "server_quic" +required-features = ["quic"] 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/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.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"); } diff --git a/tls/examples/server_quic.rs b/tls/examples/server_quic.rs new file mode 100644 index 0000000..e9c28af --- /dev/null +++ b/tls/examples/server_quic.rs @@ -0,0 +1,147 @@ +// 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(); + + // 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()) + .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}"), + } + } +} 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 0b64f1d..eac376c 100644 --- a/tls/src/client.rs +++ b/tls/src/client.rs @@ -10,22 +10,14 @@ use std::sync::Arc; use tokio::net::TcpStream; use tokio_rustls::TlsConnector; -use crate::keys::{get_attest_data, AttestConfig, ResolveSetting}; +use crate::attest; +use crate::config::{load_roots, new_tls_client_config}; +use crate::keys::AttestConfig; use crate::keys::{ CertResolver, MeasurementConnectionPolicy, RotCertVerifier, SprocketsConfig, }; -use crate::{ - certs_from_der, certs_to_der, crypto_provider, load_root_cert, 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::{ @@ -34,11 +26,10 @@ use rustls::{ ResolvesClientCert, }, sign::CertifiedKey, - version::TLS13, ClientConfig, SignatureScheme, }; -use slog::{error, info, warn}; -use x509_cert::{der::Decode, Certificate}; +use slog::{error, info}; +use x509_cert::Certificate; impl ResolvesClientCert for CertResolver { fn resolve( @@ -136,32 +127,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 +143,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,174 +174,20 @@ 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); - }; - - // 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 tq_platform_id = + platform_id_from_tls_certs(conn.peer_certificates())?; + + 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/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 1717eff..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; @@ -20,16 +24,20 @@ 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, DecodePem, Encode, Reader, SliceReader}, + der::{self, Decode, DecodePem}, Certificate, }; +mod attest; pub mod client; +mod config; pub mod ipcc; pub mod keys; +#[cfg(feature = "quic")] +pub mod quic; pub mod server; pub use client::Client; @@ -104,6 +112,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), @@ -129,6 +140,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. @@ -248,69 +277,41 @@ pub fn load_root_cert(path: &Utf8PathBuf) -> Result { Ok(cert) } -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()?); +/// 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(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; - -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 = u32::from_le_bytes(msg_len).try_into()?; - - // 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?) + Ok(PlatformId::try_from(&pki_path)?) } /// 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 /// @@ -347,7 +348,7 @@ mod tests { Utf8PathBuf::from(env!("OUT_DIR")) } - fn local_config( + pub fn local_config( n: usize, enforce: MeasurementConnectionPolicy, ) -> keys::SprocketsConfig { @@ -570,11 +571,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(); @@ -606,6 +613,82 @@ 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 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(); + + 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(); @@ -696,4 +779,79 @@ 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 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(); + + 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(); + } } diff --git a/tls/src/quic.rs b/tls/src/quic.rs new file mode 100644 index 0000000..72a146c --- /dev/null +++ b/tls/src/quic.rs @@ -0,0 +1,725 @@ +// 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 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 | +//! | [`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::{Ipv6Addr, SocketAddr, SocketAddrV6}; +use std::sync::Arc; +use std::time::Duration; +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 +/// 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; + + /// 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); + /// 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, 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 + | 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(_) + | Error::ReferenceMeasurements(_) + | Error::Attest(_) + | Error::AttestData(_) + | Error::NonceError(_) + | Error::PlatformIdPkiPath(_) + | Error::PlatformId(_) + | Error::CorimError(_) + | Error::AttestMock(_) + | Error::AttestIpcc(_) + | Error::RotRequest(_) + | 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) { + 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. +/// +/// 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 { + 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()); + let client_config = new_quic_client_config( + config.resolve.clone(), + roots.clone(), + &log, + transport.clone(), + )?; + + 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 { + 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?; + + match client_handshake( + &connection, + &self.attest_config, + &self.roots, + corpus, + self.enforce, + &self.log, + ) + .await + { + Ok((stream, peer_platform_id, appraisal)) => { + Ok(AttestedConnection::new( + connection, + stream, + peer_platform_id, + appraisal, + )) + } + Err(err) => { + close_for_error(&connection, &err); + Err(err) + } + } + } + + /// 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. retry() + // errors only for an already-validated incoming, which the guard + // above excludes. + 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?; + + match server_handshake( + &connection, + corims, + &attest_config, + &roots, + enforce, + &log, + ) + .await + { + Ok((stream, peer_platform_id, appraisal)) => Ok(( + AttestedConnection::new( + connection, + stream, + peer_platform_id, + appraisal, + ), + addr, + )), + Err(err) => { + close_for_error(&connection, &err); + Err(err) + } + } + } +} + +/// 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 +/// [`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`] +/// 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_dial_only(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 + } +} diff --git a/tls/src/quic/stream.rs b/tls/src/quic/stream.rs new file mode 100644 index 0000000..6b9e1b3 --- /dev/null +++ b/tls/src/quic/stream.rs @@ -0,0 +1,252 @@ +// 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. 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, +} + +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) + } +} diff --git a/tls/src/quic/tests.rs b/tls/src/quic/tests.rs new file mode 100644 index 0000000..0171b6d --- /dev/null +++ b/tls/src/quic/tests.rs @@ -0,0 +1,778 @@ +// 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); +} + +/// 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() { + 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 + ); +} diff --git a/tls/src/server.rs b/tls/src/server.rs index 669bf4a..54efbe2 100644 --- a/tls/src/server.rs +++ b/tls/src/server.rs @@ -4,39 +4,27 @@ //! 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, - ResolveSetting, RotCertVerifier, SprocketsConfig, + AttestConfig, CertResolver, MeasurementConnectionPolicy, RotCertVerifier, + SprocketsConfig, }; -use crate::{ - certs_from_der, certs_to_der, crypto_provider, load_root_cert, 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}, ResolvesServerCert, }, - version::TLS13, 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}; use tokio_rustls::TlsAcceptor; -use x509_cert::{ - der::{Decode, DecodePem}, - Certificate, -}; +use x509_cert::Certificate; impl ResolvesServerCert for CertResolver { fn resolve( @@ -111,183 +99,27 @@ 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?; // 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); - }; - - // 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()); - - 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 tq_platform_id = + platform_id_from_tls_certs(conn.peer_certificates())?; + + 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)) } @@ -367,89 +199,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( 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" + ); +}