From ff41648a7521d05fc0c866ca750a09fb06d678c0 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Mon, 19 Oct 2026 19:03:40 -0400 Subject: [PATCH 1/2] Every leg at its line's patched OTP floor; the box leg on aarch64 CVE-2026-55737 (heap corruption in binary_to_term/1 decoding LARGE_TUPLE_EXT) is fixed in OTP 27.3.4.15, 28.5.0.4 and 29.0.4. .tool-versions pinned 28.1.1, below its line's floor, and no CI leg asserted a patched release. - tools/otp-floors and tools/otp_floor.sh are copies of beam_mcp's: the floor of each line, and a script that reads the running release's OTP_VERSION file and refuses a runtime below its line's floor. Every CI leg runs it right after setup-beam, and prints the installed pair, the OpenSSL that :crypto is linked to, and whether it offers ML-DSA-87 (printed, not asserted: an ML-DSA signer needs to know which legs can run it). - A fourth leg, "box", runs OTP 28.5.0.8 with Elixir 1.20.4 (version-type: strict) on ubuntu-24.04-arm: the versions and the architecture a fielded edge box runs. Minutes for a public repository are free on both architectures. - .tool-versions: erlang 28.5.0.8. - CONTRIBUTING, GOVERNANCE and docs/assurance-case.md say four legs; GOVERNANCE says six required checks, which holds once the box leg's name is added to the ruleset. Measured locally: 28.1.1: below the patched floor 28.5.0.4 for line 28 (CVE-2026-55737); refused 28.5.0.8: at or above the patched floor 28.5.0.4 for line 28 29.1: at or above the patched floor 29.0.4 for line 29 The test job's steps (floor, format, compile with warnings as errors, Credo, Hex audit, tests) pass on OTP 28.5.0.8 with Elixir 1.18.4 and with Elixir 1.20.4, on x86_64. The box leg has not run on aarch64 yet; this pull request's first CI run is that measurement. Signed-off-by: Ayla Croft --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++-------- .tool-versions | 2 +- CONTRIBUTING.md | 5 +++-- GOVERNANCE.md | 6 +++--- docs/assurance-case.md | 2 +- tools/otp-floors | 9 +++++++++ tools/otp-floors.license | 2 ++ tools/otp_floor.sh | 39 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 tools/otp-floors create mode 100644 tools/otp-floors.license create mode 100755 tools/otp_floor.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d33595..b5e5578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,18 @@ # 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). +# 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,15 +26,16 @@ 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 } + - { leg: pinned, otp: "28", elixir: "1.18", os: ubuntu-24.04, version-type: loose } + - { leg: head, otp: "29", elixir: "1.20", os: ubuntu-24.04, version-type: loose } + - { leg: box, otp: "28.5.0.8", elixir: "1.20.4", os: ubuntu-24.04-arm, version-type: strict } env: MIX_ENV: test steps: @@ -35,6 +44,15 @@ jobs: with: otp-version: ${{ matrix.otp }} elixir-version: ${{ matrix.elixir }} + version-type: ${{ matrix.version-type }} + # Also printed, not asserted: the OpenSSL `:crypto` is linked to and whether it offers + # ML-DSA-87, which an ML-DSA signer needs from the leg it runs on. + - 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 + erl -noshell -eval '[{_, _, L}] = crypto:info_lib(), K = proplists:get_value(public_keys, crypto:supports()), io:format("crypto linked to ~s; mldsa87 offered: ~p~n", [L, lists:member(mldsa87, K)]), halt().' + 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..35349c8 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -27,13 +27,13 @@ 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), 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/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 From be274b3c5e1a74659530d2e49880893411be2c04 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 20 Oct 2026 08:10:15 -0400 Subject: [PATCH 2/2] The box leg on Ubuntu 26.04's arm64 runner, required to offer ML-DSA-87 The box leg ran on ubuntu-24.04-arm, where builds.hex.pm's OTP links the runner's OpenSSL 3.0.13 and :crypto offers no ML-DSA. The image a fielded edge box runs pins OpenSSL 3.5 or later, the line ML-DSA needs, so the leg did not run the runtime the box runs, and an ML-DSA-87 signer had no leg in this repository that could exercise it. - The box leg runs on ubuntu-26.04-arm. GitHub's 26.04 runner images ship OpenSSL 3.5.5 (public preview; ubuntu-latest moves to 26.04 in November 2026), and builds.hex.pm publishes OTP 28.5.0.8 for ubuntu-26.04 on arm64 and amd64. - tools/mldsa_offered.sh prints the OpenSSL that :crypto is linked to and whether it offers ML-DSA-87, on every leg. With MLDSA_REQUIRED=1, which only the box leg sets, a runtime without it is refused by name, so the leg cannot pass on a runtime that cannot sign with it. - GOVERNANCE says the box leg runs over an OpenSSL that offers ML-DSA-87. The check name is unchanged, so the ruleset entry the box leg needs is the same one. Measured locally (tools/mldsa_offered.sh): OTP 28.5.0.8 over OpenSSL 3.0.13, required: offered false; refused (exit 1) OTP 29.1 over OpenSSL 3.0.13, required: offered false; refused (exit 1) OTP 28.5.0.8 over OpenSSL 3.5.9, required: offered true (exit 0) not required, on each: printed only (exit 0) builds.hex.pm's OTP-28.5.0.8 for ubuntu-26.04 amd64, with its sha256 checked against builds.txt and run in ubuntu:26.04, is linked to OpenSSL 3.5.5 and offers mldsa87; generate, sign and verify succeed. The arm64 build has not run yet; this push's CI run is that measurement. Signed-off-by: Ayla Croft --- .github/workflows/ci.yml | 20 ++++++++++++-------- GOVERNANCE.md | 3 ++- tools/mldsa_offered.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) create mode 100755 tools/mldsa_offered.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e5578..f8da67c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,10 @@ # 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). +# 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 @@ -32,12 +35,13 @@ jobs: fail-fast: false matrix: include: - - { leg: floor, otp: "27", elixir: "1.17", os: ubuntu-24.04, version-type: loose } - - { leg: pinned, otp: "28", elixir: "1.18", os: ubuntu-24.04, version-type: loose } - - { leg: head, otp: "29", elixir: "1.20", os: ubuntu-24.04, version-type: loose } - - { leg: box, otp: "28.5.0.8", elixir: "1.20.4", os: ubuntu-24.04-arm, version-type: strict } + - { 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 @@ -45,13 +49,13 @@ jobs: otp-version: ${{ matrix.otp }} elixir-version: ${{ matrix.elixir }} version-type: ${{ matrix.version-type }} - # Also printed, not asserted: the OpenSSL `:crypto` is linked to and whether it offers - # ML-DSA-87, which an ML-DSA signer needs from the leg it runs on. + # 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 - erl -noshell -eval '[{_, _, L}] = crypto:info_lib(), K = proplists:get_value(public_keys, crypto:supports()), io:format("crypto linked to ~s; mldsa87 offered: ~p~n", [L, lists:member(mldsa87, K)]), halt().' + bash tools/mldsa_offered.sh bash tools/otp_floor.sh - run: mix deps.get - run: mix format --check-formatted diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 35349c8..8563392 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -28,7 +28,8 @@ date, so the bus factor is two, and his reviews are the public record of it. 1. A branch and a pull request; nothing is pushed to `main` directly. 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), the REUSE lint and the DCO check. + 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 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