Skip to content

Commit 76db87c

Browse files
authored
Merge slice/003-fips-build-leg
Slice 003: FIPS build leg in CI, from source. PROOF.md at slices/003-fips-build-leg/PROOF.md; gate, postgres and fips jobs green on the closing tree. Signed-off-by: Ayla Croft <aylacroft@proton.me>
2 parents 1b73b70 + ae140f6 commit 76db87c

22 files changed

Lines changed: 1082 additions & 7 deletions

‎.github/workflows/fips-image.yml‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Builds the FIPS leg's image (ci/fips/Containerfile, docs/fips-leg.md) and pushes it to the
5+
# repository's container registry under the tag scripts/fips_image_tag.sh derives from the
6+
# files that define it. It runs when one of those files changes and on demand; the gate's
7+
# `fips` job pulls the tag and never builds. A tag that already exists is not rebuilt: the
8+
# image is a function of its inputs, and OTP from source is the slow part.
9+
name: fips-image
10+
11+
on:
12+
push:
13+
paths:
14+
- .tool-versions
15+
- ci/fips/Containerfile
16+
- scripts/fips_image_tag.sh
17+
- .github/workflows/fips-image.yml
18+
workflow_dispatch:
19+
20+
permissions:
21+
contents: read
22+
packages: write
23+
24+
jobs:
25+
image:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
29+
30+
- name: The tag, and the versions .tool-versions names
31+
id: meta
32+
run: |
33+
echo "ref=$(scripts/fips_image_tag.sh --ref)" >> "$GITHUB_OUTPUT"
34+
echo "otp=$(awk '$1 == "erlang" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT"
35+
echo "elixir=$(awk '$1 == "elixir" { print $2 }' .tool-versions | cut -d- -f1)" >> "$GITHUB_OUTPUT"
36+
37+
- name: Log in to the registry
38+
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
39+
40+
- name: Skip when this tag is already published
41+
id: exists
42+
run: |
43+
if docker manifest inspect "${{ steps.meta.outputs.ref }}" >/dev/null 2>&1; then
44+
echo "published=true" >> "$GITHUB_OUTPUT"
45+
echo "::notice::${{ steps.meta.outputs.ref }} is already published; nothing to build"
46+
else
47+
echo "published=false" >> "$GITHUB_OUTPUT"
48+
fi
49+
50+
- name: Build
51+
if: steps.exists.outputs.published == 'false'
52+
run: |
53+
docker build \
54+
--build-arg "OTP_VERSION=${{ steps.meta.outputs.otp }}" \
55+
--build-arg "ELIXIR_VERSION=${{ steps.meta.outputs.elixir }}" \
56+
-t "${{ steps.meta.outputs.ref }}" \
57+
-f ci/fips/Containerfile ci/fips
58+
59+
- name: Push
60+
if: steps.exists.outputs.published == 'false'
61+
run: |
62+
docker push "${{ steps.meta.outputs.ref }}"
63+
docker image inspect "${{ steps.meta.outputs.ref }}" --format '{{ index .RepoDigests 0 }}'

‎.github/workflows/gate.yml‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,100 @@ jobs:
100100

