diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index b01cdb771..56902c134 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -127,6 +127,9 @@ jobs: - name: Verify telemetry can be compiled out run: mise run rust:verify:telemetry-off + - name: Verify system-ca-roots feature compiles and excludes bundled Mozilla roots + run: mise run rust:verify:system-ca-roots + - name: sccache stats if: always() run: | diff --git a/Cargo.toml b/Cargo.toml index 7081fe97e..a60c9a210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ rustls = { version = "0.23", default-features = false, features = ["std", "loggi rustls-pemfile = "2" rcgen = { version = "0.13", features = ["crypto", "pem"] } webpki-roots = "1" +rustls-native-certs = "0.8" # CLI clap = { version = "4.5", features = ["derive", "env"] } @@ -85,7 +86,7 @@ aws-config = { version = "1", default-features = false, features = ["rustls", "r aws-sdk-sts = { version = "1", default-features = false, features = ["rustls", "rt-tokio", "behavior-version-latest"] } # WebSocket -tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } +tokio-tungstenite = { version = "0.26", default-features = false, features = ["connect", "rustls-tls-native-roots"] } # Clipboard (OSC 52) base64 = "0.22" @@ -112,10 +113,10 @@ protobuf-src = "1.1.0" url = "2" # Database -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "sqlite", "migrate"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "sqlite", "migrate", "macros"] } # Kubernetes -kube = { version = "0.90", features = ["runtime", "derive"] } +kube = { version = "0.90", default-features = false, features = ["client", "runtime", "derive", "rustls-tls"] } kube-runtime = "0.90" k8s-openapi = { version = "0.21.1", features = ["v1_26"] } @@ -123,7 +124,7 @@ k8s-openapi = { version = "0.21.1", features = ["v1_26"] } uuid = { version = "1.10", features = ["v4"] } # SMT solver (uses system libz3; enable z3/bundled via the prover's bundled-z3 feature for local dev without system z3) -z3 = "0.19" +z3 = "0.20" [workspace.lints.rust] unsafe_code = "warn" diff --git a/architecture/build.md b/architecture/build.md index 63d4e5684..594050ce2 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -39,6 +39,21 @@ are no-ops, so the data-model types stay available and dependent crates compile unchanged. The runtime `OPENSHELL_TELEMETRY_ENABLED` switch remains the way to disable telemetry in a default (telemetry-enabled) build. +Supervisor upstream TLS root-store selection is controlled by mutually +exclusive Cargo features. Default builds enable `bundled-ca-roots`, which +preserves upstream behavior by using Mozilla roots through `webpki-roots` plus +locally-installed CAs from the system bundle. Linux distribution builds should +use `--no-default-features --features system-ca-roots` so supervisor upstream +TLS uses the native trust store and the workspace dependency graph excludes +bundled Mozilla root crates such as `webpki-roots` and `webpki-root-certs`. +Other Rustls clients use native roots directly because that already satisfies +Linux distribution trust-store policy. + +The workspace uses `z3` versions whose `z3-sys` dependency keeps downloader +HTTP/TLS support behind explicit build features, so default system-Z3 builds do +not reintroduce bundled Mozilla roots. Release builds that need bundled Z3 +continue to opt in with `bundled-z3`. + ## Linux Runtime Environments OpenShell uses different Linux libc environments for different host artifacts. diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 1cbfd7d47..497763831 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -45,7 +45,7 @@ bytes = { workspace = true } http-body-util = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } -hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring", "webpki-tokio"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring"] } rustls = { workspace = true } rustls-pemfile = { workspace = true } tokio-rustls = { workspace = true } @@ -62,7 +62,7 @@ tar = "0.4" tempfile = "3" # OIDC/Auth -oauth2 = "5" +oauth2 = { version = "5", default-features = false, features = ["reqwest"] } base64 = { workspace = true } # WebSocket (Cloudflare tunnel proxy) diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 53735a6bb..4c7375c57 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -26,7 +26,7 @@ url = { workspace = true } ipnet = "2" base64 = { workspace = true } chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true } -reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots"], optional = true } +reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"], optional = true } [features] default = ["telemetry"] diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index f9c23cf60..afa55b925 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -18,7 +18,7 @@ path = "src/main.rs" openshell-core = { path = "../openshell-core", default-features = false } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } -openshell-supervisor-network = { path = "../openshell-supervisor-network" } +openshell-supervisor-network = { path = "../openshell-supervisor-network", default-features = false } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } openshell-supervisor-process = { path = "../openshell-supervisor-process" } @@ -53,11 +53,13 @@ tracing-subscriber = { workspace = true } tracing-appender = { workspace = true } [features] -default = ["telemetry"] +default = ["telemetry", "bundled-ca-roots"] ## Compile in telemetry activity collection (forwards to openshell-core/telemetry). ## On by default; build with `--no-default-features` for a telemetry-free sandbox ## supervisor that never collects or forwards activity summaries. telemetry = ["openshell-core/telemetry"] +bundled-ca-roots = ["openshell-supervisor-network/bundled-ca-roots"] +system-ca-roots = ["openshell-supervisor-network/system-ca-roots"] [dev-dependencies] openshell-core = { path = "../openshell-core", features = ["test-helpers"] } diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 4c4f289ed..7626d4300 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -113,7 +113,7 @@ bundled-z3 = ["openshell-prover/bundled-z3"] test-support = [] [dev-dependencies] -hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "ring", "webpki-tokio"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "ring"] } rcgen = { version = "0.13", features = ["crypto", "pem"] } tokio-tungstenite = { workspace = true } futures-util = "0.3" diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 1449276f4..43351766b 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -34,6 +34,7 @@ rcgen = { workspace = true } regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob"] } reqwest = { workspace = true } rustls = { workspace = true } +rustls-native-certs = { workspace = true, optional = true } rustls-pemfile = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -47,7 +48,12 @@ tokio-rustls = { workspace = true } tower-mcp-types = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } -webpki-roots = { workspace = true } +webpki-roots = { workspace = true, optional = true } + +[features] +default = ["bundled-ca-roots"] +bundled-ca-roots = ["dep:webpki-roots"] +system-ca-roots = ["dep:rustls-native-certs"] [dev-dependencies] openshell-core = { path = "../openshell-core", features = ["test-helpers"] } diff --git a/crates/openshell-supervisor-network/src/l7/tls.rs b/crates/openshell-supervisor-network/src/l7/tls.rs index 70e198f42..942e796e1 100644 --- a/crates/openshell-supervisor-network/src/l7/tls.rs +++ b/crates/openshell-supervisor-network/src/l7/tls.rs @@ -8,7 +8,7 @@ //! store, terminates TLS from the client (presenting dynamic certs per hostname), //! inspects the plaintext HTTP, then re-encrypts to upstream using real root CAs. -use miette::{IntoDiagnostic, Result}; +use miette::{IntoDiagnostic, Result, miette}; use rcgen::{CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; use rustls::{ClientConfig, ServerConfig}; @@ -20,6 +20,12 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio_rustls::{TlsAcceptor, TlsConnector}; +#[cfg(all(feature = "bundled-ca-roots", feature = "system-ca-roots"))] +compile_error!("features bundled-ca-roots and system-ca-roots are mutually exclusive"); + +#[cfg(not(any(feature = "bundled-ca-roots", feature = "system-ca-roots")))] +compile_error!("one of bundled-ca-roots or system-ca-roots must be enabled"); + const MAX_CACHED_CERTS: usize = 256; /// System CA bundle search paths (common Linux locations). @@ -180,7 +186,7 @@ pub async fn tls_terminate_client( Ok(tls_stream) } -/// Connect TLS to an upstream server, verifying against webpki-roots. +/// Connect TLS to an upstream server, verifying against the configured CA roots. /// /// Returns a TLS stream for re-encrypted upstream communication. pub async fn tls_connect_upstream( @@ -197,34 +203,75 @@ pub async fn tls_connect_upstream( Ok(tls_stream) } -/// Build a rustls `ClientConfig` with Mozilla + system root CAs for upstream connections. +/// Build a rustls `ClientConfig` using the configured CA root source. /// -/// `system_ca_bundle` is the pre-read PEM contents of the system CA bundle -/// (from [`read_system_ca_bundle`]). Pass the same string to [`write_ca_files`] -/// to avoid reading the bundle from disk twice. -pub fn build_upstream_client_config(system_ca_bundle: &str) -> Arc { +/// In `bundled-ca-roots` mode this uses Mozilla roots from `webpki-roots` overlaid +/// with any locally-installed CAs from `system_ca_bundle` (e.g. corporate or private +/// CAs added to `/etc/pki/ca-trust`). Duplicates with the Mozilla bundle are harmless. +/// +/// In `system-ca-roots` mode this uses the platform/native trust store exclusively; +/// `system_ca_bundle` is ignored because the native store already reflects all +/// operator-installed trust anchors. +pub fn build_upstream_client_config(system_ca_bundle: &str) -> Result> { + let mut config = ClientConfig::builder() + .with_root_certificates(build_upstream_root_store(system_ca_bundle)?) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + Ok(Arc::new(config)) +} + +fn build_upstream_root_store(system_ca_bundle: &str) -> Result { let mut root_store = rustls::RootCertStore::empty(); - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - // System bundles typically overlap with webpki-roots (Mozilla roots); - // duplicates are harmless and ensure we also pick up any custom/corporate CAs. - let (added, ignored) = load_pem_certs_into_store(&mut root_store, system_ca_bundle); - if added > 0 { - tracing::debug!(added, "Loaded system CA certificates for upstream TLS"); + #[cfg(feature = "bundled-ca-roots")] + { + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + // Overlay system/corporate CAs so custom trust anchors are honoured in + // default upstream builds. Duplicates with webpki-roots are harmless. + let (added, ignored) = load_pem_certs_into_store(&mut root_store, system_ca_bundle); + if added > 0 { + tracing::debug!(added, "loaded system CA certificates for upstream TLS"); + } + if ignored > 0 { + tracing::warn!( + ignored, + "some system CA certificates could not be parsed and were ignored" + ); + } } + + #[cfg(feature = "system-ca-roots")] + { + let _ = system_ca_bundle; // native store already includes operator-installed CAs + add_native_roots(&mut root_store)?; + } + + if root_store.is_empty() { + return Err(miette!("no TLS root certificates available")); + } + + Ok(root_store) +} + +#[cfg(feature = "system-ca-roots")] +fn add_native_roots(root_store: &mut rustls::RootCertStore) -> Result<()> { + let native_certs = rustls_native_certs::load_native_certs(); + let cert_count = native_certs.certs.len(); + let (added, ignored) = root_store.add_parsable_certificates(native_certs.certs); + let ignored = ignored + native_certs.errors.len(); + if ignored > 0 { - tracing::warn!( - ignored, - "Some system CA certificates could not be parsed and were ignored" - ); + tracing::debug!(ignored, "ignored unparsable native root certificates"); } - let mut config = ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; + if added == 0 { + return Err(miette!( + "no usable native TLS root certificates found ({cert_count} loaded, {ignored} ignored)" + )); + } - Arc::new(config) + Ok(()) } /// Write CA certificate files for the sandbox trust store. @@ -234,8 +281,7 @@ pub fn build_upstream_client_config(system_ca_bundle: &str) -> Arc /// 2. Combined bundle: system CAs + sandbox CA (for `SSL_CERT_FILE` which replaces default) /// /// `system_ca_bundle` is the pre-read PEM contents of the system CA bundle -/// (from [`read_system_ca_bundle`]). Pass the same string to -/// [`build_upstream_client_config`] to avoid reading the bundle from disk twice. +/// (from [`read_system_ca_bundle`]). /// /// Returns `(ca_cert_path, combined_bundle_path)`. pub fn write_ca_files( @@ -266,6 +312,7 @@ pub fn write_ca_files( /// Returns `(added, ignored)` counts. Invalid or unparseable certificates /// are silently ignored, matching the behavior of /// `RootCertStore::add_parsable_certificates`. +#[cfg_attr(feature = "system-ca-roots", allow(dead_code))] fn load_pem_certs_into_store( root_store: &mut rustls::RootCertStore, pem_data: &str, @@ -289,7 +336,7 @@ fn load_pem_certs_into_store( /// /// Returns the PEM contents of the first non-empty bundle found, or an empty /// string if none of the well-known paths exist. Call once and pass the result -/// to both [`write_ca_files`] and [`build_upstream_client_config`]. +/// to [`write_ca_files`]. pub fn read_system_ca_bundle() -> String { for path in SYSTEM_CA_PATHS { if let Ok(contents) = std::fs::read_to_string(path) @@ -299,7 +346,6 @@ pub fn read_system_ca_bundle() -> String { } } // No system bundle found — combined file will contain only the sandbox CA. - // This is acceptable since the proxy uses webpki-roots independently. String::new() } @@ -426,7 +472,7 @@ mod tests { #[test] fn upstream_config_alpn() { let _ = rustls::crypto::ring::default_provider().install_default(); - let config = build_upstream_client_config(""); + let config = build_upstream_client_config("").unwrap(); assert_eq!(config.alpn_protocols, vec![b"http/1.1".to_vec()]); } diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index d68db6f1c..006f034ae 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -211,7 +211,7 @@ pub async fn run_networking( // path injected by enrich_*_baseline_paths(), so no // explicit Landlock entry is needed here. - let upstream_config = build_upstream_client_config(&system_ca_bundle); + let upstream_config = build_upstream_client_config(&system_ca_bundle)?; let cert_cache = CertCache::new(ca); let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config)); ocsf_emit!( diff --git a/tasks/rust.toml b/tasks/rust.toml index f035193fd..7195a3b9a 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -42,6 +42,17 @@ run = [ # Guard: telemetry-free builds must contain no telemetry markers. "cargo build -p openshell-server --bin openshell-gateway --no-default-features", "tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-gateway", - "cargo build -p openshell-sandbox --bin openshell-sandbox --no-default-features", + "cargo build -p openshell-sandbox --bin openshell-sandbox --no-default-features --features bundled-ca-roots", "tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-sandbox", ] + +["rust:verify:system-ca-roots"] +description = "Verify system-ca-roots feature compiles and excludes bundled Mozilla root crates" +run = [ + # Check that the workspace, including test targets, compiles cleanly under system-ca-roots. + "cargo check --workspace --all-targets --no-default-features --features system-ca-roots", + # Guard: webpki-roots must not appear in the production dependency graph. + "bash -c 'if cargo tree -i webpki-roots --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-roots; then echo \"ERROR: webpki-roots found in system-ca-roots production build\" >&2; exit 1; fi'", + # Guard: webpki-root-certs must not appear either (webpki-roots re-exports it). + "bash -c 'if cargo tree -i webpki-root-certs --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-root-certs; then echo \"ERROR: webpki-root-certs found in system-ca-roots production build\" >&2; exit 1; fi'", +]