diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d33595..f8da67c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,21 @@ # SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 # -# The same three OTP/Elixir pairs as beam_mcp (floor, pinned, head): format, compile with -# warnings as errors, Credo --strict (.credo.exs), the Hex audit (retired packages and OSV -# advisories against mix.lock), the suite; the REUSE lint over the tree; and the DCO sign-off -# on every commit, as beam_mcp's gate workflow checks it. Actions pinned by commit SHA with the version beside it. +# The same four OTP/Elixir legs as beam_mcp (floor, pinned, head, box): the OTP floor +# (tools/otp_floor.sh), format, compile with warnings as errors, Credo --strict (.credo.exs), +# the Hex audit (retired packages and OSV advisories against mix.lock), the suite; the REUSE +# lint over the tree; and the DCO sign-off on every commit, as beam_mcp's gate workflow checks +# it. Actions pinned by commit SHA with the version beside it. +# +# THE BOX LEG: the pair a fielded edge box runs (OTP 28 at a patched release, Elixir 1.20) on +# the architecture it runs on (aarch64, GitHub's arm64 runner, free for public repositories), +# over the OpenSSL line the box's image pins (3.5 or later, which ML-DSA needs): Ubuntu 26.04's +# runner ships 3.5.5, and builds.hex.pm's OTP for it links that, where 24.04's links 3.0.13. +# The leg sets MLDSA_REQUIRED, so an OTP without ML-DSA-87 is a red leg (tools/mldsa_offered.sh). +# Its versions are exact (`version-type: strict`), not "the newest 28": the box pins its +# runtime, so the leg does too. Every leg runs the OTP floor step, so a resolution below a +# line's patched floor (tools/otp-floors, CVE-2026-55737) is a red leg, not a green one on a +# vulnerable runtime. name: ci on: @@ -18,23 +29,34 @@ permissions: jobs: test: name: test (OTP ${{ matrix.otp }}, Elixir ${{ matrix.elixir }}, ${{ matrix.leg }}) - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: fail-fast: false matrix: include: - - { leg: floor, otp: "27", elixir: "1.17" } - - { leg: pinned, otp: "28", elixir: "1.18" } - - { leg: head, otp: "29", elixir: "1.20" } + - { leg: floor, otp: "27", elixir: "1.17", os: ubuntu-24.04, version-type: loose, mldsa: "0" } + - { leg: pinned, otp: "28", elixir: "1.18", os: ubuntu-24.04, version-type: loose, mldsa: "0" } + - { leg: head, otp: "29", elixir: "1.20", os: ubuntu-24.04, version-type: loose, mldsa: "0" } + - { leg: box, otp: "28.5.0.8", elixir: "1.20.4", os: ubuntu-26.04-arm, version-type: strict, mldsa: "1" } env: MIX_ENV: test + MLDSA_REQUIRED: ${{ matrix.mldsa }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 with: otp-version: ${{ matrix.otp }} elixir-version: ${{ matrix.elixir }} + version-type: ${{ matrix.version-type }} + # Also printed on every leg: the OpenSSL `:crypto` is linked to and whether it offers + # ML-DSA-87; asserted where the leg sets MLDSA_REQUIRED (the box leg). + - name: the pair setup-beam installed, at or above its line's patched OTP floor + run: | + erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_version)]), halt().' + elixir --version | tail -1 + bash tools/mldsa_offered.sh + bash tools/otp_floor.sh - run: mix deps.get - run: mix format --check-formatted - run: mix compile --warnings-as-errors diff --git a/.tool-versions b/.tool-versions index a13962d..cfaf56a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -erlang 28.1.1 +erlang 28.5.0.8 elixir 1.18.4-otp-28 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d786a45..5dbd0cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,8 @@ mix test Erlang/OTP 27 or newer and Elixir 1.17 or newer, the same floor as `beam_mcp`. CI runs `mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix credo --strict`, -`mix hex.audit` and `mix test` on three OTP/Elixir pairs; run the same before you push. +`mix hex.audit` and `mix test` on four OTP/Elixir legs, after checking the runtime is at or above +its line's patched OTP floor (`tools/otp_floor.sh`); run the same before you push. ## The rules, all enforced @@ -42,7 +43,7 @@ Erlang/OTP 27 or newer and Elixir 1.17 or newer, the same floor as `beam_mcp`. C ## Code review **How it is done.** Every change reaches `main` through a pull request, the maintainer's -included, rebased after CI is green on all three OTP/Elixir pairs. The reviewer reads the diff, +included, rebased after CI is green on all four OTP/Elixir legs. The reviewer reads the diff, the commit messages and the CI results, and runs the suite locally for any change under `lib/`. **What must be checked.** diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 5612e7c..8563392 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -27,13 +27,14 @@ date, so the bus factor is two, and his reviews are the public record of it. ## How a change lands 1. A branch and a pull request; nothing is pushed to `main` directly. -2. CI on three OTP/Elixir pairs (the floor, the pinned line, the newest), the REUSE lint and - the DCO check. +2. CI on four OTP/Elixir legs (the floor, the pinned line, the newest, and the box: OTP + 28.5.0.8 with Elixir 1.20.4 on aarch64, over an OpenSSL that offers ML-DSA-87), the REUSE + lint and the DCO check. 3. The maintainer's review, then a rebase merge. From 2026-09-26 a repository ruleset on `main` holds steps 1 to 3 rather than leaving them to habit, as `beam_mcp`'s does: no deletion or force-push, changes by pull request only, a rebase -merge and linear history, and the five checks above required and up to date, with no bypass. +merge and linear history, and the six checks above required and up to date, with no bypass. 4. A release is a signed tag and a hex.pm publish by the maintainer, recorded in the CHANGELOG; `docs/verifying-releases.md` says how to check one. diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 46d8d36..e00b9ec 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -86,5 +86,5 @@ core answers such a call instead of raising. ## How the case is kept true -CI runs the suite, the census and Credo on every push on three OTP/Elixir pairs; a change to how +CI runs the suite, the census and Credo on every push on four OTP/Elixir legs; a change to how the key is read or returned changes this page in the same pull request (`GOVERNANCE.md`). diff --git a/tools/mldsa_offered.sh b/tools/mldsa_offered.sh new file mode 100755 index 0000000..6c6d6c2 --- /dev/null +++ b/tools/mldsa_offered.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Prints the OpenSSL that `:crypto` is linked to and whether it offers ML-DSA-87. With +# MLDSA_REQUIRED=1 a runtime that does not offer it is refused by name, so a leg that must run +# ML-DSA cannot pass without it. ML-DSA arrived in OpenSSL 3.5; an OTP linked to an older +# libcrypto (Ubuntu 24.04's 3.0.13, which builds.hex.pm's OTPs link there) has none. Run by +# every leg of CI's test job (.github/workflows/ci.yml); the box leg sets MLDSA_REQUIRED=1. +# +# Not a FIPS-mode check: in FIPS mode OTP leaves `mldsa87` out of `crypto:supports/1` while +# the algorithm still works, so a check meant for that mode signs and verifies a known answer +# instead. No leg here runs in FIPS mode. +set -euo pipefail +# The application is started first (a bare module load logs a fips_mode report on some +# runtimes), and only the tagged line is read, so nothing else the runtime prints is parsed. +line=$(erl -noshell -eval ' + {ok, _} = application:ensure_all_started(crypto), + [{_, _, Lib}] = crypto:info_lib(), + Offered = lists:member(mldsa87, proplists:get_value(public_keys, crypto:supports())), + io:format("mldsa_offered: ~s ~s~n", [Offered, Lib]), + halt().' | sed -n 's/^mldsa_offered: //p') +offered=${line%% *} +lib=${line#* } +echo "crypto linked to $lib; mldsa87 offered: $offered" +if [ "${MLDSA_REQUIRED:-0}" = "1" ] && [ "$offered" != "true" ]; then + echo "this leg must offer mldsa87 (MLDSA_REQUIRED=1), and its OTP's libcrypto does not; refused" + exit 1 +fi diff --git a/tools/otp-floors b/tools/otp-floors new file mode 100644 index 0000000..d47b381 --- /dev/null +++ b/tools/otp-floors @@ -0,0 +1,9 @@ +# The lowest patched release of each Erlang/OTP line this project runs on: CVE-2026-55737 +# (heap corruption in binary_to_term/1 decoding LARGE_TUPLE_EXT) is fixed in 27.3.4.15, +# 28.5.0.4 and 29.0.4 (https://www.erlang.org/patches/otp-28.5.0.4). tools/otp_floor.sh +# refuses a runtime below its line's floor. A line here moves only to a later patch. A copy of +# beam_mcp's tools/otp-floors (the same floor as beam_mcp): the two files change together. +# major floor +27 27.3.4.15 +28 28.5.0.4 +29 29.0.4 diff --git a/tools/otp-floors.license b/tools/otp-floors.license new file mode 100644 index 0000000..f1cc249 --- /dev/null +++ b/tools/otp-floors.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +SPDX-License-Identifier: Apache-2.0 diff --git a/tools/otp_floor.sh b/tools/otp_floor.sh new file mode 100755 index 0000000..10efcc9 --- /dev/null +++ b/tools/otp_floor.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The running Erlang/OTP is at or above its line's patched floor (tools/otp-floors), or this +# refuses by name. The full version is read from the release's own OTP_VERSION file, not from +# `otp_release` (which is the major alone, so 28.1.1 and 28.5.0.8 both answer "28"). A line +# newer than every recorded one passes and says so; a line older than every recorded one, or a +# recorded line below its floor, is refused. Run by every leg of CI's test job +# (.github/workflows/ci.yml) after the toolchain is installed. A copy of beam_mcp's +# tools/otp_floor.sh: the two packages hold the same floor, and change together. +set -euo pipefail +root=$(git rev-parse --show-toplevel) +floors="$root/tools/otp-floors" +have=$(erl -noshell -eval ' + R = erlang:system_info(otp_release), + F = filename:join([code:root_dir(), "releases", R, "OTP_VERSION"]), + {ok, B} = file:read_file(F), + io:format("~s", [string:trim(B)]), + halt().') +have=${have%%[!0-9.]*} +major=${have%%.*} +floor=$(awk -v m="$major" '$1 == m { print $2 }' "$floors") +newest=$(awk '$1 ~ /^[0-9]+$/ { print $1 }' "$floors" | sort -n | tail -1) +if [ -z "$floor" ]; then + if [ "$major" -gt "$newest" ]; then + echo "OTP $have: a line newer than every recorded floor (newest recorded: $newest); passes" + exit 0 + fi + echo "OTP $have: line $major has no patched release recorded in tools/otp-floors; refused" + exit 1 +fi +lowest=$(printf '%s\n%s\n' "$floor" "$have" | sort -V | head -1) +if [ "$lowest" = "$floor" ]; then + echo "OTP $have: at or above the patched floor $floor for line $major" +else + echo "OTP $have: below the patched floor $floor for line $major (CVE-2026-55737); refused" + exit 1 +fi