101101
- run: mix ecto.reset
102102
- run: mix test --exclude sqlite
103+
104+
# Slice 003: the same gate on an OTP built from source with --enable-fips, in FIPS mode,
105+
# against the FIPS provider of a UBI9 container (docs/fips-leg.md). A second job rather than
106+
# a matrix entry, as `postgres` is, so `gate` keeps its context name. The image is built by
107+
# fips-image.yml and pulled here by the tag scripts/fips_image_tag.sh derives from the
108+
# files that define it; this job never builds it.
109+
fips-tag:
110+
runs-on: ubuntu-latest
111+
outputs:
112+
ref: ${{ steps.tag.outputs.ref }}
113+
steps:
114+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
115+
- id: tag
116+
run: echo "ref=$(scripts/fips_image_tag.sh --ref)" >> "$GITHUB_OUTPUT"
117+
118+
fips:
119+
needs: fips-tag
120+
runs-on: ubuntu-latest
121+
container:
122+
image: ${{ needs.fips-tag.outputs.ref }}
123+
credentials:
124+
username: ${{ github.actor }}
125+
password: ${{ secrets.GITHUB_TOKEN }}
126+
env:
127+
MIX_ENV: test
128+
TRINITY_DB: sqlite
129+
# test/test_helper.exs includes the :fips tests under this, and test/fips/mode_test.exs
130+
# asserts the runtime agrees, so a leg that failed to enter the mode is red, not quiet.
131+
TRINITY_FIPS_LEG: "1"
132+
# FIPS mode is entered by OTP's own configuration, read by the crypto NIF when it loads,
133+
# which needs the crypto application loaded first; ERL_AFLAGS is prepended to every
134+
# erl command line, so every `mix` and `elixir` here runs in the mode. Measured at G1:
135+
# ERL_FLAGS alone leaves the mode off (the NIF loads before the application does).
136+
ERL_AFLAGS: "-crypto fips_mode true -eval application:load(crypto)"
137+
steps:
138+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
139+
with:
140+
fetch-depth: 0
141+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
142+
143+
# The checkout action marks the workspace safe under a temporary HOME it removes when
144+
# it is done; every later git call in the container (plan_check, the names, secrets and
145+
# reuse censuses, all of which read `git ls-files`) then hits git's dubious-ownership
146+
# refusal and reads an empty tree. Found on run 35537462271: plan_check rule 4 named
147+
# every slice as missing its SLICE.md.
148+
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
149+
150+
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
151+
with:
152+
path: |
153+
deps
154+
_build
155+
key: fips-mix-${{ needs.fips-tag.outputs.ref }}-${{ hashFiles('mix.lock') }}
156+
restore-keys: fips-mix-${{ needs.fips-tag.outputs.ref }}-
157+
158+
# The one step outside the mode. The fetch, because Hex's client offers TLS 1.0 and 1.1
159+
# beside 1.2 and ssl in the mode refuses the set (insufficient_crypto_support); the
160+
# dependency compile, because rustler_precompiled downloads mdex's NIF at compile time
161+
# and OTP's TLS 1.3 client in the mode fails that host's HelloRetryRequest
162+
# (erlang/otp#8470). docs/fips-leg.md findings 1 and 2; neither is what the leg
163+
# measures. The gate's own hex.audit step clears ERL_AFLAGS for the same reason (mix.exs).
164+
- name: Fetch and compile dependencies (FIPS mode off)
165+
env:
166+
ERL_AFLAGS: "-eval application:load(crypto)"
167+
run: |
168+
mix deps.get
169+
mix deps.compile
170+
171+
# AC1, before anything else runs: the runtime is in the mode and names the provider.
172+
- name: The runtime is in FIPS mode
173+
run: |
174+
erl -noshell -eval 'enabled = crypto:info_fips(), io:format("~p~n", [crypto:info()]), halt().'
175+
176+
# AC3, the default half: the listing with the mode off equals the committed file; the
177+
# mode-on half is test/fips/mode_test.exs, inside the suite.
178+
- name: crypto:supports/0 with the mode off matches docs/fips-leg/supports-default.txt
179+
env:
180+
ERL_AFLAGS: "-eval application:load(crypto)"
181+
run: elixir scripts/crypto_supports.exs | diff - docs/fips-leg/supports-default.txt
182+
183+
- run: ./scripts/plan_check.sh
184+
185+
- name: DCO sign-off present on every commit
186+
run: |
187+
git log --format=%B ${{ github.event.pull_request.base.sha || 'HEAD~1' }}..HEAD 2>/dev/null \
188+
| grep -q '^Signed-off-by: ' || {
189+
echo "::error::a commit in this range has no Signed-off-by line"; exit 1; }
190+
191+
- run: mix gate
192+
193+
# AC2 and AC3 by test name with this run's id in the log, and AC5's coverage line for
194+
# this leg. The gate above already ran the suite; this repeats the four FIPS tests with
195+
# their names printed, then the whole suite once more under the coverage tool.
196+
- name: The FIPS tests by name, and this leg's coverage
197+
run: |
198+
mix test --trace test/fips
199+
mix test --cover | grep -E 'Result:|\| *Total'

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ standards register names the rows that ask for them.
3232
| 000 | Toolchain, repo bootstrap, quality gate | 0 Foundation | L | none | approved |
3333
| 001 | Packaging spike: Burrito + ex_tauri smoke build | 0 Foundation | M | 000 | approved |
3434
| 002 | Supply chain, early: SBOM, build provenance, the TLS floor | 0 Foundation | S | 000 | planned |
35-
| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | planned |
35+
| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | done |
3636
| 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | approved |
3737
| 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved |
3838
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved |

