Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
35280b5
fix: repair master after the 09-07 batch merge
Kartikey1306 Sep 11, 2026
2b30fd7
test(imgpack): pass the version as --version=X so argparse never sees…
Kartikey1306 Sep 11, 2026
a95400f
test(fw_update): pin the fixture header to its generator
Kartikey1306 Sep 14, 2026
edfbaed
docs: record the install-path check ordering as ADR-020 and log the r…
Kartikey1306 Sep 14, 2026
b01e9ce
docs: state the Valgrind gap and the ADR's provenance exactly
Kartikey1306 Sep 14, 2026
e152d8e
test(fw_update): exercise the stale-header report, not only the match
Kartikey1306 Sep 14, 2026
547ab9f
fix(keystore): the compiled-in dev trust anchor was not a point on th…
Kartikey1306 Sep 11, 2026
ef5b70d
test(keystore): compare the anchor against the generated fixture key,…
Kartikey1306 Sep 14, 2026
f8afc12
build: a Release build of a real board refuses to configure without a…
Kartikey1306 Sep 14, 2026
2096bc3
ci(release): pass the production anchor, and refuse an artifact that …
Kartikey1306 Sep 14, 2026
58f8216
test: pin the production-anchor gate, the release workflow, and the p…
Kartikey1306 Sep 14, 2026
700da34
docs: key lifecycle and changelog for the production anchor
Kartikey1306 Sep 14, 2026
b26c45a
test: drive the workflow guard with a synthetic document so its branc…
Kartikey1306 Sep 14, 2026
a7d3500
build: gate every release-shaped build type, and refuse a key the ver…
Kartikey1306 Sep 14, 2026
17709bd
ci(build): the STM32F4 compile check opts into the development key
Kartikey1306 Sep 14, 2026
a17d272
ci(release): one artifact scanner that decodes what it scans, in plac…
Kartikey1306 Sep 14, 2026
723a631
build: a production key that cannot be checked is refused, not warned…
Kartikey1306 Sep 14, 2026
7ddc735
build: cross-compile the production-key branch before a tag, and say …
Kartikey1306 Sep 14, 2026
e5dbbb4
ci(build): the production-key proof step looks for .obj too
Kartikey1306 Sep 14, 2026
a50df28
ci(release): scan .obj as well as .o, and say what the key check cann…
Kartikey1306 Sep 15, 2026
18fab20
docs(key_lifecycle): state the typo-collision rate as the derivable o…
Kartikey1306 Sep 15, 2026
0c7c8f5
fix(sign_image): --genkey writes the key the build reads, not a heade…
Kartikey1306 Sep 15, 2026
180a74f
docs(key_lifecycle): show the flags sign_image.py has, not two it nev…
Kartikey1306 Sep 15, 2026
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
31 changes: 31 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,45 @@ jobs:

- name: Configure
run: |
# A production key, not the dev-key opt-out. With the trust-anchor gate
# in place, every other pre-merge job that configures a real board
# passes -DEBLDR_ALLOW_DEV_KEY=ON, so the branch a real device takes --
# build/generated/production_key.c and the #ifdef EBLDR_PRODUCTION_KEY
# half of core/keystore.c -- was first cross-compiled inside release.yml,
# when a tag was pushed. A link or section-placement fault in the
# generated TU would have surfaced during a release, not a review.
# The key is RFC 8032 section 7.1 TEST 2's public key: a genuine curve
# point (the configure-time check runs on it), the same fixture the host
# tests use, and one whose secret is published -- this job's output
# reaches no device. ci.yml's ARM leg still compiles the dev-key branch.
cmake -B build-arm \
-DCMAKE_SYSTEM_NAME=Generic \
-DCMAKE_C_COMPILER=arm-none-eabi-gcc \
-DCMAKE_BUILD_TYPE=Release \
-DEBLDR_BOARD=stm32f4 \
-DEBLDR_PRODUCTION_KEY=3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c \
-DCMAKE_C_FLAGS="-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -specs=nosys.specs"

- name: Build
run: cmake --build build-arm --parallel
- name: The production-key branch was compiled, not the dev key
run: |
test -f build-arm/generated/production_key.c
# CMake names objects .obj, not .o, when CMAKE_SYSTEM_NAME=Generic;
# the first run of this step looked for .o and reported the TU
# uncompiled when the build log showed it compiling. Match both.
obj=$(find build-arm \( -name 'production_key.c.o' -o -name 'production_key.c.obj' \) | head -1)
test -n "$obj" || { echo '::error::generated/production_key.c was not compiled'; exit 1; }
# the object must carry the fixture key and must not carry the dev key
python3 - "$obj" <<'PY'
import sys
o = open(sys.argv[1], 'rb').read()
fixture = bytes.fromhex('3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c')
dev = bytes.fromhex('d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a')
assert fixture in o, 'the configured production key is not in the object'
assert dev not in o, 'the development key is in the production-key object'
print('production_key.c.o carries the fixture key and not the dev key')
PY

