From a612edf3df2ebcfce42b29c0e6684f4baa784003 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 6 Nov 2026 15:24:31 -0500 Subject: [PATCH 1/4] fix(fips): assert startup behavior in both host modes Run all four policy startup scenarios under an explicit mode that must match the kernel flag. Configurable-provider tests prove crypto rejection; FIPS-host tests prove the same configurations retain enforced FIPS crypto and allow valid construction. Require provider configuration and child assertion completion, and fail the startup gate on ignored or missing scenarios. Run configurable-provider checks in the hosted FIPS workflow and retain all scenarios on the FIPS host. Match the provider cache ownership namespace to the test container. Validated all four cases with the CI UBI/OpenSSL packages in provider mode and a container simulation of the RHEL kernel-FIPS path. Wrong modes, missing prerequisites, unexpected enforcement, and incomplete test execution fail. Targeted Clippy, rustfmt, and actionlint pass; real FIPS-host CI remains required. Signed-off-by: Shane Utt --- .github/workflows/fips.yaml | 10 +- Makefile | 61 ++-- .../security/policy/fips_startup_tests.rs | 279 ++++++++++++------ docs/developing/fips.md | 57 ++-- 4 files changed, 266 insertions(+), 141 deletions(-) diff --git a/.github/workflows/fips.yaml b/.github/workflows/fips.yaml index beac7effe..f94e2ba34 100644 --- a/.github/workflows/fips.yaml +++ b/.github/workflows/fips.yaml @@ -4,8 +4,9 @@ name: FIPS # Workflow Settings # ------------------------------------------------------------------------------ # -# Two jobs. `ubi-image` is the static proof, on GitHub's runners: the FIPS -# build on UBI 9, checked the way Red Hat checks it, first the compliance +# Two jobs. `ubi-image` checks the image and configurable-provider startup +# behavior on GitHub's runners: the FIPS build on UBI 9, checked the way +# Red Hat checks it, first the compliance # report (a reason and a pointer for every finding), then Red Hat's own # scanner, openshift/check-payload at its Rust-support revision, against the # runtime image with warnings fatal. It runs on every pull request and push to @@ -100,6 +101,11 @@ jobs: - name: Build on UBI 9 with Red Hat's toolchain and print the compliance report run: make fips-check + # Both environments are required: this proves negative startup cases; + # fips-host proves the same configs retain kernel-enforced FIPS crypto. + - name: Policy startup with configurable providers (all four scenarios) + run: make test-fips-policy-provider + - name: Build the FIPS runtime image run: make container-fips diff --git a/Makefile b/Makefile index 4a9972081..dd042b8f6 100644 --- a/Makefile +++ b/Makefile @@ -492,30 +492,38 @@ lint-fips: ## clippy (all targets) + rustfmt for the FIPS feature set # need one: the host's in FIPS mode, or FIPS_PROVIDER_CONF, an OpenSSL config # that activates the FIPS module for the test processes only. FIPS_PROVIDER_CONF ?= -_FIPS_TEST_ENV = $(if $(FIPS_PROVIDER_CONF),OPENSSL_CONF=$(abspath $(FIPS_PROVIDER_CONF))) +_FIPS_TEST_ENV = $(if $(FIPS_PROVIDER_CONF),OPENSSL_CONF=$(abspath $(FIPS_PROVIDER_CONF))) $(_FIPS_POLICY_TEST_ENV) test-fips: ## unit tests resolved as the FIPS build $(_FIPS_TEST_ENV) cargo test --target-dir $(FIPS_TARGET_DIR) --no-default-features \ -p praxis-proxy -p praxis-proxy-protocol -p praxis-proxy-filter \ -p praxis-proxy-core -p praxis-proxy-tls \ --features $(FIPS_FEATURES_QUALIFIED) $(FIPS_CARGO_EXTRA) $(_NOCAPTURE) -# Policy FIPS construction in isolated child processes: a missing SHA-256 -# provider, FIPS JWT without `default_properties = fips=yes`, a rival -# jsonwebtoken provider, and repeated FIPS JWT loads. The provider-dependent -# cases need FIPS_POLICY_PROVIDER_CONF, an OpenSSL config that activates the -# FIPS module with FIPS defaults; without it they print a counted -# `fips-startup: skipped` line and pass, and PRAXIS_FIPS_HOST makes that a -# failure. Policy stays out of FIPS_FEATURES; this target names its own. +# Every startup scenario runs in both modes. The configurable-provider mode +# proves rejection; kernel-fips proves the host cannot be weakened by those +# configs. An explicit mode and a provider config are mandatory. Check both +# libtest's result and child completion markers: zero matches, ignored tests, +# and early returns must not silently turn this gate green. FIPS_POLICY_STARTUP_FEATURES := policy-jwt-fips,policy-api-key-fips -FIPS_POLICY_PROVIDER_CONF ?= -test-fips-policy-startup: ## Policy FIPS construction checks, one process per case - $(if $(FIPS_POLICY_PROVIDER_CONF),PRAXIS_FIPS_PROVIDER_CONF=$(abspath $(FIPS_POLICY_PROVIDER_CONF))) \ - cargo test --target-dir $(FIPS_TARGET_DIR) --no-default-features -p praxis-proxy-filter \ +FIPS_POLICY_PROVIDER_CONF ?= $(FIPS_PROVIDER_CONF) +FIPS_POLICY_STARTUP_MODE ?= +_FIPS_POLICY_TEST_ENV = PRAXIS_POLICY_FIPS_TEST_MODE=$(FIPS_POLICY_STARTUP_MODE) \ + $(if $(FIPS_POLICY_PROVIDER_CONF),PRAXIS_FIPS_PROVIDER_CONF=$(abspath $(FIPS_POLICY_PROVIDER_CONF))) +test-fips-policy-startup: ## all four Policy FIPS startup cases (explicit mode and config required) + @case "$(FIPS_POLICY_STARTUP_MODE)" in configurable-provider|kernel-fips) ;; \ + *) echo "set FIPS_POLICY_STARTUP_MODE=configurable-provider or kernel-fips"; exit 1 ;; esac + @test -r "$(FIPS_POLICY_PROVIDER_CONF)" || { echo "FIPS_POLICY_PROVIDER_CONF must name a readable provider config"; exit 1; } + @log=$$(mktemp); trap 'rm -f "$$log"' EXIT HUP INT TERM; \ + if ! $(_FIPS_POLICY_TEST_ENV) cargo test --target-dir $(FIPS_TARGET_DIR) --no-default-features -p praxis-proxy-filter \ --features $(FIPS_POLICY_STARTUP_FEATURES) $(FIPS_CARGO_EXTRA) --lib \ - -- policy::fips_startup_tests --nocapture + -- policy::fips_startup_tests --nocapture --color never >"$$log" 2>&1; then cat "$$log"; exit 1; fi; \ + cat "$$log"; \ + grep -q '^test result: ok. 4 passed; 0 failed; 0 ignored;' "$$log" && \ + [ "$$(grep -o 'fips-startup: ran [[:alnum:]_]*' "$$log" | sort -u | wc -l)" -eq 4 ] || \ + { echo "all four startup scenarios must execute their assertions and pass"; exit 1; } .PHONY: test-integration-fips test-conformance-fips test-fips-host fips-toolchain fips-host-facts test-fips-policy-startup \ - test-fips-provider fips-toolchain-present + test-fips-provider test-fips-policy-provider fips-toolchain-present # The integration suites against the FIPS build. The proxy they start in # process is built without default features, and the tests that spawn the @@ -563,12 +571,13 @@ test-fips-host: fips-toolchain ## every suite as the FIPS build, on a FIPS host $(FIPS_TOOLCHAIN_IMAGE) \ make fips-host-facts test-fips test-fips-policy-startup test-integration-fips test-conformance-fips \ FIPS_TARGET_DIR=/target FIPS_CARGO_EXTRA=--ignore-rust-version FIPS_POLICY_PROFILE=$(FIPS_POLICY_PROFILE) \ - FIPS_POLICY_PROVIDER_CONF=xtask/assets/fips/fips-provider.cnf $(if $(V),V=$(V)) + FIPS_POLICY_PROVIDER_CONF=xtask/assets/fips/fips-provider.cnf \ + FIPS_POLICY_STARTUP_MODE=kernel-fips $(if $(V),V=$(V)) # The unit and integration suites as the FIPS build inside the toolchain -# image, on any host: Red Hat's FIPS module, activated by -# xtask/assets/fips/fips-provider.cnf, does the crypto, but the kernel is not -# in FIPS mode, so the tests that need a FIPS host take their other branch. +# image, on a host without kernel FIPS enforcement: Red Hat's FIPS module, +# activated by xtask/assets/fips/fips-provider.cnf, does the crypto, but the +# kernel is not in FIPS mode, so host-specific tests take their other branch. # Runs the selected FIPS_POLICY_PROFILE; any container engine works. # The suites run as the invoking user: praxis refuses to start as root. _FIPS_RUN_AS = $(if $(findstring podman,$(notdir $(CONTAINER_ENGINE))),--userns=keep-id,--user $(shell id -u):$(shell id -g) -e HOME=/tmp) @@ -580,21 +589,25 @@ fips-toolchain-present: | require-container-engine @$(CONTAINER_ENGINE) image inspect $(FIPS_TOOLCHAIN_IMAGE) >/dev/null 2>&1 || { \ echo "$(FIPS_TOOLCHAIN_IMAGE) is missing: run make fips-toolchain with podman, then" \ "podman save $(FIPS_TOOLCHAIN_IMAGE) | $(notdir $(CONTAINER_ENGINE)) load"; exit 1; } -test-fips-provider: $(_FIPS_TOOLCHAIN_DEP) | require-container-engine ## FIPS suites on the FIPS module, any host (toolchain image) - @# A freshly created volume is root-owned; hand it to the user the suites run as. - $(CONTAINER_ENGINE) run --rm --security-opt label=disable --user 0:0 \ +test-fips-provider: ## FIPS unit, startup and integration suites on a non-FIPS kernel (toolchain image) +test-fips-policy-provider: ## all four Policy startup cases on a non-FIPS kernel (toolchain image) +test-fips-provider test-fips-policy-provider: $(_FIPS_TOOLCHAIN_DEP) | require-container-engine + @# Match the test container's user namespace when assigning cache ownership. + $(CONTAINER_ENGINE) run --rm --security-opt label=disable \ + $(if $(findstring podman,$(notdir $(CONTAINER_ENGINE))),--userns=keep-id) --user 0:0 \ -v praxis-fips-host-cargo:/cargo -v praxis-fips-host-target:/target \ - $(FIPS_TOOLCHAIN_IMAGE) chown $(shell id -u):$(shell id -g) /cargo /target + $(FIPS_TOOLCHAIN_IMAGE) chown -R $(shell id -u):$(shell id -g) /cargo /target $(CONTAINER_ENGINE) run --rm --security-opt label=disable $(_FIPS_RUN_AS) \ -v $(CURDIR):/src -w /src \ -v praxis-fips-host-cargo:/cargo \ -v praxis-fips-host-target:/target \ -e CARGO_TERM_COLOR=always -e CARGO_INCREMENTAL=0 \ $(FIPS_TOOLCHAIN_IMAGE) \ - make test-fips test-fips-policy-startup test-integration-fips \ + make $(if $(filter test-fips-provider,$@),test-fips) test-fips-policy-startup $(if $(filter test-fips-provider,$@),test-integration-fips) \ FIPS_TARGET_DIR=/target FIPS_CARGO_EXTRA=--ignore-rust-version FIPS_POLICY_PROFILE=$(FIPS_POLICY_PROFILE) \ FIPS_PROVIDER_CONF=xtask/assets/fips/fips-provider.cnf \ - FIPS_POLICY_PROVIDER_CONF=xtask/assets/fips/fips-provider.cnf $(if $(V),V=$(V)) + FIPS_POLICY_PROVIDER_CONF=xtask/assets/fips/fips-provider.cnf \ + FIPS_POLICY_STARTUP_MODE=configurable-provider $(if $(V),V=$(V)) # What the process the suites run as actually sees, printed into the log next # to the results: the user, the kernel flag and boot parameter, the crypto diff --git a/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs b/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs index 6e97997c7..c78ba3934 100644 --- a/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs +++ b/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs @@ -13,15 +13,16 @@ //! in a child copy of this test binary with its own `OPENSSL_CONF`. The //! parent spawns it and asserts on its outcome. //! -//! The missing-provider scenario needs only OpenSSL's `base` provider and -//! runs everywhere. The others need the FIPS module: they run when -//! `PRAXIS_FIPS_PROVIDER_CONF` names an OpenSSL config that activates it -//! with `default_properties = fips=yes` (`make test-fips-policy-startup` -//! inside the toolchain image), and otherwise print -//! `fips-startup: skipped ` and pass. `PRAXIS_FIPS_HOST` makes a -//! missing config a failure instead. +//! Every scenario runs in both environments. `PRAXIS_POLICY_FIPS_TEST_MODE` +//! explicitly selects `configurable-provider` or `kernel-fips`, and must +//! match the kernel flag. The former proves rejection of unavailable crypto; +//! the latter proves RHEL's automatic FIPS activation survives those configs. +//! JWT cases require `PRAXIS_FIPS_PROVIDER_CONF`; missing prerequisites fail. -use std::{path::Path, process::Command}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; use super::feature_tests::{filter_config, write_policy}; use crate::builtins::http::security::policy::filter::PolicyFilter; @@ -73,82 +74,142 @@ global: // ----------------------------------------------------------------------------- #[test] -fn without_a_sha256_provider_construction_fails_with_a_crypto_error() { - let dir = tempfile::TempDir::new().expect("create tempdir"); - let conf = dir.path().join("no-provider.cnf"); - std::fs::write(&conf, NO_PROVIDER_CONF).expect("write the OpenSSL config"); - run_child("no_provider", &conf); +fn base_only_config_preserves_crypto_validation() { + run_isolated( + "base_only_config_preserves_crypto_validation", + no_provider_conf, + child_base_only, + ); } #[cfg(feature = "policy-jwt-openssl-fips")] #[test] -fn jwt_without_fips_default_properties_fails_to_load() { - let Some(fips_conf) = provider_conf("jwt_without_fips_defaults") else { - return; - }; - let dir = tempfile::TempDir::new().expect("create tempdir"); - let conf = dir.path().join("fips-without-defaults.cnf"); - let text = std::fs::read_to_string(&fips_conf).expect("read the FIPS provider config"); - let relaxed: String = text - .lines() - .filter(|line| !line.trim_start().starts_with("default_properties")) - .flat_map(|line| match line.trim() { - "[provider_sect]" => vec![line, "default = default_sect"], - _ => vec![line], - }) - .map(|line| format!("{line}\n")) - .chain(["\n[default_sect]\nactivate = 1\n".to_owned()]) - .collect(); - std::fs::write(&conf, relaxed).expect("write the relaxed config"); - run_child("jwt_without_fips_defaults", &conf); +fn jwt_config_without_fips_defaults_preserves_crypto_validation() { + run_isolated( + "jwt_config_without_fips_defaults_preserves_crypto_validation", + relaxed_provider_conf, + child_without_fips_defaults, + ); } #[cfg(feature = "policy-jwt-openssl-fips")] #[test] fn a_rival_jsonwebtoken_provider_is_refused() { - if let Some(conf) = provider_conf("rival_provider") { - run_child("rival_provider", &conf); - } + run_isolated( + "a_rival_jsonwebtoken_provider_is_refused", + |_| provider_conf(), + child_rival_provider, + ); } #[cfg(feature = "policy-jwt-openssl-fips")] #[test] fn reloading_a_fips_jwt_policy_twice_does_not_conflict() { - if let Some(conf) = provider_conf("double_reload") { - run_child("double_reload", &conf); + run_isolated( + "reloading_a_fips_jwt_policy_twice_does_not_conflict", + |_| provider_conf(), + child_double_reload, + ); +} + +// ----------------------------------------------------------------------------- +// Test Utilities +// ----------------------------------------------------------------------------- + +/// The explicitly requested environment; never inferred from crypto availability. +#[derive(Clone, Copy)] +enum HostMode { + /// Configuration controls which providers and default properties are active. + ConfigurableProvider, + /// RHEL activates FIPS from the kernel flag, even under a relaxed config. + KernelFips, +} + +impl HostMode { + /// Require the selected environment to agree with the actual kernel flag. + fn required() -> Self { + let mode = std::env::var("PRAXIS_POLICY_FIPS_TEST_MODE").expect( + "PRAXIS_POLICY_FIPS_TEST_MODE must be configurable-provider or kernel-fips; use the Makefile FIPS targets", + ); + let kernel_fips = match std::fs::read_to_string("/proc/sys/crypto/fips_enabled") { + Ok(flag) => match flag.trim() { + "0" => false, + "1" => true, + other => panic!("unexpected kernel FIPS flag: {other}"), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => panic!("cannot read the kernel FIPS flag: {error}"), + }; + match mode.as_str() { + "configurable-provider" => { + assert!( + !kernel_fips, + "configurable-provider tests require a kernel without FIPS enforcement" + ); + Self::ConfigurableProvider + }, + "kernel-fips" => { + assert!(kernel_fips, "kernel-fips tests require the kernel FIPS flag to be 1"); + Self::KernelFips + }, + other => panic!("unknown policy FIPS test mode: {other}"), + } } } -#[test] -fn policy_fips_child() { - let Ok(scenario) = std::env::var(CHILD_ENV) else { - return; - }; - match scenario.as_str() { - "no_provider" => child_no_provider(), - #[cfg(feature = "policy-jwt-openssl-fips")] - "jwt_without_fips_defaults" => { - child_expect_jwt_crypto_failure("do not restrict operations to the FIPS provider"); +/// A base-only config either lacks SHA-256 or retains the host's FIPS enforcement. +fn child_base_only(mode: HostMode) { + let (_dir, path) = write_policy("plugins: []\n"); + match mode { + HostMode::ConfigurableProvider => { + assert!( + openssl::md::Md::fetch(None, "SHA256", Some("fips=yes")).is_err(), + "the negative case must lack a FIPS SHA-256 implementation" + ); + let error = construction_error(path); + assert!( + error.contains(ppe::CRYPTO_FAILED_CODE), + "a missing SHA-256 provider must fail construction with the crypto code: {error}" + ); + }, + HostMode::KernelFips => { + assert_fips_crypto(); + assert!( + PolicyFilter::new(filter_config(path), None).is_ok(), + "kernel-enforced FIPS construction must succeed" + ); }, - #[cfg(feature = "policy-jwt-openssl-fips")] - "rival_provider" => child_rival_provider(), - #[cfg(feature = "policy-jwt-openssl-fips")] - "double_reload" => child_double_reload(), - other => panic!("unknown FIPS child scenario {other}"), } } -// ----------------------------------------------------------------------------- -// Test Utilities -// ----------------------------------------------------------------------------- +/// Relaxed defaults must be rejected unless the host still enforces FIPS. +#[cfg(feature = "policy-jwt-openssl-fips")] +fn child_without_fips_defaults(mode: HostMode) { + match mode { + HostMode::ConfigurableProvider => { + assert!( + openssl::md::Md::fetch(None, "SHA256", Some("fips=yes")).is_ok(), + "FIPS SHA-256 must remain available before checking the relaxed defaults" + ); + assert!( + openssl::md::Md::fetch(None, "MD5", None).is_ok(), + "the negative case must have non-FIPS defaults" + ); + child_expect_jwt_crypto_failure("do not restrict operations to the FIPS provider"); + }, + HostMode::KernelFips => child_double_reload(mode), + } +} -/// Engine initialization refuses a backend with no SHA-256. -fn child_no_provider() { - let (_dir, path) = write_policy("plugins: []\n"); - let error = construction_error(path); +/// The effective default context admits FIPS SHA-256 and refuses non-FIPS MD5. +fn assert_fips_crypto() { + assert!( + openssl::md::Md::fetch(None, "SHA256", Some("fips=yes")).is_ok(), + "FIPS SHA-256 must be available" + ); assert!( - error.contains(ppe::CRYPTO_FAILED_CODE), - "a missing SHA-256 provider must fail construction with the crypto code: {error}" + openssl::md::Md::fetch(None, "MD5", None).is_err(), + "FIPS defaults must refuse MD5" ); } @@ -165,14 +226,16 @@ fn child_expect_jwt_crypto_failure(detail: &str) { /// A provider installed before Policy loads JWT config is refused. #[cfg(feature = "policy-jwt-openssl-fips")] -fn child_rival_provider() { +fn child_rival_provider(_mode: HostMode) { + assert_fips_crypto(); rival::install(); child_expect_jwt_crypto_failure("another jsonwebtoken crypto provider"); } /// Policy's own provider, installed on the first load, is accepted again. #[cfg(feature = "policy-jwt-openssl-fips")] -fn child_double_reload() { +fn child_double_reload(_mode: HostMode) { + assert_fips_crypto(); let (_keys, path) = write_jwt_policy(); for attempt in 1..=3 { if let Err(error) = PolicyFilter::new(filter_config(path.clone()), None) { @@ -181,55 +244,85 @@ fn child_double_reload() { } } -/// The FIPS provider config, or `None` after printing a counted skip. +/// Require a readable FIPS configuration; absence is a test failure. #[cfg(feature = "policy-jwt-openssl-fips")] -#[expect(clippy::print_stdout, reason = "the skip line is how a run counts what it skipped")] -fn provider_conf(scenario: &str) -> Option { - let conf = std::env::var_os(PROVIDER_CONF_ENV).map(std::path::PathBuf::from); - if conf.is_none() { - assert!( - !fips_host_declared(), - "PRAXIS_FIPS_HOST is set, so {PROVIDER_CONF_ENV} must name the FIPS provider config" - ); - println!("fips-startup: skipped {scenario} ({PROVIDER_CONF_ENV} is not set)"); - } +fn provider_conf() -> PathBuf { + let path = std::env::var_os(PROVIDER_CONF_ENV).map(PathBuf::from).expect( + "PRAXIS_FIPS_PROVIDER_CONF must name a FIPS provider config; use FIPS_POLICY_PROVIDER_CONF with the Makefile targets", + ); + assert!(path.is_file(), "FIPS provider config must exist: {}", path.display()); + path +} + +/// Write the deliberately base-only config used by the first scenario. +fn no_provider_conf(dir: &Path) -> PathBuf { + let conf = dir.join("no-provider.cnf"); + std::fs::write(&conf, NO_PROVIDER_CONF).expect("write the OpenSSL config"); conf } -/// Whether `PRAXIS_FIPS_HOST` declares a FIPS host. An empty value, `0`, -/// `false`, `no` or `off` leave it undeclared, as in the test harness. +/// Load both providers without requesting FIPS defaults in the configuration. #[cfg(feature = "policy-jwt-openssl-fips")] -fn fips_host_declared() -> bool { - std::env::var("PRAXIS_FIPS_HOST").is_ok_and(|value| { - !matches!( - value.trim().to_ascii_lowercase().as_str(), - "" | "0" | "false" | "no" | "off" - ) - }) +fn relaxed_provider_conf(dir: &Path) -> PathBuf { + let text = std::fs::read_to_string(provider_conf()).expect("read the FIPS provider config"); + let relaxed: String = text + .lines() + .filter(|line| !line.trim_start().starts_with("default_properties")) + .flat_map(|line| match line.trim() { + "[provider_sect]" => vec![line, "default = default_sect"], + _ => vec![line], + }) + .map(|line| format!("{line}\n")) + .chain(["\n[default_sect]\nactivate = 1\n".to_owned()]) + .collect(); + let conf = dir.join("fips-without-defaults.cnf"); + std::fs::write(&conf, relaxed).expect("write the relaxed config"); + conf } -/// Run [`policy_fips_child`] for `scenario` under `openssl_conf`. -/// -/// [`policy_fips_child`]: policy_fips_child -#[expect(clippy::print_stdout, reason = "the ran line is how a run counts what it ran")] -fn run_child(scenario: &str, openssl_conf: &Path) { +/// Re-execute this exact test in an isolated process, or perform its assertions. +#[expect( + clippy::print_stdout, + reason = "the completion marker proves the child executed its assertions" +)] +fn run_isolated(test: &str, prepare: impl FnOnce(&Path) -> PathBuf, check: fn(HostMode)) { + let mode = HostMode::required(); + match std::env::var(CHILD_ENV) { + Ok(child) => { + assert_eq!(child, test, "a child must execute the requested test"); + check(mode); + println!("fips-startup: checked {test}"); + }, + Err(std::env::VarError::NotPresent) => { + let dir = tempfile::TempDir::new().expect("create tempdir"); + run_child(test, &prepare(dir.path())); + }, + Err(error) => panic!("invalid FIPS child marker: {error}"), + } +} + +/// Require the exact child test to pass and report that its assertions completed. +#[expect(clippy::print_stdout, reason = "the ran line records every completed scenario")] +fn run_child(test: &str, openssl_conf: &Path) { let output = Command::new(std::env::current_exe().expect("test executable path")) .args([ "--exact", - "builtins::http::security::policy::fips_startup_tests::policy_fips_child", + &format!("builtins::http::security::policy::fips_startup_tests::{test}"), ]) .args(["--nocapture", "--test-threads=1"]) - .env(CHILD_ENV, scenario) + .env(CHILD_ENV, test) .env("OPENSSL_CONF", openssl_conf) .output() .expect("spawn the FIPS child"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - output.status.success() && stdout.contains("1 passed"), - "FIPS child {scenario} failed:\n{stdout}\n{stderr}" + output.status.success() + && stdout.contains("1 passed") + && stdout.contains(&format!("fips-startup: checked {test}")), + "FIPS child {test} failed:\n{stdout}\n{stderr}" ); - println!("fips-startup: ran {scenario}"); + println!("fips-startup: ran {test}"); } /// The construction error for `config_path`, which must fail to build. diff --git a/docs/developing/fips.md b/docs/developing/fips.md index eb66d3c0d..40ddcb260 100644 --- a/docs/developing/fips.md +++ b/docs/developing/fips.md @@ -86,25 +86,38 @@ answers with a chosen `ServerHello` or alert, then watches for the client's alert. No cryptography is involved, which is the point: the probes work against any listener, including the shipped image, on any host. -**Policy FIPS construction.** `make test-fips-policy-startup` builds the -filter crate with `policy-jwt-fips,policy-api-key-fips` and runs each Policy -crypto startup case in a child process with its own `OPENSSL_CONF`, since -the provider, the default properties and `jsonwebtoken`'s provider are -fixed once per process. The cases follow the host contract in -[The policy filter on FIPS](../operating/fips.md#the-policy-filter-on-fips): -a backend with no SHA-256 fails construction with `policy.crypto_failed` -(this one needs only OpenSSL's `base` provider and runs anywhere); FIPS JWT -with the module loaded beside the default provider but without -`default_properties = fips=yes` fails its config load; a rival -`jsonwebtoken` provider installed first is refused; and three FIPS JWT -loads in one process all succeed, as reloads must. The last three need the -module: `FIPS_POLICY_PROVIDER_CONF` names a config that activates it with -FIPS defaults, and `test-fips-host` passes -`xtask/assets/fips/fips-provider.cnf`. Without it each prints -`fips-startup: skipped ` and passes, so the log counts what ran; -`PRAXIS_FIPS_HOST` turns a missing config into a failure. Praxis adds no -`default_properties` check of its own: Policy's JWT load enforces it, and -the filter reports that as a construction error. +**Policy FIPS construction.** Every run executes four startup scenarios in +isolated child processes with their own `OPENSSL_CONF`. The expected result +is explicit and checked against the kernel flag: + +| Scenario | Configurable provider, non-FIPS kernel | RHEL FIPS kernel | +|---|---|---| +| Base-only config | SHA-256 is unavailable; construction fails with `policy.crypto_failed` | FIPS SHA-256 remains available, MD5 is refused, construction succeeds | +| JWT config without FIPS defaults | FIPS SHA-256 and MD5 are available; JWT construction rejects non-FIPS defaults | MD5 remains refused; FIPS JWT construction succeeds | +| Rival `jsonwebtoken` provider | Construction refuses the rival provider | Construction refuses the rival provider | +| Three JWT loads in one process | All succeed | All succeed | + +RHEL automatically activates its FIPS provider and default properties when +the kernel requires FIPS, even if the supplied config omits them. The tests +assert the effective crypto state before checking Policy construction. + +`make test-fips-policy-provider` runs all four cases in the UBI toolchain +image on a non-FIPS kernel. The hosted `FIPS` job requires it on every PR +and push. `make test-fips-host` runs all four on the FIPS runner, alongside +the unit, integration and conformance suites. `make test-fips-provider` +also includes the configurable-provider startup checks. + +For a direct run inside the toolchain image, use: + +```console +make test-fips-policy-startup FIPS_POLICY_STARTUP_MODE=configurable-provider \ + FIPS_POLICY_PROVIDER_CONF=xtask/assets/fips/fips-provider.cnf +``` + +Use mode `kernel-fips` on the FIPS host. A missing mode, wrong kernel mode, +missing provider config, ignored test or missing scenario fails the gate. +There are no successful skips. The tests exercise Policy's existing crypto +validation; Praxis adds no production crypto checks. **Policy FIPS profiles.** The default FIPS build leaves Policy out. `FIPS_POLICY_PROFILE` opts one in, and every FIPS target honors it: the @@ -124,9 +137,9 @@ A Policy profile refuses to build its filter without a FIPS provider, so its tests need one. `make test-fips-provider FIPS_POLICY_PROFILE=jwt` runs `test-fips`, `test-fips-policy-startup` and `test-integration-fips` inside the toolchain image with Red Hat's module activated by -`xtask/assets/fips/fips-provider.cnf`. It works on any host and with docker or -podman, but the kernel is not in FIPS mode, so `test-fips-host` remains the -FIPS-host proof. On the host itself those tests check the refusal instead. The +`xtask/assets/fips/fips-provider.cnf`. It works with docker or podman on a +non-FIPS kernel, so `test-fips-host` remains the FIPS-host proof. On the host +itself those tests check the refusal instead. The `FIPS Policy profiles` workflow runs the four profiles nightly and on dispatch. The JWT example tests sign RS256 and ES256 tokens and DPoP proofs at run time with the test-only keys in `tests/integration/fixtures/policy-fips/`; From 8f6923c1aee602c2eee56f64ba0a8e4d0fd431a3 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 6 Nov 2026 15:40:26 -0500 Subject: [PATCH 2/4] fix(fips): explain empty startup test modes Signed-off-by: Shane Utt --- .../builtins/http/security/policy/fips_startup_tests.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs b/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs index c78ba3934..1834d0fe7 100644 --- a/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs +++ b/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs @@ -128,9 +128,12 @@ enum HostMode { impl HostMode { /// Require the selected environment to agree with the actual kernel flag. fn required() -> Self { - let mode = std::env::var("PRAXIS_POLICY_FIPS_TEST_MODE").expect( - "PRAXIS_POLICY_FIPS_TEST_MODE must be configurable-provider or kernel-fips; use the Makefile FIPS targets", - ); + let mode = std::env::var("PRAXIS_POLICY_FIPS_TEST_MODE") + .ok() + .filter(|mode| !mode.is_empty()) + .expect( + "PRAXIS_POLICY_FIPS_TEST_MODE must be configurable-provider or kernel-fips; use the Makefile FIPS targets", + ); let kernel_fips = match std::fs::read_to_string("/proc/sys/crypto/fips_enabled") { Ok(flag) => match flag.trim() { "0" => false, From 418b3431dabe60119d4d54008239a499a6702f46 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 6 Nov 2026 16:08:12 -0500 Subject: [PATCH 3/4] fix(fips): isolate startup scenarios from unit targets Signed-off-by: Shane Utt --- Makefile | 6 ++++-- docs/developing/fips.md | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index dd042b8f6..b74035056 100644 --- a/Makefile +++ b/Makefile @@ -491,13 +491,15 @@ lint-fips: ## clippy (all targets) + rustfmt for the FIPS feature set # A Policy profile refuses to construct without a FIPS provider, so its tests # need one: the host's in FIPS mode, or FIPS_PROVIDER_CONF, an OpenSSL config # that activates the FIPS module for the test processes only. +# Startup scenarios require an explicit mode and run in test-fips-policy-startup. FIPS_PROVIDER_CONF ?= -_FIPS_TEST_ENV = $(if $(FIPS_PROVIDER_CONF),OPENSSL_CONF=$(abspath $(FIPS_PROVIDER_CONF))) $(_FIPS_POLICY_TEST_ENV) +_FIPS_TEST_ENV = $(if $(FIPS_PROVIDER_CONF),OPENSSL_CONF=$(abspath $(FIPS_PROVIDER_CONF))) test-fips: ## unit tests resolved as the FIPS build $(_FIPS_TEST_ENV) cargo test --target-dir $(FIPS_TARGET_DIR) --no-default-features \ -p praxis-proxy -p praxis-proxy-protocol -p praxis-proxy-filter \ -p praxis-proxy-core -p praxis-proxy-tls \ - --features $(FIPS_FEATURES_QUALIFIED) $(FIPS_CARGO_EXTRA) $(_NOCAPTURE) + --features $(FIPS_FEATURES_QUALIFIED) $(FIPS_CARGO_EXTRA) -- \ + --skip policy::fips_startup_tests $(if $(V),--nocapture) # Every startup scenario runs in both modes. The configurable-provider mode # proves rejection; kernel-fips proves the host cannot be weakened by those diff --git a/docs/developing/fips.md b/docs/developing/fips.md index 40ddcb260..64554e308 100644 --- a/docs/developing/fips.md +++ b/docs/developing/fips.md @@ -107,6 +107,11 @@ and push. `make test-fips-host` runs all four on the FIPS runner, alongside the unit, integration and conformance suites. `make test-fips-provider` also includes the configurable-provider startup checks. +The general `test-fips` unit target excludes these startup scenarios, so it +does not require `FIPS_POLICY_STARTUP_MODE`. Both container entrypoints run +the dedicated startup target separately, including when a Policy profile +is enabled. + For a direct run inside the toolchain image, use: ```console From 06972bb2cebab87b5053be681d21af77ca4664a3 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 6 Nov 2026 16:08:16 -0500 Subject: [PATCH 4/4] test(fips): support custom provider section names Signed-off-by: Shane Utt --- .../builtins/http/security/policy/fips_startup_tests.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs b/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs index 1834d0fe7..45c610b98 100644 --- a/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs +++ b/crates/filter/src/builtins/http/security/policy/fips_startup_tests.rs @@ -188,6 +188,8 @@ fn child_base_only(mode: HostMode) { /// Relaxed defaults must be rejected unless the host still enforces FIPS. #[cfg(feature = "policy-jwt-openssl-fips")] fn child_without_fips_defaults(mode: HostMode) { + let _default_provider = openssl::provider::Provider::load(None, "default").expect("load the default provider"); + match mode { HostMode::ConfigurableProvider => { assert!( @@ -264,19 +266,14 @@ fn no_provider_conf(dir: &Path) -> PathBuf { conf } -/// Load both providers without requesting FIPS defaults in the configuration. +/// Remove FIPS defaults from the supplied configuration. #[cfg(feature = "policy-jwt-openssl-fips")] fn relaxed_provider_conf(dir: &Path) -> PathBuf { let text = std::fs::read_to_string(provider_conf()).expect("read the FIPS provider config"); let relaxed: String = text .lines() .filter(|line| !line.trim_start().starts_with("default_properties")) - .flat_map(|line| match line.trim() { - "[provider_sect]" => vec![line, "default = default_sect"], - _ => vec![line], - }) .map(|line| format!("{line}\n")) - .chain(["\n[default_sect]\nactivate = 1\n".to_owned()]) .collect(); let conf = dir.join("fips-without-defaults.cnf"); std::fs::write(&conf, relaxed).expect("write the relaxed config");