‎VERSIONS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ never pin a version hex marks as retired or vulnerable.
7474
| `Rust` | **1.92.0** | ✅ `rust-toolchain.toml` | Measured at Slice 001 line 3: `rustc --version` reports 1.92.0 (ded5c06cf 2025-12-08), exit 0. Pinned in `rust-toolchain.toml`, **not** `.tool-versions`: `asdf` here has no rust plugin and silently ignores a rust line, whereas `rustup show active-toolchain` reports this file as an override. See NOTES.md deviation D1. Corrected 2026-09-06: this row previously read `Rust + Tauri CLI | stable | ✅ .tool-versions`, which named a file carrying neither. |
7575
| `Tauri CLI` | **2.11.4** | 📐 `_build/_tauri/bin/cargo-tauri tauri --version` | Measured at Slice 001 line 3. Not on `PATH` and not pinned by any file in the tree: `ex_tauri` provisions it with `cargo install tauri-cli --version ^2 --root .` inside `_build/_tauri`, which is gitignored, so `cargo tauri --version` exits 101 on a fresh machine. 📐 rather than ✅ because nothing at this sha verifies it. The `^2` floats; 2.11.4 is what it resolved to on 2026-09-06. |
7676
| `Zig` | **0.16.0** | ✅ `.tool-versions` | Measured at Slice 001 line 3: burrito 1.6.0 compares Zig for **equality**, not a range (`@zig_version_expected` in `deps/burrito/lib/burrito.ex`), and exits 1 on any other version. `zig version` reports 0.16.0, exit 0. Installed through the asdf zig plugin, added this slice. Corrected 2026-09-06: this row previously read `version required by Burrito | ✅ .tool-versions` and that file carried no zig line. |
77+
| `FIPS leg base image (UBI9)` | **sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb** | ✅ `ci/fips/Containerfile` | Added at Slice 003: the base of the FIPS build leg's image (docs/fips-leg.md), `registry.access.redhat.com/ubi9/ubi:latest` resolved by digest on 2026-09-20 (Red Hat Enterprise Linux release 9.8, `openssl-libs 3.5.8-1.el9_8`, `openssl-fips-provider-so 3.0.7-11.el9_8`). The image itself is OTP 28.5.0.5 built from source with `--enable-fips` against that OpenSSL, plus Elixir 1.20.4; the two archives are pinned by SHA-256 in the Containerfile and the image tag is `scripts/fips_image_tag.sh` over `.tool-versions` and the Containerfile. The FIPS provider the image runs is what the distribution ships and names; docs/fips-leg.md states what Trinity does and does not claim about it. |
7778

7879
### Core libraries
7980

