FIPS Tooling
On this page
Local, reproducible checks that a praxis-ai build is on the path to FIPS
140-3 compliance on Red Hat Enterprise Linux. The checks mirror what Red Hat’s
release scanner (openshift/check-payload, Rust support in its PR #360) looks
at, so a clean local report is a strong predictor of a clean scan. The build
targets themselves (make release-fips, make container-fips) are described
in Getting Started.
Everything here is a cargo xtask fips command (xtask/src/fips/), wrapped
by a Makefile target. The Makefile builds xtask without its default features
for these targets (XTASK_FIPS), so they never compile the standard proxy
build to run; the same invocation runs inside the report stage of
Containerfile.fips.
| Command | Makefile | Purpose |
|---|---|---|
cargo xtask fips report [--deps-only] [--features LIST] [--offline] [--out FILE] [BINARY] | fips-deps, fips-report, fips-check | The compliance report: environment, dependency graph, binary structure, source guards, with a reason and a pointer for every finding. Exit status 1 while findings remain. |
cargo xtask fips verify-image REFERENCE | fips-verify-image (run by container-fips and fips-check first) | Refuses any base image that is not digest-pinned, from registry.access.redhat.com, and signed by Red Hat’s release key. |
cargo xtask fips signature-store [--install] | fips-signature-store | Whether podman’s registries.d names Red Hat’s signature store, without which every Red Hat image looks unsigned; --install adds the bundled entry for the current user on hosts whose podman packaging ships none (Debian, Ubuntu, GitHub’s runners). CI runs it before fips-verify-image. |
check-payload scan image ... | fips-scanner, fips-oc, fips-scan | Red Hat’s own scanner at a pinned revision, run against the FIPS image with warnings fatal: the actual gate. |
cargo xtask fips host-check [--image IMAGE] [--require-certified] [--out FILE] [--json FILE] | fips-host-check | The FIPS-host attestation: the kernel flag, fips=1 on the command line, the FIPS crypto policy, fips-mode-setup --check, the module the host’s OpenSSL loads; with --image, the crypto policy podman propagates into the container and the build of fips.so inside it, graded against certified-modules.json. Exit 1 on any unmet requirement; a module in validation warns unless --require-certified. |
cargo xtask fips runtime-probe IMAGE [--toolchain-image IMAGE] [--host-cargo] [--log FILE] | fips-runtime-probe | Runs the shipped FIPS image under PRAXIS_REQUIRE_FIPS=1 with a generated listener config, waits for a real TLS handshake, drives the fips::listener_ probes of the integration suite against it (from the toolchain image, or with the host’s cargo), and checks the startup status line reports FIPS on both signals. |
PRAXIS_TEST_FIPS_PROVIDER=1 cargo test ... --config 'target."cfg(all())".runner=["env","OPENSSL_CONF=$PWD/xtask/assets/fips/fips-provider.cnf"]' | test-fips-provider | The FIPS feature set’s unit tests with the RHEL FIPS provider active (fips=yes default properties) in every test process, so the digests and MACs the code computes, aws_sigv4_sign’s HMAC-SHA256 included, have to come from the provider. The runner sets OPENSSL_CONF on the test binaries only: cargo itself (its libgit2) cannot run under it, which is also why only lib and bin unit tests run. The path must be absolute (cargo runs each test binary from its package directory) and OpenSSL silently ignores a configuration file it cannot find, so PRAXIS_TEST_FIPS_PROVIDER makes the apis and filters test processes assert that the provider reports FIPS-approved default properties and refuses MD5; a run that never reached the provider fails instead of passing. Needs the host’s fips.so. The UBI 9 report stage (fips-check) runs the crypto tests (hash::, aws::) the same way. |
What the report checks
- Dependency graph: no crate on the scanner’s
rust_denied_cryptolist (ring,aws-lc-rs,sha2,hmac, …) in the shipped binary’s normal dependency graph, resolved for the assessed feature set.--deps-onlystops here; this is whatmake lintruns. Every finding names the ai feature that pulls the crate (the stores through sqlx, the reqwest-based filters, the policy engine), so the fix is usually a line inFIPS_FEATURES. Asha2orhmacfinding that namesaws-sigv4means the crate escapeddev-dependencies:aws_sigv4_signsigns through OpenSSL and only tests against it. - Binary: links the system
libcrypto.so.3dynamically, defines no symbol of a bundled crypto backend (ring_core_,aws_lc_,BORINGSSL_,OPENSSL_), imports OpenSSL, carries the cargo-auditable manifest (.dep-v0, built from cargo’s SBOM precursor, listing no denied crate) and the rustc producer string. - Source guards: the application never enables a FIPS provider itself, never uses OpenSSL’s legacy (non-provider) digest API, never vendors or statically links OpenSSL.
Every finding comes with why the scanner cares, where in the tree to look and what to do about it.
Data compiled into xtask
From xtask/assets/fips/:
| File | Purpose |
|---|---|
redhat-release-key-2.asc | Red Hat, Inc. (release key 2), the GPG key Red Hat signs its container images with. See provenance below. |
registry.access.redhat.com.yaml | The registries.d entry that tells podman where Red Hat’s signature store is. podman reads only its own registries.d (~/.config/containers/registries.d when it exists, else /etc/containers/registries.d), so verify-image checks the directory podman reads names the store, and signature-store --install (make fips-signature-store) writes this file into the user’s directory when it does not. Fedora and RHEL ship the same entry in containers-common; Debian and Ubuntu ship no registries.d at all. |
fips-provider.cnf | An OPENSSL_CONF that activates the RHEL FIPS provider for one process, used by the report to probe FIPS behaviour on hosts that are not in FIPS mode. Test infrastructure only; the application never enables FIPS itself. |
certified-modules.json | The builds of Red Hat’s OpenSSL FIPS provider module, by the version string the module reports, with their CMVP status and sources. host-check grades the module a host or an image carries against it: a build on an active certificate passes, one in validation is stated as such (the pinned UBI 9 images currently carry one), one unknown is a finding. Re-verify the sources when a UBI pin or a RHEL release changes. |
Provenance of the signing key
redhat-release-key-2.asc was downloaded on 2026-09-22 (for the praxis
repository, from which this copy comes) from Red Hat’s key distribution URL, https://access.redhat.com/security/data/fd431d51.txt. Its
fingerprint,
567E 347A D004 4ADE 55BA 8A5F 199E 2F91 FD43 1D51
matches the fingerprint Red Hat publishes for “Red Hat, Inc. (release key 2)”
at https://access.redhat.com/security/team/key. verify-image recomputes
the fingerprint on every run (RFC 4880 v4: SHA-1 over the public key packet,
in Rust) and refuses to proceed if it differs; cargo test -p xtask fips
checks the same. podman’s own signature check still needs gnupg installed
(it verifies signedBy policies through gpgme), which verify-image
checks for up front.
The copy of this key shipped inside the UBI image itself is an older export that lacks the binding for the subkey Red Hat currently signs with; that is why the published file, not the in-image file, is used.
cargo-auditable
Red Hat’s scanner finds pure-Rust cryptography through the crate list that
cargo auditable build embeds in the binary (the .dep-v0 section); a binary
without it is graded inconclusive. The UBI builder installs cargo-auditable
from crates.io at the version pinned in Containerfile.fips
(CARGO_AUDITABLE_VERSION), and make release-fips uses it when it is
installed locally (cargo install cargo-auditable --version 0.7.6 --locked).
It is maintained by the Rust Secure Code Working Group and embeds data only,
never code. The report decodes the section itself (zlib-compressed JSON).
Why the build uses cargo’s SBOM precursor
The manifest has to list exactly the crates compiled into the binary: the
scanner fails on a denied crate’s name alone, whether or not its code was
linked. On a stable toolchain cargo-auditable derives the list from
cargo metadata, and that resolve is not the build’s:
- it unifies features across every workspace member, dev-dependencies
included (the test utilities enable the policy engine, the stores and the
reqwest-based filters, which pull
aws-lc-rs,sha2andhmac), and - it activates weak features (
dep?/feature) that the real build never turns on: rustls always enables rustls-webpki’salloc, whosering?/allocentry putsringin the resolve of a binary that never compiled it. That one cannot be avoided by any rustls user.
Cargo’s SBOM precursor (build.sbom, still unstable behind -Zsbom) is
written by cargo’s own unit graph and is exact; cargo-auditable 0.7 reads
it when present. make release-fips and Containerfile.fips therefore run
RUSTC_BOOTSTRAP=1 CARGO_BUILD_SBOM=true cargo auditable -Zsbom \
--config 'env.RUSTC_BOOTSTRAP.value="-1"' \
--config 'env.RUSTC_BOOTSTRAP.force=true' \
build --release -p praxis-ai-proxy ...
RUSTC_BOOTSTRAP=1 lets a stable cargo accept the -Z flag (it also relaxes
cargo’s guard against a build script setting RUSTC_BOOTSTRAP through
cargo:rustc-env from an error to a warning; the value is never forwarded
to rustc). The two env overrides replace it with RUSTC_BOOTSTRAP=-1 for
rustc and every build script, and rustc treats -1 as “no unstable
features”, so the code compiled is exactly the stable code (the build scripts
of proc-macro2 and thiserror probe for unstable APIs when the variable is
set; with -1 the probes fail, as they do without it). The report checks
the manifest’s format field, which is 8 when it came from the precursor,
so a build that silently fell back to cargo metadata is a finding.
Cargo before 1.99 does not relink a binary when only the SBOM setting
changed (rust-lang/cargo#15695, fixed by #17216), so both recipes remove the
old binary first; drop that once the toolchains in use are 1.99 or newer.
Drop the whole workaround once build.sbom is stable
(rust-lang/cargo#13709).
Red Hat’s scanner
make fips-scanner fetches the pinned commit of openshift/check-payload
(CHECK_PAYLOAD_REV in the Makefile, the head of its PR #360, fetched by
commit so a rewrite of the PR cannot break the build) and builds it the way
upstream does (CGO_ENABLED=0 go build, vendored modules) into
target/fips/check-payload/; it needs Go 1.26 or newer. The scanner refuses
to run without the OpenShift CLI (oc) on PATH, even for a local image
scan: make fips-oc downloads Red Hat’s pinned client release
(OC_VERSION) into target/fips/bin/, checks it against the sha256 Red Hat
publishes next to the tarball, and make fips-scan puts that directory on
PATH (an oc already on PATH works too). make fips-scan runs the
scanner against the FIPS image from podman’s image store (under
podman unshare when podman is rootless) with --fail-on-warnings, so an
inconclusive verdict such as a missing manifest fails, as it does in Red
Hat’s gated scans. All of this needs a Linux podman, rootless or root, not a
podman machine. Point CHECK_PAYLOAD at another build to use it instead.
Move CHECK_PAYLOAD_REV forward once the PR merges or a release carries
Rust support.
The FIPS host run
make test-fips-host is the runtime proof: on a RHEL 9 host in FIPS mode it
runs make test-fips test-integration-fips test-schema-fips inside the
toolchain stage of Containerfile.fips (make fips-toolchain), so the
compiler and OpenSSL are the same Red Hat packages the FIPS image is built
with, while the kernel flag and the FIPS crypto policy are the host’s,
which podman passes into the container. The run first prints
make fips-host-facts: the kernel flag, boot parameter, crypto policy,
OpenSSL packages and providers, and whether MD5 is refused, and fails there,
before anything compiles, unless they all agree that the container is in
FIPS mode.
Three variables drive the suites. PRAXIS_FIPS_HOST=1 declares the host to
be in FIPS mode: the harness then fails closed the first time it installs
the crypto provider on a host that is not, and every FIPS behavior test
(tests/integration/tests/suite/fips.rs, server/tests/fips_required.rs)
insists on its approved-mode branch instead of keying on whatever the
provider reports. PRAXIS_REQUIRE_FIPS=1 makes every proxy the suites start
enforce FIPS mode. PRAXIS_TEST_FIPS_PROVIDER=1 arms the hash and
aws_sigv4_sign unit tests’ assertion that the provider is in approved
mode. The cargo home and target directory live in the named volumes
praxis-ai-fips-host-cargo and praxis-ai-fips-host-target, so a second
run is incremental; the container runs as the invoking user
(--userns=keep-id), because praxis-ai refuses to start as root.
On a developer machine that is not in FIPS mode, make test-integration-fips
and make test-schema-fips still run the same suites as the FIPS build; the
FIPS behavior tests then assert their non-approved branch, so both sides of
every expectation stay exercised everywhere.
The runner job
The fips-host job of the FIPS workflow runs on a self-hosted RHEL 9
runner in FIPS mode, selected by the labels fips and rhel. It tests the
exact image the hosted ubi-image job built and scanned, handed over as an
artifact and checked by image id, then runs make fips-host-check,
make test-fips-host and make fips-runtime-probe through
.github/actions/fips-host; the release workflow requires a recorded green
fips-host run for the exact commit being released (its fips-proof gate
polls out a run still in flight) and runs the same composite action (without
the suites) against the pushed -fips image, pulled by digest. That digest
attestation runs alongside the release rather than gating it: an offline
runner leaves a self-hosted job queued, never skipped, so it reports red on
the release run instead of holding the release back. Releasing without the
recorded proof takes the release workflow’s explicit skip-fips-proof
dispatch input, so a runner outage is a visible maintainer decision, never
a silent pass.
The runner needs git, make, podman (rootless), gnupg2, gcc,
gcc-c++, cmake and openssl-devel, checked up front by
.github/actions/fips-runner-check, and its runner group must grant this
repository access. The job never runs a fork’s code (same-repository pull
requests only) and never runs in the merge queue, so queue throughput does
not depend on the single runner. The attestation and the probe log are
uploaded as artifacts on every run.
Updating the pinned base image
The digests live in the Makefile (FIPS_UBI9_DIGEST,
FIPS_UBI9_MINIMAL_DIGEST) and, as defaults, in Containerfile.fips. To move
to a newer UBI 9:
curl -sI -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
https://registry.access.redhat.com/v2/ubi9/ubi/manifests/latest | grep -i docker-content-digest
cargo xtask fips verify-image registry.access.redhat.com/ubi9/ubi@sha256:<new digest>
Only update both places once the verification passes.