- name: Report size
run: arm-none-eabi-size build-arm/*.elf 2>/dev/null || true
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,15 @@ jobs:

- name: Configure (ARM)
run: |
# A Release build of a real board refuses to configure without a
# production trust anchor. This build checks that the tree
# cross-compiles; nothing it produces reaches a device, so it says so.
cmake -B build/arm -G Ninja \
-DCMAKE_BUILD_TYPE=$BUILD_TYPE \
-DCMAKE_TOOLCHAIN_FILE=toolchains/arm-none-eabi.cmake \
-DEBLDR_BOARD=stm32f4 \
-DEBLDR_BUILD_TESTS=OFF
-DEBLDR_BUILD_TESTS=OFF \
-DEBLDR_ALLOW_DEV_KEY=ON

- name: Build (ARM)
run: cmake --build build/arm --parallel $(nproc)
Expand Down Expand Up @@ -250,7 +254,7 @@ jobs:
ci-gate:
name: CI Gate
runs-on: ubuntu-22.04
needs: [test, build-arm, static-analysis]
needs: [test, build-arm, fuzz-build, static-analysis]
if: always()
steps:
- uses: actions/checkout@v4
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/eosim-sanity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ jobs:
install-validate:
name: Install & Validate (${{ matrix.os }}, Python ${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
# Every step here is written in bash: `$(...)`, `|| { ... }`, a heredoc,
# and /tmp. Without this the Windows legs ran them under PowerShell, where
# `SITE_PACKAGES=$(...)` is an unknown command and the job went red, while
# the `|| { exit 1 }` guard in "Verify installation" parsed as an unexecuted
# script block and could never fail.
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
Expand Down
45 changes: 37 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Refuse a production key the bootloader could not use
# Every firmware job below needs this job, so a bad secret fails the
# release here -- before a single board is configured -- rather than
# shipping a fleet whose bootloader refuses every image it is offered.
# cmake/ProductionKey.cmake repeats the check per board when python3
# is present; this is the one place it is guaranteed to run, on the
# exact bytes the secret holds. A missing or empty secret fails too.
env:
KEY: ${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}
run: python3 tools/check_production_key.py "$KEY"
- name: Build & Test
run: |
cmake -B build -DEBLDR_BUILD_TESTS=ON -DEBLDR_HARDENING=ON
Expand Down Expand Up @@ -47,9 +57,18 @@ jobs:
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build firmware
# EBLDR_PRODUCTION_KEY_HEX is the raw Ed25519 public key, 64 hex
# characters, that boards without OTP compile in as their trust
# anchor. With the secret unset the value is empty and CMake refuses
# to configure a Release build of a real board -- so a release cannot
# be cut with the RFC 8032 test key until a maintainer provides the
# anchor. That is the intended shape; do not work around it with
# EBLDR_ALLOW_DEV_KEY here.
run: |
cmake -B build -DEBLDR_BOARD=${{ matrix.board }} -DCMAKE_BUILD_TYPE=Release
cmake -B build -DEBLDR_BOARD=${{ matrix.board }} -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
- name: Refuse an artifact that embeds the development anchor
run: python3 tools/check_no_dev_anchor.py build
- name: Collect artifacts
run: |
mkdir -p fw
Expand Down Expand Up @@ -77,10 +96,12 @@ jobs:
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build
run: |
cmake -B build -DEBLDR_BOARD=rpi4 \
cmake -B build -DEBLDR_BOARD=rpi4 -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" \
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
-DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
- name: Refuse an artifact that embeds the development anchor
run: python3 tools/check_no_dev_anchor.py build
- name: Collect artifacts
run: |
mkdir -p fw
Expand All @@ -107,10 +128,12 @@ jobs:
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build
run: |
cmake -B build -DEBLDR_BOARD=riscv64_virt \
cmake -B build -DEBLDR_BOARD=riscv64_virt -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" \
-DCMAKE_C_COMPILER=riscv64-linux-gnu-gcc \
-DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
- name: Refuse an artifact that embeds the development anchor
run: python3 tools/check_no_dev_anchor.py build
- name: Collect artifacts
run: |
mkdir -p fw
Expand Down Expand Up @@ -152,10 +175,12 @@ jobs:
run: |
source ~/esp-idf/export.sh
# Use ESP-IDF's idf.py if a top-level CMakeLists has IDF setup, else direct cmake
cmake -B build -DEBLDR_BOARD=esp32 -DCMAKE_BUILD_TYPE=Release \
cmake -B build -DEBLDR_BOARD=esp32 -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE=$IDF_PATH/tools/cmake/toolchain-esp32.cmake || \
cmake -B build -DEBLDR_BOARD=esp32 -DCMAKE_BUILD_TYPE=Release
cmake -B build -DEBLDR_BOARD=esp32 -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
- name: Refuse an artifact that embeds the development anchor
run: python3 tools/check_no_dev_anchor.py build
- name: Collect artifacts
run: |
mkdir -p fw
Expand Down Expand Up @@ -195,10 +220,12 @@ jobs:
- name: Build firmware (RISC-V)
run: |
source ~/esp-idf/export.sh
cmake -B build -DEBLDR_BOARD=esp32c3 -DCMAKE_BUILD_TYPE=Release \
cmake -B build -DEBLDR_BOARD=esp32c3 -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE=$IDF_PATH/tools/cmake/toolchain-esp32c3.cmake || \
cmake -B build -DEBLDR_BOARD=esp32c3 -DCMAKE_BUILD_TYPE=Release
cmake -B build -DEBLDR_BOARD=esp32c3 -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
- name: Refuse an artifact that embeds the development anchor
run: python3 tools/check_no_dev_anchor.py build
- name: Collect artifacts
run: |
mkdir -p fw
Expand All @@ -224,8 +251,10 @@ jobs:
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build EFI firmware
run: |
cmake -B build -DEBLDR_BOARD=x86_64_efi -DCMAKE_BUILD_TYPE=Release
cmake -B build -DEBLDR_BOARD=x86_64_efi -DEBLDR_PRODUCTION_KEY="${{ secrets.EBLDR_PRODUCTION_KEY_HEX }}" -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
- name: Refuse an artifact that embeds the development anchor
run: python3 tools/check_no_dev_anchor.py build
- name: Collect artifacts
run: |
mkdir -p fw
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ossf/scorecard-action@v2.4.0
- uses: ossf/scorecard-action@v2.4.3
with:
results_file: results.sarif
results_format: sarif
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
## [Unreleased]

### Security
- **The production-key gate covers every release-shaped build, and the key it accepts is checked against the curve.** The gate matched the literal string `Release` and nothing else, so `MinSizeRel`, `RelWithDebInfo`, `release`, `RELEASE` and an unset build type all configured a real board on the development anchor. It now exempts only `Debug`. Separately, `EBLDR_PRODUCTION_KEY` was checked for length and hex-ness but never for being a point on edwards25519 — the defect the development key itself had — so one mistyped hex digit in the release secret would have shipped a fleet that refuses every image, with a green build. `tools/check_production_key.py` applies the verifier's own rule (`[L]P == identity`, `P != identity`, not the development key) in pure Python; `cmake/ProductionKey.cmake` runs it at configure when python3 is present and warns in so many words when it is not, and `release.yml` runs it on the secret before any board is configured.
- **A release can no longer be built on the development trust anchor.** `EBLDR_PRODUCTION_KEY` had no CMake option, nothing set it, and its `#else` branch declared an `extern` nothing defined -- so every firmware job in `release.yml` compiled the `#ifndef` branch and shipped the RFC 8032 test key as the anchor for every board without OTP, which is every board under `boards/`. The only guard was a `#warning`. Now: `-DEBLDR_PRODUCTION_KEY=<64 hex characters>` generates `ebldr_production_key[]` (`cmake/ProductionKey.cmake`, declared in `include/eos_production_key.h`) and selects the production branch of `core/keystore.c`; a Release build of a real board refuses to configure without it unless it says `-DEBLDR_ALLOW_DEV_KEY=ON`; the development key and malformed values are refused as production keys; `release.yml` passes the `EBLDR_PRODUCTION_KEY_HEX` secret on every board configure (empty until a maintainer provides it, so a release fails closed) and scans every built `.elf`/`.bin`/`.a`/`.o` for the development key's bytes. `tests/unit/test_keystore_production.c` compiles the production branch with a fixture key on every host build; `tests/unit/test_production_key_gate.py` and `test_release_workflow_production_key.py` pin the gate and the workflow.
- **The compiled-in development trust anchor was not a point on the curve.** `core/keystore.c` `default_dev_key` was described as the RFC 8032 section 7.1 TEST 1 public key, but it diverged from that key at byte 21 and the bytes it held did not decode to a point on the curve, so no signature could ever verify against it. It is now the RFC key, and `tests/unit/test_keystore.c` checks that the anchor verifies the RFC's own signature. Behaviour change: a board with no OTP and no `EBLDR_PRODUCTION_KEY` goes from refusing every image (since #104 made signature verification unconditional at install) to accepting images signed with the public RFC test key -- the documented development intent of the `#warning` in `core/keystore.c`, and a key that must never reach a device. #120 tracks making that structural.
- **Image header is now authenticated (header format v2).** `eos_image_verify_signature()` signed `hdr->hash` only — 32 of the header's 156 bytes. Everything else (`image_size`, `load_addr`, `entry_addr`, `flags`, `sig_type`, `image_version`) sat outside the signature, so an attacker holding a legitimately signed image could relocate it, move its entry point, or clear `EOS_IMG_FLAG_HASH_SHA256` to downgrade integrity checking from SHA-256 to forgeable CRC32 — all while keeping the signature valid. The signature now covers `EOS_IMG_SIGNED_LEN` (92) bytes: the whole header except `signature[]` itself. **Existing signed images must be re-signed.**
- **`eos_image_parse_header`:** validates `hdr_version`, rejecting 0 and anything newer than this build understands.
- **`tools/eos_sign.py`:** `SIG_TYPE_ED25519` was `1` — that is `EOS_SIG_CRC32` in `eos_types.h`, which `eos_image_verify_signature()` rejects outright — and `IMG_FLAG_SIGNED` was `1 << 2`, which is `EOS_IMG_FLAG_DEBUG`. It also never set `EOS_IMG_FLAG_HASH_SHA256`, so the bootloader read the stored SHA-256 as a CRC32. Constants now match `include/eos_types.h`.
Expand All @@ -13,6 +16,13 @@
- **`image_verify.c`:** `eos_image_verify_integrity` rejects a zero `image_size`, and an `addr + hdr_size` that wraps `uint32_t`, instead of computing a payload address that is not the payload.

### Fixed
- **The tree did not configure, compile or link after the 09-07 batch merge.** `tests/CMakeLists.txt` registered `eboot_test_fdt_loader` twice; `core/sha512.c` had been replaced by a version predating the `bitlen[2]`/`buffer_len` context; `core/boot_log.c`, `core/secure_boot.c` and `core/fdt_loader.c` had been dropped from `eboot_core`; `scalarbase()` and `k_low_order[]` were defined twice; and the `eos_boot_log_get_head()` declaration was lost. All restored.
- **Install-path verification order settled: signature before anti-rollback.** `eos_fw_update_finalize()` verifies the Ed25519 signature over the signed header prefix first and reads the TLV security counter only after the prefix that binds it is authenticated (see `docs/adr/ADR-020`). The `fw_update` and `fw_transport` suites now stream genuinely signed images; `tools/gen_fw_update_test_sigs.py` emits their signatures as `tests/vectors/fw_update_test_sigs.h`, and `tests/unit/test_fw_update_test_sigs.py` pins the committed header to the generator's output.
- **`tests/CMakeLists.txt`:** the Valgrind list is derived from the registered suites again; a hand-written copy had replaced it, eleven registered suites were missing from `EBLDR_UNIT_TESTS`, and seven of those (`test_eos_sign_boot_path`, `test_fdt_loader`, `test_fw_decrypt`, `test_fw_update_sig`, `test_jump_app_bounds`, `test_qemu_arm64_timer`, `test_secure_boot_policy`) had no Valgrind run at all; the other four were only in the hand-written list. All eleven are appended.
- **`.github/workflows/ci.yml`:** `fuzz-build` is in the CI gate. It was added after the gate job and the gate never waited for it.
- **Unit suites count `tests_run`** as each test executes instead of assigning it a literal that the summary line then trusted.
- **`.github/workflows/eosim-sanity.yml`:** the install-validate job's steps are bash and now run under `shell: bash` on the Windows legs, where PowerShell rejected `SITE_PACKAGES=$(...)` and parsed `|| { exit 1 }` as an unexecuted script block.
- **`.github/workflows/scorecard.yml`:** `ossf/scorecard-action` moved to v2.4.3, the release hosted on ghcr.io; v2.4.0 pulls from gcr.io, which now requires GCP billing.
- **The tree did not compile.** `include/eos_image.h` declared `eos_crc32()` as `int eos_crc32(uint32_t, size_t, uint32_t *)` while `core/image_verify.c` defined it as `uint32_t eos_crc32(uint32_t, size_t)` -- a conflicting-types error that stopped the build at the first core source file. The declaration now matches the definition and the documented behaviour.
- **`ed25519_verify.c`:** `eos_ed25519_verify()` never performed the verification. Two merged copies of the challenge-hash step had been left in the function, the second referring to identifiers that do not exist (`sha512_ctx_t`, `sc_reduce`), and RFC 8032 step 4 -- the `[S]B == R + [k]A` check -- was absent entirely, leaving the function returning an undeclared `diff`. The duplicate is removed and the group-equation check restored; the function now passes the RFC 8032 test vectors and rejects tampered messages, every single-bit signature flip, wrong keys and malleated signatures.
- **`recovery.c`:** `recovery_handle_write()` declared `slot_size` twice, which does not compile. The bounds check now calls `eos_recovery_write_in_range()` -- the helper the unit tests already exercise -- so the wire-input rule has one definition, and an unmapped slot (`base == 0`) is rejected too.
Expand Down
Loading
Loading