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..b74035056 100644 --- a/Makefile +++ b/Makefile @@ -491,31 +491,41 @@ 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))) 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. + --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 +# 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 +573,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 +591,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..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 @@ -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,147 @@ 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") + .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, + "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) { + let _default_provider = openssl::provider::Provider::load(None, "default").expect("load the default provider"); + + 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!( - error.contains(ppe::CRYPTO_FAILED_CODE), - "a missing SHA-256 provider must fail construction with the crypto code: {error}" + openssl::md::Md::fetch(None, "SHA256", Some("fips=yes")).is_ok(), + "FIPS SHA-256 must be available" + ); + assert!( + openssl::md::Md::fetch(None, "MD5", None).is_err(), + "FIPS defaults must refuse MD5" ); } @@ -165,14 +231,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 +249,80 @@ 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. +/// Remove FIPS defaults from the supplied 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")) + .map(|line| format!("{line}\n")) + .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..64554e308 100644 --- a/docs/developing/fips.md +++ b/docs/developing/fips.md @@ -86,25 +86,43 @@ 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. + +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 +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 +142,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/`;