diff --git a/.github/workflows/test-rust-core.yml b/.github/workflows/test-rust-core.yml index ded243baaf..f60cd98aa0 100644 --- a/.github/workflows/test-rust-core.yml +++ b/.github/workflows/test-rust-core.yml @@ -134,7 +134,7 @@ jobs: shared-key: "no-protobuf" - name: Build sf_core without protobuf feature - run: cargo build --locked --package sf_core --no-default-features --features fips + run: cargo build --locked --package sf_core --no-default-features --features fips-tls # Compile-test the library's unit tests (tests inside sf_core/src/*) to catch # modules that use protobuf types without a `#[cfg(feature = "protobuf")]` guard. @@ -144,8 +144,17 @@ jobs: # tracked separately; this check still catches the more impactful class of bugs # (accidentally exposing protobuf-dependent code in the library's public surface). # --no-run skips execution so this lane stays fast and doesn't need network access. + # + # Which also means the `fips-tls` assertions in tls::fips_tests are compiled + # here but never executed: this step and the build above are the only places + # the feature is turned on, and neither runs a test. The assertions exist -- + # `cargo test --features fips-tls --lib` runs them locally -- but no CI lane + # executes them, so a regression in provider installation or + # `ClientConfig::fips()` would not be caught here. The lanes that will run + # them are added with the FIPS release pipelines, which need the pinned + # GCC 13 toolchain `aws-lc-fips-sys` requires. - name: Compile library unit tests without protobuf feature - run: cargo test --locked --no-run --lib --package sf_core --no-default-features --features fips + run: cargo test --locked --no-run --lib --package sf_core --no-default-features --features fips-tls - name: Clean up python_bridge artifacts from target if: always() @@ -392,12 +401,17 @@ jobs: # library machine type 'x64' conflicts with target machine type 'ARM64' # Evidence points to aws-lc-fips-sys Windows build env setup: # builder/printenv.bat calls vcvarsall.bat x64, overriding ARM64 toolchain env. - # Workaround: do not enable fips on this lane. + # Workaround: do not enable fips-tls on this lane. # Upstream issue: https://github.com/aws/aws-lc-rs/issues/1057 # Repro job: https://github.com/snowflakedb/drivers/actions/runs/22779459689/job/66081199710 # TODO: re-enable --all-features after fix. - # The test suite is identical with and without fips — no tests are gated - # behind cfg(feature = "fips"). Only the linked TLS crypto backend differs + # Coverage note: the only tests gated behind cfg(feature = "fips-tls") are the + # tls::fips_tests assertions, which verify FIPS-ness end to end: the linked + # aws-lc module reports FIPS mode, and the installed rustls provider / + # ClientConfig are FIPS (approved cipher suites only). All of that is + # meaningless on this non-FIPS lane by construction, so + # skipping them here loses no coverage. Every other test runs identically with + # and without fips-tls; only the linked TLS crypto backend differs # (aws-lc-sys vs aws-lc-fips-sys). test_windows_arm64_nonfips: needs: load-core-matrix diff --git a/Cargo.lock b/Cargo.lock index 09535fd263..05c931e5e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1588,9 +1588,13 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.8" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] [[package]] name = "data-encoding" @@ -2138,11 +2142,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasi 0.14.7+wasi-0.2.4", - "wasm-bindgen", ] [[package]] @@ -2995,6 +2997,18 @@ dependencies = [ "libc", ] +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -3041,12 +3055,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -4288,61 +4296,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls 0.23.32", - "socket2 0.6.0", - "thiserror 2.0.16", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.3", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls 0.23.32", - "rustls-pki-types", - "slab", - "thiserror 2.0.16", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.0", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -4565,7 +4518,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls 0.23.32", "rustls-native-certs", "rustls-pki-types", @@ -4724,7 +4676,6 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki 0.103.6", "subtle", @@ -4758,7 +4709,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ - "web-time", "zeroize", ] @@ -4772,17 +4722,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - [[package]] name = "rustls-webpki" version = "0.103.6" @@ -5020,6 +4959,7 @@ dependencies = [ "clap", "const-oid", "criterion", + "ctor", "der 0.7.10", "der-parser", "dirs", @@ -5062,7 +5002,6 @@ dependencies = [ "rustls 0.23.32", "rustls-native-certs", "rustls-pemfile", - "rustls-webpki 0.102.8", "scopeguard", "serde", "serde_json", diff --git a/jdbc_bridge/Cargo.toml b/jdbc_bridge/Cargo.toml index 068080c401..657b4b7f45 100644 --- a/jdbc_bridge/Cargo.toml +++ b/jdbc_bridge/Cargo.toml @@ -10,6 +10,10 @@ crate-type = ["cdylib"] [features] # Static OpenSSL for the release fat-jar libs (dev/test builds link system OpenSSL). vendored-openssl = ["sf_core/vendored-openssl"] +# Links the FIPS-validated TLS backend (aws-lc-fips-sys) instead of aws-lc-sys. +# Covers TLS only, which is what the name says -- see the `fips-tls` feature +# comment in sf_core/Cargo.toml for why this is not a FIPS artifact on its own. +fips-tls = ["sf_core/fips-tls"] [dependencies] sf_core = { path = "../sf_core" } diff --git a/nodejs_bridge/Cargo.toml b/nodejs_bridge/Cargo.toml index 0ed9d8eaf3..1d30741814 100644 --- a/nodejs_bridge/Cargo.toml +++ b/nodejs_bridge/Cargo.toml @@ -8,6 +8,12 @@ publish = false name = "nodejs_bridge" crate-type = ["cdylib"] +[features] +# Links the FIPS-validated TLS backend (aws-lc-fips-sys) instead of aws-lc-sys. +# Covers TLS only, which is what the name says -- see the `fips-tls` feature +# comment in sf_core/Cargo.toml for why this is not a FIPS artifact on its own. +fips-tls = ["sf_core/fips-tls"] + [dependencies] sf_core = { path = "../sf_core" } sf_types = { path = "../sf_types" } diff --git a/odbc/Cargo.toml b/odbc/Cargo.toml index 4c42ea6dce..95e4316190 100644 --- a/odbc/Cargo.toml +++ b/odbc/Cargo.toml @@ -47,6 +47,10 @@ default = ["sonic-json"] sonic-json = ["sf_core/sonic-json"] perf_timing = ["sf_core/perf_timing"] vendored-openssl = ["sf_core/vendored-openssl"] +# Links the FIPS-validated TLS backend (aws-lc-fips-sys) instead of aws-lc-sys. +# Covers TLS only, which is what the name says -- see the `fips-tls` feature +# comment in sf_core/Cargo.toml for why this is not a FIPS artifact on its own. +fips-tls = ["sf_core/fips-tls"] # Exposes `bench_support` (doc-hidden) so the `conversion` criterion bench can # reach the otherwise-private fetch-conversion pipeline. Off by default; not # part of the public API. diff --git a/python/Cargo.lock.sdist b/python/Cargo.lock.sdist index 7d5719b639..a8f9c631e1 100644 --- a/python/Cargo.lock.sdist +++ b/python/Cargo.lock.sdist @@ -1543,6 +1543,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -2075,11 +2085,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasi 0.14.7+wasi-0.2.4", - "wasm-bindgen", ] [[package]] @@ -2867,6 +2875,18 @@ dependencies = [ "libc", ] +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -2913,12 +2933,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -4022,61 +4036,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls 0.23.32", - "socket2 0.6.0", - "thiserror 2.0.16", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.3", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls 0.23.32", - "rustls-pki-types", - "slab", - "thiserror 2.0.16", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.0", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -4299,7 +4258,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls 0.23.32", "rustls-native-certs", "rustls-pki-types", @@ -4458,7 +4416,6 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki 0.103.6", "subtle", @@ -4492,7 +4449,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ - "web-time", "zeroize", ] @@ -4506,17 +4462,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - [[package]] name = "rustls-webpki" version = "0.103.6" @@ -4732,6 +4677,7 @@ dependencies = [ "clap", "const-oid", "criterion", + "ctor", "der 0.7.10", "der-parser", "dirs", @@ -4774,7 +4720,6 @@ dependencies = [ "rustls 0.23.32", "rustls-native-certs", "rustls-pemfile", - "rustls-webpki 0.102.8", "scopeguard", "serde", "serde_json", diff --git a/python_bridge/Cargo.toml b/python_bridge/Cargo.toml index 13021dd572..d8dfe5e785 100644 --- a/python_bridge/Cargo.toml +++ b/python_bridge/Cargo.toml @@ -12,6 +12,10 @@ default = ["extension-module"] extension-module = ["pyo3/extension-module"] vendored-openssl = ["sf_core/vendored-openssl"] native-arrow = ["dep:arrow", "dep:snafu", "dep:error_trace", "dep:sf_types", "dep:chrono", "pyo3/chrono"] +# Links the FIPS-validated TLS backend (aws-lc-fips-sys) instead of aws-lc-sys. +# Covers TLS only, which is what the name says -- see the `fips-tls` feature +# comment in sf_core/Cargo.toml for why this is not a FIPS artifact on its own. +fips-tls = ["sf_core/fips-tls"] [dependencies] arrow = { version = ">=54.0.0, <59", features = ["ffi"], optional = true } diff --git a/sf_core/Cargo.toml b/sf_core/Cargo.toml index f7e83e7655..426572dcb8 100644 --- a/sf_core/Cargo.toml +++ b/sf_core/Cargo.toml @@ -11,7 +11,20 @@ crate-type = ["cdylib", "rlib"] [features] default = ["protobuf", "sonic-json"] sonic-json = ["sonic-rs"] -fips = ["rustls/fips"] +# Swaps the TLS backend to aws-lc-fips-sys. `aws-lc-rs/fips` is named explicitly +# rather than left to cargo feature unification: `tls::x509_utils` calls +# `aws_lc_rs::signature::*` directly for CRL verification, and those calls must +# run against the FIPS module by declaration, not by accident of the resolver +# happening to unify rustls's transitive activation into the same crate. +# +# Named `fips-tls` rather than `fips` because it covers TLS only. JWT signing, +# stage file encryption, DPoP and key parsing still run on OpenSSL, which has no +# FIPS-validated provider -- and under `vendored-openssl` that unvalidated copy +# is statically linked where a customer cannot replace it. An artifact built +# from this feature is therefore not a FIPS artifact, and the narrower name is +# what keeps the two apart: `fips` is left unclaimed for the complete thing, +# once the non-TLS crypto is ported off OpenSSL. +fips-tls = ["rustls/fips", "aws-lc-rs/fips"] protobuf = ["prost", "proto_utils"] vendored-openssl = ["openssl/vendored"] auth_mfa_e2e = [] @@ -37,11 +50,23 @@ bytes = "1" # rustls is the driver's only TLS backend. `default-features = false` drops # reqwest's `default-tls` (native-tls/OpenSSL/SChannel) so it is not compiled in # at all and rustls becomes the default backend uniformly (the CRL path and the -# storage clients already use rustls). `rustls-tls-native-roots` keeps the OS -# trust store as the default root source, matching the prior native-tls -# behaviour. `charset`/`http2`/`macos-system-configuration` are re-added from -# reqwest's default set (only `default-tls` is intentionally dropped). -reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots", "gzip", "stream", "http2", "charset", "macos-system-configuration"] } +# storage clients already use rustls). The root store is bundled webpki roots +# plus the OS trust store: `rustls-tls-webpki-roots-no-provider` + +# `rustls-tls-native-roots-no-provider` is the same composition the previous +# `rustls-tls` (an alias for `rustls-tls-webpki-roots`) + `rustls-tls-native-roots` +# selection produced. `charset`/`http2`/`macos-system-configuration` are +# re-added from reqwest's default set (only `default-tls` is intentionally +# dropped). +# +# The `-no-provider` variants are deliberate: plain `rustls-tls` / +# `rustls-tls-native-roots` pull reqwest's `__rustls-ring`, which links `ring` +# and -- more importantly -- makes reqwest silently fall back to +# `rustls::crypto::ring::default_provider()` for any client built before a +# process default is installed. `ring` is not a FIPS-validated module, so that +# fallback would quietly defeat `--features fips-tls`. Without it reqwest instead +# panics ("No provider set"), which is why every client construction path calls +# `tls::ensure_crypto_provider()` first. +reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls-no-provider", "rustls-tls-native-roots-no-provider", "rustls-tls-webpki-roots-no-provider", "gzip", "stream", "http2", "charset", "macos-system-configuration"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = { version = "1.0.143", features = ["raw_value"] } futures = "0.3" @@ -59,7 +84,13 @@ arrow-ipc = ">=54.0.0, <59" flate2 = "1.1.2" openssl = "0.10.73" jwt = { version = "0.16.0", features = ["openssl"] } -oauth2 = { version = "5", features = ["timing-resistant-secret-traits"] } +# `default-features = false` drops oauth2's `rustls-tls`, which is only a +# forwarder to `reqwest/rustls-tls` -- and that pulls `__rustls-ring`, which via +# cargo feature unification would re-arm reqwest's silent ring fallback for the +# whole build and defeat the `-no-provider` selection above. The `reqwest` +# feature is kept; oauth2 still gets a TLS-capable reqwest because sf_core +# selects reqwest's rustls features directly. +oauth2 = { version = "5", default-features = false, features = ["reqwest", "timing-resistant-secret-traits"] } axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "query"] } webbrowser = "1" http = "1" @@ -99,7 +130,6 @@ glob = "0.3.3" # CRL and TLS dependencies rustls = { version = "0.23.20", features = ["aws_lc_rs"] } rustls-pemfile = "2.2.0" -rustls-webpki = "0.102" rustls-native-certs = "0.8" thiserror = "1.0.69" aws-lc-rs = "1.13.2" @@ -149,7 +179,12 @@ flate2 = "1.1.2" test-case = "3.3.1" # Dev/test reqwest: also rustls-only so test builds don't re-introduce # native-tls (feature unification would otherwise pull `default-tls` back in). -reqwest = { version = "0.12.23", default-features = false, features = ["blocking", "json", "rustls-tls", "rustls-tls-native-roots", "http2", "charset"] } +# Mirrors the `-no-provider` selection above for the same reason: feature +# unification means a `rustls-tls` here would re-arm the silent ring fallback +# for the lib under test too. Consequence: raw `reqwest::Client`s panic with +# "No provider set" unless a provider is installed first, which is why the +# test harnesses install one in a ctor (see below). +reqwest = { version = "0.12.23", default-features = false, features = ["blocking", "json", "rustls-tls-no-provider", "rustls-tls-native-roots-no-provider", "rustls-tls-webpki-roots-no-provider", "http2", "charset"] } wiremock = "0.6" rcgen = "0.13" tokio-rustls = "0.26" @@ -157,6 +192,23 @@ tracing-test = { version = "0.2", features = ["no-env-filter"] } criterion = { version = "0.5", features = ["async_tokio"] } temp-env = { version = "0.3.6", features = ["async_closure"] } scopeguard = "1.2" +# These two entries serve the test-harness provider installs: with the +# `-no-provider` reqwest features above, any test binary that builds raw +# `reqwest::Client`s (local plain-HTTP test servers, telemetry fakes) must +# install a crypto provider itself before the first client is constructed. +# `ctor` is test-only; `rustls` is also a production dependency above -- this +# dev entry just lets test code name it directly. The initializers live in +# src/lib.rs (`#[cfg(test)]`, for the lib unit-test binary) and +# tests/common/crypto_provider.rs, which integration_tests picks up through +# tests/common/mod.rs and logging_query_tests includes directly. +# +# Ordering note for the `fips-tls` lane: aws-lc-fips runs its power-on self-test +# from its own library constructor, and these initializers add a second one. +# Ordering between translation units is unspecified, so the two are only safe +# together because `install_default` just stores an `Arc` and touches no +# primitive. If that lane ever aborts without explanation, look here first. +rustls = "0.23" +ctor = "1" [target.'cfg(not(target_os = "windows"))'.dev-dependencies] pprof = { version = "0.14", features = ["flamegraph", "criterion"] } diff --git a/sf_core/src/apis/database_driver_v1/connection.rs b/sf_core/src/apis/database_driver_v1/connection.rs index 872b8baa1d..c40fda5cd9 100644 --- a/sf_core/src/apis/database_driver_v1/connection.rs +++ b/sf_core/src/apis/database_driver_v1/connection.rs @@ -495,6 +495,7 @@ impl DatabaseDriverV1 { &retry_policy, prebuilt_credentials, xp_backend.as_deref(), + self.crl_worker.clone(), ); let login_result = if let Some(budget) = timeout_config.login_timeout { diff --git a/sf_core/src/apis/database_driver_v1/global_state.rs b/sf_core/src/apis/database_driver_v1/global_state.rs index 9e88798124..f72264ae68 100644 --- a/sf_core/src/apis/database_driver_v1/global_state.rs +++ b/sf_core/src/apis/database_driver_v1/global_state.rs @@ -238,6 +238,12 @@ impl DatabaseDriverV1 { } pub fn with_providers(providers: DriverProviders) -> Self { + // Pin the rustls crypto backend before anything can build an HTTP + // client, so every connection this driver makes -- including telemetry + // and CRL fetches that run ahead of the first query -- shares one + // provider. See `tls::ensure_crypto_provider`. + crate::tls::ensure_crypto_provider(); + let xp_slot = Arc::new(match providers.running_inside_xp { Some(inside) => XpSlot::new(inside, providers.xp_backend), None => XpSlot::from_env(providers.xp_backend), diff --git a/sf_core/src/crl/cache.rs b/sf_core/src/crl/cache.rs index 7a1c0558be..99e47a0d28 100644 --- a/sf_core/src/crl/cache.rs +++ b/sf_core/src/crl/cache.rs @@ -811,6 +811,9 @@ impl CrlCache { } pub fn new(config: CrlConfig) -> Result { + // CRL fetching can be the first HTTP the process does; pin the crypto + // provider before reqwest resolves one at build time. + crate::tls::ensure_crypto_provider(); let memory_cache = if config.enable_memory_caching { Some(Arc::new(Mutex::new(HashMap::new()))) } else { @@ -860,6 +863,9 @@ impl CrlCache { outcome_cache: None, url_locks: Arc::new(Mutex::new(HashMap::new())), backoff: Arc::new(Mutex::new(HashMap::new())), + // `CrlCache::new` installs the crypto provider + // as its first statement, so both attempts + // above ran it before they could fail. http_client: reqwest::Client::new(), scheduler_tx: OnceCell::new(), metrics: CrlMetrics::init(&global::meter("sf_core.crl")), diff --git a/sf_core/src/file_manager/cloud_http.rs b/sf_core/src/file_manager/cloud_http.rs index af40dbb041..84882762de 100644 --- a/sf_core/src/file_manager/cloud_http.rs +++ b/sf_core/src/file_manager/cloud_http.rs @@ -92,11 +92,19 @@ impl StorageHttp { Ok(match stage_info.location_type { LocationType::Azure => Self::Azure(build_azure_client(stage_info)?), LocationType::Gcs => Self::Gcs(build_gcs_client(stage_info)?), - LocationType::S3 => Self::S3(crate::tls::aws_http_client::build_s3_reqwest_client( - &stage_info.tls_config, - Some(&stage_info.proxy_config), - stage_info.crl_worker.clone(), - )?), + // The sole producer of an `S3` slot, and so the sole reason + // `AwsSdkReqwestClient::from_shared` is sound -- see the doc on + // `into_shared`. Keep it that way: a second producer that skips + // this constructor would put an unconstrained client on the SDK + // transport without the type system objecting. + LocationType::S3 => Self::S3( + crate::tls::aws_http_client::AwsSdkReqwestClient::build( + &stage_info.tls_config, + Some(&stage_info.proxy_config), + stage_info.crl_worker.clone(), + )? + .into_shared(), + ), }) } diff --git a/sf_core/src/file_manager/s3_transfer.rs b/sf_core/src/file_manager/s3_transfer.rs index 6179a10ff4..2504ac45d1 100644 --- a/sf_core/src/file_manager/s3_transfer.rs +++ b/sf_core/src/file_manager/s3_transfer.rs @@ -1787,11 +1787,12 @@ async fn create_s3_client( // through one shared implementation. Fails the build on a bad custom root // store or CRL verifier, matching Azure/GCS. let http_client = cloud_http::shared_or_build_client(shared, || { - crate::tls::aws_http_client::build_s3_reqwest_client( + crate::tls::aws_http_client::AwsSdkReqwestClient::build( &stage_info.tls_config, Some(&stage_info.proxy_config), stage_info.crl_worker.clone(), ) + .map(crate::tls::aws_http_client::AwsSdkReqwestClient::into_shared) }) .map_err(CreateS3ClientError::HttpClient)?; @@ -1801,7 +1802,7 @@ async fn create_s3_client( .retry_config(to_aws_retry_config(policy)) .timeout_config(to_aws_timeout_config(policy)) .http_client(crate::tls::aws_http_client::reqwest_aws_http_client( - http_client, + crate::tls::aws_http_client::AwsSdkReqwestClient::from_shared(http_client), )); let config = loader.load().await; diff --git a/sf_core/src/lib.rs b/sf_core/src/lib.rs index 7d2f9ed1d7..f90999bf74 100644 --- a/sf_core/src/lib.rs +++ b/sf_core/src/lib.rs @@ -34,3 +34,16 @@ pub mod utils; #[cfg(feature = "protobuf")] pub mod protobuf; + +// Unit tests across this crate build ad-hoc `reqwest::Client`s (wiremock +// servers, telemetry fakes) without going through the driver's TLS factories. +// The dev reqwest dependency uses the `-no-provider` rustls features (see +// Cargo.toml), so a client built before any provider install panics with +// "No provider set"; install the process default up front so tests are not +// order-dependent. Production builds have no such initializer -- they rely on +// `tls::ensure_crypto_provider()` at the client-construction chokepoints. +#[cfg(test)] +#[ctor::ctor(unsafe)] +fn install_default_crypto_provider_for_tests() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); +} diff --git a/sf_core/src/protobuf/apis/database_driver_v1/converter.rs b/sf_core/src/protobuf/apis/database_driver_v1/converter.rs index 9211f1fa50..76b82f4ee6 100644 --- a/sf_core/src/protobuf/apis/database_driver_v1/converter.rs +++ b/sf_core/src/protobuf/apis/database_driver_v1/converter.rs @@ -1508,7 +1508,7 @@ mod tests { oidc_token: None, }; let client = reqwest::Client::new(); - let source = workload_identity::create_attestation(&client, &config) + let source = workload_identity::create_attestation(&client, None, &config) .await .expect_err("OIDC provider with no token must fail"); diff --git a/sf_core/src/protobuf/apis/database_driver_v1/mod.rs b/sf_core/src/protobuf/apis/database_driver_v1/mod.rs index 2d6ad1356e..9fcce9a9ae 100644 --- a/sf_core/src/protobuf/apis/database_driver_v1/mod.rs +++ b/sf_core/src/protobuf/apis/database_driver_v1/mod.rs @@ -1272,13 +1272,21 @@ impl DatabaseDriver for DatabaseDriverImpl { // `ProxyConfig` from. Closing that gap needs new proxy-config // plumbing independent of any connection handle. Tracked under // SNOW-2912540. + // No `ensure_crypto_provider()` needed here: this is a method on + // `DatabaseDriverImpl`, which only exists via `new`/`new_with` -> + // `DatabaseDriverV1::with_providers`, and that installs the provider as + // its first statement (global_state.rs) -- same invariant as the CRL + // cache fallback. `create_attestation` additionally re-pins the + // provider and applies the fail-closed FIPS gate at its entry, so this + // plain client cannot carry attestation traffic on a non-FIPS provider + // in a `fips-tls` build. let client = reqwest::Client::new(); // This client has no request timeout, so for the AWS/Azure/GCP providers // — which each await a cloud metadata or IdP endpoint — `operation_ctx` is the only // thing that can end the call short of the endpoint answering. The OIDC // provider makes no request and so has nothing to observe. let create = async { - workload_identity::create_attestation(&client, &config) + workload_identity::create_attestation(&client, None, &config) .await .context(WorkloadIdentityAttestationSnafu) }; diff --git a/sf_core/src/rest/snowflake/mod.rs b/sf_core/src/rest/snowflake/mod.rs index 0661dd5137..9badc9cb05 100644 --- a/sf_core/src/rest/snowflake/mod.rs +++ b/sf_core/src/rest/snowflake/mod.rs @@ -766,6 +766,12 @@ async fn credentials_for_login( create_credentials(login_parameters).await } +// Upstream's `prebuilt_credentials` and this change's `crl_worker` land on the +// same signature, pushing it to 8. Matches the existing allow on +// `snowflake_login_with_client`, which is over the limit for the same reason: +// these are login plumbing, and bundling the arguments into a struct would move +// the churn rather than remove it. +#[allow(clippy::too_many_arguments)] pub async fn auth_request_data( client: &reqwest::Client, login_parameters: &LoginParameters, @@ -774,6 +780,7 @@ pub async fn auth_request_data( prompt_locks: Option<&std::sync::Arc>, retry_policy: &RetryPolicy, prebuilt_credentials: Option, + crl_worker: SharedCrlWorker, ) -> Result { let mut data = base_auth_request_data(login_parameters); data.spcs_token = login_parameters.spcs_token.clone(); @@ -909,9 +916,33 @@ pub async fn auth_request_data( // fetching cloud credentials. See workload_identity::host_allowlist. workload_identity::ensure_allowed_host(&login_parameters.server_url) .context(WorkloadIdentityAttestationSnafu)?; - let attestation = workload_identity::create_attestation(client, cfg) - .await - .context(WorkloadIdentityAttestationSnafu)?; + // AWS STS calls need the SDK-constrained transport (see + // `tls::aws_http_client`), which is a builder-level property the + // already-built `client` cannot provide -- so build a second client + // from the same connection TLS/proxy inputs, but only for the AWS + // provider: the other providers never touch it, and should neither + // pay for nor be able to fail on its construction. The connection's + // shared CRL worker is threaded in so a CRL-enabled login reuses + // the existing worker thread instead of spawning its own. + let aws_sdk_http = if matches!( + cfg.provider, + crate::config::rest_parameters::WifProvider::Aws + ) { + Some( + crate::tls::aws_http_client::AwsSdkReqwestClient::build( + &login_parameters.client_info.tls_config, + Some(&login_parameters.client_info.proxy_config), + crl_worker, + ) + .context(CrlValidationSnafu)?, + ) + } else { + None + }; + let attestation = + workload_identity::create_attestation(client, aws_sdk_http.as_ref(), cfg) + .await + .context(WorkloadIdentityAttestationSnafu)?; data.authenticator = Some(authenticator::WORKLOAD_IDENTITY.to_string()); data.provider = Some(attestation.provider.to_string()); data.token = Some(attestation.token); @@ -1123,7 +1154,7 @@ pub async fn snowflake_login( session_parameters: Option<&HashMap>, crl_worker: SharedCrlWorker, ) -> Result { - let client = build_tls_http_client(&login_parameters.client_info, crl_worker)?; + let client = build_tls_http_client(&login_parameters.client_info, crl_worker.clone())?; let policy = RetryPolicy::default(); snowflake_login_with_client( &client, @@ -1134,6 +1165,7 @@ pub async fn snowflake_login( &policy, None, None, + crl_worker, ) .await } @@ -1146,7 +1178,8 @@ pub async fn snowflake_login( token_cache, retry_policy, prebuilt_credentials, - xp_backend + xp_backend, + crl_worker ), fields(account_name, login_name) )] @@ -1160,6 +1193,7 @@ pub async fn snowflake_login_with_client( retry_policy: &RetryPolicy, prebuilt_credentials: Option, xp_backend: Option<&dyn crate::xp_backend::SnowflakeBackend>, + crl_worker: SharedCrlWorker, ) -> Result { tracing::info!("Starting Snowflake login process"); @@ -1298,6 +1332,7 @@ pub async fn snowflake_login_with_client( prompt_locks, retry_policy, prebuilt_credentials, + crl_worker.clone(), ) .await?; tracing::Span::current().record("login_name", &login_request_data.login_name); @@ -1358,6 +1393,7 @@ pub async fn snowflake_login_with_client( prompt_locks, retry_policy, None, + crl_worker.clone(), ) .await?; let retry_request = AuthRequest { data: retry_data }; @@ -1415,6 +1451,7 @@ pub async fn snowflake_login_with_client( prompt_locks, retry_policy, None, + crl_worker.clone(), ) .await?; let retry_request = AuthRequest { data: retry_data }; @@ -3344,6 +3381,7 @@ mod tests { &RetryPolicy::default(), None, Some(&backend), + crate::crl::worker::CrlWorker::shared_lazy(), ) .await; @@ -3853,6 +3891,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -3887,6 +3926,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -3987,6 +4027,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -4015,6 +4056,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -4041,6 +4083,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -4072,6 +4115,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -4113,6 +4157,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); assert_eq!( @@ -4137,6 +4182,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -4160,6 +4206,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), )) .unwrap(); @@ -4258,6 +4305,7 @@ mod tests { &RetryPolicy::default(), None, None, + crate::crl::worker::CrlWorker::shared_lazy(), ) .await } @@ -5097,6 +5145,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), ) .await .expect_err("disallowed WIF host must fail closed"); @@ -5131,6 +5180,7 @@ mod tests { None, &RetryPolicy::default(), None, + crate::crl::worker::CrlWorker::shared_lazy(), ) .await .expect_err("missing OIDC token must fail"); diff --git a/sf_core/src/rest/snowflake/workload_identity/aws.rs b/sf_core/src/rest/snowflake/workload_identity/aws.rs index e651389707..5a30f1ab31 100644 --- a/sf_core/src/rest/snowflake/workload_identity/aws.rs +++ b/sf_core/src/rest/snowflake/workload_identity/aws.rs @@ -33,6 +33,7 @@ use snafu::{Location, OptionExt, ResultExt, Snafu}; use std::collections::BTreeMap; use crate::config::rest_parameters::WorkloadIdentityConfig; +use crate::tls::aws_http_client::AwsSdkReqwestClient; use super::AttestationEndpoints; @@ -54,6 +55,12 @@ pub enum AwsAttestationError { #[snafu(implicit)] location: Location, }, + #[snafu(display("Failed to build the AWS SDK HTTP client"))] + SdkHttpClient { + source: crate::tls::error::TlsError, + #[snafu(implicit)] + location: Location, + }, #[snafu(display("Failed to load AWS credentials"))] CredentialsLoad { source: Box, @@ -110,14 +117,14 @@ pub enum AwsAttestationError { /// `workload_identity_aws_use_outbound_token=true` or /// `SNOWFLAKE_ENABLE_AWS_WIF_OUTBOUND_TOKEN=true`. pub(super) async fn get_attestation_token( - client: &reqwest::Client, + sdk_http: &AwsSdkReqwestClient, config: &WorkloadIdentityConfig, endpoints: &AttestationEndpoints, ) -> Result { if enable_outbound_token(config) { - get_web_identity_token(client, config, endpoints).await + get_web_identity_token(sdk_http, config, endpoints).await } else { - get_caller_identity_token(config, endpoints).await + get_caller_identity_token(sdk_http, config, endpoints).await } } @@ -138,11 +145,12 @@ fn enable_outbound_token(config: &WorkloadIdentityConfig) -> bool { /// Build a pre-signed STS `GetCallerIdentity` request and return it /// base64-encoded as `{"url":…,"method":"POST","headers":{…}}`. async fn get_caller_identity_token( + sdk_http: &AwsSdkReqwestClient, config: &WorkloadIdentityConfig, endpoints: &AttestationEndpoints, ) -> Result { let region = resolve_region(endpoints).await; - let credentials = resolve_credentials(config, ®ion).await?; + let credentials = resolve_credentials(sdk_http, config, ®ion).await?; let now = chrono::Utc::now(); let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); @@ -241,22 +249,19 @@ fn build_signed_caller_identity_request( /// /// Only called when `SNOWFLAKE_ENABLE_AWS_WIF_OUTBOUND_TOKEN=true`. async fn get_web_identity_token( - _client: &reqwest::Client, + sdk_http: &AwsSdkReqwestClient, config: &WorkloadIdentityConfig, endpoints: &AttestationEndpoints, ) -> Result { let region = resolve_region(endpoints).await; - let sdk_config = aws_config::defaults(BehaviorVersion::latest()) - .region(Region::new(region.clone())) - .load() - .await; + let sdk_config = sdk_config_with_shared_transport(sdk_http, ®ion, None).await; let sts_client = StsClient::new(&sdk_config); let credentials = if config.impersonation_path.is_empty() { None } else { - Some(chain_assume_role(®ion, &config.impersonation_path).await?) + Some(chain_assume_role(sdk_http, ®ion, &config.impersonation_path).await?) }; let final_sts_client = if let Some(creds) = credentials { @@ -306,17 +311,45 @@ fn sts_sdk_error_status(err: &aws_sdk_sts::error::SdkError) -> Option } } +/// Builds an AWS SDK config whose HTTP client is the driver's shared reqwest +/// transport, the same adapter S3 transfers use. +/// +/// Without this the AWS SDK falls back to `aws-smithy-http-client`'s own +/// bundled TLS stack, which honours none of the connection's `TlsConfig` -- +/// no protocol-version window, no CRL revocation checking, no custom root +/// store -- and none of its proxy settings. Routing STS through the shared +/// transport keeps WIF on one implementation of the connection's TLS policy +/// and keeps the crypto backend consistent with the rest of the driver. +/// +/// Takes [`AwsSdkReqwestClient`] rather than a bare `reqwest::Client` because +/// these are SigV4-signed SDK calls: the transport must not follow redirects, +/// auto-decompress, or negotiate HTTP/2 (see `tls::aws_http_client`), and the +/// newtype is what guarantees a general-purpose client cannot end up here. +async fn sdk_config_with_shared_transport( + sdk_http: &AwsSdkReqwestClient, + region: &str, + credentials: Option<&Credentials>, +) -> aws_config::SdkConfig { + let mut loader = aws_config::defaults(BehaviorVersion::latest()) + .region(Region::new(region.to_string())) + .http_client(crate::tls::aws_http_client::reqwest_aws_http_client( + sdk_http.clone(), + )); + if let Some(creds) = credentials { + loader = loader.credentials_provider(SharedCredentialsProvider::new(creds.clone())); + } + loader.load().await +} + /// Resolve final credentials: load ambient creds and optionally walk an /// impersonation chain via `sts:AssumeRole`. async fn resolve_credentials( + sdk_http: &AwsSdkReqwestClient, config: &WorkloadIdentityConfig, region: &str, ) -> Result { if config.impersonation_path.is_empty() { - let sdk_config = aws_config::defaults(BehaviorVersion::latest()) - .region(Region::new(region.to_string())) - .load() - .await; + let sdk_config = sdk_config_with_shared_transport(sdk_http, region, None).await; let provider = sdk_config .credentials_provider() .context(NoCredentialsProviderSnafu)?; @@ -326,7 +359,7 @@ async fn resolve_credentials( .boxed() .context(CredentialsLoadSnafu) } else { - chain_assume_role(region, &config.impersonation_path).await + chain_assume_role(sdk_http, region, &config.impersonation_path).await } } @@ -348,6 +381,7 @@ trait AssumeRoleProvider: Send + Sync { /// Production [`AssumeRoleProvider`]: issues a real `sts:AssumeRole` call /// via `aws_sdk_sts::Client`. struct StsAssumeRoleProvider { + sdk_http: AwsSdkReqwestClient, region: String, } @@ -358,12 +392,8 @@ impl AssumeRoleProvider for StsAssumeRoleProvider { credentials: Option<&'a Credentials>, ) -> BoxFuture<'a, Result> { async move { - let mut loader = aws_config::defaults(BehaviorVersion::latest()) - .region(Region::new(self.region.clone())); - if let Some(creds) = credentials { - loader = loader.credentials_provider(SharedCredentialsProvider::new(creds.clone())); - } - let sdk_config = loader.load().await; + let sdk_config = + sdk_config_with_shared_transport(&self.sdk_http, &self.region, credentials).await; let client = StsClient::new(&sdk_config); let session_name = format!("snowflake-wif-{}", std::process::id()); @@ -410,10 +440,12 @@ impl AssumeRoleProvider for StsAssumeRoleProvider { /// Walk an impersonation chain via `sts:AssumeRole`, returning the /// credentials obtained after assuming all roles in `region`. async fn chain_assume_role( + sdk_http: &AwsSdkReqwestClient, region: &str, role_arns: &[String], ) -> Result { let provider = StsAssumeRoleProvider { + sdk_http: sdk_http.clone(), region: region.to_string(), }; chain_assume_role_via(&provider, role_arns).await @@ -460,6 +492,7 @@ async fn resolve_region(endpoints: &AttestationEndpoints) -> String { } async fn try_imds_region(imds_base_url: &str) -> Option { + crate::tls::ensure_crypto_provider(); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() @@ -779,7 +812,9 @@ mod tests { ("AWS_EC2_METADATA_DISABLED", Some("true")), ], async { - let err = resolve_credentials(&config, "us-east-1") + let sdk_http = AwsSdkReqwestClient::with_default_tls() + .expect("default-TLS SDK client must build"); + let err = resolve_credentials(&sdk_http, &config, "us-east-1") .await .expect_err("expected no ambient AWS credentials to be found"); assert!( diff --git a/sf_core/src/rest/snowflake/workload_identity/mod.rs b/sf_core/src/rest/snowflake/workload_identity/mod.rs index a69a4cc919..aaa59d97c1 100644 --- a/sf_core/src/rest/snowflake/workload_identity/mod.rs +++ b/sf_core/src/rest/snowflake/workload_identity/mod.rs @@ -13,6 +13,7 @@ mod oidc; use crate::config::rest_parameters::{WifProvider, WorkloadIdentityConfig}; use crate::sensitive::SensitiveString; +use crate::tls::aws_http_client::AwsSdkReqwestClient; use host_allowlist::is_snowflake_host_for_workload_identity; use snafu::{Location, ResultExt, Snafu}; @@ -73,6 +74,20 @@ pub enum AttestationError { #[snafu(implicit)] location: Location, }, + /// Raised before any provider is dispatched, in `fips-tls` builds whose + /// process-global rustls provider is not FIPS. The AWS, Azure and GCP + /// providers exchange authentication material over the caller-supplied + /// clients, so the same fail-closed gate the TLS factories apply belongs + /// here too -- notably for the `wif_create_attestation` RPC, whose plain + /// client is not built through those factories. OIDC reads a token it was + /// already given and so cannot reach this, but the gate runs before the + /// dispatch that would tell them apart. + #[snafu(display("Refusing Workload Identity attestation"))] + CryptoProvider { + source: crate::tls::error::TlsError, + #[snafu(implicit)] + location: Location, + }, } /// Verifies that `server_url` names a Snowflake host before any ambient @@ -143,14 +158,42 @@ impl Default for AttestationEndpoints { /// /// Dispatches to the provider-specific module and returns the raw token /// together with the provider label expected by GS. -pub async fn create_attestation( +/// +/// `aws_sdk_http` backs the AWS provider's STS calls (which need the +/// SDK-constrained transport, see `tls::aws_http_client`). Pass a +/// connection-scoped client so those calls honour the connection's TLS +/// policy; `None` builds a default-TLS one, for callers with no connection +/// context (the `wif_create_attestation` RPC). The other providers only use +/// `client`. +pub(crate) async fn create_attestation( client: &reqwest::Client, + aws_sdk_http: Option<&AwsSdkReqwestClient>, config: &WorkloadIdentityConfig, ) -> Result { + // The AWS, Azure and GCP providers exchange authentication material over + // the supplied clients (OIDC makes no request). Pinning and gating the + // crypto backend at this single entry point, before the dispatch that + // distinguishes them, gives a caller-built plain client (the + // `wif_create_attestation` RPC) the same fail-closed FIPS behaviour as + // clients built by the TLS factories, which run both calls in + // `configure_tls_builder`. Redundant for the login path -- both calls are + // `Once`-cheap and idempotent. + crate::tls::ensure_crypto_provider(); + crate::tls::require_fips_provider().context(CryptoProviderSnafu)?; let endpoints = AttestationEndpoints::default(); match config.provider { WifProvider::Aws => { - let token = aws::get_attestation_token(client, config, &endpoints) + let default_sdk_http; + let sdk_http = match aws_sdk_http { + Some(sdk_http) => sdk_http, + None => { + default_sdk_http = AwsSdkReqwestClient::with_default_tls() + .context(aws::SdkHttpClientSnafu) + .context(AwsAttestationSnafu)?; + &default_sdk_http + } + }; + let token = aws::get_attestation_token(sdk_http, config, &endpoints) .await .context(AwsAttestationSnafu)?; Ok(Attestation { diff --git a/sf_core/src/telemetry/platform_detection/aws.rs b/sf_core/src/telemetry/platform_detection/aws.rs index 66a480863f..e4094d5a9c 100644 --- a/sf_core/src/telemetry/platform_detection/aws.rs +++ b/sf_core/src/telemetry/platform_detection/aws.rs @@ -16,7 +16,31 @@ pub(crate) struct StsCallerIdentityProvider; impl CallerIdentityProvider for StsCallerIdentityProvider { fn caller_identity_arn(&self) -> BoxFuture<'_, Option> { async move { - let config = aws_config::defaults(BehaviorVersion::latest()).load().await; + // Route the SDK through the driver's reqwest transport rather than + // aws-smithy-http-client's bundled TLS stack, for the same reason + // as `workload_identity::aws::sdk_config_with_shared_transport`: + // the bundled stack resolves its own crypto backend at runtime. + // Built here rather than sharing the detector's general client + // because SDK calls need the SDK-constrained transport (no + // redirects/gzip/HTTP2 -- see `tls::aws_http_client`); default TLS + // matches the plain client platform detection already uses. + let sdk_http = + match crate::tls::aws_http_client::AwsSdkReqwestClient::with_default_tls() { + Ok(sdk_http) => sdk_http, + Err(err) => { + tracing::debug!( + error = ?err, + "failed to build STS HTTP client; treating has_aws_identity as false", + ); + return None; + } + }; + let config = aws_config::defaults(BehaviorVersion::latest()) + .http_client(crate::tls::aws_http_client::reqwest_aws_http_client( + sdk_http, + )) + .load() + .await; let client = aws_sdk_sts::Client::new(&config); match client.get_caller_identity().send().await { Ok(response) => response.arn, diff --git a/sf_core/src/telemetry/platform_detection/mod.rs b/sf_core/src/telemetry/platform_detection/mod.rs index 75986d2719..3bec78cd55 100644 --- a/sf_core/src/telemetry/platform_detection/mod.rs +++ b/sf_core/src/telemetry/platform_detection/mod.rs @@ -61,6 +61,10 @@ pub async fn detect_platforms(config: &DetectionConfig) -> Vec { return vec!["disabled".to_string()]; } + // Platform probes can run before any connection is opened, so this may be + // the first HTTP client in the process. reqwest picks its crypto backend at + // build time, so pin the provider first. + crate::tls::ensure_crypto_provider(); let http = reqwest::Client::new(); let detectors: Vec<(&'static str, BoxFuture<'_, bool>)> = vec![ diff --git a/sf_core/src/tls/aws_http_client.rs b/sf_core/src/tls/aws_http_client.rs index d628002316..356f7d7961 100644 --- a/sf_core/src/tls/aws_http_client.rs +++ b/sf_core/src/tls/aws_http_client.rs @@ -4,17 +4,40 @@ //! trust store — no min/max protocol-version knob, no CRL hook, no custom root //! store — so it cannot honour the connection's full [`TlsConfig`]. This adapter //! instead hands the AWS SDK an [`HttpClient`] over the same `reqwest::Client` -//! Azure and GCS transfers build via [`configure_storage_client_builder`], so S3 -//! inherits one implementation of the connection's TLS policy (version window, -//! CRL, custom root store), proxy handling (`proxy_host`/`proxy_port`/`no_proxy`/ -//! `use_proxy_env`, HTTPS CONNECT-tunnelling, `HTTP_PROXY`/`HTTPS_PROXY` fallback), -//! and gzip disabled so response bodies arrive exactly as they were on the wire. +//! stack that Azure and GCS transfers build via [`configure_tls_builder`], so +//! every AWS SDK consumer that *has* a connection — S3 transfers and the WIF +//! STS calls made during login — inherits one implementation of that +//! connection's TLS policy (version window, CRL, custom root store) and proxy +//! handling (`proxy_host`/`proxy_port`/`no_proxy`/`use_proxy_env`, HTTPS +//! CONNECT-tunnelling, `HTTP_PROXY`/`HTTPS_PROXY` fallback). +//! +//! The two consumers with no connection to inherit from — platform detection's +//! STS probe and the `wif_create_attestation` RPC — go through +//! `AwsSdkReqwestClient::with_default_tls` instead, which has no `TlsConfig`, +//! no CRL worker and no explicit `ProxyConfig` (only the `HTTP_PROXY`-style +//! env vars reqwest detects on its own). They still share the SDK-owned +//! transport adjustments below; they just have no connection policy to apply. +//! +//! It stops one step short of [`configure_storage_client_builder`](crate::tls::client::configure_storage_client_builder), the +//! Azure/GCS entry point, which is that function plus `.no_gzip()`. Gzip has to +//! be off here too — the driver treats downloaded bytes as opaque, so digest, +//! Content-Length and ranged-offset math all assume wire bytes are body bytes +//! (SNOW-4073008) — but [`AwsSdkReqwestClient::with_default_tls`] has no +//! connection `TlsConfig` and so cannot route through the storage builder at +//! all. Applying it alongside the other two adjustments keeps one place +//! responsible for all three, on both constructors. //! //! Two further `reqwest` defaults are adjusted for the SDK: //! - redirect following — the SDK owns signing and retries, and a SigV4-signed //! request cannot be redirected without re-signing; //! - HTTP version — pinned to HTTP/1.1 to match the AWS SDK's default connector //! rather than negotiating HTTP/2 via the enabled `http2` feature. +//! +//! Those adjustments are builder-level, so they cannot be applied to an +//! already-built general-purpose client. [`AwsSdkReqwestClient`] makes that +//! constraint structural: it is the only type [`reqwest_aws_http_client`] +//! accepts, and its constructors are the only way to obtain one, so a client +//! with reqwest's general defaults cannot back the SDK adapter. use aws_smithy_runtime_api::client::http::{ HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector, @@ -26,43 +49,116 @@ use aws_smithy_types::body::SdkBody; use snafu::ResultExt; use crate::crl::worker::SharedCrlWorker; -use crate::tls::client::configure_storage_client_builder; +use crate::tls::client::configure_tls_builder; use crate::tls::config::{ProxyConfig, TlsConfig}; use crate::tls::error::{ClientBuildSnafu, TlsError}; -/// Builds the `reqwest::Client` that backs the S3 [`HttpClient`] adapter. -/// -/// Delegates to [`configure_storage_client_builder`] — the exact TLS + proxy + -/// `.no_gzip()` path Azure and GCS transfers use — so S3 gets identical -/// `TlsConfig`/`ProxyConfig` handling and wire-byte bodies, then chains -/// `.redirect(Policy::none())` because a SigV4-signed request cannot be -/// followed without re-signing. `.http1_only()` matches the Azure/GCS storage -/// builders and the AWS SDK's default connector (the crate compiles reqwest -/// with `http2`). No request-level `.timeout()` is set: the SDK's -/// `TimeoutConfig` (`operation_attempt_timeout`/`operation_timeout`) governs -/// S3 request timing. -/// -/// Connection-pool tuning is deliberately left at `reqwest`'s defaults (no -/// `pool_idle_timeout`/`pool_max_idle_per_host`/`tcp_keepalive`), matching -/// Azure/GCS on this shared entry point; only the GS/REST client tunes the pool -/// (via `configure_http_client`). -pub(crate) fn build_s3_reqwest_client( - tls_config: &TlsConfig, - proxy: Option<&ProxyConfig>, - crl_worker: SharedCrlWorker, -) -> Result { - configure_storage_client_builder(reqwest::Client::builder(), tls_config, proxy, crl_worker)? - .redirect(reqwest::redirect::Policy::none()) - .http1_only() - .build() - .context(ClientBuildSnafu) +/// A `reqwest::Client` carrying the three SDK-owned transport adjustments +/// (no redirect following, no gzip auto-decompression, HTTP/1.1 only — see the +/// module docs for why each matters). Constructing one is the only way to back +/// [`reqwest_aws_http_client`], which is what keeps a general-purpose client — +/// whose builder-level defaults cannot be un-done after `build()` — out of the +/// AWS SDK transport. +#[derive(Clone, Debug)] +pub(crate) struct AwsSdkReqwestClient(reqwest::Client); + +impl AwsSdkReqwestClient { + /// Builds the SDK-backing client for a connection context. + /// + /// Delegates to [`configure_tls_builder`] — the TLS + proxy core that the + /// Azure and GCS transfers also reach, through the `.no_gzip()` wrapper + /// [`configure_storage_client_builder`](crate::tls::client::configure_storage_client_builder) — so the SDK gets identical + /// `TlsConfig`/`ProxyConfig` handling, then applies the SDK adjustments + /// (gzip among them, see the module docs). + /// No request-level `.timeout()` is set: the SDK's `TimeoutConfig` + /// (`operation_attempt_timeout`/`operation_timeout`) governs request + /// timing. + /// + /// Connection-pool tuning is deliberately left at `reqwest`'s defaults (no + /// `pool_idle_timeout`/`pool_max_idle_per_host`/`tcp_keepalive`), matching + /// Azure/GCS on this shared entry point; only the GS/REST client tunes the + /// pool (via `configure_http_client`). + pub(crate) fn build( + tls_config: &TlsConfig, + proxy: Option<&ProxyConfig>, + crl_worker: SharedCrlWorker, + ) -> Result { + Self::finish(configure_tls_builder( + reqwest::Client::builder(), + tls_config, + proxy, + crl_worker, + )?) + } + + /// Builds an SDK-backing client with reqwest's default TLS (no connection + /// `TlsConfig` to honour), for contexts that have no connection: the + /// `wif_create_attestation` RPC (no `conn_handle`, see SNOW-2912540) and + /// platform detection's STS probe. Proxy env vars + /// (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`) are still auto-detected, same + /// as a plain `reqwest::Client::new()`. + /// + /// Installs the process crypto provider itself (this can be the first + /// client the process builds) and applies the same fail-closed FIPS gate + /// as [`configure_tls_builder`]. + pub(crate) fn with_default_tls() -> Result { + crate::tls::ensure_crypto_provider(); + crate::tls::require_fips_provider()?; + Self::finish(reqwest::Client::builder()) + } + + fn finish(builder: reqwest::ClientBuilder) -> Result { + builder + .redirect(reqwest::redirect::Policy::none()) + .no_gzip() + .http1_only() + .build() + .context(ClientBuildSnafu) + .map(Self) + } + + /// Unwraps the client so tests can probe it directly (TLS version window, + /// ALPN pinning). One-directional escape hatch: a constrained client used + /// generally is harmless, while the reverse — a general client backing the + /// SDK — is what the newtype exists to prevent. Test-gated because no + /// production path unwraps. + #[cfg(test)] + pub(crate) fn into_inner(self) -> reqwest::Client { + self.0 + } + + /// Unwraps the client for storage in a batch-scoped + /// [`StorageHttp`](crate::file_manager::cloud_http::StorageHttp) slot, and + /// re-wraps it on the way back out. + /// + /// These exist as a pair because a PUT/GET batch shares one client across + /// every file in the command, and it is carried there by `TransferCtx` as a + /// cloud-agnostic `&reqwest::Client` — one field serving S3, GCS and Azure + /// alike. That field is where the newtype is necessarily erased. + /// + /// So for the shared path the construction guarantee rests on there being + /// exactly one producer of a `StorageHttp::S3` slot — `StorageHttp::for_stage`, + /// which builds through [`build`](Self::build) — rather than on the type. + /// That is weaker than the rest of the module, and deliberately narrow: + /// `from_shared` is not a general `From`, and it must not + /// become one. Anything reaching the SDK by any other route still has to go + /// through a real constructor. + pub(crate) fn into_shared(self) -> reqwest::Client { + self.0 + } + + /// See [`into_shared`](Self::into_shared) — re-wraps a client that a + /// `StorageHttp::S3` slot already built through [`build`](Self::build). + pub(crate) fn from_shared(client: reqwest::Client) -> Self { + Self(client) + } } -/// Wraps a `reqwest::Client` in an [`HttpClient`] the AWS SDK can consume via -/// `aws_config::defaults(...).http_client(...)`. -pub(crate) fn reqwest_aws_http_client(client: reqwest::Client) -> impl HttpClient + 'static { +/// Wraps an [`AwsSdkReqwestClient`] in an [`HttpClient`] the AWS SDK can +/// consume via `aws_config::defaults(...).http_client(...)`. +pub(crate) fn reqwest_aws_http_client(client: AwsSdkReqwestClient) -> impl HttpClient + 'static { ReqwestHttpClient { - connector: SharedHttpConnector::new(ReqwestConnector { client }), + connector: SharedHttpConnector::new(ReqwestConnector { client: client.0 }), } } @@ -141,8 +237,9 @@ mod tests { use tokio::net::TcpListener; fn default_client() -> reqwest::Client { - build_s3_reqwest_client(&TlsConfig::default(), None, CrlWorker::new_lazy()) - .expect("default S3 reqwest client must build") + AwsSdkReqwestClient::build(&TlsConfig::default(), None, CrlWorker::new_lazy()) + .expect("default SDK reqwest client must build") + .into_inner() } /// Serves exactly one HTTP/1.1 request on a fresh loopback port, replying @@ -204,7 +301,7 @@ mod tests { }, ..TlsConfig::default() }; - build_s3_reqwest_client(&cfg, None, CrlWorker::new_lazy()) + AwsSdkReqwestClient::build(&cfg, None, CrlWorker::new_lazy()) .expect("TLS 1.3-only window must build"); } @@ -217,7 +314,7 @@ mod tests { }, ..TlsConfig::default() }; - build_s3_reqwest_client(&cfg, None, CrlWorker::new_lazy()) + AwsSdkReqwestClient::build(&cfg, None, CrlWorker::new_lazy()) .expect("TLS 1.2-only window must build"); } @@ -228,7 +325,7 @@ mod tests { port: Some(3128), ..Default::default() }; - build_s3_reqwest_client(&TlsConfig::default(), Some(&proxy), CrlWorker::new_lazy()) + AwsSdkReqwestClient::build(&TlsConfig::default(), Some(&proxy), CrlWorker::new_lazy()) .expect("explicit-proxy client must build"); } @@ -369,7 +466,7 @@ mod tests { // A TLS 1.3-only floor against a server that only offers TLS 1.2 must // fail the handshake. - let narrow_client = build_s3_reqwest_client( + let narrow_client = AwsSdkReqwestClient::build( &TlsConfig { custom_root_store_path: Some(cert_file.path().to_path_buf()), versions: TlsVersions { @@ -381,7 +478,8 @@ mod tests { None, CrlWorker::new_lazy(), ) - .expect("client must build"); + .expect("client must build") + .into_inner(); let resp = narrow_client.get(&url).send().await; assert!( resp.is_err(), @@ -392,7 +490,7 @@ mod tests { // default window (which includes TLS 1.2), must succeed — proving the // rejection above is specifically the version floor, not a cert or // connectivity problem. - let permissive_client = build_s3_reqwest_client( + let permissive_client = AwsSdkReqwestClient::build( &TlsConfig { custom_root_store_path: Some(cert_file.path().to_path_buf()), ..TlsConfig::default() @@ -400,7 +498,8 @@ mod tests { None, CrlWorker::new_lazy(), ) - .expect("client must build"); + .expect("client must build") + .into_inner(); let resp = permissive_client.get(&url).send().await; assert!( resp.is_ok(), @@ -470,7 +569,7 @@ mod tests { (addr, cert_pem, alpn_rx) } - /// `build_s3_reqwest_client` pins the client to HTTP/1.1 (`.http1_only()`) so + /// `AwsSdkReqwestClient` pins the client to HTTP/1.1 (`.http1_only()`) so /// it never negotiates HTTP/2 with S3, even though the `http2` feature is /// compiled in. Against a TLS server offering both `h2` (preferred) and /// `http/1.1`, a client that still advertised `h2` would negotiate it — so @@ -484,7 +583,7 @@ mod tests { cert_file.write_all(cert_pem.as_bytes()).expect("write pem"); cert_file.flush().expect("flush"); - let client = build_s3_reqwest_client( + let client = AwsSdkReqwestClient::build( &TlsConfig { custom_root_store_path: Some(cert_file.path().to_path_buf()), ..TlsConfig::default() @@ -492,7 +591,8 @@ mod tests { None, CrlWorker::new_lazy(), ) - .expect("client must build"); + .expect("client must build") + .into_inner(); let url = format!("https://127.0.0.1:{}/", addr.port()); let response = diff --git a/sf_core/src/tls/client.rs b/sf_core/src/tls/client.rs index 0c02ee37c2..905d116eaa 100644 --- a/sf_core/src/tls/client.rs +++ b/sf_core/src/tls/client.rs @@ -58,6 +58,18 @@ pub(crate) fn build_tls_client_and_rustls_config( connect_timeout: Option, need_diag_config: bool, ) -> Result<(Client, Option>), TlsError> { + // Must precede every `Client::build()` below, including the insecure + // early-return: reqwest resolves its crypto backend at build time, and + // with the `-no-provider` feature selection it has no fallback to resolve + // to, so a client built before the provider is installed panics with + // "No provider set". + super::ensure_crypto_provider(); + // Fail closed rather than serve traffic on a non-approved module: in + // `fips-tls` builds this refuses to build a client when the provider that + // won the process-global slot is not FIPS. Compiles away without the + // feature. + super::require_fips_provider()?; + if !tls_config.verify_certificates { tracing::warn!("Creating insecure TLS client - certificate verification disabled"); let builder = apply_reqwest_tls_versions( @@ -74,7 +86,6 @@ pub(crate) fn build_tls_client_and_rustls_config( return Ok((client, need_diag_config.then(build_insecure_rustls_config))); } - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let protocol_versions = tls_config.versions.enabled_rustls_versions(); let root_certificates = load_root_certificates(tls_config)?; @@ -190,6 +201,11 @@ pub(crate) fn configure_tls_builder( proxy: Option<&ProxyConfig>, crl_worker: SharedCrlWorker, ) -> Result { + // Same ordering constraint as `build_tls_client_and_rustls_config`: the + // returned builder is `.build()`-ed by the caller, so the provider has to + // be in place before this function hands the builder back. + super::ensure_crypto_provider(); + super::require_fips_provider()?; let builder = apply_proxy_to_builder(builder, proxy)?; if !tls_config.verify_certificates { tracing::warn!("Creating insecure TLS client - certificate verification disabled"); @@ -198,8 +214,6 @@ pub(crate) fn configure_tls_builder( .danger_accept_invalid_hostnames(true)); } - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let root_certificates = load_root_certificates(tls_config)?; match tls_config.crl_config.check_mode { @@ -227,8 +241,8 @@ pub(crate) fn configure_tls_builder( } } -/// [`configure_tls_builder`] plus `.no_gzip()`, for the storage clients -/// (Azure, GCS, S3) that move opaque, possibly CSE-encrypted bytes whose +/// [`configure_tls_builder`] plus `.no_gzip()`, for the Azure and GCS +/// transfers that move opaque, possibly CSE-encrypted bytes whose /// downstream SHA-256 digest / Content-Length / ranged-download checks /// assume wire bytes == body bytes. Without it, a response carrying /// `Content-Encoding: gzip` (e.g. from `gsutil cp -Z`, BigQuery exports, or @@ -240,9 +254,11 @@ pub(crate) fn configure_tls_builder( /// (`storage_client.py:54-59`). /// /// The GS/REST client still wants gzip, so this can't be folded into -/// `configure_tls_builder` itself. S3 then chains `.redirect(Policy::none())` -/// in [`crate::tls::aws_http_client::build_s3_reqwest_client`]. All three -/// storage clients pin `.http1_only()` on their own builders. +/// `configure_tls_builder` itself. S3 does not come through here: it reaches +/// the AWS SDK through [`AwsSdkReqwestClient`](crate::tls::aws_http_client::AwsSdkReqwestClient), +/// which calls `configure_tls_builder` directly and then applies `.no_gzip()` +/// alongside the two SDK-only adjustments (`.redirect(Policy::none())`, +/// `.http1_only()`) that cannot be set on an already-built client. pub(crate) fn configure_storage_client_builder( builder: ClientBuilder, tls_config: &TlsConfig, @@ -313,7 +329,7 @@ fn build_plain_rustls_client_config( /// false-negative TLS failures in environments with custom or self-signed CAs, which is /// exactly the case where users reach for `verify_certificates=false`. pub(crate) fn build_insecure_rustls_config() -> Arc { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + super::ensure_crypto_provider(); Arc::new( rustls::ClientConfig::builder() .dangerous() diff --git a/sf_core/src/tls/error.rs b/sf_core/src/tls/error.rs index 086788208e..6790013a1e 100644 --- a/sf_core/src/tls/error.rs +++ b/sf_core/src/tls/error.rs @@ -48,6 +48,15 @@ pub enum TlsError { location: Location, }, + #[snafu(display( + "driver was built with the `fips-tls` feature but the active rustls crypto provider \ + is not in FIPS mode; refusing to create a TLS client" + ))] + FipsModeUnavailable { + #[snafu(implicit)] + location: Location, + }, + #[snafu(display("Failed to build WebPki verifier"))] VerifierBuild { source: Box, diff --git a/sf_core/src/tls/mod.rs b/sf_core/src/tls/mod.rs index 8db78f0730..b0f296a991 100644 --- a/sf_core/src/tls/mod.rs +++ b/sf_core/src/tls/mod.rs @@ -15,3 +15,151 @@ pub use client::{ pub use config::{ProxyConfig, TlsConfig}; pub(crate) use crl_verifier::CrlServerCertVerifier; pub use x509_utils::{crl_times, extract_skid, subject_der_hash, verify_crl_signature}; + +/// Guarantees a process-wide rustls default provider is in place, installing +/// aws-lc-rs if nothing has claimed the slot yet. Runs its work exactly once. +/// +/// Every `reqwest::Client` the driver builds resolves its crypto backend +/// through `CryptoProvider::get_default()`. Installing from one place, rather +/// than ad hoc next to each client, is what stops a client built early in the +/// process (telemetry, CRL prefetch, cloud transfers) from silently resolving +/// to a different backend than the one carrying the session's own traffic -- +/// under `--features fips-tls` that difference is the whole compliance claim. +/// +/// Installation is best-effort by design: `install_default` returns `Err` when +/// an embedding application has already installed its own provider, and +/// stomping on that would be worse than honouring it. In `fips-tls` builds the +/// resulting provider is checked and a mismatch is logged loudly rather than +/// panicking, because this runs beneath an FFI boundary where unwinding is +/// undefined behaviour. +pub(crate) fn ensure_crypto_provider() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + // `install_default` has exactly one failure mode -- a provider is + // already installed -- and the error payload is that provider, so log + // what actually won rather than just that we lost the race. + if let Err(existing) = rustls::crypto::aws_lc_rs::default_provider().install_default() { + tracing::debug!( + existing_provider_is_fips = existing.fips(), + "rustls crypto provider already installed; leaving it in place" + ); + } + + #[cfg(feature = "fips-tls")] + if !fips_mode_active() { + tracing::error!( + "driver was built with the `fips-tls` feature but the active rustls crypto \ + provider is not in FIPS mode; TLS is NOT FIPS compliant" + ); + } + }); +} + +/// Whether the crypto provider actually in force is operating in FIPS mode. +/// +/// Reports on the installed provider rather than on build flags, so it stays +/// honest when an embedding application installed a provider of its own before +/// the driver initialised. A build without `fips-tls` links non-FIPS aws-lc-sys +/// and so reports `false` unless such an application installed a FIPS provider +/// first -- in which case `true` is the accurate answer for TLS, and remains +/// only an answer about TLS. +/// +/// Deliberately not gated on the feature. The wrappers will surface this as a +/// customer-facing accessor (plan Phase 4), and a function that is *absent* +/// from standard builds would make "you installed the wrong artifact" look +/// identical to "you are running a driver too old to have the accessor at +/// all". Always present, answering `false`, keeps those two distinguishable. +/// +/// `pub` rather than `pub(crate)` for the same reason: both in-crate callers +/// (the mismatch log in `ensure_crypto_provider`, the gate in +/// `require_fips_provider`) sit under `#[cfg(feature = "fips-tls")]`, so a +/// crate-private version is dead code in every standard build. The only ways +/// to keep it crate-private are an `#[allow(dead_code)]` or the feature gate +/// this doc block just explained we do not want -- both of which hide the +/// accessor Phase 4 is going to export anyway. +pub fn fips_mode_active() -> bool { + rustls::crypto::CryptoProvider::get_default().is_some_and(|p| p.fips()) +} + +/// Fails closed in `fips-tls` builds when the provider that actually won the +/// process-global slot is not in FIPS mode. +/// +/// `ensure_crypto_provider` only logs the mismatch, because it cannot fail: it +/// runs from constructors and from paths with no error channel, and it sits +/// beneath an FFI boundary where unwinding is undefined behaviour. Logging +/// alone would mean a `fips-tls` build silently serving traffic on a non-approved +/// module, so every TLS client construction routes through here instead, where +/// there *is* an error channel and the failure propagates as a `TlsError` out +/// through the normal FFI error path. +/// +/// Compiles to `Ok(())` without the feature. +/// +/// Scope: this gates the clients that carry connection traffic (everything +/// built through `build_tls_client_and_rustls_config` / `configure_tls_builder`). +/// Auxiliary raw clients -- telemetry, CRL fetch, IMDS -- have no error channel +/// to fail into and still rely on the logged mismatch. +pub(crate) fn require_fips_provider() -> Result<(), error::TlsError> { + #[cfg(feature = "fips-tls")] + if !fips_mode_active() { + return Err(error::FipsModeUnavailableSnafu.build()); + } + Ok(()) +} + +/// Proves the `fips-tls` feature actually puts the linked crypto module into FIPS +/// mode, rather than merely pulling `aws-lc-fips-sys` into the link. +/// +/// This is the check that distinguishes "we depend on a FIPS-capable crate" +/// from "we are running approved algorithms in an approved mode" -- the former +/// is a build-graph property, the latter is what an auditor asks about. +#[cfg(all(test, feature = "fips-tls"))] +mod fips_tests { + /// The aws-lc module reports FIPS mode at runtime. Fails if the build + /// silently linked non-FIPS aws-lc-sys instead of aws-lc-fips-sys. + #[test] + fn aws_lc_reports_fips_mode() { + assert!( + aws_lc_rs::try_fips_mode().is_ok(), + "aws-lc is linked but not in FIPS mode" + ); + } + + /// The provider `ensure_crypto_provider` actually installs is FIPS-approved + /// end to end: its RNG, key provider, and every offered cipher suite. + /// + /// Asserts against the *process default* rather than a freshly constructed + /// provider, because that is what `tls::client` resolves: it builds configs + /// with `ClientConfig::builder()`, which reads the installed default. A + /// fresh `aws_lc_rs::default_provider()` would only restate what + /// `aws_lc_reports_fips_mode` already covers. + #[test] + fn installed_rustls_provider_is_fips() { + super::ensure_crypto_provider(); + let provider = rustls::crypto::CryptoProvider::get_default() + .expect("ensure_crypto_provider must leave a process-default provider installed"); + assert!( + provider.fips(), + "installed rustls provider that tls::client will resolve is not FIPS" + ); + assert!( + provider.cipher_suites.iter().all(|cs| cs.fips()), + "installed provider offers a non-FIPS cipher suite" + ); + } + + /// A `ClientConfig` built the way `tls::client` builds it stays FIPS after + /// the builder chain -- `ClientConfig::fips()` is the assertion rustls + /// documents for this, and it is what should eventually gate startup. + /// + /// Uses `ClientConfig::builder()`, the same entry point `tls::client` uses, + /// so this exercises the installed process default instead of a provider + /// handed in by the test. + #[test] + fn client_config_is_fips() { + super::ensure_crypto_provider(); + let config = rustls::ClientConfig::builder() + .with_root_certificates(rustls::RootCertStore::empty()) + .with_no_client_auth(); + assert!(config.fips(), "ClientConfig is not in FIPS mode"); + } +} diff --git a/sf_core/tests/common/crypto_provider.rs b/sf_core/tests/common/crypto_provider.rs new file mode 100644 index 0000000000..0872d6d46a --- /dev/null +++ b/sf_core/tests/common/crypto_provider.rs @@ -0,0 +1,23 @@ +// Installs the process-default rustls provider for a test binary. +// +// Why this exists: the dev `reqwest` dependency uses the `-no-provider` rustls +// features (see sf_core/Cargo.toml), matching the production selection so tests +// exercise the same wiring. The consequence is that a raw +// `reqwest::Client::new()` panics with "No provider set" unless a provider was +// installed first, and without this initializer whichever test happened to run +// first would decide whether that happens. +// +// The rule for adding it: every `[[test]]` target is a separate binary, so a +// ctor only runs for the binary it is compiled into. Any target that can reach +// a raw `reqwest::Client` -- directly or through a `#[path]`-included file -- +// must pull this module in. Targets that only touch the driver API do not need +// it, because production code calls `tls::ensure_crypto_provider()` itself. +// +// Note the limit of this safety net: because it runs before any test code, a +// production path that forgets `ensure_crypto_provider()` will generally NOT be +// caught by tests. The mechanical guard for that is the planned cargo-deny +// crypto denylist. +#[ctor::ctor(unsafe)] +fn install_default_crypto_provider() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); +} diff --git a/sf_core/tests/common/mod.rs b/sf_core/tests/common/mod.rs index e3e83224d6..01bdd41bcf 100644 --- a/sf_core/tests/common/mod.rs +++ b/sf_core/tests/common/mod.rs @@ -1,3 +1,5 @@ +pub mod crypto_provider; + pub mod arrow_deserialize; pub mod arrow_extract_value; pub mod arrow_result_helper; diff --git a/sf_core/tests/e2e/session/session_refresh.rs b/sf_core/tests/e2e/session/session_refresh.rs index b632a3c33b..da51245710 100644 --- a/sf_core/tests/e2e/session/session_refresh.rs +++ b/sf_core/tests/e2e/session/session_refresh.rs @@ -117,6 +117,7 @@ fn should_refresh_session_proactively() { &policy, None, None, + sf_core::crl::CrlWorker::shared_lazy(), ) .await .expect("Login should succeed"); diff --git a/sf_core/tests/logging_query_tests.rs b/sf_core/tests/logging_query_tests.rs index 4a22b50270..937e5a458b 100644 --- a/sf_core/tests/logging_query_tests.rs +++ b/sf_core/tests/logging_query_tests.rs @@ -1,2 +1,7 @@ +// `query_logging.rs` builds a raw `reqwest::Client`, and this target does not +// include `common/mod.rs`, so it needs the provider initializer of its own. +#[path = "common/crypto_provider.rs"] +mod crypto_provider; + #[path = "integration/logging/query_logging.rs"] mod query_logging; diff --git a/tests/performance/drivers/core/app/Cargo.lock b/tests/performance/drivers/core/app/Cargo.lock index 02879da7f5..31ce5acd0f 100644 --- a/tests/performance/drivers/core/app/Cargo.lock +++ b/tests/performance/drivers/core/app/Cargo.lock @@ -1083,17 +1083,6 @@ 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.45" @@ -1917,11 +1906,8 @@ 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]] @@ -2695,12 +2681,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "matchers" version = "0.2.0" @@ -3532,62 +3512,6 @@ dependencies = [ "syn 3.0.3", ] -[[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 0.23.43", - "socket2 0.6.5", - "thiserror 2.0.19", - "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 = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls 0.23.43", - "rustls-pki-types", - "slab", - "thiserror 2.0.19", - "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 0.6.5", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.47" @@ -3639,17 +3563,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[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]] name = "rand_chacha" version = "0.3.1" @@ -3688,21 +3601,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[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 = "redox_users" version = "0.4.6" @@ -3799,7 +3697,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls 0.23.43", "rustls-native-certs", "rustls-pki-types", @@ -3884,12 +3781,6 @@ dependencies = [ "ordered-multimap", ] -[[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" @@ -3942,7 +3833,6 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki 0.103.13", "subtle", @@ -3976,7 +3866,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] @@ -3990,17 +3879,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -4245,7 +4123,6 @@ dependencies = [ "rustls 0.23.43", "rustls-native-certs", "rustls-pemfile", - "rustls-webpki 0.102.8", "serde", "serde_json", "sf_params_spec", @@ -4637,7 +4514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2",