---
status: accepted
date: 2026-05-14
decision-makers: Constructor Fabric Steering Committee
---

# Workspace-level FIPS dependency policy via cargo-deny

**ID**: `cpt-toolkit-adr-fips-dependency-policy`

## Table of Contents

<!-- generated by `cypilot toc` -->

## Background

The macOS-FIPS work ([ADR 0001](0001-macos-fips-via-corecrypto-provider.md), [ADR 0004](0004-macos-server-side-tls-via-corecrypto.md)) and Windows-FIPS plan ([ADR 0003](0003-windows-fips-via-rustls-cng-crypto.md)) route the **TLS data path** through validated cryptographic modules (Apple corecrypto, Windows CNG, AWS-LC FIPS). That gives a defensible claim "TLS cryptographic operations performed within a FIPS 140-3 validated cryptographic module."

It does **not** stop application code or transitive dependencies from calling **non-approved cryptography directly** — e.g. a third-party crate `use sha1::*` for cache keys, or a JWT library reaching for MD5. Those calls happen outside our validated boundary and would silently weaken any "FIPS-conformant build" claim. The FIPS audit (PRD §7.2) already flags this gap.

Go 1.25's `fips140=only` mode addresses this at language-runtime level: any non-approved algorithm call panics at runtime. Rust has no equivalent: there is no stdlib crypto, every crate brings its own primitive implementation, and there is no global hook to redirect or refuse those calls.

This ADR captures the workaround: **dependency-graph policy enforced at build time** via `cargo-deny`. When `--features fips` is on, the build is refused if non-FIPS-validated crypto crates exist anywhere in the resolved dependency graph.

## Considered Options

* **Option A** — `cargo-deny` `[bans]` policy enforced under a separate config — **chosen**
* **Option B** — Workspace-wide custom `dylint` rule that refuses `use md5;` / `use sha1;` / etc. at gear level
* **Option C** — Runtime check at `init_crypto_provider`: probe loaded libraries with `dladdr` / `vmmap`, refuse to start if non-validated crypto libs are present
* **Option D** — Accept the current gap; document as a known limitation

## Decision Outcome

Chosen option: **Option A — `cargo-deny` `[bans]` with a dedicated `deny-fips.toml` config**.

Key reasons:

* `cargo-deny` already runs in CI via `make deny` and the team is familiar with its config shape.
* `[bans]` enforces at dep-graph resolution time, before any compilation — catches accidental additions in the smallest possible blast radius.
* The `[graph] features = ["fips"]` directive scopes the policy to the FIPS build, so non-fips developer workflows are unaffected.
* `make fips-policy` is the explicit gate; CI wires it into `make security`.
* Option B (dylint) is complementary but lower priority — catches workspace-internal `use` paths that bypass the graph (rare). Tracked separately as Phase C below if/when needed.
* Option C (runtime probe) was rejected: brittle (every macOS update changes loader behavior), opaque to operators, and doesn't catch a non-FIPS crypto crate that compiles in but doesn't call `dlopen`.
* Option D was rejected: the gap is documented in PRD §7.2 already; closing it via build-time policy adds measurable assurance with low effort.

### Consequences