‎ci/fips/Containerfile‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# The FIPS build leg's image (slice 003, docs/fips-leg.md): OTP built from source with
5+
# --enable-fips against the OpenSSL of a UBI9 container, whose FIPS provider is the one Red Hat
6+
# publishes a validation for, plus Elixir and everything `mix gate` shells out to. The gate
7+
# leg pulls this image; nothing here runs Trinity.
8+
#
9+
# The versions arrive as build arguments read from .tool-versions by the image workflow, so
10+
# that file stays the one source. The two archives are checked against digests pinned here;
11+
# a new OTP or Elixir pin is a new pair of digests in this file, read from the release page.
12+
#
13+
# Base pinned by digest, not tag: registry.access.redhat.com/ubi9/ubi:latest resolved on
14+
# 2026-09-20 (Red Hat Enterprise Linux release 9.8, openssl-libs 3.5.8-1.el9_8, FIPS provider
15+
# 3.0.7). A base change is a diff here and a rebuild, never a silent drift under a moving tag.
16+
FROM registry.access.redhat.com/ubi9/ubi@sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb
17+
18+
ARG OTP_VERSION
19+
ARG ELIXIR_VERSION
20+
ARG OTP_SHA256=5231ba18f31f8041c2d6514cc8842e46954d3b39a53f1617f03f2abe6fea59c7
21+
ARG ELIXIR_SHA256=ea7ff98bc1ed76c663a5d034c863c6fd37e65c9c855b6bbf6ccb2034d806673c
22+
23+
# The toolchain OTP's configure needs, plus what the gate's steps call from the shell: git
24+
# (plan_check, the names and secrets census), python3 with PyYAML (plan_check rule 12), gcc
25+
# (exqlite compiles SQLite; mdex_native's precompiled artifact loads on glibc), procps-ng (the
26+
# shell tool's tests read `ps`), diffutils (the leg diffs the supports listing; UBI's base has no
27+
# `diff`, found on the leg's first run). Kept in the image on purpose: it is a CI image, not a runtime.
28+
RUN dnf -y install --setopt=install_weak_deps=False \
29+
gcc gcc-c++ make autoconf ncurses-devel openssl-devel \
30+
tar gzip unzip git diffutils python3-pyyaml procps-ng which \
31+
&& dnf clean all
32+
33+
# OTP from source. --enable-fips compiles the crypto NIF's FIPS support; --with-ssl points at
34+
# the distribution's OpenSSL and its dynamic library, which is where the validated provider
35+
# lives, so the link is dynamic by design (a static OpenSSL would carry no FIPS provider).
36+
# Applications with no place on a CI leg are left out to shorten the build.
37+
RUN set -eu; \
38+
curl -fsSLo /tmp/otp.tar.gz \
39+
"https://github.com/erlang/otp/releases/download/OTP-${OTP_VERSION}/otp_src_${OTP_VERSION}.tar.gz"; \
40+
echo "${OTP_SHA256} /tmp/otp.tar.gz" | sha256sum -c -; \
41+
mkdir -p /tmp/otp && tar -xzf /tmp/otp.tar.gz -C /tmp/otp --strip-components=1; \
42+
cd /tmp/otp; \
43+
./configure --prefix=/usr/local --enable-fips --with-ssl=/usr \
44+
--without-javac --without-jinterface --without-wx --without-odbc \
45+
--without-debugger --without-observer --without-et --without-megaco; \
46+
make -j"$(nproc)"; \
47+
make install; \
48+
rm -rf /tmp/otp /tmp/otp.tar.gz
49+
50+
# Elixir, precompiled for this OTP major (the release's own zip and its published digest).
51+
RUN set -eu; \
52+
curl -fsSLo /tmp/elixir.zip \
53+
"https://github.com/elixir-lang/elixir/releases/download/v${ELIXIR_VERSION}/elixir-otp-28.zip"; \
54+
echo "${ELIXIR_SHA256} /tmp/elixir.zip" | sha256sum -c -; \
55+
mkdir -p /usr/local/elixir && unzip -q /tmp/elixir.zip -d /usr/local/elixir; \
56+
rm -f /tmp/elixir.zip
57+
58+
# Hex and rebar in a path that does not depend on HOME: a GitHub container job sets HOME to
59+
# /github/home, and an install under /root would be invisible there.
60+
ENV PATH=/usr/local/elixir/bin:/usr/local/bin:$PATH \
61+
LANG=C.UTF-8 \
62+
MIX_HOME=/opt/mix \
63+
HEX_HOME=/opt/hex
64+
RUN mix local.hex --force && mix local.rebar --force
65+
66+
# The image proves its own build: the crypto NIF reports the FIPS provider available, and
67+
# entering FIPS mode is possible. Which mode a container runs in is the leg's decision, made
68+
# by OPENSSL_FORCE_FIPS_MODE and `-crypto fips_mode true` at run time, never here.
69+
RUN erl -noshell -eval \
70+
'true = maps:get(fips_provider_available, crypto:info()), true = crypto:enable_fips_mode(true), enabled = crypto:info_fips(), io:format("~p~n", [crypto:info()]), halt().' \
71+
&& elixir --version

‎coverage.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ slice_id percent sha date
99
021 72.45 1f3727f 2026-09-20
1010
022 74.85 1fb1372 2026-09-20
1111
023 75.39 5989b73 2026-09-20
12+
003 75.39 39518c2 2026-09-20

‎docs/09-standards-register.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status
1818

1919
| Control | Where Trinity satisfies it | Evidence path | Status | Decider |
2020
|---|---|---|---|---|
21-
| FIPS 140-3 validated cryptography in FIPS mode | OTP built with `--enable-fips` against a validated provider; `crypto:info_fips()` enabled; slice 024's signer selects an approved algorithm or denies | slice 003 (FIPS build leg), slice 024 AC8 | `:unknown` until 003 and 024 land | owner, then an assessor |
21+
| FIPS 140-3 validated cryptography in FIPS mode | OTP built with `--enable-fips` against a validated provider; `crypto:info_fips()` enabled; slice 024's signer selects an approved algorithm or denies | slice 003 (FIPS build leg), slice 024 AC8 | 003 landed 2026-09-20: the gate runs in the mode on every push (`fips` job, docs/fips-leg.md); the provider is the one the distribution ships and names, and the word validated is the certificate's, not this tree's; 024 AC8 still `:unknown` | owner, then an assessor |
2222
| CNSA 1.0 signature suite (ECDSA P-384, SHA-384) | slice 024 amendment 2, FIPS mode selection | slice 024 AC8 | `:unknown` until 024 lands | owner |
2323
| CNSA 2.0 readiness (ML-DSA-87) | slice 024 amendment 6, behind the same seam, compile-conditional on OpenSSL 3.5 or later; never default | none yet | `not claimed` | owner; the word validated waits on the CMVP listing of the provider that carries it |
2424
| FIPS 140-2 certificates on the Historical list from 2026-09-22 | not applicable: Trinity cites no 140-2 module | none | `real-world dependency` (deployment's modules) | deployment |

0 commit comments

Comments
 (0)