Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/fips-image.yml
Original file line number Diff line number Diff line change
@@ -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 }}'
97 changes: 97 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,100 @@ 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 }}

# 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: |
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

# 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'
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 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 |
Expand Down
1 change: 1 addition & 0 deletions VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
71 changes: 71 additions & 0 deletions ci/fips/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 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`), 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 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
# 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()), true = crypto:enable_fips_mode(true), enabled = crypto:info_fips(), io:format("~p~n", [crypto:info()]), halt().' \
&& elixir --version
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/09-standards-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading