From 7179cac1a38bb0b38c9a11db62e3481c6832a41d Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 16:27:38 -0400 Subject: [PATCH 1/9] docs(s003): G1 plan with the FIPS mechanism measured, and the slice opens ROADMAP row 003 to in_progress. NOTES.md records that the UBI9 container enters FIPS mode by Red Hat's OPENSSL_FORCE_FIPS_MODE on a host that is not in FIPS mode (Ed25519 refused, P-384 signs), that UBI 9.8 ships OpenSSL 3.5.8 with the 3.0.7 FIPS provider, then the eight-line build plan, the empty manual queue, and two deviations stated before any code. plan_check: PASS. Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- slices/003-fips-build-leg/NOTES.md | 63 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 slices/003-fips-build-leg/NOTES.md diff --git a/ROADMAP.md b/ROADMAP.md index 3781104..0207493 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,7 +32,7 @@ standards register names the rows that ask for them. | 000 | Toolchain, repo bootstrap, quality gate | 0 Foundation | L | none | approved | | 001 | Packaging spike: Burrito + ex_tauri smoke build | 0 Foundation | M | 000 | approved | | 002 | Supply chain, early: SBOM, build provenance, the TLS floor | 0 Foundation | S | 000 | planned | -| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | planned | +| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | in_progress | | 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | approved | | 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved | | 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved | diff --git a/slices/003-fips-build-leg/NOTES.md b/slices/003-fips-build-leg/NOTES.md new file mode 100644 index 0000000..fec9f10 --- /dev/null +++ b/slices/003-fips-build-leg/NOTES.md @@ -0,0 +1,63 @@ +# Slice 003: NOTES + +## Facts measured 2026-09-20 before any code + +**The container can enter FIPS mode on a host that is not in FIPS mode.** `docker run --rm +registry.access.redhat.com/ubi9/ubi:latest` on this machine (Red Hat Enterprise Linux release 9.8, `openssl-libs +3.5.8-1.el9_8`, no `/proc/sys/crypto/fips_enabled`): `openssl list -providers` names only `default`; with +`OPENSSL_FORCE_FIPS_MODE=1` it names `base`, `default` and `fips` ("Red Hat Enterprise Linux 9 - OpenSSL FIPS +Provider", version `3.0.7-cda111b5812c30d4`, from `/usr/lib64/ossl-modules/fips.so`); under it `openssl genpkey +-algorithm ed25519` fails with `unsupported ... Algorithm (ED25519 : 111)` and a P-384 key generates. That +environment variable is Red Hat's own patch, so it is the leg's mechanism and is named as such in `docs/fips-leg.md`. +GitHub's hosted runners are not FIPS hosts, so the leg depends on it. + +**UBI9 is no longer a 3.0 line.** SLICE.md's "Out" paragraph says the UBI9 OpenSSL is a 3.0 line and carries no +ML-DSA. As of 9.8 the library is 3.5.8 and only the FIPS provider is the 3.0.7 module. ML-DSA-87 through the +default provider on this image is therefore measurable in non-FIPS mode; that measurement belongs to slice 024 +(amendment 6) and is written there when 024 opens, not here. SLICE.md is not edited; this note supersedes the +sentence for the reader. + +**The registry.** `gh api /orgs/ScriptKittyOS/packages` answers 403 for this token (no `read:packages` scope), so +whether the image can be pushed to `ghcr.io/scriptkittyos/trinity-fips` is learned by the image workflow's first +run, under the job's own `packages: write` permission; the fallback SLICE.md names (schedule and on demand, the +image built in the leg itself) is line 6 below. + +## G1 plan, 2026-09-20 + +Tree at `1b73b70` on `main` (023 approved); branch `slice/003-fips-build-leg`; ROADMAP row 003 to `in_progress` in +this commit. Each line names its test or its evidence. + +1. `ci/fips/Containerfile`: `registry.access.redhat.com/ubi9/ubi:9.8` pinned by digest; `dnf install` of the build + toolchain, `openssl-devel`, `ncurses-devel`; OTP 28.5.0.5 from the source tarball with `--enable-fips`; Elixir + 1.20.4 from the precompiled `elixir-otp-28.zip`; the versions read from `.tool-versions` as build args so the + file is the one source. Evidence: `erl -noshell -eval 'io:format("~p~n",[crypto:info()])'` in the build log + prints `fips_provider_available => true`. +2. `.github/workflows/fips-image.yml`: builds and pushes `ghcr.io/scriptkittyos/trinity-fips:` on push when either file changes, and on `workflow_dispatch`; + `permissions: packages: write`. Evidence: a green run id and the image digest in PROOF.md. +3. `.github/workflows/gate.yml`: a second job `fips` (not a matrix entry, so `gate` keeps its context name, as + `postgres` did) on `container: ghcr.io/scriptkittyos/trinity-fips:` with `OPENSSL_FORCE_FIPS_MODE=1` + and `ERL_FLAGS="-crypto fips_mode true"`; the same steps as `gate`. Evidence: the run id; AC5. +4. `test/fips/mode_test.exs`, tagged `:fips`, excluded by tag on the default legs and included on this one: + `crypto:info_fips() == :enabled`, `fips_provider_buildinfo` present in `crypto:info/0` (AC1); `crypto:sign/4` + with `eddsa` raises `notsup`, `ecdsa`/`secp384r1`/`sha384` signs and verifies (AC2). Red first on this machine + by construction (`not_supported` here), which is why the tag exists and the default legs exclude rather than + skip it. +5. `docs/fips-leg.md` and `docs/fips-leg-supports.diff`: `crypto:supports/1` printed by `scripts/crypto_supports.exs` + on both legs and the diff committed; `test/fips/supports_diff_test.exs` regenerates the leg's half and asserts + it equals the committed file (AC3). The FIPS provider's version string and Red Hat's certificate reference are + cited from Red Hat's page with the URL and the date read; nothing restated as Trinity's claim. +6. Every red on the leg is fixed in this slice when the fault is the test's, or listed by test name with its + owning slice in `docs/fips-leg.md` when the fault is a removed algorithm (AC4); no skip tag is added. The + registry fallback: if the push in line 2 is refused, the leg builds the image itself on a schedule and on + `workflow_dispatch`, and SLICE.md's risk paragraph is what PROOF.md points at. +7. Minutes of the first ten leg runs, from `gh run list`, into this file (SLICE.md deliverable). +8. `docs/packaging.md` gains one paragraph pointing at `docs/fips-leg.md`; VERSIONS.md gains the image and its + digest; the coverage line from the leg is reported beside the default leg's in PROOF.md (AC5). + +Manual verification queue: none. Every criterion is `[auto]`, as SLICE.md states. + +Deviations stated before any code: (a) the leg is a second job rather than a matrix entry, for the required-check +name, as SLICE.md's own precedent (`postgres`) did; (b) FIPS mode is entered by Red Hat's environment variable +rather than by the host, because the runner cannot be a FIPS host; both halves (the provider list and the +`notsup`) are asserted by the tests in line 4 so the mechanism is measured on every run. From ad8bc17aee6be33dda66a7dc80e06bd2aa70a76f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 16:53:19 -0400 Subject: [PATCH 2/9] feat(s003): the FIPS image, the leg in the gate, the mode tests and the supports record ci/fips/Containerfile builds OTP 28.5.0.5 from source with --enable-fips against UBI9's OpenSSL (both archives pinned by SHA-256, the base by digest); fips-image.yml pushes it under the tag scripts/fips_image_tag.sh derives from the two files that define it; the gate gains the fips-tag and fips jobs, which enter the mode with OTP's own fips_mode setting through ERL_AFLAGS (the crypto application loaded first, measured as the condition). test/fips/mode_test.exs binds TRINITY_FIPS_LEG to crypto:info_fips/0 everywhere and carries AC1 to AC3 under the :fips tag; scripts/crypto_supports.exs prints the listing and docs/fips-leg/ holds both listings and their diff (51 entries removed). The gate's hex.audit step clears ERL_AFLAGS: Hex 2.5.1 offers TLS 1.0 and 1.1 and ssl in the mode refuses the set (docs/fips-leg.md, finding 1). Measured in the image on this machine: mix gate exit 0 in the mode, 268 passed, 12 excluded; on this machine, 265 passed, 15 excluded. Signed-off-by: Ayla Croft --- .github/workflows/fips-image.yml | 63 ++++++++++++++ .github/workflows/gate.yml | 82 +++++++++++++++++++ VERSIONS.md | 1 + ci/fips/Containerfile | 70 ++++++++++++++++ docs/fips-leg.md | 127 +++++++++++++++++++++++++++++ docs/fips-leg/supports-default.txt | 124 ++++++++++++++++++++++++++++ docs/fips-leg/supports-fips.txt | 74 +++++++++++++++++ docs/fips-leg/supports.diff | 60 ++++++++++++++ docs/packaging.md | 9 ++ lib/trinity/versions.ex | 10 +++ mix.exs | 9 +- scripts/crypto_supports.exs | 28 +++++++ scripts/fips_image_tag.sh | 19 +++++ test/fips/mode_test.exs | 80 ++++++++++++++++++ test/gate_alias_test.exs | 2 +- test/test_helper.exs | 6 +- 16 files changed, 761 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/fips-image.yml create mode 100644 ci/fips/Containerfile create mode 100644 docs/fips-leg.md create mode 100644 docs/fips-leg/supports-default.txt create mode 100644 docs/fips-leg/supports-fips.txt create mode 100644 docs/fips-leg/supports.diff create mode 100644 scripts/crypto_supports.exs create mode 100755 scripts/fips_image_tag.sh create mode 100644 test/fips/mode_test.exs diff --git a/.github/workflows/fips-image.yml b/.github/workflows/fips-image.yml new file mode 100644 index 0000000..d096a12 --- /dev/null +++ b/.github/workflows/fips-image.yml @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Builds the FIPS leg's image (ci/fips/Containerfile, docs/fips-leg.md) and pushes it to the +# repository's container registry under the tag scripts/fips_image_tag.sh derives from the +# files that define it. It runs when one of those files changes and on demand; the gate's +# `fips` job pulls the tag and never builds. A tag that already exists is not rebuilt: the +# image is a function of its inputs, and OTP from source is the slow part. +name: fips-image + +on: + push: + paths: + - .tool-versions + - ci/fips/Containerfile + - scripts/fips_image_tag.sh + - .github/workflows/fips-image.yml + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: The tag, and the versions .tool-versions names + id: meta + run: | + echo "ref=$(scripts/fips_image_tag.sh --ref)" >> "$GITHUB_OUTPUT" + echo "otp=$(awk '$1 == "erlang" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "elixir=$(awk '$1 == "elixir" { print $2 }' .tool-versions | cut -d- -f1)" >> "$GITHUB_OUTPUT" + + - name: Log in to the registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Skip when this tag is already published + id: exists + run: | + if docker manifest inspect "${{ steps.meta.outputs.ref }}" >/dev/null 2>&1; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "::notice::${{ steps.meta.outputs.ref }} is already published; nothing to build" + else + echo "published=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build + if: steps.exists.outputs.published == 'false' + run: | + docker build \ + --build-arg "OTP_VERSION=${{ steps.meta.outputs.otp }}" \ + --build-arg "ELIXIR_VERSION=${{ steps.meta.outputs.elixir }}" \ + -t "${{ steps.meta.outputs.ref }}" \ + -f ci/fips/Containerfile ci/fips + + - name: Push + if: steps.exists.outputs.published == 'false' + run: | + docker push "${{ steps.meta.outputs.ref }}" + docker image inspect "${{ steps.meta.outputs.ref }}" --format '{{ index .RepoDigests 0 }}' diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 43aeab9..9aba07e 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -100,3 +100,85 @@ jobs: - run: mix ecto.reset - run: mix test --exclude sqlite + + # Slice 003: the same gate on an OTP built from source with --enable-fips, in FIPS mode, + # against the FIPS provider of a UBI9 container (docs/fips-leg.md). A second job rather than + # a matrix entry, as `postgres` is, so `gate` keeps its context name. The image is built by + # fips-image.yml and pulled here by the tag scripts/fips_image_tag.sh derives from the + # files that define it; this job never builds it. + fips-tag: + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.tag.outputs.ref }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - id: tag + run: echo "ref=$(scripts/fips_image_tag.sh --ref)" >> "$GITHUB_OUTPUT" + + fips: + needs: fips-tag + runs-on: ubuntu-latest + container: + image: ${{ needs.fips-tag.outputs.ref }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + env: + MIX_ENV: test + TRINITY_DB: sqlite + # test/test_helper.exs includes the :fips tests under this, and test/fips/mode_test.exs + # asserts the runtime agrees, so a leg that failed to enter the mode is red, not quiet. + TRINITY_FIPS_LEG: "1" + # FIPS mode is entered by OTP's own configuration, read by the crypto NIF when it loads, + # which needs the crypto application loaded first; ERL_AFLAGS is prepended to every + # erl command line, so every `mix` and `elixir` here runs in the mode. Measured at G1: + # ERL_FLAGS alone leaves the mode off (the NIF loads before the application does). + ERL_AFLAGS: "-crypto fips_mode true -eval application:load(crypto)" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + deps + _build + key: fips-mix-${{ needs.fips-tag.outputs.ref }}-${{ hashFiles('mix.lock') }} + restore-keys: fips-mix-${{ needs.fips-tag.outputs.ref }}- + + # The one step outside the mode. The fetch, because Hex's client offers TLS 1.0 and 1.1 + # beside 1.2 and ssl in the mode refuses the set (insufficient_crypto_support); the + # dependency compile, because rustler_precompiled downloads mdex's NIF at compile time + # and OTP's TLS 1.3 client in the mode fails that host's HelloRetryRequest + # (erlang/otp#8470). docs/fips-leg.md findings 1 and 2; neither is what the leg + # measures. The gate's own hex.audit step clears ERL_AFLAGS for the same reason (mix.exs). + - name: Fetch and compile dependencies (FIPS mode off) + env: + ERL_AFLAGS: "-eval application:load(crypto)" + run: | + mix deps.get + mix deps.compile + + # AC1, before anything else runs: the runtime is in the mode and names the provider. + - name: The runtime is in FIPS mode + run: | + erl -noshell -eval 'enabled = crypto:info_fips(), io:format("~p~n", [crypto:info()]), halt().' + + # AC3, the default half: the listing with the mode off equals the committed file; the + # mode-on half is test/fips/mode_test.exs, inside the suite. + - name: crypto:supports/0 with the mode off matches docs/fips-leg/supports-default.txt + env: + ERL_AFLAGS: "-eval application:load(crypto)" + run: elixir scripts/crypto_supports.exs | diff - docs/fips-leg/supports-default.txt + + - run: ./scripts/plan_check.sh + + - name: DCO sign-off present on every commit + run: | + git log --format=%B ${{ github.event.pull_request.base.sha || 'HEAD~1' }}..HEAD 2>/dev/null \ + | grep -q '^Signed-off-by: ' || { + echo "::error::a commit in this range has no Signed-off-by line"; exit 1; } + + - run: mix gate diff --git a/VERSIONS.md b/VERSIONS.md index 8473d32..3182990 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -74,6 +74,7 @@ never pin a version hex marks as retired or vulnerable. | `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. | | `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. | | `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. | +| `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. | ### Core libraries diff --git a/ci/fips/Containerfile b/ci/fips/Containerfile new file mode 100644 index 0000000..1b4a0f5 --- /dev/null +++ b/ci/fips/Containerfile @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The FIPS build leg's image (slice 003, docs/fips-leg.md): OTP built from source with +# --enable-fips against the OpenSSL of a UBI9 container, whose FIPS provider is the one Red Hat +# publishes a validation for, plus Elixir and everything `mix gate` shells out to. The gate +# leg pulls this image; nothing here runs Trinity. +# +# The versions arrive as build arguments read from .tool-versions by the image workflow, so +# that file stays the one source. The two archives are checked against digests pinned here; +# a new OTP or Elixir pin is a new pair of digests in this file, read from the release page. +# +# Base pinned by digest, not tag: registry.access.redhat.com/ubi9/ubi:latest resolved on +# 2026-09-20 (Red Hat Enterprise Linux release 9.8, openssl-libs 3.5.8-1.el9_8, FIPS provider +# 3.0.7). A base change is a diff here and a rebuild, never a silent drift under a moving tag. +FROM registry.access.redhat.com/ubi9/ubi@sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb + +ARG OTP_VERSION +ARG ELIXIR_VERSION +ARG OTP_SHA256=5231ba18f31f8041c2d6514cc8842e46954d3b39a53f1617f03f2abe6fea59c7 +ARG ELIXIR_SHA256=ea7ff98bc1ed76c663a5d034c863c6fd37e65c9c855b6bbf6ccb2034d806673c + +# The toolchain OTP's configure needs, plus what the gate's steps call from the shell: git +# (plan_check, the names and secrets census), python3 with PyYAML (plan_check rule 12), gcc +# (exqlite compiles SQLite; mdex_native's precompiled artifact loads on glibc), procps-ng (the +# shell tool's tests read `ps`). Kept in the image on purpose: it is a CI image, not a runtime. +RUN dnf -y install --setopt=install_weak_deps=False \ + gcc gcc-c++ make autoconf ncurses-devel openssl-devel \ + tar gzip unzip git python3-pyyaml procps-ng which \ + && dnf clean all + +# OTP from source. --enable-fips compiles the crypto NIF's FIPS support; --with-ssl points at +# the distribution's OpenSSL and its dynamic library, which is where the validated provider +# lives, so the link is dynamic by design (a static OpenSSL would carry no FIPS provider). +# Applications with no place on a CI leg are left out to shorten the build. +RUN set -eu; \ + curl -fsSLo /tmp/otp.tar.gz \ + "https://github.com/erlang/otp/releases/download/OTP-${OTP_VERSION}/otp_src_${OTP_VERSION}.tar.gz"; \ + echo "${OTP_SHA256} /tmp/otp.tar.gz" | sha256sum -c -; \ + mkdir -p /tmp/otp && tar -xzf /tmp/otp.tar.gz -C /tmp/otp --strip-components=1; \ + cd /tmp/otp; \ + ./configure --prefix=/usr/local --enable-fips --with-ssl=/usr \ + --without-javac --without-jinterface --without-wx --without-odbc \ + --without-debugger --without-observer --without-et --without-megaco; \ + make -j"$(nproc)"; \ + make install; \ + rm -rf /tmp/otp /tmp/otp.tar.gz + +# Elixir, precompiled for this OTP major (the release's own zip and its published digest). +RUN set -eu; \ + curl -fsSLo /tmp/elixir.zip \ + "https://github.com/elixir-lang/elixir/releases/download/v${ELIXIR_VERSION}/elixir-otp-28.zip"; \ + echo "${ELIXIR_SHA256} /tmp/elixir.zip" | sha256sum -c -; \ + mkdir -p /usr/local/elixir && unzip -q /tmp/elixir.zip -d /usr/local/elixir; \ + rm -f /tmp/elixir.zip + +# Hex and rebar in a path that does not depend on HOME: a GitHub container job sets HOME to +# /github/home, and an install under /root would be invisible there. +ENV PATH=/usr/local/elixir/bin:/usr/local/bin:$PATH \ + LANG=C.UTF-8 \ + MIX_HOME=/opt/mix \ + HEX_HOME=/opt/hex +RUN mix local.hex --force && mix local.rebar --force + +# The image proves its own build: the crypto NIF reports the FIPS provider available, and +# entering FIPS mode is possible. Which mode a container runs in is the leg's decision, made +# by OPENSSL_FORCE_FIPS_MODE and `-crypto fips_mode true` at run time, never here. +RUN erl -noshell -eval \ + 'true = maps:get(fips_provider_available, crypto:info()), ok = crypto:enable_fips_mode(true), enabled = crypto:info_fips(), io:format("~p~n", [crypto:info()]), halt().' \ + && elixir --version diff --git a/docs/fips-leg.md b/docs/fips-leg.md new file mode 100644 index 0000000..927bb16 --- /dev/null +++ b/docs/fips-leg.md @@ -0,0 +1,127 @@ +# The FIPS build leg + +Slice 003. A second job in the gate workflow, `fips`, runs the whole gate on an OTP built from source with +`--enable-fips`, in FIPS mode, against the FIPS provider of a UBI9 container. It exists because slice 024 says +that in FIPS mode Trinity's receipts select ECDSA P-384 and never deny for want of a signer; until a build runs in +the mode, that is a sentence. The default legs (`gate`, `postgres`) stay exactly as they were. + +Everything below was measured on 2026-09-20 on the image described here, unless a line says otherwise. Counts +name the command that produced them. + +## What the leg runs + +| Piece | Where | What it does | +|---|---|---| +| `ci/fips/Containerfile` | the image | UBI9 by digest; OTP 28.5.0.5 from the release tarball with `--enable-fips --with-ssl=/usr`; Elixir 1.20.4 from the release zip; both archives checked against SHA-256 digests pinned in the file; git, python3 with PyYAML, gcc and procps for the gate's own steps | +| `.github/workflows/fips-image.yml` | builds the image | On a change to `.tool-versions`, the Containerfile or the tag script, and on demand; pushes to `ghcr.io/scriptkittyos/trinity-fips` under the tag below; a tag already published is not rebuilt | +| `scripts/fips_image_tag.sh` | both workflows | The tag: sixteen hex digits of SHA-256 over `.tool-versions` and the Containerfile, so the builder and the puller cannot disagree | +| `.github/workflows/gate.yml`, jobs `fips-tag` and `fips` | the leg | Pulls the image, fetches and compiles dependencies with the mode off, asserts the mode, checks the mode-off listing, then runs `plan_check`, the DCO check and `mix gate`, all in the mode | +| `test/fips/mode_test.exs` | the suite | One untagged test binds `TRINITY_FIPS_LEG` to `crypto:info_fips/0` on every leg; three `:fips` tests (AC1 to AC3) run only where the leg declares itself | +| `scripts/crypto_supports.exs` | the listing | Prints `crypto:supports/0` one item per line, sorted, with the mode and library named first | +| `docs/fips-leg/supports-default.txt`, `supports-fips.txt`, `supports.diff` | the record | The two listings from the same image, mode off and mode on, and their `diff` | + +## How the mode is entered + +OTP reads the crypto application's `fips_mode` setting when the crypto NIF loads, and only if the application is +already loaded at that moment (`lib/crypto/src/crypto.erl`, `on_load/0`: an `undefined` setting is read as "the +application is not loaded" and the mode stays off with a warning). So the leg sets, for every `erl` the job runs: + +``` +ERL_AFLAGS="-crypto fips_mode true -eval application:load(crypto)" +``` + +`ERL_AFLAGS` is prepended to the command line, so the load runs before Elixir starts and before anything touches +`:crypto`. Measured on the image: with `ERL_FLAGS` (appended) carrying the same setting, `crypto:info_fips()` +answers `not_enabled` under `elixir -e`, because the NIF has loaded before the application; with `ERL_AFLAGS` it +answers `enabled`. Red Hat's own `OPENSSL_FORCE_FIPS_MODE=1` activates the provider for the `openssl` command but +leaves OTP's report at `not_enabled` on its own; it is not needed once OTP enters the mode itself, and the leg does +not set it. Entering and leaving the mode on a running node is not something the leg does: the setting is in the +environment, and no test toggles it. + +`TRINITY_FIPS_LEG=1` is the leg's declaration. `test/test_helper.exs` includes the `:fips` tag under it and +excludes the tag elsewhere; `test/fips/mode_test.exs` asserts the runtime agrees with the declaration on every +leg, so a leg that failed to enter the mode is red rather than a quiet pass over excluded tests. + +## The provider, and what is and is not claimed + +The image's `fips.so` comes from the package `openssl-fips-provider-so-3.0.7-11.el9_8` (`rpm -qf +/usr/lib64/ossl-modules/fips.so`), whose description reads "a custom build of the OpenSSL FIPS module that has +been submitted to NIST for certification". `crypto:info/0` on the leg reports `fips_provider_buildinfo` as +`3.0.7-cda111b5812c30d4`. The distribution's library is `openssl-libs 3.5.8-1.el9_8`; the provider is the 3.0.7 +module loaded beside it. + +Red Hat's page, https://access.redhat.com/compliance/fips, read 2026-09-20, lists for Red Hat Enterprise Linux +9.8 the module "OpenSSL", version `3.0.7-395c1a240fbfffd8`, CMVP certificate #4857, status "Active". The version +string the image reports and the version string the page lists differ in their build hash. Trinity does not claim +they are the same binary, and does not claim a validation. What the leg establishes is narrower and exact: the +gate and the suite run with OTP in FIPS mode against the FIPS provider that this distribution ships and names, +with the algorithms the mode removes listed below. Whether a deployment's provider is a validated one is that +deployment's question, answered from the certificate and the running binary, not from this file. + +## What the mode removes + +`diff docs/fips-leg/supports-default.txt docs/fips-leg/supports-fips.txt` (the committed `supports.diff`): +51 entries removed (`grep -c '^<'`), one line changed (`fips_mode`). By category (`grep -c '^< :'`): + +| Category | Removed | Names | +|---|---|---| +| curves | 13 | brainpoolP256r1, brainpoolP256t1, brainpoolP320r1, brainpoolP320t1, brainpoolP384r1, brainpoolP384t1, brainpoolP512r1, brainpoolP512t1, ed25519, ed448, secp256k1, x25519, x448 | +| ciphers | 13 | blowfish_cbc, blowfish_cfb64, blowfish_ecb, blowfish_ofb64, chacha20, chacha20_poly1305, des_cbc, des_cfb, des_ecb, des_ede3_cbc, des_ede3_cfb, rc2_cbc, rc4 | +| hashs | 6 | blake2b, blake2s, md4, md5, ripemd160, sm3 | +| public_keys | 18 | eddh, eddsa, mldsa44, mldsa65, mldsa87, the twelve slh_dsa variants, srp | +| macs, rsa_opts | 0 | | + +Curves that remain: prime256v1, secp224r1, secp256r1, secp384r1, secp521r1. Measured beside the listing, with a +fixed RFC 8032 key: `crypto:sign/4` and `crypto:verify/5` with `eddsa` raise `{notsup, {"pkey.c", 235}, +"Unsupported algorithm in FIPS mode"}`; `crypto:generate_key(eddsa, ed25519)` fails earlier and differently +(`{error, {"evp.c", 264}, "Can't make context"}`); `ecdsa` on `secp384r1` with `sha384` signs (a 103-byte DER +signature) and verifies; `md5` raises `notsup`; `sha`, HMAC-SHA-256 and `strong_rand_bytes` work. + +For slice 024: this OTP, built against OpenSSL 3.5.8, lists `mldsa44`, `mldsa65` and `mldsa87` with the mode off, +so amendment 6 of that slice is measurable on this image in non-FIPS mode. The 024 slice text assumed UBI9's +OpenSSL was a 3.0 line; as of 9.8 it is not. + +## Findings + +1. **Hex cannot reach hex.pm in the mode.** Hex 2.5.1 hardcodes `[:"tlsv1.2", :"tlsv1.1", :tlsv1]` + (`lib/hex/http/ssl.ex`, `@default_versions`) and ssl in FIPS mode refuses the set: + `{options, {insufficient_crypto_support, {'tlsv1.1', {versions, ['tlsv1.2', 'tlsv1.1', tlsv1]}}}}`. So + `mix deps.get` runs with the mode off in the leg, and the gate's `hex.audit` step runs with `ERL_AFLAGS` + cleared (`mix.exs`), which on the default legs changes nothing. `mix deps.audit` (mix_audit) works in the + mode. Owner: Hex, upstream; the fetch is not a property the leg measures. +2. **OTP's TLS 1.3 client fails a HelloRetryRequest, and the mode makes that common.** ssl 11.6.0.4: a server + that answers the client's first key share with a HelloRetryRequest gets a client alert, "Failed to assert + middlebox server message" from state `hello_retry_middlebox_assert`. Measured with the mode off by forcing + `secp521r1` first: the same alert, so the fault is the retry handling, not the mode. The mode exposes it + because `x25519` leaves and `secp521r1` leads the default group order (`ssl:groups/0` in the mode: + secp521r1, secp384r1, secp256r1, the ML-KEM hybrids, the ffdhe groups), and `release-assets.githubusercontent.com` + and `repo.hex.pm` both retry. `github.com` and `api.github.com` complete. Upstream: erlang/otp issue #8470 + (labelled not a bug, stalled), earlier #7586. Two workarounds measured against both hosts in the mode: + `middlebox_comp_mode: false`, or `supported_groups: [:secp256r1, :secp384r1, :secp521r1]`. In the leg the + dependency compile runs with the mode off, because `rustler_precompiled` downloads mdex's NIF from the first + host at compile time. **Owner in the tree: slice 002 (the TLS floor)**, which sets the group order for Trinity's + own clients (Req, req_llm, the web tools); a FIPS deployment that fetches from such hosts hits this at run time. +3. **The provider's version string is not the page's.** See the section above; owner: the deployment's assessor, + named in `docs/09-standards-register.md`. + +## Reds on the leg, by test name + +Filled at G3 from the leg's first run: every red is fixed here when the fault is the test's, or listed with the +slice that owns it when the fault is a removed algorithm. No skip tag exists for this. + +## Reproducing the image and the leg on a machine + +``` +docker build --build-arg OTP_VERSION=28.5.0.5 --build-arg ELIXIR_VERSION=1.20.4 \ + -t trinity-fips:local -f ci/fips/Containerfile ci/fips +docker run --rm -v "$PWD:/work" -w /work \ + -e MIX_ENV=test -e TRINITY_DB=sqlite -e TRINITY_FIPS_LEG=1 trinity-fips:local bash -c ' + git config --global --add safe.directory /work + ERL_AFLAGS="-eval application:load(crypto)" mix deps.get + ERL_AFLAGS="-eval application:load(crypto)" mix deps.compile + export ERL_AFLAGS="-crypto fips_mode true -eval application:load(crypto)" + mix gate' +``` + +On the machine this was written on (32 cores) the OTP build step took 41 s; the runner's time is in the slice's +NOTES.md once the first ten runs exist. diff --git a/docs/fips-leg/supports-default.txt b/docs/fips-leg/supports-default.txt new file mode 100644 index 0000000..6625421 --- /dev/null +++ b/docs/fips-leg/supports-default.txt @@ -0,0 +1,124 @@ +fips_mode: :not_enabled +fips_provider_available: true +cryptolib_version_linked: OpenSSL 3.5.8 25 Aug 2026 +otp_crypto_version: 5.8.3.2 +ciphers: :aes_128_cbc +ciphers: :aes_128_ccm +ciphers: :aes_128_cfb128 +ciphers: :aes_128_cfb8 +ciphers: :aes_128_ctr +ciphers: :aes_128_ecb +ciphers: :aes_128_gcm +ciphers: :aes_128_ofb +ciphers: :aes_192_cbc +ciphers: :aes_192_ccm +ciphers: :aes_192_cfb128 +ciphers: :aes_192_cfb8 +ciphers: :aes_192_ctr +ciphers: :aes_192_ecb +ciphers: :aes_192_gcm +ciphers: :aes_192_ofb +ciphers: :aes_256_cbc +ciphers: :aes_256_ccm +ciphers: :aes_256_cfb128 +ciphers: :aes_256_cfb8 +ciphers: :aes_256_ctr +ciphers: :aes_256_ecb +ciphers: :aes_256_gcm +ciphers: :aes_256_ofb +ciphers: :aes_cbc +ciphers: :aes_ccm +ciphers: :aes_cfb128 +ciphers: :aes_cfb8 +ciphers: :aes_ctr +ciphers: :aes_ecb +ciphers: :aes_gcm +ciphers: :blowfish_cbc +ciphers: :blowfish_cfb64 +ciphers: :blowfish_ecb +ciphers: :blowfish_ofb64 +ciphers: :chacha20 +ciphers: :chacha20_poly1305 +ciphers: :des_cbc +ciphers: :des_cfb +ciphers: :des_ecb +ciphers: :des_ede3_cbc +ciphers: :des_ede3_cfb +ciphers: :rc2_cbc +ciphers: :rc4 +curves: :brainpoolP256r1 +curves: :brainpoolP256t1 +curves: :brainpoolP320r1 +curves: :brainpoolP320t1 +curves: :brainpoolP384r1 +curves: :brainpoolP384t1 +curves: :brainpoolP512r1 +curves: :brainpoolP512t1 +curves: :ed25519 +curves: :ed448 +curves: :prime256v1 +curves: :secp224r1 +curves: :secp256k1 +curves: :secp256r1 +curves: :secp384r1 +curves: :secp521r1 +curves: :x25519 +curves: :x448 +hashs: :blake2b +hashs: :blake2s +hashs: :md4 +hashs: :md5 +hashs: :ripemd160 +hashs: :sha +hashs: :sha224 +hashs: :sha256 +hashs: :sha384 +hashs: :sha3_224 +hashs: :sha3_256 +hashs: :sha3_384 +hashs: :sha3_512 +hashs: :sha512 +hashs: :sha512_224 +hashs: :sha512_256 +hashs: :shake128 +hashs: :shake256 +hashs: :sm3 +kems: :mlkem1024 +kems: :mlkem512 +kems: :mlkem768 +macs: :cmac +macs: :hmac +macs: :poly1305 +public_keys: :dh +public_keys: :dss +public_keys: :ecdh +public_keys: :ecdsa +public_keys: :eddh +public_keys: :eddsa +public_keys: :mldsa44 +public_keys: :mldsa65 +public_keys: :mldsa87 +public_keys: :rsa +public_keys: :slh_dsa_sha2_128f +public_keys: :slh_dsa_sha2_128s +public_keys: :slh_dsa_sha2_192f +public_keys: :slh_dsa_sha2_192s +public_keys: :slh_dsa_sha2_256f +public_keys: :slh_dsa_sha2_256s +public_keys: :slh_dsa_shake_128f +public_keys: :slh_dsa_shake_128s +public_keys: :slh_dsa_shake_192f +public_keys: :slh_dsa_shake_192s +public_keys: :slh_dsa_shake_256f +public_keys: :slh_dsa_shake_256s +public_keys: :srp +rsa_opts: :rsa_mgf1_md +rsa_opts: :rsa_no_padding +rsa_opts: :rsa_oaep_label +rsa_opts: :rsa_oaep_md +rsa_opts: :rsa_pkcs1_oaep_padding +rsa_opts: :rsa_pkcs1_padding +rsa_opts: :rsa_pkcs1_pss_padding +rsa_opts: :rsa_pss_saltlen +rsa_opts: :rsa_x931_padding +rsa_opts: :signature_md diff --git a/docs/fips-leg/supports-fips.txt b/docs/fips-leg/supports-fips.txt new file mode 100644 index 0000000..b01f06e --- /dev/null +++ b/docs/fips-leg/supports-fips.txt @@ -0,0 +1,74 @@ +fips_mode: :enabled +fips_provider_available: true +cryptolib_version_linked: OpenSSL 3.5.8 25 Aug 2026 +otp_crypto_version: 5.8.3.2 +ciphers: :aes_128_cbc +ciphers: :aes_128_ccm +ciphers: :aes_128_cfb128 +ciphers: :aes_128_cfb8 +ciphers: :aes_128_ctr +ciphers: :aes_128_ecb +ciphers: :aes_128_gcm +ciphers: :aes_128_ofb +ciphers: :aes_192_cbc +ciphers: :aes_192_ccm +ciphers: :aes_192_cfb128 +ciphers: :aes_192_cfb8 +ciphers: :aes_192_ctr +ciphers: :aes_192_ecb +ciphers: :aes_192_gcm +ciphers: :aes_192_ofb +ciphers: :aes_256_cbc +ciphers: :aes_256_ccm +ciphers: :aes_256_cfb128 +ciphers: :aes_256_cfb8 +ciphers: :aes_256_ctr +ciphers: :aes_256_ecb +ciphers: :aes_256_gcm +ciphers: :aes_256_ofb +ciphers: :aes_cbc +ciphers: :aes_ccm +ciphers: :aes_cfb128 +ciphers: :aes_cfb8 +ciphers: :aes_ctr +ciphers: :aes_ecb +ciphers: :aes_gcm +curves: :prime256v1 +curves: :secp224r1 +curves: :secp256r1 +curves: :secp384r1 +curves: :secp521r1 +hashs: :sha +hashs: :sha224 +hashs: :sha256 +hashs: :sha384 +hashs: :sha3_224 +hashs: :sha3_256 +hashs: :sha3_384 +hashs: :sha3_512 +hashs: :sha512 +hashs: :sha512_224 +hashs: :sha512_256 +hashs: :shake128 +hashs: :shake256 +kems: :mlkem1024 +kems: :mlkem512 +kems: :mlkem768 +macs: :cmac +macs: :hmac +macs: :poly1305 +public_keys: :dh +public_keys: :dss +public_keys: :ecdh +public_keys: :ecdsa +public_keys: :rsa +rsa_opts: :rsa_mgf1_md +rsa_opts: :rsa_no_padding +rsa_opts: :rsa_oaep_label +rsa_opts: :rsa_oaep_md +rsa_opts: :rsa_pkcs1_oaep_padding +rsa_opts: :rsa_pkcs1_padding +rsa_opts: :rsa_pkcs1_pss_padding +rsa_opts: :rsa_pss_saltlen +rsa_opts: :rsa_x931_padding +rsa_opts: :signature_md diff --git a/docs/fips-leg/supports.diff b/docs/fips-leg/supports.diff new file mode 100644 index 0000000..a27b56c --- /dev/null +++ b/docs/fips-leg/supports.diff @@ -0,0 +1,60 @@ +1c1 +< fips_mode: :not_enabled +--- +> fips_mode: :enabled +36,58d35 +< ciphers: :blowfish_cbc +< ciphers: :blowfish_cfb64 +< ciphers: :blowfish_ecb +< ciphers: :blowfish_ofb64 +< ciphers: :chacha20 +< ciphers: :chacha20_poly1305 +< ciphers: :des_cbc +< ciphers: :des_cfb +< ciphers: :des_ecb +< ciphers: :des_ede3_cbc +< ciphers: :des_ede3_cfb +< ciphers: :rc2_cbc +< ciphers: :rc4 +< curves: :brainpoolP256r1 +< curves: :brainpoolP256t1 +< curves: :brainpoolP320r1 +< curves: :brainpoolP320t1 +< curves: :brainpoolP384r1 +< curves: :brainpoolP384t1 +< curves: :brainpoolP512r1 +< curves: :brainpoolP512t1 +< curves: :ed25519 +< curves: :ed448 +61d37 +< curves: :secp256k1 +65,71d40 +< curves: :x25519 +< curves: :x448 +< hashs: :blake2b +< hashs: :blake2s +< hashs: :md4 +< hashs: :md5 +< hashs: :ripemd160 +85d53 +< hashs: :sm3 +96,100d63 +< public_keys: :eddh +< public_keys: :eddsa +< public_keys: :mldsa44 +< public_keys: :mldsa65 +< public_keys: :mldsa87 +102,114d64 +< public_keys: :slh_dsa_sha2_128f +< public_keys: :slh_dsa_sha2_128s +< public_keys: :slh_dsa_sha2_192f +< public_keys: :slh_dsa_sha2_192s +< public_keys: :slh_dsa_sha2_256f +< public_keys: :slh_dsa_sha2_256s +< public_keys: :slh_dsa_shake_128f +< public_keys: :slh_dsa_shake_128s +< public_keys: :slh_dsa_shake_192f +< public_keys: :slh_dsa_shake_192s +< public_keys: :slh_dsa_shake_256f +< public_keys: :slh_dsa_shake_256s +< public_keys: :srp diff --git a/docs/packaging.md b/docs/packaging.md index b7063b8..7cf2b6f 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -194,3 +194,12 @@ extracted to `~/.local/share/.burrito/_erts-_` for as long as th so a local run of a new binary at the same version runs the old code until that directory is removed; and the Burrito wrapper starts `erlexec` directly, so `RELEASE_NAME` is unset in the packaged app (the migrator no longer keys on it). + +## FIPS mode (slice 003) + +The desktop bundle above does not run in FIPS mode: its ERTS is Burrito's prebuilt one, and whether Burrito can +wrap an ERTS built with `--enable-fips` is slice 100's question. What the tree proves about the mode it proves +on the gate's `fips` job: OTP built from source with `--enable-fips` against a UBI9 container's OpenSSL, the +whole gate run with the mode on, and the algorithms the mode removes listed in the tree. `docs/fips-leg.md` has +the image, how the mode is entered, the diff, and the findings (two of them about reaching hex.pm and GitHub +from inside the mode). diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 2d25c65..a950ba5 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -102,6 +102,16 @@ defmodule Trinity.Versions do from: {:file, ".tool-versions", "zig 0.16.0"}, note: "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." + }, + %{ + name: "FIPS leg base image (UBI9)", + pin: "**sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb**", + lock: nil, + from: + {:file, "ci/fips/Containerfile", + "registry.access.redhat.com/ubi9/ubi@sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb"}, + note: + "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." } ] diff --git a/mix.exs b/mix.exs index cd20dc1..ee54859 100644 --- a/mix.exs +++ b/mix.exs @@ -222,7 +222,14 @@ defmodule Trinity.MixProject do # `cmd` runs it as its own OS process: Hex's tasks are not reliably resolvable from # inside an alias after another task has run, and a separate process also gives this # step its own exit code rather than one shared with the alias. - "cmd mix hex.audit", + # + # Slice 003: the process runs with ERL_AFLAGS cleared, so that on the FIPS leg this one + # step runs outside FIPS mode. Hex 2.5.1 offers TLS 1.0 and 1.1 beside 1.2 (its + # lib/hex/http/ssl.ex hardcodes the three) and ssl in the mode refuses the set with + # insufficient_crypto_support, so the audit cannot reach hex.pm in the mode; the audit + # is a registry lookup, not a property the leg measures (docs/fips-leg.md, finding 1). + # Elsewhere the variable is unset and clearing it changes nothing. + "cmd env ERL_AFLAGS= mix hex.audit", "deps.audit", "versions.verify", "versions.gen --check", diff --git a/scripts/crypto_supports.exs b/scripts/crypto_supports.exs new file mode 100644 index 0000000..ce3411f --- /dev/null +++ b/scripts/crypto_supports.exs @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Prints what this runtime's crypto supports, one item per line, sorted, so two runs of the +# same file can be diffed (slice 003, docs/fips-leg.md). Plain `elixir`, no Mix: the FIPS leg +# runs it once with FIPS mode off and the suite runs it once with the mode on, and the +# committed files under docs/fips-leg/ are what those two runs printed. +# +# elixir scripts/crypto_supports.exs +# +# The first lines name the mode and the library, so a listing can never be read without its +# provenance. `crypto:supports/0` is the population; nothing here is a hand list. + +info = :crypto.info() + +header = [ + "fips_mode: #{inspect(:crypto.info_fips())}", + "fips_provider_available: #{inspect(Map.get(info, :fips_provider_available))}", + "cryptolib_version_linked: #{List.to_string(Map.get(info, :cryptolib_version_linked, ~c"?"))}", + "otp_crypto_version: #{List.to_string(Map.get(info, :otp_crypto_version, ~c"?"))}" +] + +body = + for {category, items} <- :crypto.supports(), + item <- items, + do: "#{category}: #{inspect(item)}" + +IO.puts(Enum.join(header ++ Enum.sort(body), "\n")) diff --git a/scripts/fips_image_tag.sh b/scripts/fips_image_tag.sh new file mode 100755 index 0000000..2f569e4 --- /dev/null +++ b/scripts/fips_image_tag.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The FIPS leg image's tag: the first sixteen hex digits of the SHA-256 over the two files +# that define the image (slice 003, docs/fips-leg.md). The image workflow pushes under it and +# the gate leg pulls by it, so the two can never disagree about which image a tree wants, and +# a change to either file is a new tag, never a rebuild under an old one. +# +# scripts/fips_image_tag.sh prints the tag +# scripts/fips_image_tag.sh --ref prints the full image reference +set -eu +cd "$(dirname "$0")/.." +tag=$(cat .tool-versions ci/fips/Containerfile | sha256sum | cut -c1-16) +case "${1:-}" in + --ref) printf 'ghcr.io/scriptkittyos/trinity-fips:%s\n' "$tag" ;; + "") printf '%s\n' "$tag" ;; + *) echo "usage: $0 [--ref]" >&2; exit 2 ;; +esac diff --git a/test/fips/mode_test.exs b/test/fips/mode_test.exs new file mode 100644 index 0000000..39d7975 --- /dev/null +++ b/test/fips/mode_test.exs @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Fips.ModeTest do + @moduledoc """ + Slice 003: the FIPS build leg is a measurement, not a sentence (SLICE.md, Why). + + The first test runs on every leg and binds the leg's declaration (`TRINITY_FIPS_LEG`) to the + runtime's report (`crypto:info_fips/0`): a FIPS leg whose OTP did not enter the mode is red + here, instead of quietly excluding everything tagged `:fips` and passing. The tagged tests + are AC1 and AC2 and run only where the mode is on. + """ + use ExUnit.Case, async: true + + @payload "the leg is the measurement" + + test "the leg's declaration and the runtime's FIPS report agree" do + case System.get_env("TRINITY_FIPS_LEG") do + "1" -> + assert :crypto.info_fips() == :enabled, + "TRINITY_FIPS_LEG=1 but crypto:info_fips() is #{inspect(:crypto.info_fips())}" + + _ -> + assert :crypto.info_fips() in [:not_supported, :not_enabled] + end + end + + describe "on the FIPS leg" do + @describetag :fips + + test "AC1: the mode is enabled and crypto:info/0 names the FIPS provider's build" do + assert :crypto.info_fips() == :enabled + info = :crypto.info() + assert info.fips_provider_available == true + # A charlist naming the provider's build, measured on the image before this test was + # written: ~c"3.0.7-cda111b5812c30d4" (docs/fips-leg.md). + assert is_list(info.fips_provider_buildinfo) + assert List.to_string(info.fips_provider_buildinfo) =~ ~r/^\d+\.\d+\.\d+/ + end + + test "AC2: eddsa is refused with notsup; ecdsa on secp384r1 with sha384 signs and verifies" do + {pub, priv} = :crypto.generate_key(:ecdh, :secp384r1) + sig = :crypto.sign(:ecdsa, :sha384, @payload, [priv, :secp384r1]) + assert :crypto.verify(:ecdsa, :sha384, @payload, sig, [pub, :secp384r1]) + refute :crypto.verify(:ecdsa, :sha384, @payload <> "x", sig, [pub, :secp384r1]) + + # Key generation is refused too, at a different place (`evp.c`, "Can't make context") + # than signing; the claim under test is the signing one, so a fixed key carries it. + assert_raise ErlangError, fn -> :crypto.generate_key(:eddsa, :ed25519) end + + {ed_pub, ed_priv} = ed25519_fixture() + assert_notsup(fn -> :crypto.sign(:eddsa, :none, @payload, [ed_priv, :ed25519]) end) + + assert_notsup(fn -> + :crypto.verify(:eddsa, :none, @payload, <<0::512>>, [ed_pub, :ed25519]) + end) + end + + test "AC3: the committed listing is what this leg prints" do + {out, 0} = System.cmd("elixir", [Path.expand("scripts/crypto_supports.exs")]) + expected = File.read!(Path.expand("docs/fips-leg/supports-fips.txt")) + assert out == expected, "docs/fips-leg/supports-fips.txt differs from the leg's own listing" + assert String.starts_with?(out, "fips_mode: :enabled\n") + end + end + + # Measured on the image: `{notsup, {"pkey.c", 235}, "Unsupported algorithm in FIPS mode"}` + # raised as an ErlangError. `notsup` at the head is the claim; the file and line are OTP's. + defp assert_notsup(fun) do + error = assert_raise(ErlangError, fun) + assert {:notsup, _where, _text} = error.original + end + + # A fixed Ed25519 pair (RFC 8032 test vector 1) so the refusal is measured on sign and + # verify, not only on key generation. + defp ed25519_fixture do + priv = Base.decode16!("9D61B19DEFFD5A60BA844AF492EC2CC44449C5697B326919703BAC031CAE7F60") + pub = Base.decode16!("D75A980182B10AB7D54BFED3C964073A0EE172F3DAA62325AF021A68F707511A") + {pub, priv} + end +end diff --git a/test/gate_alias_test.exs b/test/gate_alias_test.exs index 577fca3..619ae5a 100644 --- a/test/gate_alias_test.exs +++ b/test/gate_alias_test.exs @@ -27,7 +27,7 @@ defmodule GateAliasTest do for required <- ~w(format credo sobelow hex.audit deps.audit trinity.version_form trinity.names trinity.secrets.scan trinity.reuse test trinity.coverage) do - # Containment, not prefix: hex.audit runs as "cmd mix hex.audit" so it gets its own OS + # Containment, not prefix: hex.audit runs as "cmd env ERL_AFLAGS= mix hex.audit" so it gets its own OS # process and its own exit code. The property is that the gate runs it, not how it is spelled. assert Enum.any?(steps, &String.contains?(&1, required)), "the gate is missing the #{required} step. Steps: #{inspect(steps)}" diff --git a/test/test_helper.exs b/test/test_helper.exs index f7d50b2..340eee3 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -4,7 +4,11 @@ # and TRINITY_LIVE=1) and :desktop (needs the Tauri shell). Slice 011 made this explicit; until # then a :live test would have run in the default suite and been refused by the network guard. # Slice 023: :eval (the compaction eval harness, and the suites later slices add) is opt-in too. -ExUnit.start(exclude: [:live, :desktop, :eval]) +# Slice 003: :fips (tests that need FIPS mode on) runs only where the leg declares itself with +# TRINITY_FIPS_LEG=1; excluded by tag elsewhere, never skipped. test/fips/mode_test.exs runs +# everywhere and is what makes a leg that failed to enter the mode red rather than quiet. +fips_leg? = System.get_env("TRINITY_FIPS_LEG") == "1" +ExUnit.start(exclude: [:live, :desktop, :eval] ++ if(fips_leg?, do: [], else: [:fips])) Ecto.Adapters.SQL.Sandbox.mode(Trinity.Repo, :manual) # Slice 011: the Mox mock the registry's :mock provider points at. From b74da2fdc4a14e98e7948714649567fea93fac30 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 16:56:38 -0400 Subject: [PATCH 3/9] fix(s003): the image's self-check matches enable_fips_mode/1's true The final RUN matched ok where crypto:enable_fips_mode/1 returns true, so the first fips-image run (35536968071) failed at the check after a complete build. Measured locally: the image now builds through the check. Signed-off-by: Ayla Croft --- ci/fips/Containerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/fips/Containerfile b/ci/fips/Containerfile index 1b4a0f5..774f8dd 100644 --- a/ci/fips/Containerfile +++ b/ci/fips/Containerfile @@ -66,5 +66,5 @@ RUN mix local.hex --force && mix local.rebar --force # entering FIPS mode is possible. Which mode a container runs in is the leg's decision, made # by OPENSSL_FORCE_FIPS_MODE and `-crypto fips_mode true` at run time, never here. RUN erl -noshell -eval \ - 'true = maps:get(fips_provider_available, crypto:info()), ok = crypto:enable_fips_mode(true), enabled = crypto:info_fips(), io:format("~p~n", [crypto:info()]), halt().' \ + '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().' \ && elixir --version From 6ed96bf4fa3bd4c21a5ff6f101167d0ce4e9307e Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:02:52 -0400 Subject: [PATCH 4/9] fix(s003): diffutils in the image The leg's supports step failed on run 35537134655 with 'diff: command not found': UBI9's base carries no diffutils. Added to the package list; the tag rolls with the file. Signed-off-by: Ayla Croft --- ci/fips/Containerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ci/fips/Containerfile b/ci/fips/Containerfile index 774f8dd..e619bb5 100644 --- a/ci/fips/Containerfile +++ b/ci/fips/Containerfile @@ -23,10 +23,11 @@ ARG ELIXIR_SHA256=ea7ff98bc1ed76c663a5d034c863c6fd37e65c9c855b6bbf6ccb2034d80667 # The toolchain OTP's configure needs, plus what the gate's steps call from the shell: git # (plan_check, the names and secrets census), python3 with PyYAML (plan_check rule 12), gcc # (exqlite compiles SQLite; mdex_native's precompiled artifact loads on glibc), procps-ng (the -# shell tool's tests read `ps`). Kept in the image on purpose: it is a CI image, not a runtime. +# shell tool's tests read `ps`), diffutils (the leg diffs the supports listing; UBI's base has no +# `diff`, found on the leg's first run). Kept in the image on purpose: it is a CI image, not a runtime. RUN dnf -y install --setopt=install_weak_deps=False \ gcc gcc-c++ make autoconf ncurses-devel openssl-devel \ - tar gzip unzip git python3-pyyaml procps-ng which \ + tar gzip unzip git diffutils python3-pyyaml procps-ng which \ && dnf clean all # OTP from source. --enable-fips compiles the crypto NIF's FIPS support; --with-ssl points at From ed2f74a8e27ce3a9862ad1cad7e4a70101c1c525 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:09:00 -0400 Subject: [PATCH 5/9] fix(s003): the fips job marks the workspace safe for git Run 35537462271: plan_check rule 4 named every slice as missing its SLICE.md, because git ls-files inside the container read an empty tree; the checkout action's safe.directory entry lives under a HOME it removes afterwards. One git config line before anything reads the tree. Signed-off-by: Ayla Croft --- .github/workflows/gate.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 9aba07e..3ff0737 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -140,6 +140,13 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }} + # The checkout action marks the workspace safe under a temporary HOME it removes when + # it is done; every later git call in the container (plan_check, the names, secrets and + # reuse censuses, all of which read `git ls-files`) then hits git's dubious-ownership + # refusal and reads an empty tree. Found on run 35537462271: plan_check rule 4 named + # every slice as missing its SLICE.md. + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | From f9c5eed8e4219b4af17ccb1053a354fffcc2df55 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:13:05 -0400 Subject: [PATCH 6/9] fix(s000): plan_check's exists_prefix and the sign-off check read their whole input Under set -o pipefail, printf | grep -q exits at the first match and printf can take SIGPIPE on the rest; the pipeline then reports 141 and the rule a miss. Measured on a clean tree: 1 spurious FAIL in 30 runs (the ADR-0005 citation in slices/001), 0 in 60 after the change. Found at slice 003 when a local run failed and the next three passed. Signed-off-by: Ayla Croft --- scripts/plan_check.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/plan_check.sh b/scripts/plan_check.sh index 185a544..53b87bf 100755 --- a/scripts/plan_check.sh +++ b/scripts/plan_check.sh @@ -85,7 +85,11 @@ fi # ---------------------------------------------------------------- 6 section "6. No reference to a plan path absent from git ls-files" -exists_prefix() { printf '%s\n' "$tracked" | grep -q "^$1"; } +# `grep` without -q on purpose: under `set -o pipefail`, `grep -q` exits at the first match +# and printf can take SIGPIPE writing the rest, and the pipeline then reports printf's 141 as +# a miss. Measured at slice 003: 1 spurious FAIL in 30 runs on a clean tree (the ADR-0005 +# citation in slices/001). Reading the whole input has no early exit and no race. +exists_prefix() { printf '%s\n' "$tracked" | grep "^$1" >/dev/null; } for f in $(git ls-files '*.md'); do # doc and slice path references grep -oE '(docs/adr/[0-9]{4}|docs/[0-9]{2}|slices/[0-9]{3}|templates/[A-Za-z-]+\.md)[A-Za-z0-9._-]*' "$f" 2>/dev/null | sort -u | while read -r ref; do @@ -142,7 +146,7 @@ for c in $(git log --format=%H); do fail=1 fi case " $unsigned_merge_exempt " in *" $c "*) continue ;; esac - printf '%s\n' "$body" | grep -q '^Signed-off-by: ' \ + printf '%s\n' "$body" | grep '^Signed-off-by: ' >/dev/null \ || report "FAIL commit $c: no Signed-off-by line ($(git log -1 --format=%s "$c"))" done From 39518c22f89a762a714b9b4e0469a65f4f9e7036 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:15:27 -0400 Subject: [PATCH 7/9] feat(s003): the leg prints the FIPS tests by name and its coverage line After the gate, the fips job repeats test/fips with --trace so AC2 and AC3 read by test name against a run id, and runs the suite under the coverage tool once for AC5's line. Signed-off-by: Ayla Croft --- .github/workflows/gate.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 3ff0737..8dcadab 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -189,3 +189,11 @@ jobs: echo "::error::a commit in this range has no Signed-off-by line"; exit 1; } - run: mix gate + + # AC2 and AC3 by test name with this run's id in the log, and AC5's coverage line for + # this leg. The gate above already ran the suite; this repeats the four FIPS tests with + # their names printed, then the whole suite once more under the coverage tool. + - name: The FIPS tests by name, and this leg's coverage + run: | + mix test --trace test/fips + mix test --cover | grep -E 'Result:|\| *Total' From 567507dac0ab37f7d504a23a61cd0bff519a996e Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:21:32 -0400 Subject: [PATCH 8/9] feat(s003): complete slice 003 (FIPS build leg) PROOF.md with every criterion's evidence against run 35538136447 (the gate in the mode: 268 passed, 12 excluded, exit 0; the four FIPS tests by name; both legs' coverage at 75.39 %). NOTES.md: ten findings, the runner minutes so far, follow-ups for 002 and 024. docs/fips-leg.md records that no test in the tree reaches a removed algorithm on this date. The standards register row for FIPS names what 003 established and what stays unknown. ROADMAP row 003 to done; coverage.tsv row added. Gate exit 0 here: 265 passed, 15 excluded, plan_check PASS. Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- coverage.tsv | 1 + docs/09-standards-register.md | 2 +- docs/fips-leg.md | 10 ++- slices/003-fips-build-leg/NOTES.md | 77 +++++++++++++++++ slices/003-fips-build-leg/PROOF.md | 134 +++++++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 slices/003-fips-build-leg/PROOF.md diff --git a/ROADMAP.md b/ROADMAP.md index 0207493..325d774 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,7 +32,7 @@ standards register names the rows that ask for them. | 000 | Toolchain, repo bootstrap, quality gate | 0 Foundation | L | none | approved | | 001 | Packaging spike: Burrito + ex_tauri smoke build | 0 Foundation | M | 000 | approved | | 002 | Supply chain, early: SBOM, build provenance, the TLS floor | 0 Foundation | S | 000 | planned | -| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | in_progress | +| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | done | | 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | approved | | 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | approved | | 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | approved | diff --git a/coverage.tsv b/coverage.tsv index 605c5c8..d0d7629 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -9,3 +9,4 @@ slice_id percent sha date 021 72.45 1f3727f 2026-09-20 022 74.85 1fb1372 2026-09-20 023 75.39 5989b73 2026-09-20 +003 75.39 39518c2 2026-09-20 diff --git a/docs/09-standards-register.md b/docs/09-standards-register.md index 8187de7..be4e12f 100644 --- a/docs/09-standards-register.md +++ b/docs/09-standards-register.md @@ -18,7 +18,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status | Control | Where Trinity satisfies it | Evidence path | Status | Decider | |---|---|---|---|---| -| 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 | +| 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 | | 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 | | 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 | | 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 | diff --git a/docs/fips-leg.md b/docs/fips-leg.md index 927bb16..39df047 100644 --- a/docs/fips-leg.md +++ b/docs/fips-leg.md @@ -106,8 +106,14 @@ OpenSSL was a 3.0 line; as of 9.8 it is not. ## Reds on the leg, by test name -Filled at G3 from the leg's first run: every red is fixed here when the fault is the test's, or listed with the -slice that owns it when the fault is a removed algorithm. No skip tag exists for this. +None on 2026-09-20: `mix gate` on the leg, run 35538136447, 268 passed and 12 excluded (the same twelve the +default leg excludes by tag), exit 0. No test in the tree reached a removed algorithm. This is a fact about the +tree that day; a later slice that adds one sees it here, on the leg, as a red to fix or to list below with its +owner. No skip tag exists for this: `git grep -n '@tag :skip\|@moduletag :skip' test/` prints nothing. + +| test | owning slice | why it is red on the leg | status | +|---|---|---|---| +| (none) | | | | ## Reproducing the image and the leg on a machine diff --git a/slices/003-fips-build-leg/NOTES.md b/slices/003-fips-build-leg/NOTES.md index fec9f10..c67ee2c 100644 --- a/slices/003-fips-build-leg/NOTES.md +++ b/slices/003-fips-build-leg/NOTES.md @@ -61,3 +61,80 @@ Deviations stated before any code: (a) the leg is a second job rather than a mat name, as SLICE.md's own precedent (`postgres`) did; (b) FIPS mode is entered by Red Hat's environment variable rather than by the host, because the runner cannot be a FIPS host; both halves (the provider list and the `notsup`) are asserted by the tests in line 4 so the mechanism is measured on every run. + +## Findings, 2026-09-20, in the order they were met + +1. **`ERL_FLAGS` does not enter the mode; `ERL_AFLAGS` with the application loaded first does.** OTP's + `crypto:on_load/0` reads `fips_mode` only when `application:get_env(crypto, fips_mode)` is defined, which it is + not before `application:load(crypto)`; the NIF loads on the first `:crypto` call, which under `elixir` comes + before Mix loads the applications. Measured on the image with `elixir -e`: `ERL_FLAGS="-crypto fips_mode true"` + leaves `crypto:info_fips()` at `not_enabled` and prints OTP's warning; `ERL_AFLAGS="-crypto fips_mode true + -eval application:load(crypto)"` gives `enabled`. Red Hat's `OPENSSL_FORCE_FIPS_MODE=1` alone gives + `not_enabled` in OTP's report, and is not needed once OTP loads the provider itself. **Supersedes G1 + deviation (b)**: the mechanism is OTP's own configuration, not the distribution's variable. +2. **The image's self-check matched the wrong atom.** `crypto:enable_fips_mode(true)` returns `true`, not `ok`; + the first local build and the first `fips-image` run (35536968071) failed at the check after a complete + build. `fix(s003)` at `b74da2f`. +3. **Hex cannot reach hex.pm in the mode** (docs/fips-leg.md finding 1): Hex 2.5.1 hardcodes TLS 1.0 and 1.1 + beside 1.2. `HEX_OFFLINE=1` was tried and does not serve `hex.audit`: neither `deps.get` on a complete lock nor + an online `hex.audit` leaves registry entries an offline audit can read (measured, runs 3 and 4 in the + container). The gate alias's `hex.audit` step now runs with `ERL_AFLAGS` cleared; a default leg does not set + it, so nothing changes there. +4. **OTP's TLS 1.3 client fails a HelloRetryRequest, and the mode makes that common** (docs/fips-leg.md finding + 2, erlang/otp #8470): `rustler_precompiled` could not download mdex's NIF at compile time. The leg compiles + dependencies with the mode off. Both workarounds were measured in the mode against + `release-assets.githubusercontent.com` and `repo.hex.pm`: `middlebox_comp_mode: false`, or + `supported_groups: [:secp256r1, :secp384r1, :secp521r1]`. **Follow-up for slice 002 (the TLS floor)**: + Trinity's own clients in a FIPS deployment need one of these, or they fail against every server that retries. +5. **UBI's base has no `diff`** (run 35537134655). `diffutils` added to the image. +6. **`git ls-files` in the container read an empty tree** (run 35537462271): the checkout action's safe.directory + entry lives under a HOME it removes afterwards. One `git config --global --add safe.directory` step before + anything reads the tree. +7. **`plan_check.sh` flaked on a clean tree**: `printf | grep -q` under `set -o pipefail`, 1 spurious FAIL in 30 + runs (the ADR-0005 citation in slice 001's file), 0 in 60 after reading the whole input. Fixed as + `fix(s000)` at `f9c5eed`; found because a local run failed and the next three passed. The push that carried + the safe.directory fix went out on a `plan_check | tail -1` pipeline whose exit was `tail`'s, the mistake the + gate alias's comment describes; recorded here so it is not repeated. +8. **Red Hat's page and the image disagree on the provider's build hash** (docs/fips-leg.md, the provider + section): the page lists `3.0.7-395c1a240fbfffd8` under certificate #4857 for 9.8; the image reports + `3.0.7-cda111b5812c30d4` from `openssl-fips-provider-so-3.0.7-11.el9_8`. Stated, not resolved; owner: the + deployment's assessor. +9. **This OTP lists ML-DSA and SLH-DSA with the mode off** (`docs/fips-leg/supports-default.txt`: `mldsa44`, + `mldsa65`, `mldsa87`, twelve `slh_dsa_*`), because UBI 9.8 links OpenSSL 3.5.8. Slice 024's amendment 6 can be + measured on this image in non-FIPS mode; recorded here for 024's G1. +10. **No test in the tree reaches a removed algorithm.** The gate in the mode: 268 passed, 12 excluded, exit 0 + (in the image on this machine, run 5, 33.5 s wall; on the leg, run 35538136447). AC4's list of reds by test + name is therefore empty as a fact about the tree on this date, not as a property of the leg. + +## Runner minutes + +Durations of the `image` and `fips` jobs on this branch, from `gh run view --json jobs` (latest attempt of +each run; reruns replace the earlier attempt's times), derived 2026-09-20 after run 35538136447. Fewer than the +ten runs SLICE.md asks for exist at this line; the closing correction in PROOF.md extends the table with the +runs the close itself produces, and the ten-run figure is filled by the first `fix` or the next slice that +touches the leg, whichever is first. + +| run | job | result | duration | +|---|---|---|---| +| 35536968071 | image | failure (finding 2) | 2m51s | +| 35537134611 | image | success | 2m55s | +| 35537462269 | image | success | 3m03s | +| 35536967968 | fips | failure (image not yet published) | 0m10s | +| 35537134655 | fips | failure (finding 5), rerun | 1m58s | +| 35537462271 | fips | failure (finding 6), rerun | 1m54s | +| 35537793241 | fips | success | 3m19s | +| 35538008648 | fips | success | 1m28s | +| 35538136447 | fips | success | 2m03s | + +The image build is under three minutes on the hosted runner (OTP from source with the unused applications left +out), so the schedule-and-on-demand fallback SLICE.md names was not needed; the push to the registry under the +job's `packages: write` succeeded on the first complete build (finding 2 was the check, not the push). On a push +that changes the Containerfile, the `fips` job of the same push starts before the image lands and fails at the +pull; a rerun of the failed job after `fips-image` finishes is the procedure, and it happened twice here. + +## Follow-ups +- Slice 002 (the TLS floor): set `supported_groups` or `middlebox_comp_mode` for Trinity's clients (finding 4). +- Slice 024: amendment 6 (ML-DSA-87) is measurable on this image with the mode off (finding 9); P-384 signatures + from OTP are DER, 103 to 104 bytes, not the 96 the slice table names. +- Upstream: Hex's TLS version list (finding 3); OTP's HelloRetryRequest handling (#8470, finding 4). +- The ten-run minutes table (above). diff --git a/slices/003-fips-build-leg/PROOF.md b/slices/003-fips-build-leg/PROOF.md new file mode 100644 index 0000000..faebf07 --- /dev/null +++ b/slices/003-fips-build-leg/PROOF.md @@ -0,0 +1,134 @@ +# Proof for slice 003: A FIPS build leg in CI, from source + +Agent: Trinity ยท Coding Agent ยท Date: 2026-09-20 ยท Branch: slice/003-fips-build-leg ยท Final commit: (the commit carrying this file; named in the closing correction) + +## Summary +A `fips` job in the gate workflow runs the whole gate on an OTP 28.5.0.5 built from source with `--enable-fips` +against the OpenSSL of a UBI9 container (Red Hat Enterprise Linux 9.8, `openssl-libs 3.5.8`, the 3.0.7 FIPS +provider), in FIPS mode, on every push. The image is built by its own workflow and pushed to the repository's +registry under a tag derived from the two files that define it. The mode is entered by OTP's own `fips_mode` +setting with the crypto application loaded first (`ERL_AFLAGS`), which was the measured condition. Ten findings +in NOTES.md; the two that reach past this slice are that Hex's client and OTP's TLS 1.3 client cannot reach +hex.pm and GitHub's release host from inside the mode (docs/fips-leg.md, findings 1 and 2), so the leg fetches +and compiles dependencies with the mode off and the gate's `hex.audit` step clears the flags. + +## Gate +On the leg (run 35538136447, job 106150777683, `fips`, all steps success): +``` +$ mix gate (ERL_AFLAGS="-crypto fips_mode true -eval application:load(crypto)", TRINITY_FIPS_LEG=1) +1123 mods/funs, found no issues. +... SCAN COMPLETE ... +No retired or security advisory packages found +No vulnerabilities found. +versions.verify: OK. 87 locked packages, none disagreeing with 50 pins +Result: 268 passed, 12 excluded +trinity.coverage: 023 75.39% vs 022 74.85%: OK +plan_check: PASS +``` +On this machine (default leg's view, `crypto:info_fips()` = `not_supported`, under a 32 GiB cgroup, tree 39518c2): +``` +$ mix gate +1123 mods/funs, found no issues. +Result: 265 passed, 15 excluded +trinity.coverage: 023 75.39% vs 022 74.85%: OK +plan_check: PASS +exit=0 +``` +`mix credo --strict --all`: 1123 mods/funs, found no issues. + +## Tests +``` +$ mix test --cover (this machine, tree 39518c2) +Result: 265 passed, 15 excluded +| 75.39% | Total | +``` +On the leg (run 35538136447, step "The FIPS tests by name, and this leg's coverage"): +``` +$ mix test --cover | grep -E 'Result:|\| *Total' +Result: 268 passed, 12 excluded +| 75.39% | Total | +``` +`coverage.tsv` row: `003 75.39 39518c2 2026-09-20` (unchanged from 023: the four new tests cover no `lib/` +line, and the leg reports the same total). + +The slice's four tests, `mix test --trace test/fips` on the leg (run 35538136447): +``` +Trinity.Fips.ModeTest [test/fips/mode_test.exs] + * test the leg's declaration and the runtime's FIPS report agree (0.02ms) [L#16] + * test on the FIPS leg AC2: eddsa is refused with notsup; ecdsa on secp384r1 with sha384 signs and verifies (13.5ms) [L#40] + * test on the FIPS leg AC3: the committed listing is what this leg prints (386.7ms) [L#58] + * test on the FIPS leg AC1: the mode is enabled and crypto:info/0 names the FIPS provider's build (0.04ms) [L#30] +Result: 4 passed +``` +On this machine the same file: `Result: 1 passed, 3 excluded` (the untagged test asserts `not_supported` here). + +## Acceptance criteria evidence + +### AC1 [auto]: on the FIPS leg, `crypto:info_fips()` returns `enabled` and `crypto:info/0` carries `fips_provider_buildinfo` (log excerpt with the run id) +Run 35538136447, job 106150777683, step "The runtime is in FIPS mode", which runs +`erl -noshell -eval 'enabled = crypto:info_fips(), io:format("~p~n", [crypto:info()]), halt().'` (a badmatch exits +non-zero; the step is success): +``` +#{otp_crypto_version => "5.8.3.2",compile_type => normal,link_type => dynamic, + cryptolib_version_compiled => "OpenSSL 3.5.8 25 Aug 2026", + cryptolib_version_linked => "OpenSSL 3.5.8 25 Aug 2026", + fips_provider_available => true, + fips_provider_buildinfo => "3.0.7-cda111b5812c30d4"} +``` +And the test `on the FIPS leg AC1: ...` above, passed on the same run. + +### AC2 [auto]: on the FIPS leg, `crypto:sign/4` with `eddsa` returns `notsup` and with `ecdsa` on `secp384r1` and `sha384` returns a signature that verifies (test) +`on the FIPS leg AC2: eddsa is refused with notsup; ecdsa on secp384r1 with sha384 signs and verifies`, passed on +run 35538136447. The test signs and verifies with a fresh P-384 key, refutes a verify over altered data, and +asserts `{:notsup, _, _}` raised as `ErlangError` for `crypto:sign/4` and `crypto:verify/5` with a fixed RFC 8032 +Ed25519 key; measured on the image before the test was written, the value is +`{notsup, {"pkey.c", 235}, "Unsupported algorithm in FIPS mode"}`. Key generation for Ed25519 fails at a different +place (`{error, {"evp.c", 264}, "Can't make context"}`), so the test asserts only that it raises. + +### AC3 [auto]: the committed `crypto:supports/1` diff between the two legs matches what the leg prints (test) +Two halves on the same image, so the diff isolates the mode alone. Mode on: the test `on the FIPS leg AC3: the +committed listing is what this leg prints` runs `elixir scripts/crypto_supports.exs` and asserts its output equals +`docs/fips-leg/supports-fips.txt` byte for byte; passed on run 35538136447. Mode off: the step +`crypto:supports/0 with the mode off matches docs/fips-leg/supports-default.txt` runs the same script under +`ERL_AFLAGS="-eval application:load(crypto)"` and `diff`s against the committed file; success on the same run. +`docs/fips-leg/supports.diff` is `diff` of the two committed files: 51 entries removed (`grep -c '^<'`), listed by +category in docs/fips-leg.md. + +### AC4 [auto]: the whole gate runs on the leg; every red is either fixed or listed by test name with its owning slice in `docs/fips-leg.md`; there is no skip tag +The gate section above: `mix gate` exit 0 on the leg with 268 passed and 12 excluded, the same 12 the default +leg excludes by tag (`:live`, `:desktop`, `:eval`). No test in the tree went red for a removed algorithm, so the +list in docs/fips-leg.md is empty as a fact about this tree on this date, and says so. The reds the leg did +produce on the way were the leg's own (NOTES findings 2, 5 and 6) and are fixed. `git grep -n '@tag :skip\|@moduletag :skip' test/` prints nothing. + +### AC5 [auto]: gate green on both legs; coverage line reported +Run 35538136447: `gate` success, `postgres` success, `fips-tag` success, `fips` success. Coverage: 75.39 % on the +default leg (`Result: 265 passed, 15 excluded`, `trinity.coverage: 023 75.39% vs 022 74.85%: OK`) and 75.39 % on +the FIPS leg (the Tests section above). + +## Manual verification for the reviewer +None; SLICE.md tags every criterion `[auto]`. If the reviewer wants the leg on a machine, the last section of +docs/fips-leg.md is the two commands. + +## Deviations from SLICE.md +Stated in NOTES.md before code: (a) a second job rather than a matrix entry, as `postgres` is, so the required +check keeps its name; (b) superseded by finding 1: the mode is entered by OTP's `fips_mode` setting through +`ERL_AFLAGS`, not by Red Hat's variable. Two more, found during the build and recorded in NOTES.md: (c) the +dependency fetch and compile run with the mode off, and the gate alias's `hex.audit` step clears `ERL_AFLAGS` +(findings 3 and 4; docs/fips-leg.md findings 1 and 2); (d) SLICE.md's "Out" paragraph calls UBI9's OpenSSL a +3.0 line and it is 3.5.8 as of 9.8, so ML-DSA is listed with the mode off (finding 9); SLICE.md is not edited. + +## Versions touched +`VERSIONS.md` updated: yes, one toolchain row for the image's base (`ci/fips/Containerfile`, by digest); no +dependency changed. `mix hex.outdated` not run. + +## Git +``` +$ git log --oneline main..HEAD +39518c2 feat(s003): the leg prints the FIPS tests by name and its coverage line +f9c5eed fix(s000): plan_check's exists_prefix and the sign-off check read their whole input +ed2f74a fix(s003): the fips job marks the workspace safe for git +6ed96bf fix(s003): diffutils in the image +b74da2f fix(s003): the image's self-check matches enable_fips_mode/1's true +ad8bc17 feat(s003): the FIPS image, the leg in the gate, the mode tests and the supports record +7179cac docs(s003): G1 plan with the FIPS mechanism measured, and the slice opens +``` From ae140f672e39e5c179cceaa12a6ef120f4b11bed Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:28:21 -0400 Subject: [PATCH 9/9] docs(s003): PROOF.md names the closing commit's sha; NOTES.md the close's run and a flake for 021 The closing correction names 567507d. The minutes table gains run 35538464458 (fips 3m46s). Finding 11 records the default leg's one red on that run, session_flow_test.exs:108 after a Gate crash on a sandbox ownership error, passed on rerun; owned by slice 021, listed as a fix(s021) follow-up. Signed-off-by: Ayla Croft --- slices/003-fips-build-leg/NOTES.md | 11 +++++++++++ slices/003-fips-build-leg/PROOF.md | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/slices/003-fips-build-leg/NOTES.md b/slices/003-fips-build-leg/NOTES.md index c67ee2c..0b0de8f 100644 --- a/slices/003-fips-build-leg/NOTES.md +++ b/slices/003-fips-build-leg/NOTES.md @@ -125,6 +125,7 @@ touches the leg, whichever is first. | 35537793241 | fips | success | 3m19s | | 35538008648 | fips | success | 1m28s | | 35538136447 | fips | success | 2m03s | +| 35538464458 | fips | success (the closing commit, 567507d) | 3m46s | The image build is under three minutes on the hosted runner (OTP from source with the unused applications left out), so the schedule-and-on-demand fallback SLICE.md names was not needed; the push to the registry under the @@ -138,3 +139,13 @@ pull; a rerun of the failed job after `fips-image` finishes is the procedure, an from OTP are DER, 103 to 104 bytes, not the 96 the slice table names. - Upstream: Hex's TLS version list (finding 3); OTP's HelloRetryRequest handling (#8470, finding 4). - The ten-run minutes table (above). + +11. **A flake on the default leg at the close, owned by slice 021.** Run 35538464458, job `gate`, seed 306150: + `test/trinity/permissions/session_flow_test.exs:108` (AC5 deny) collected events up to `approval_wait` and + never saw the session resume; the same log shows `Trinity.Permissions.Gate` terminating on + `DBConnection.OwnershipError` ("cannot find ownership process"), which reads as an expiry timer from an + earlier test's request firing after that test's sandbox owner had exited, so the Gate crashed, restarted, and + the decision was lost. The `fips` and `postgres` jobs of the same run passed the test, and the rerun of the + job passed (264/265, then 265/265). Not touched here: it is 021's test isolation, and this slice's closing + commit changed records only. **Follow-up, `fix(s021)`**: the Gate's expiry timers, or the test's sandbox + allowance, need to outlive the test that armed them, or be cancelled on the owner's exit. diff --git a/slices/003-fips-build-leg/PROOF.md b/slices/003-fips-build-leg/PROOF.md index faebf07..cf92018 100644 --- a/slices/003-fips-build-leg/PROOF.md +++ b/slices/003-fips-build-leg/PROOF.md @@ -132,3 +132,8 @@ b74da2f fix(s003): the image's self-check matches enable_fips_mode/1's true ad8bc17 feat(s003): the FIPS image, the leg in the gate, the mode tests and the supports record 7179cac docs(s003): G1 plan with the FIPS mechanism measured, and the slice opens ``` + +## Closing correction, 2026-09-20 +Supersedes the header's "Final commit" placeholder: the closing commit is `567507d` (`feat(s003): complete slice +003 (FIPS build leg)`), and this correction rides on the commit after it, which also names the CI run of the +close in NOTES.md's minutes table.