* New file [`deny-fips.toml`](../../../../deny-fips.toml) at workspace root, separate from `deny.toml`. Inherits advisories + licenses but adds a strict `[bans]` deny list scoped to `features = ["fips"]`.
* New `make fips-policy` target; CI security pipeline runs `make security` which now includes both `deny` (license/advisory) and `fips-policy` (deny-list).
* New contributors adding a non-FIPS-validated crypto crate to the dep graph will be rejected at PR time.
* Phasing discipline: deny list expands in named phases (A, B, ...) as transitive dependencies are cleaned up — see [Phasing](#phasing) below.

### Confirmation

* `make fips-policy` exits clean on the current workspace (verified at landing time, no Phase A crate present in `--features fips` graph).
* Any future PR that adds, e.g., `chacha20poly1305` as a dep on Cargo.toml of a workspace member, fails `make fips-policy` with a precise pointer to the offending crate.

## Phasing

The deny list grows in phases, each verified to keep `make fips-policy` green before merging.

### Phase A — Shipped now (zero-pain regression gate)

Bans crates that are **NOT** currently pulled transitively under `--features fips`. Cost-free regression gate; future PRs adding them fail.

Categories:

| Category | Crates banned in Phase A |
|---|---|
| Non-approved hashes | `md2`, `md4`, `groestl`, `ripemd`, `tiger`, `streebog`, `whirlpool` |
| Non-approved AEAD / stream / authenticator | `chacha20poly1305`, `salsa20`, `xsalsa20poly1305`, `xchacha20poly1305` |
| Curve25519 family (non-approved) | `x25519-dalek`, `curve25519-dalek`, `ed25519-dalek`, `ed25519`, `ed25519-compact` |
| TLS / crypto frameworks outside our chain | `openssl`, `openssl-sys`, `boring`, `boring-sys`, `native-tls`, `tokio-native-tls`, `async-native-tls` |
| Alternative rustls providers | `rustls-symcrypt`, `rustls-mbedcrypto-provider`, `rustls-openssl`, `rustls-rustcrypto`, `rustls-graviola`, `rustls-wolfcrypto-provider`, `boring-rustls-provider` |

### Phase B — Pending transitive cleanup

The following crates are **legitimately transitively pulled** by upstream dependencies under `--features fips`. Each requires upstream-side work before it can be moved to the deny list. Snapshot verified at landing time via `cargo tree -p cf-gears-fips-probe --features fips`:

| Crate (currently in graph) | Pulled by (representative upstream) | Cleanup approach |
|---|---|---|
| `ring` 0.17 | `pingora-rustls` / `ureq` | Migrate JWT verify to a FIPS-friendly backend |
| `aws-lc-rs` 1.x (non-FIPS) | rustls's default features | Use `rustls/fips` exclusively on Linux; macOS/Windows already excluded via shim — but the non-FIPS crate is pulled into the same binary by Cargo feature unification |
| `chacha20` 0.10 | `rand` ecosystem | Reroute `rand`'s ChaCha-based DRBG through our provider, or switch to a SecRandom-based source |
| `md-5`, `sha1`, `blake2`, `blake3`, `aes`, `hmac`, `hkdf`, `pbkdf2`, `rsa`, `p256`, `p384`, `k256` (if added in future) | TBD audit | Replace upstream call sites with our `rustls::crypto::*` impls or remove the upstream |

These will be denied as their upstream usage is removed. Each move from Phase B → Phase A requires a workspace PR that documents the upstream-side fix. PRD §10 TODO-7 tracks this work overall.

### Phase C — Source-level enforcement (deferred)

If even stricter assurance is needed later, a custom `dylint` lint can walk `use` and `extern crate` paths at the source-code level. This catches a forbidden crate that is **in** the dep graph but the workspace's own code does not directly import — covering workspace discipline beyond what `cargo-deny` enforces. Not built today because Phase A + B closes the practical gap.

## Pros and Cons of the Options

### Option A — `cargo-deny` `[bans]` (chosen)

* Good: tool already in the build pipeline; team familiar.
* Good: catches additions at dep-graph resolution, before compile.
* Good: `[graph] features = ["fips"]` scopes the policy to FIPS build, no impact on non-fips developer workflow.
* Good: phased deny-list approach gives a clear road-map for moving Phase B crates to Phase A as transitives are cleaned.
* Neutral: two `*.toml` configs to maintain (`deny.toml` + `deny-fips.toml`); minor cognitive overhead.
* Bad: does not catch a forbidden crate that is in the graph but only used by transitive deps (a workspace member could `use` it via re-export tricks). Phase C / dylint covers that.

### Option B — `dylint` rule (deferred)

* Good: covers source-level imports that `cargo-deny` cannot.
* Bad: no protection against transitive crates linked into the binary but `use`d only by transitive code — `cargo-deny` protects against that.
* Bad: dylint rules are more expensive to author and maintain.
* Deferred until measurable signal that workspace-internal imports bypass the graph policy.

### Option C — Runtime probe via dladdr / vmmap

* Good: catches dynamically-loaded crypto.
* Bad: brittle across OS releases.
* Bad: opaque to operators ("why won't my service start?").
* Bad: misses the case of a non-FIPS crypto crate linked statically but not actively called yet (could be triggered later).

### Option D — Accept current gap

* Good: zero implementation cost.
* Bad: FIPS claim materially weaker than Go 1.25's; gap is closable with little effort.

## More Information

* [`deny-fips.toml`](../../../../deny-fips.toml) — full ban list with rationale per crate.
* [`Makefile`](../../../../Makefile) — `make fips-policy` target.
* Go 1.25 `fips140=only` documentation: <https://pkg.go.dev/crypto/fips140>.

## Traceability

- **DESIGN**: [`deny-fips.toml`](../../../../deny-fips.toml), [`Makefile`](../../../../Makefile) (`fips-policy` target).
- **Related ADRs**: [ADR 0001](0001-macos-fips-via-corecrypto-provider.md), [ADR 0002](0002-fips-feature-target-conditional-shim.md), [ADR 0003](0003-windows-fips-via-rustls-cng-crypto.md), [ADR 0004](0004-macos-server-side-tls-via-corecrypto.md).
- **Strategy doc**: [FIPS PRD](../PRD.md) — §7.2 calls out the non-TLS-crypto-in-binary gap; this ADR is its build-time enforcement.

This decision directly addresses:

* `cpt-toolkit-nfr-fips-boundary-strictness` — Build-time enforcement that the FIPS boundary contains only validated cryptographic modules.
* `cpt-toolkit-design-fips-supply-chain` — Workspace deny-list ensures the dep graph cannot drift into non-validated crypto without explicit governance.
