FIPS 140-3 mode¶
Klangk can run its workspace containers with a FIPS 140-3 validated cryptographic module for deployments that must demonstrate use of validated cryptography (federal, defense, regulated industry). This chapter explains what is covered, what the validation boundary is, and how to build and use the FIPS workspace image — and, for the containerized-backend deployment, the FIPS host image.
Background: what "FIPS mode" means here¶
FIPS 140-3 validation applies to a specific cryptographic module —
for OpenSSL, the FIPS provider (fips.so) — not to an application or
a container. The OpenSSL project's validated module is
- OpenSSL 3.1.2 FIPS Provider, CMVP certificate #4985, FIPS 140-3 Level 1, active until 2030-03-10.
That module is forward-compatible: per the validation announcement it may be hosted by "any version of OpenSSL 3.0, 3.1, 3.2, 3.3, 3.4 and future 3.5" libcrypto. Klangk's image uses this property — the module runs on top of the distribution's own libcrypto (Debian trixie ships 3.5.x), so nothing in the base image needs to be replaced or rebuilt.
An OpenSSL 3.5.4-based module is in CMVP review; when its certificate issues, the image can swap the module in place (see Upgrading the module).
What the FIPS image does¶
src/containers/workspace/Dockerfile.fips builds on the regular
workspace image and
- compiles OpenSSL 3.1.2 with
enable-fipsfrom a sha256-pinned tarball (checksum verified against the OpenSSL project's published value) and extracts onlyfips.so; - installs it into the multiarch provider directory;
- runs
openssl fipsinstallwith the system CLI, which verifies the module's self-tests and writes the module MAC data; - writes an activation config that loads the
fipsandbaseproviders and not thedefaultprovider — so non-approved algorithms (MD5, DES, …) are rejected rather than silently available — and pins ambient fetches tofips=yes(default_propertiesin thealg_section), the config-level form of the countermeasure klangkd applies in its own process (#3350). The pin also makes Node'scrypto.getFips()report1, so FIPS-aware libraries select approved algorithms — without it, jiti (pi's TypeScript extension loader) computed its cache-key hash with MD5 and every pi extension failed to load (#3359); - exports
OPENSSL_CONF(andOPENSSL_MODULES, needed by Node.js) image-wide and in/etc/profile.d/so login shells reached throughsudo -i/suare covered too; - fails the build if the provider does not activate, if
openssl md5succeeds, or if Node.js can compute an MD5 digest.
Building¶
# produce the regular workspace image first
devenv shell -- bash scripts/build-workspace-image.sh
# then the FIPS layer (default base is the locally built image)
devenv shell -- podman build \
-f src/containers/workspace/Dockerfile.fips \
-t klangk-workspace-fips \
src/containers/workspace
To build on a published image instead, override the base tag — the
registry carries <calver>-<commit> tags plus the immutable vX.Y.Z
release tags, never :latest:
podman build \
--build-arg WORKSPACE_IMAGE=ghcr.io/mcdonc/klangk/klangk-workspace:2026.08.19-<commit> \
-f src/containers/workspace/Dockerfile.fips -t klangk-workspace-fips \
src/containers/workspace
Point klangkd at the image with KLANGKD_IMAGE_NAME=klangk-workspace-fips.
The containerized backend (FIPS host image)¶
When klangkd runs inside a container (the docker host-container
deployment, src/containers/host/Dockerfile), its own OpenSSL is
the crypto boundary for operations that no workspace gate covers:
password hashing (hashlib.pbkdf2_hmac, auth.py), JWT HMAC-SHA256
signing (python-jose), and outbound TLS (ssl/httpx to the LLM proxy,
OIDC discovery, SMTP). A stock host image ships Debian OpenSSL with no
provider activated — outside the validated boundary.
src/containers/host/Dockerfile.fips fixes that by layering
the same validated module onto the host image, and swaps the embedded
workspace tarball for the FIPS workspace image's — a FIPS host must
ship a FIPS workspace, or every workspace start would fail the
KLANGKD_FIPS_MODE gate:
# prerequisites: the stock host image and the FIPS workspace image
devenv shell -- bash scripts/build-host-image.sh
devenv shell -- bash scripts/build-fips-image.sh
# then the FIPS host layer
devenv shell -- bash scripts/build-fips-host-image.sh
# → klangk-host-fips:latest (+ :<version>)
Build-time proof mirrors the workspace variant and additionally proves
what klangkd's runtime boot gate checks: the provider activates,
CLI/python MD5 are rejected, and the real auth KDF
(hashlib.pbkdf2_hmac("sha512", …)) still works.
CI builds and publishes this image on every change to its inputs
(image-host-fips.yml): ghcr.io/mcdonc/klangk/klangk-host-fips,
tagged :<calver>-<commit> plus a floating :latest. A v* tag push
publishes the same image — and the FIPS workspace image — under the
immutable vX.Y.Z tag as well: release.yml calls the same image
workflows, so a release pins an auditable host/workspace combination
(#3140). The workflow's runtime spot-check runs klangkd's actual boot
gate both ways — it must pass inside the FIPS image and refuse to boot
inside the stock one.
Enforcement posture inside a container: with
KLANGKD_FIPS_MODE on, klangkd detects it is containerized (the
/.dockerenv / /run/.containerenv markers) and a failed process
probe aborts the boot — inside an image we ship there is no "the
control host is the operator's problem" excuse. On a control host
(not containerized) the failed probe remains a logged warning and only
workspace containers are gated. Boundary notes for this deployment:
| Component in the host container | Covered? | Why |
|---|---|---|
| klangkd's python crypto (PBKDF2, JWT, outbound TLS) | Yes | python:3.14-slim's _hashlib/_ssl link the distro libcrypto, which loads the validated provider via OPENSSL_CONF. |
| Embedded workspace + sidecar images | Yes | The workspace tar embedded by this variant IS the FIPS workspace image; the sidecar makes no crypto choices of its own. |
| Caddy (reverse proxy) | No | Go binary with statically linked crypto — never routes through libcrypto. TLS termination at the proxy is already out of scope below. |
| Podman / passt inside the container | No | Go/Rust static crypto (registry pulls over TLS). Pull integrity is governed by the signature policy, not the FIPS module. |
The validation boundary¶
Inside the workspace container:
| Component | Covered? | Why |
|---|---|---|
openssl CLI, curl, git (https), distro python _ssl/_hashlib |
Yes | Dynamically link the distro libcrypto.so.3, which loads the FIPS provider via OPENSSL_CONF. |
Node.js crypto/tls (including the pi coding agent) |
Yes | Node's bundled libcrypto reads the nodejs_conf config section (the image aliases it) and loads the validated fips.so via OPENSSL_MODULES. Verified: createHash('md5') is rejected, TLS and approved digests route through the provider, and the config-level fetch pin makes crypto.getFips() report 1 — jiti (pi's TS extension loader) relies on that to pick SHA-256 for its cache-key hash (#3359). No Node rebuild is involved — provider-based FIPS is a runtime configuration of the OpenSSL that Node already links. |
CPython's hashlib.md5() |
No — by design | CPython falls back to its built-in _md5 module when the provider refuses; that code never touches OpenSSL and no provider can gate it. This is PEP 452's usedforsecurity behavior: legacy non-security MD5 (etags, cache keys) keeps working. Klangk itself does not call MD5 anywhere. |
| Statically-linked crypto in third-party tooling users install | No | Outside the provider model entirely; such tools bring their own crypto. |
Out of scope (the deploying organization's responsibility): TLS termination at the reverse proxy, kernel crypto (dm-crypt, IPsec), key management for database encryption, and the ATO decision itself.
Runtime verification¶
To confirm FIPS is genuinely active, probe the provider path, not
hashlib.md5() (see the table above):
# CLI: fips + base active, default absent
openssl list -providers
# Provider-aware python probe: must raise ValueError under FIPS
python3 -c "import _hashlib; _hashlib.openssl_md5(b'x')"
# -> ValueError: [digital envelope routines] unsupported
# Node.js probe
node -e "require('crypto').createHash('md5').update('x').digest('hex')"
# -> error:0308010C ... unsupported
KLANGKD_FIPS_MODE=1 automates this at runtime: with
the mode on, every workspace container is probed when klangkd starts
or adopts it — fresh creates at the create choke point, and
previously-running containers on the first reconnect after a klangkd
restart — by the same distro-agnostic checks (a non-approved digest
must be rejected on the OpenSSL fetch path, or openssl
list -digest-algorithms -propquery 'fips=yes' shows a SHA-2-only
approved set). A container that cannot prove enforcement is removed
and its start refused. Neither a SIGHUP reload nor a klangkd restart
leaves an unprobed container serving: SIGHUP's runtime restart stops
all containers, and the startup reap removes any leftover before
recreation. (The one residual window — a container whose startup reap
failed on a best-effort error — is closed by the adoption probe at
the next connect.) The klangkd process's own OpenSSL is probed once at
startup: on a control host the result is logged for audit (warning on
failure), while a containerized backend refuses to boot on a failed
probe (see the containerized backend
above); see the environment
reference for the setting.
The same startup verifies the JWT route (#3175): python-jose must bind
its cryptography backend, and cryptography must be linked to the
process's own provider-gated OpenSSL (identity + MD5 refusal — see the
cryptographic inventory).
The manual probes above remain the diagnostic equivalent.
Caveat on the probe's meaning: it is a canary for provider
enforcement, not a certificate validator. An OpenSSL built without
MD5 at all (no FIPS provider involved) would also pass layer 1; the
audit trail for "which validated module" comes from the image build
(Dockerfile.fips pins the CMVP-certified tarball), not from the
runtime probe.
Upgrading the module¶
When a newer validated module becomes available (e.g. the OpenSSL
3.5.4 module currently in CMVP review), the change is confined to the
builder stages: bump OPENSSL_FIPS_VERSION and OPENSSL_FIPS_SHA256
to the new tarball in both src/containers/workspace/Dockerfile.fips
and src/containers/host/Dockerfile.fips. The activation steps are
unchanged. Always re-run the build-time proof and the runtime probes
above before rolling out.
FIPS posture decision (V-222555)¶
STIG V-222555 requires cryptographic modules to be FIPS 140-2/3
validated and used in FIPS mode. Klangk ships FIPS as an opt-in
deployment posture — the operator enables it with
KLANGKD_FIPS_MODE=true and uses the FIPS images — rather than
making it the default. Rationale:
- Not every deployment requires FIPS. Development, evaluation, and non-federal deployments should not pay the operational overhead (separate image, restricted algorithm set, provider enforcement) when they have no compliance requirement.
- Opt-in is explicit and auditable. The gate (
KLANGKD_FIPS_MODE) makes the posture a deliberate deployment decision, recorded in the environment configuration — never an accident of image selection. When the mode is on, enforcement is comprehensive: the boot gate verifies the process's OpenSSL provider, every workspace container is probed at start and adoption, and the JWT route is verified at startup (backend binding plus cryptography's linkage to the validated libcrypto). - Default-off does not weaken FIPS deployments. The FIPS images carry the validated module and activate it unconditionally. The gate adds runtime verification — it does not install the module. A FIPS deployment uses the FIPS image and the gate; a non-FIPS deployment uses the stock image with no gate.
Organizations subject to V-222555 deploy with the FIPS host image
(klangk-host-fips) and KLANGKD_FIPS_MODE=true. The STIG reviewer
should confirm both are present in the deployment configuration.
Cryptographic inventory¶
Complete list of cryptographic operations klangkd performs, the module each routes through, and the FIPS validation status under the FIPS image:
| Operation | Algorithm | Code path | Module | FIPS-validated? |
|---|---|---|---|---|
| Password hashing | PBKDF2-HMAC-SHA512 (600k iterations) | hashlib.pbkdf2_hmac (auth.py) |
OpenSSL _hashlib → libcrypto → FIPS provider |
Yes (CMVP #4985) |
| JWT signing/verification | HMAC-SHA256 (HS256) | python-jose → cryptography backend → cryptography.hazmat.primitives.hmac.HMAC (auth.py) |
distro libcrypto, via the FIPS image's cryptography relink (see below) |
Yes (CMVP #4985) |
| DPoP proof verification | ECDSA P-256 (ES256) + SHA-256 | cryptography directly — ec.EllipticCurvePublicNumbers.verify + hashlib.sha256 (dpop.py, #3218; deliberately not jose's EC route, which could bind to the pure-Python ecdsa package) |
distro libcrypto, via the FIPS image's cryptography relink (see below) |
Yes (CMVP #4985) |
| Outbound TLS | TLS 1.2/1.3 | ssl module / httpx (LLM proxy, OIDC, SMTP) |
OpenSSL _ssl → libcrypto → FIPS provider |
Yes (CMVP #4985) |
| CA fingerprinting (allowlist) | SHA-256 (cert DER) | cryptography Certificate.fingerprint (ssl_trust.py, #3198) |
distro libcrypto, via the FIPS image's cryptography relink (see below) |
Yes (CMVP #4985) |
| Password timing equalization | HMAC comparison | hmac.compare_digest (auth.py) |
C-level constant-time compare (no crypto module) | N/A (comparison only) |
| Token identifiers | UUID4 | uuid.uuid4 / secrets.token_bytes |
OS urandom |
N/A (randomness source, not a crypto algorithm) |
JWT module boundary (#3175): the JWT row above holds because the
FIPS host image rebuilds cryptography from source against the
distro libcrypto — a PyPI manylinux wheel statically links a
private OpenSSL that never reads OPENSSL_CONF and cannot load the
validated provider. This is the industry posture (Red Hat ships
distro-linked python3-cryptography; Chainguard's FIPS images relink
it; pyca's own docs direct FIPS users to pip install --no-binary
cryptography). One wrinkle (#3350): cryptography unconditionally
loads OpenSSL's default provider at import (its rust init calls
OSSL_PROVIDER_load(NULL, "default")), so property-less fetches in
any process that imported it — MD5 among them — are served from that
unvalidated provider even under a fips-only activation config. The
countermeasure is EVP_default_properties_enable_fips — the same
call cryptography's own enable_fips makes — which requires
fips=yes on every subsequent fetch: MD5 is refused again while
approved algorithms keep working through the fips provider. The FIPS
images set the same pin config-wide since #3359
(default_properties = fips=yes in the activation config), so every
process in the container starts with ambient fetches already pinned;
klangkd's runtime pin stays in place as the control-host path and is
idempotent under the image pin. The
relink and the pin are proven together: at image build time (each
probe check prints a numbered marker; the cryptography checks pin
fetches first — cryptography's OpenSSL version must equal the
process's, MD5 through cryptography must be refused, and a jose HS256
sign/verify round-trip must succeed under the active provider), and
at klangkd startup under KLANGKD_FIPS_MODE — once the process
posture probe confirms the fips provider is active, klangkd pins
ambient fetches to fips=yes and then verifies that python-jose
binds its cryptography backend (a silent fallback to another
backend would be an unprovisioned, unverified route and aborts the
boot) and that cryptography's OpenSSL is the process's own
provider-gated library (identity check + MD5 refusal; a failed pin or
mismatched linkage follows the same warn-on-host / abort-in-container
posture as the process OpenSSL probe).
Outside the module: a stock host image (or any deployment using
the PyPI cryptography wheel) signs JWTs on the wheel's private
OpenSSL — outside every validated boundary, exactly like Caddy's
statically linked Go crypto. FIPS-scoped deployments must use the
FIPS host image; the startup gate makes the difference auditable.
Algorithms not used: bcrypt (removed in #2576, bundles non-FIPS
crypto), MD5, DES, RC4, or any non-approved digest. The FIPS provider
activation config disables the default OpenSSL provider, so
non-approved algorithms fail closed even if called accidentally.
Why the container meets FIPS requirements¶
The strongest form of the argument, in the order an assessor is likely to probe it:
- The validated thing is the module, and we use that exact
module. FIPS 140-3 validates a specific cryptographic module
binary — here the OpenSSL 3.1.2 FIPS Provider, CMVP certificate
#4985. The image compiles that exact version from the official
tarball (sha256-pinned to the OpenSSL project's published checksum)
with the required
enable-fipsconfiguration. The provenance chain is reproducible from the Dockerfile. - The hosting arrangement is the one the certificate's owner documents. The OpenSSL validation announcement states the module "is compatible with any version of OpenSSL 3.0, 3.1, 3.2, 3.3, 3.4 and future 3.5" — forward-compatibility across libcrypto cores is a supported, endorsed deployment mode, not an ad-hoc stretch. Debian trixie's 3.5.x libcrypto and Node.js's bundled 3.5.7 libcrypto both host the module under that allowance. This is also how the OpenSSL maintainers themselves direct distributors to combine a validated FIPS provider with a current libcrypto.
- The module operates in its approved mode. Validation applies
"when operated in approved mode": the activation config loads
fips(and the non-cryptographicbaseprovider) and disablesdefault, so the module's power-on self-tests run (enforced byfipsinstallat build time) and non-approved algorithms fail closed — verified for the CLI, python's OpenSSL path, and Node.js, at build time (the image refuses to build otherwise) and re-checkable at runtime with the probes above. - Power-on self-test evidence is generated, not assumed.
fipsinstallexecutes the module's KAT/PCT suite and writes the module-MAC configuration the provider verifies on every load. A tampered module or config fails self-test and the provider enters its error state — the failure mode is visible, not silent. - Platform portability is sanctioned. The module's own Security Policy (the certificate #4985 entry in the CMVP registry) defines its operational environment; the OpenSSL validation announcement states the module "is compatible with any version of OpenSSL 3.0, 3.1, 3.2, 3.3, 3.4 and future 3.5". The container runs the validated module binary unmodified on general-purpose compute (Debian userspace on a Linux kernel) and does not alter the module or its boundary.
- What is claimed is scoped honestly. The claim is about the
workspace container's cryptographic services: TLS, hashing, KDFs,
signatures, and encryption performed via OpenSSL (system CLI,
dynamically-linked tools, python
_ssl/_hashlib, Node.jscrypto/tls). The boundary table above states what is outside (CPython's built-in_md5fallback, statically-linked crypto in user-installed tooling) and the deploying organization's responsibilities (proxy TLS, kernel crypto, key management, the ATO). An assessor can verify each line of the table empirically with the documented probes.
The honest counterpoints an assessor may raise — and the answers — are in the Notes for auditors section below; the strongest residual risk is the last item there (Node.js ships its own bundled OpenSSL core, a Node-maintained build of upstream OpenSSL — not the Debian package), which the module's forward-compatibility allowance addresses but a strict single-core reading may not accept.
Notes for auditors¶
- The validated module is the OpenSSL 3.1.2 FIPS Provider, certificate #4985, overall Level 1. Hosted by Debian trixie's libcrypto 3.5.x under the module's documented forward-compatibility ("any version of OpenSSL 3.0 … future 3.5").
- Node.js's bundled libcrypto (3.5.x) hosts the same validated module; the Node.js project documents that no rebuild is required for provider-based FIPS.
- The image disables the
defaultprovider, so non-approved algorithms fail closed rather than falling back. - Organizations requiring a single-OpenSSL-core boundary can build
Node.js
--shared-opensslagainst the distro libcrypto; klangk does not ship such a build today (defense-in-depth refinement, not a correctness requirement). - One residual consideration: Node.js's bundled libcrypto is a
Node-maintained build of upstream OpenSSL (not the Debian package).
It loads the same validated
fips.sounder the same forward- compatibility allowance; provider-based FIPS is a runtime configuration of that OpenSSL (thenodejs_confsection andOPENSSL_CONFmechanism Node documents), so no rebuild is involved. A strict single-core reading can be satisfied with the--shared-opensslbuild above.