diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e3ea60c..c157307 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ae0b7..4dbaac1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) @@ -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 diff --git a/.github/workflows/eosim-sanity.yml b/.github/workflows/eosim-sanity.yml index e323860..9c6e3b4 100644 --- a/.github/workflows/eosim-sanity.yml +++ b/.github/workflows/eosim-sanity.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0980421..b66c223 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e8507e5..4a0033e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb33e3..970199d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. @@ -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. diff --git a/CMakeLists.txt b/CMakeLists.txt index 728e028..97b74fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,9 +27,58 @@ option(EBLDR_VERIFY_STAGE1 "Verify Stage-1 hash before jump" ON) option(EBLDR_HARDENING "Enable compiler hardening flags" ON) option(EBLDR_SANITIZE "Enable ASAN/UBSAN for host builds" OFF) option(EBLDR_BUILD_FUZZ "Build libFuzzer fuzz targets" OFF) +set(EBLDR_PRODUCTION_KEY "" CACHE STRING + "Ed25519 public key, 64 hex characters, compiled in as the trust anchor for \ +a board without OTP. A Release build of a real board refuses to configure without it.") +option(EBLDR_ALLOW_DEV_KEY + "Let a Release build of a real board fall back to the RFC 8032 test key \ +(bring-up and CI cross-compiles only; never a device)" OFF) set(EBLDR_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include) +# ==================================================================== +# Trust anchor for boards without OTP +# ==================================================================== +# core/keystore.c falls back to a compiled-in public key when the board has +# no OTP -- which today is every board under boards/, none of which +# implements otp_read. Without a production key that fallback is the RFC 8032 +# test key, whose secret is published, and an artifact built that way accepts +# anyone's firmware. So a Release build of a real board refuses to configure +# until it is given a key, or told in so many words that it is not a release. +include(cmake/ProductionKey.cmake) +# +# What counts as a release. The gate used to match the literal string +# "Release" and nothing else, so MinSizeRel -- the ordinary build type for a +# bootloader -- RelWithDebInfo, "release", "RELEASE" and an unset build type +# all configured a real board around it. Only Debug is not release-shaped: +# every other value is optimised, an unset value on a cross build still gets +# -Os from this file, and a multi-config generator has no build type at all at +# configure time. So the gate asks "is this Debug?" and refuses everything +# else, rather than asking "is this spelled Release?". +string(TOUPPER "${CMAKE_BUILD_TYPE}" _ebldr_build_type) +if(NOT EBLDR_PRODUCTION_KEY STREQUAL "") + ebldr_write_production_key_source("${EBLDR_PRODUCTION_KEY}" + "${CMAKE_BINARY_DIR}/generated/production_key.c") + set(EBLDR_PRODUCTION_KEY_SOURCE "${CMAKE_BINARY_DIR}/generated/production_key.c") + message(STATUS " Trust anchor: production key from EBLDR_PRODUCTION_KEY") +elseif(NOT EBLDR_BOARD STREQUAL "none" AND NOT EBLDR_ALLOW_DEV_KEY + AND NOT _ebldr_build_type STREQUAL "DEBUG") + if(CMAKE_BUILD_TYPE STREQUAL "") + set(_ebldr_build_desc "build with no CMAKE_BUILD_TYPE") + else() + set(_ebldr_build_desc "${CMAKE_BUILD_TYPE} build") + endif() + message(FATAL_ERROR + "EBLDR_PRODUCTION_KEY is not set: a ${_ebldr_build_desc} of board " + "'${EBLDR_BOARD}' would compile in the RFC 8032 test key as its trust " + "anchor, and anyone can sign for that key. Only a Debug build is exempt. " + "Pass -DEBLDR_PRODUCTION_KEY=<64 hex characters> (the raw Ed25519 public " + "key), or, for a bring-up or CI build that will never reach a device, " + "-DEBLDR_ALLOW_DEV_KEY=ON.") +else() + message(STATUS " Trust anchor: RFC 8032 test key (development only; see core/keystore.c)") +endif() + # ==================================================================== # Compiler flags # ==================================================================== @@ -80,6 +129,7 @@ target_include_directories(eboot_hal PUBLIC ${EBLDR_INCLUDE_DIR}) # ---- Core boot logic ---- add_library(eboot_core STATIC core/bootctl.c + core/boot_log.c core/image_verify.c core/slot_manager.c core/boot_policy.c @@ -106,14 +156,22 @@ add_library(eboot_core STATIC core/os_adapter.c core/ed25519_verify.c core/sha512.c - core/keystore.c + core/secure_boot.c + core/fdt_loader.c core/rollback.c + core/keystore.c core/debug_lock.c core/fw_decrypt.c core/image_tlv.c ) target_include_directories(eboot_core PUBLIC ${EBLDR_INCLUDE_DIR}) target_link_libraries(eboot_core PUBLIC eboot_hal) +if(EBLDR_PRODUCTION_KEY_SOURCE) + # The generated ebldr_production_key[] and the switch that makes + # core/keystore.c use it instead of the development key. + target_sources(eboot_core PRIVATE ${EBLDR_PRODUCTION_KEY_SOURCE}) + target_compile_definitions(eboot_core PRIVATE EBLDR_PRODUCTION_KEY=1) +endif() # ---- Stage-1 boot manager ---- add_library(eboot_stage1 STATIC diff --git a/cmake/ProductionKey.cmake b/cmake/ProductionKey.cmake new file mode 100644 index 0000000..a867c8c --- /dev/null +++ b/cmake/ProductionKey.cmake @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project +# +# The compiled-in trust anchor. +# +# core/keystore.c falls back to a compiled-in public key when the board has no +# OTP to read one from. With EBLDR_PRODUCTION_KEY unset that key is the RFC 8032 +# section 7.1 TEST 1 public key, whose secret is printed in the RFC, so a device +# built that way accepts firmware from anyone. These two functions are how a +# real key gets in: the top-level CMakeLists.txt calls them for the value of +# EBLDR_PRODUCTION_KEY, and tests/CMakeLists.txt calls them for a fixture key +# so the production branch of keystore.c is compiled and exercised on every +# host build. + +# The development key, lower-case hex. A production key that equals it is +# refused: it is not a secret, and the whole point of the option is to keep it +# out of an artifact. +set(EBLDR_DEV_KEY_HEX + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a") + +# Where this module lives, captured at include time: inside a function, +# CMAKE_CURRENT_LIST_DIR is the caller's directory, not this file's. +set(_EBLDR_PRODUCTION_KEY_MODULE_DIR "${CMAKE_CURRENT_LIST_DIR}") + +# Fail the configure unless `hex` is a raw Ed25519 public key that is not the +# development key -- and, when python3 is available, one the verifier would +# accept: a point on edwards25519, in the prime-order subgroup. +# +# The length and hex checks say nothing about whether the bytes are a key at +# all. The development key shipped for months decoding to no point on the +# curve, and one mistyped hex digit in a release secret reproduces that: the +# build is green, the artifact scan is green, the status line says +# "production key", and the device refuses every image it is ever offered. +# tools/check_production_key.py applies core/ed25519_verify.c's own rule, +# [L]P == identity and P != identity, in pure Python. CMake cannot do the +# field arithmetic itself, so the check needs python3; without it the +# configure warns, in so many words, about what was not checked. +function(ebldr_check_production_key_hex hex) + string(LENGTH "${hex}" _len) + if(NOT _len EQUAL 64 OR NOT hex MATCHES "^[0-9a-fA-F]+$") + message(FATAL_ERROR + "EBLDR_PRODUCTION_KEY must be a raw Ed25519 public key as exactly 64 " + "hexadecimal characters; got ${_len} character(s).") + endif() + string(TOLOWER "${hex}" _lower) + if(_lower STREQUAL EBLDR_DEV_KEY_HEX) + message(FATAL_ERROR + "EBLDR_PRODUCTION_KEY is the RFC 8032 section 7.1 TEST 1 public key -- " + "the development key, whose secret is published. It cannot be a " + "production trust anchor.") + endif() + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + execute_process( + COMMAND "${Python3_EXECUTABLE}" + "${_EBLDR_PRODUCTION_KEY_MODULE_DIR}/../tools/check_production_key.py" + "${hex}" + RESULT_VARIABLE _rc + OUTPUT_VARIABLE _out + ERROR_VARIABLE _err) + if(NOT _rc EQUAL 0) + message(FATAL_ERROR + "EBLDR_PRODUCTION_KEY is not a usable Ed25519 public key: ${_err}" + "A device built with it would refuse every firmware image.") + endif() + else() + # Not a warning. This gate is the only control for anyone building a + # device image outside release.yml, and a warning scrolls past. A + # production-key configure that cannot validate its key does not + # compile an unchecked key in; it stops, and says what to install. + # Development builds (no EBLDR_PRODUCTION_KEY) never reach this. + message(FATAL_ERROR + "EBLDR_PRODUCTION_KEY was given but python3 was not found, so it cannot " + "be checked for being a point in the prime-order subgroup of " + "edwards25519. A key that is not one ships a device that refuses every " + "image, so the key is not compiled in unchecked. Install python3, or " + "configure on a machine that has it.") + endif() +endfunction() + +# Write a C translation unit defining ebldr_production_key[] from `hex` to +# `out`. The file is only rewritten when its content changes, so an unchanged +# key does not rebuild the keystore on every configure. +function(ebldr_write_production_key_source hex out) + ebldr_check_production_key_hex("${hex}") + string(TOLOWER "${hex}" _lower) + string(REGEX MATCHALL "[0-9a-f][0-9a-f]" _bytes "${_lower}") + set(_body "") + set(_i 0) + foreach(_b IN LISTS _bytes) + math(EXPR _col "${_i} % 8") + if(_col EQUAL 0) + string(APPEND _body " ") + endif() + string(APPEND _body "0x${_b},") + if(_col EQUAL 7) + string(APPEND _body "\n") + else() + string(APPEND _body " ") + endif() + math(EXPR _i "${_i} + 1") + endforeach() + set(_content +"/* SPDX-License-Identifier: MIT */ +/* Generated by cmake/ProductionKey.cmake from a 64-hex-character Ed25519 + * public key. Do not edit; change the key the build was configured with. */ +#include \"eos_production_key.h\" + +const uint8_t ebldr_production_key[EOS_ED25519_PUB_KEY_SIZE] = { +${_body}}; +") + get_filename_component(_dir "${out}" DIRECTORY) + file(MAKE_DIRECTORY "${_dir}") + file(WRITE "${out}.in" "${_content}") + configure_file("${out}.in" "${out}" COPYONLY) +endfunction() diff --git a/core/ed25519_verify.c b/core/ed25519_verify.c index 7d230fd..3e9e4b1 100644 --- a/core/ed25519_verify.c +++ b/core/ed25519_verify.c @@ -300,16 +300,6 @@ static int point_is_identity(gf p[4]) return diff == 0; } -static void scalarbase(gf r[4], const uint8_t *s) -{ - gf q[4]; - fe_copy16(q[0], BX); - fe_copy16(q[1], BY); - fe_copy16(q[2], gf1); - fe_mul(q[3], BX, BY); - scalarmult(r, q, s); -} - /* Reject a public key outside the prime-order subgroup. * * Decoding a point is not enough. Ed25519 has eight points of low order, and diff --git a/core/keystore.c b/core/keystore.c index a86654d..aebd0d2 100644 --- a/core/keystore.c +++ b/core/keystore.c @@ -14,24 +14,45 @@ #include "eos_keystore.h" #include "eos_hal.h" #include +#ifdef EBLDR_PRODUCTION_KEY +#include "eos_production_key.h" +#endif -/* Default development key — REPLACE with production key before deployment. +/* The compiled-in trust anchor, used when the board has no OTP to read a + * key from -- which today is every board under boards/. + * + * Without EBLDR_PRODUCTION_KEY it is the public half of TEST 1 in RFC 8032 + * section 7.1. The matching private key is printed in the RFC, so anyone at + * all can produce a signature that a bootloader trusting this key will + * accept. It is a usable default for bring-up and for the unit tests, and it + * must never reach a device. The #warning below is deliberate: this key going + * out silently is the failure mode, so a build that embeds it says so on + * every compile. + * + * A production key is supplied at configure time: * - * This is the public half of TEST 1 in RFC 8032 section 7.1. The matching - * private key is printed in the RFC, so anyone at all can produce a signature - * that a bootloader trusting this key will accept. It is a usable default for - * bring-up and for the unit tests, and it must never reach a device. + * cmake -B build -DEBLDR_BOARD= -DCMAKE_BUILD_TYPE=Release \ + * -DEBLDR_PRODUCTION_KEY=<64 hex characters, the raw Ed25519 public key> * - * Production builds set EBLDR_PRODUCTION_KEY, which replaces it. The #warning - * below is deliberate: this key going out silently is the failure mode, so a - * build that embeds it says so on every compile. */ + * cmake/ProductionKey.cmake turns that into a generated translation unit + * defining ebldr_production_key[] (declared in eos_production_key.h), + * compiles it into eboot_core and defines EBLDR_PRODUCTION_KEY, which selects + * the #else branch below. A Release build of a real board that sets no key + * refuses to configure unless it also sets EBLDR_ALLOW_DEV_KEY=ON, and + * .github/workflows/release.yml refuses any artifact that still contains the + * development key's bytes. */ #ifndef EBLDR_PRODUCTION_KEY #warning "eBoot: building with the RFC 8032 test-vector public key as the secure-boot trust anchor; define EBLDR_PRODUCTION_KEY for any real device" +/* RFC 8032 section 7.1, TEST 1, PUBLIC KEY -- all 32 bytes of it. The array + * shipped from v0.1.0 to here agreed with the RFC for 21 bytes and then did + * not, and the result was not a point on the curve: no signature could ever + * verify against it, on any board that fell back to it. See + * tests/unit/test_keystore.c::test_compiled_in_anchor_verifies_its_own_rfc_vector. */ static const uint8_t default_dev_key[EOS_ED25519_PUB_KEY_SIZE] = { 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a, - 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa3, 0xf4, 0xa1, - 0x8c, 0x42, 0xc4, 0x76, 0x84, 0x37, 0x77, 0x25, + 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, + 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a, }; #endif @@ -117,7 +138,6 @@ int eos_keystore_init(eos_keystore_t *ks) #ifndef EBLDR_PRODUCTION_KEY memcpy(ks->slots[0].key, default_dev_key, EOS_ED25519_PUB_KEY_SIZE); #else - extern const uint8_t ebldr_production_key[EOS_ED25519_PUB_KEY_SIZE]; memcpy(ks->slots[0].key, ebldr_production_key, EOS_ED25519_PUB_KEY_SIZE); #endif ks->slots[0].valid = true; diff --git a/core/sha512.c b/core/sha512.c index 4a1ecb5..d9aa57c 100644 --- a/core/sha512.c +++ b/core/sha512.c @@ -1,147 +1,247 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EoS Project -// ISO/IEC 25000 | ISO/IEC/IEEE 15288:2023 - -/** - * @file sha512.c - * @brief SHA-512 (NIST FIPS 180-4) — required by Ed25519 (RFC 8032) - * - * Ed25519 as specified in RFC 8032 derives its challenge scalar from - * SHA-512. A verifier using any other hash cannot check a signature made - * by a conforming signer, so this primitive is not optional for - * interoperability with standard tooling. - * - * Self-contained, no dynamic allocation, suitable for a bootloader. - */ #include "eos_crypto_boot.h" #include -static const uint64_t K512[80] = { - 0x428a2f98d728ae22ULL, 0x7137449123ef65cdULL, 0xb5c0fbcfec4d3b2fULL, 0xe9b5dba58189dbbcULL, - 0x3956c25bf348b538ULL, 0x59f111f1b605d019ULL, 0x923f82a4af194f9bULL, 0xab1c5ed5da6d8118ULL, - 0xd807aa98a3030242ULL, 0x12835b0145706fbeULL, 0x243185be4ee4b28cULL, 0x550c7dc3d5ffb4e2ULL, - 0x72be5d74f27b896fULL, 0x80deb1fe3b1696b1ULL, 0x9bdc06a725c71235ULL, 0xc19bf174cf692694ULL, - 0xe49b69c19ef14ad2ULL, 0xefbe4786384f25e3ULL, 0x0fc19dc68b8cd5b5ULL, 0x240ca1cc77ac9c65ULL, - 0x2de92c6f592b0275ULL, 0x4a7484aa6ea6e483ULL, 0x5cb0a9dcbd41fbd4ULL, 0x76f988da831153b5ULL, - 0x983e5152ee66dfabULL, 0xa831c66d2db43210ULL, 0xb00327c898fb213fULL, 0xbf597fc7beef0ee4ULL, - 0xc6e00bf33da88fc2ULL, 0xd5a79147930aa725ULL, 0x06ca6351e003826fULL, 0x142929670a0e6e70ULL, - 0x27b70a8546d22ffcULL, 0x2e1b21385c26c926ULL, 0x4d2c6dfc5ac42aedULL, 0x53380d139d95b3dfULL, - 0x650a73548baf63deULL, 0x766a0abb3c77b2a8ULL, 0x81c2c92e47edaee6ULL, 0x92722c851482353bULL, - 0xa2bfe8a14cf10364ULL, 0xa81a664bbc423001ULL, 0xc24b8b70d0f89791ULL, 0xc76c51a30654be30ULL, - 0xd192e819d6ef5218ULL, 0xd69906245565a910ULL, 0xf40e35855771202aULL, 0x106aa07032bbd1b8ULL, - 0x19a4c116b8d2d0c8ULL, 0x1e376c085141ab53ULL, 0x2748774cdf8eeb99ULL, 0x34b0bcb5e19b48a8ULL, - 0x391c0cb3c5c95a63ULL, 0x4ed8aa4ae3418acbULL, 0x5b9cca4f7763e373ULL, 0x682e6ff3d6b2b8a3ULL, - 0x748f82ee5defb2fcULL, 0x78a5636f43172f60ULL, 0x84c87814a1f0ab72ULL, 0x8cc702081a6439ecULL, - 0x90befffa23631e28ULL, 0xa4506cebde82bde9ULL, 0xbef9a3f7b2c67915ULL, 0xc67178f2e372532bULL, - 0xca273eceea26619cULL, 0xd186b8c721c0c207ULL, 0xeada7dd6cde0eb1eULL, 0xf57d4f7fee6ed178ULL, - 0x06f067aa72176fbaULL, 0x0a637dc5a2c898a6ULL, 0x113f9804bef90daeULL, 0x1b710b35131c471bULL, - 0x28db77f523047d84ULL, 0x32caab7b40c72493ULL, 0x3c9ebe0a15c9bebcULL, 0x431d67c49c100d4cULL, - 0x4cc5d4becb3e42b6ULL, 0x597f299cfc657e2aULL, 0x5fcb6fab3ad6faecULL, 0x6c44198c4a475817ULL, +#define ROTR64(x, n) (((x) >> (n)) | ((x) << (64 - (n)))) + +#define CH(x, y, z) (((x) & (y)) ^ (~(x) & (z))) +#define MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) + +#define BSIG0(x) (ROTR64((x), 28) ^ ROTR64((x), 34) ^ ROTR64((x), 39)) +#define BSIG1(x) (ROTR64((x), 14) ^ ROTR64((x), 18) ^ ROTR64((x), 41)) + +#define SSIG0(x) (ROTR64((x), 1) ^ ROTR64((x), 8) ^ ((x) >> 7)) +#define SSIG1(x) (ROTR64((x), 19) ^ ROTR64((x), 61) ^ ((x) >> 6)) + +static const uint64_t K[80] = { + 0x428a2f98d728ae22ULL, + 0x7137449123ef65cdULL, + 0xb5c0fbcfec4d3b2fULL, + 0xe9b5dba58189dbbcULL, + 0x3956c25bf348b538ULL, + 0x59f111f1b605d019ULL, + 0x923f82a4af194f9bULL, + 0xab1c5ed5da6d8118ULL, + 0xd807aa98a3030242ULL, + 0x12835b0145706fbeULL, + 0x243185be4ee4b28cULL, + 0x550c7dc3d5ffb4e2ULL, + 0x72be5d74f27b896fULL, + 0x80deb1fe3b1696b1ULL, + 0x9bdc06a725c71235ULL, + 0xc19bf174cf692694ULL, + 0xe49b69c19ef14ad2ULL, + 0xefbe4786384f25e3ULL, + 0x0fc19dc68b8cd5b5ULL, + 0x240ca1cc77ac9c65ULL, + 0x2de92c6f592b0275ULL, + 0x4a7484aa6ea6e483ULL, + 0x5cb0a9dcbd41fbd4ULL, + 0x76f988da831153b5ULL, + 0x983e5152ee66dfabULL, + 0xa831c66d2db43210ULL, + 0xb00327c898fb213fULL, + 0xbf597fc7beef0ee4ULL, + 0xc6e00bf33da88fc2ULL, + 0xd5a79147930aa725ULL, + 0x06ca6351e003826fULL, + 0x142929670a0e6e70ULL, + 0x27b70a8546d22ffcULL, + 0x2e1b21385c26c926ULL, + 0x4d2c6dfc5ac42aedULL, + 0x53380d139d95b3dfULL, + 0x650a73548baf63deULL, + 0x766a0abb3c77b2a8ULL, + 0x81c2c92e47edaee6ULL, + 0x92722c851482353bULL, + 0xa2bfe8a14cf10364ULL, + 0xa81a664bbc423001ULL, + 0xc24b8b70d0f89791ULL, + 0xc76c51a30654be30ULL, + 0xd192e819d6ef5218ULL, + 0xd69906245565a910ULL, + 0xf40e35855771202aULL, + 0x106aa07032bbd1b8ULL, + 0x19a4c116b8d2d0c8ULL, + 0x1e376c085141ab53ULL, + 0x2748774cdf8eeb99ULL, + 0x34b0bcb5e19b48a8ULL, + 0x391c0cb3c5c95a63ULL, + 0x4ed8aa4ae3418acbULL, + 0x5b9cca4f7763e373ULL, + 0x682e6ff3d6b2b8a3ULL, + 0x748f82ee5defb2fcULL, + 0x78a5636f43172f60ULL, + 0x84c87814a1f0ab72ULL, + 0x8cc702081a6439ecULL, + 0x90befffa23631e28ULL, + 0xa4506cebde82bde9ULL, + 0xbef9a3f7b2c67915ULL, + 0xc67178f2e372532bULL, + 0xca273eceea26619cULL, + 0xd186b8c721c0c207ULL, + 0xeada7dd6cde0eb1eULL, + 0xf57d4f7fee6ed178ULL, + 0x06f067aa72176fbaULL, + 0x0a637dc5a2c898a6ULL, + 0x113f9804bef90daeULL, + 0x1b710b35131c471bULL, + 0x28db77f523047d84ULL, + 0x32caab7b40c72493ULL, + 0x3c9ebe0a15c9bebcULL, + 0x431d67c49c100d4cULL, + 0x4cc5d4becb3e42b6ULL, + 0x597f299cfc657e2aULL, + 0x5fcb6fab3ad6faecULL, + 0x6c44198c4a475817ULL }; -#define ROTR64(x, n) (((x) >> (n)) | ((x) << (64 - (n)))) -#define CH64(x, y, z) (((x) & (y)) ^ (~(x) & (z))) -#define MAJ64(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) -#define EP0_64(x) (ROTR64(x, 28) ^ ROTR64(x, 34) ^ ROTR64(x, 39)) -#define EP1_64(x) (ROTR64(x, 14) ^ ROTR64(x, 18) ^ ROTR64(x, 41)) -#define SIG0_64(x) (ROTR64(x, 1) ^ ROTR64(x, 8) ^ ((x) >> 7)) -#define SIG1_64(x) (ROTR64(x, 19) ^ ROTR64(x, 61) ^ ((x) >> 6)) - -static void sha512_transform(eos_sha512_ctx_t *ctx) +static uint64_t load_be64(const uint8_t *p) +{ + return ((uint64_t)p[0] << 56) | + ((uint64_t)p[1] << 48) | + ((uint64_t)p[2] << 40) | + ((uint64_t)p[3] << 32) | + ((uint64_t)p[4] << 24) | + ((uint64_t)p[5] << 16) | + ((uint64_t)p[6] << 8) | + ((uint64_t)p[7]); +} + +static void store_be64(uint8_t *p, uint64_t x) +{ + p[0] = (uint8_t)(x >> 56); + p[1] = (uint8_t)(x >> 48); + p[2] = (uint8_t)(x >> 40); + p[3] = (uint8_t)(x >> 32); + p[4] = (uint8_t)(x >> 24); + p[5] = (uint8_t)(x >> 16); + p[6] = (uint8_t)(x >> 8); + p[7] = (uint8_t)x; +} + +static void sha512_transform(eos_sha512_ctx_t *ctx, + const uint8_t block[128]) { uint64_t w[80]; - uint64_t a, b, c, d, e, f, g, h, t1, t2; - - for (int i = 0; i < 16; i++) { - w[i] = ((uint64_t)ctx->buffer[i * 8 + 0] << 56) | - ((uint64_t)ctx->buffer[i * 8 + 1] << 48) | - ((uint64_t)ctx->buffer[i * 8 + 2] << 40) | - ((uint64_t)ctx->buffer[i * 8 + 3] << 32) | - ((uint64_t)ctx->buffer[i * 8 + 4] << 24) | - ((uint64_t)ctx->buffer[i * 8 + 5] << 16) | - ((uint64_t)ctx->buffer[i * 8 + 6] << 8) | - ((uint64_t)ctx->buffer[i * 8 + 7]); - } - for (int i = 16; i < 80; i++) { - w[i] = SIG1_64(w[i - 2]) + w[i - 7] + SIG0_64(w[i - 15]) + w[i - 16]; - } - a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3]; - e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7]; + for (int i = 0; i < 16; i++) + w[i] = load_be64(block + i * 8); + + for (int i = 16; i < 80; i++) + w[i] = SSIG1(w[i - 2]) + w[i - 7] + + SSIG0(w[i - 15]) + w[i - 16]; + + uint64_t a = ctx->state[0]; + uint64_t b = ctx->state[1]; + uint64_t c = ctx->state[2]; + uint64_t d = ctx->state[3]; + uint64_t e = ctx->state[4]; + uint64_t f = ctx->state[5]; + uint64_t g = ctx->state[6]; + uint64_t h = ctx->state[7]; for (int i = 0; i < 80; i++) { - t1 = h + EP1_64(e) + CH64(e, f, g) + K512[i] + w[i]; - t2 = EP0_64(a) + MAJ64(a, b, c); - h = g; g = f; f = e; e = d + t1; - d = c; c = b; b = a; a = t1 + t2; + uint64_t t1 = h + BSIG1(e) + CH(e, f, g) + K[i] + w[i]; + uint64_t t2 = BSIG0(a) + MAJ(a, b, c); + + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; } - ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d; - ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h; + ctx->state[0] += a; + ctx->state[1] += b; + ctx->state[2] += c; + ctx->state[3] += d; + ctx->state[4] += e; + ctx->state[5] += f; + ctx->state[6] += g; + ctx->state[7] += h; } void eos_sha512_init(eos_sha512_ctx_t *ctx) { - ctx->state[0] = 0x6a09e667f3bcc908ULL; ctx->state[1] = 0xbb67ae8584caa73bULL; - ctx->state[2] = 0x3c6ef372fe94f82bULL; ctx->state[3] = 0xa54ff53a5f1d36f1ULL; - ctx->state[4] = 0x510e527fade682d1ULL; ctx->state[5] = 0x9b05688c2b3e6c1fULL; - ctx->state[6] = 0x1f83d9abfb41bd6bULL; ctx->state[7] = 0x5be0cd19137e2179ULL; - ctx->count = 0; - memset(ctx->buffer, 0, sizeof(ctx->buffer)); + ctx->state[0] = 0x6a09e667f3bcc908ULL; + ctx->state[1] = 0xbb67ae8584caa73bULL; + ctx->state[2] = 0x3c6ef372fe94f82bULL; + ctx->state[3] = 0xa54ff53a5f1d36f1ULL; + ctx->state[4] = 0x510e527fade682d1ULL; + ctx->state[5] = 0x9b05688c2b3e6c1fULL; + ctx->state[6] = 0x1f83d9abfb41bd6bULL; + ctx->state[7] = 0x5be0cd19137e2179ULL; + + ctx->bitlen[0] = 0; + ctx->bitlen[1] = 0; + ctx->buffer_len = 0; } -void eos_sha512_update(eos_sha512_ctx_t *ctx, const uint8_t *data, size_t len) +void eos_sha512_update(eos_sha512_ctx_t *ctx, + const uint8_t *data, + size_t len) { - size_t idx = (size_t)(ctx->count % EOS_SHA512_BLOCK_SIZE); + while (len > 0) { + size_t copy = 128 - ctx->buffer_len; - ctx->count += len; + if (copy > len) + copy = len; - while (len > 0) { - size_t take = EOS_SHA512_BLOCK_SIZE - idx; - if (take > len) take = len; - memcpy(ctx->buffer + idx, data, take); - idx += take; - data += take; - len -= take; - if (idx == EOS_SHA512_BLOCK_SIZE) { - sha512_transform(ctx); - idx = 0; + memcpy(ctx->buffer + ctx->buffer_len, data, copy); + + ctx->buffer_len += copy; + data += copy; + len -= copy; + + uint64_t bits = (uint64_t)copy << 3; + + uint64_t old_low = ctx->bitlen[1]; + ctx->bitlen[1] += bits; + + if (ctx->bitlen[1] < old_low) + ctx->bitlen[0]++; + + ctx->bitlen[0] += (uint64_t)copy >> 61; + + if (ctx->buffer_len == 128) { + sha512_transform(ctx, ctx->buffer); + ctx->buffer_len = 0; } } } -void eos_sha512_final(eos_sha512_ctx_t *ctx, uint8_t digest[EOS_SHA512_DIGEST_SIZE]) +void eos_sha512_final(eos_sha512_ctx_t *ctx, + uint8_t digest[EOS_SHA512_DIGEST_SIZE]) { - /* SHA-512 encodes the message length as a 128-bit big-endian bit count. - * A bootloader never hashes anywhere near 2^61 bytes, so the high 64 - * bits are always zero; they are still written so the padding block is - * byte-exact against FIPS 180-4. */ - uint64_t bits = ctx->count * 8ULL; - size_t idx = (size_t)(ctx->count % EOS_SHA512_BLOCK_SIZE); - - ctx->buffer[idx++] = 0x80; - - if (idx > 112) { - while (idx < EOS_SHA512_BLOCK_SIZE) ctx->buffer[idx++] = 0; - sha512_transform(ctx); - idx = 0; - } - while (idx < 112) ctx->buffer[idx++] = 0; + size_t i = ctx->buffer_len; - memset(ctx->buffer + 112, 0, 8); /* high 64 bits of length */ - for (int i = 0; i < 8; i++) { - ctx->buffer[120 + i] = (uint8_t)(bits >> (56 - 8 * i)); - } - sha512_transform(ctx); + ctx->buffer[i++] = 0x80; - for (int i = 0; i < 8; i++) { - for (int j = 0; j < 8; j++) { - digest[i * 8 + j] = (uint8_t)(ctx->state[i] >> (56 - 8 * j)); - } + if (i > 112) { + while (i < 128) + ctx->buffer[i++] = 0; + + sha512_transform(ctx, ctx->buffer); + i = 0; } - /* Do not leave hash state on the stack of a boot path. */ + while (i < 112) + ctx->buffer[i++] = 0; + + store_be64(ctx->buffer + 112, ctx->bitlen[0]); + store_be64(ctx->buffer + 120, ctx->bitlen[1]); + + sha512_transform(ctx, ctx->buffer); + + for (int i2 = 0; i2 < 8; i2++) + store_be64(digest + i2 * 8, ctx->state[i2]); + memset(ctx, 0, sizeof(*ctx)); } diff --git a/docs/adr/ADR-020-install-path-verifies-signature-before-anti-rollback.md b/docs/adr/ADR-020-install-path-verifies-signature-before-anti-rollback.md new file mode 100644 index 0000000..cf501df --- /dev/null +++ b/docs/adr/ADR-020-install-path-verifies-signature-before-anti-rollback.md @@ -0,0 +1,58 @@ +--- +adr: 20 +title: Install path verifies the signature before anti-rollback +status: Proposed +date: 2026-09-14 +deciders: Architecture Council, eBoot maintainers +source: EmbeddedOS Master Design v2.0 §8.1 (boot order) and §15 (update flow), as cited in the architecture review of #115; ADR-011 (eOTA firmware/update contract) +--- + +# ADR-020 — Install path verifies the signature before anti-rollback + +## Context + +Two pull requests merged on 09-07 both change `eos_fw_update_finalize()` and disagree on +the order of its checks: + +- #103 added an authenticated TLV anti-rollback counter: the image's security counter is + read from its TLV area and compared against the persistent floor at install. +- #104 made the install path verify the image signature unconditionally, no longer gated + on the header's own `sig_type`. + +#103's authored commits all predate #104's merge, and #104 was written without #103's +counter check in place, so neither says which check comes first. The master design orders BOOT as Verify Manifest → Verify Image → +Check Version Policy (§8.1). Its update flow (§15) is Download → Verify → Install and +never places the anti-rollback check. The ordering the install path uses therefore +existed only in a PR body. + +## Decision + +In the install path (`core/fw_update.c`, `eos_fw_update_finalize()`): + +1. Signature verification over the signed header prefix (`EOS_IMG_SIGNED_LEN`, the first + 92 bytes of the header) precedes anti-rollback evaluation. +2. The TLV security counter is read only after the prefix that binds it (`tlv_len`, + `tlv_hash`) has been authenticated. +3. An image that fails signature verification is refused as `EOS_ERR_SIGNATURE` and its + counter is never consulted. + +This mirrors the boot ordering of §8.1: authenticate first, then apply version policy to +what was authenticated. + +## Consequences + +- A test that wants to observe the anti-rollback stage must present a genuinely signed + image. eBoot has no Ed25519 signer in C, so `tools/gen_fw_update_test_sigs.py` signs + the header prefixes that `tests/unit/test_fw_update.c` and + `tests/unit/test_fw_transport.c` build and emits `tests/vectors/fw_update_test_sigs.h`; + `tests/unit/test_fw_update_test_sigs.py` pins the committed header to the generator's + output. +- An unsigned image cannot demonstrate a rollback regression. It is refused as + `EOS_ERR_SIGNATURE` before its counter is compared, so a rollback test built on an + unsigned image exercises the signature check, not the floor. + +## Note on numbering + +ADR-001 through ADR-011 belong to the master-design series and are not in this +repository; `eos` holds ADR-012 through ADR-019. This record is numbered 020 so that no +number is reused. It extends ADR-011, the eOTA firmware/update contract. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..bc72656 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,22 @@ +# Architecture Decision Records + +One file per decision. A record is never edited after it reaches **Accepted** — it is +superseded by a later record that names it. + +| Status | Meaning | +|---|---| +| Proposed | Written, not yet ratified by the maintainers named in `deciders`. | +| Accepted | Ratified. Binding on new code. | +| Superseded | Replaced; the replacing ADR is named in the header. | + +## Index + +| ADR | Title | Status | +|---|---|---| +| 020 | [Install path verifies the signature before anti-rollback](ADR-020-install-path-verifies-signature-before-anti-rollback.md) | Proposed | + +## Note on numbering + +ADR-001 through ADR-011 belong to the master-design series and are not in this +repository. The `eos` repository holds ADR-012 through ADR-019. This set starts at 020 so +that no number is reused; ADR-020 extends ADR-011, the eOTA firmware/update contract. diff --git a/docs/key_lifecycle.md b/docs/key_lifecycle.md index a9400db..98dbfd2 100644 --- a/docs/key_lifecycle.md +++ b/docs/key_lifecycle.md @@ -74,10 +74,13 @@ echo "test" | openssl pkeyutl -sign -inkey eboot_signing_key.pem | \ **Alternative — using eBootloader tooling:** ```bash -# Generate keypair and C header in one step -python3 tools/sign_image.py --genkey \ - --key-out keys/production_key.pem \ - --pub-header include/eos_signing_key.h +# Generate the keypair and the value the bootloader build takes as its +# trust anchor. Writes keys/private.pem, keys/public.pem and +# keys/public_key.hex (64 hex characters). There is no header to embed: +# the anchor is compiled in at configure time from the flag below. +python3 tools/sign_image.py --genkey --output keys/ +cmake -B build -DEBLDR_BOARD= -DCMAKE_BUILD_TYPE=Release \ + -DEBLDR_PRODUCTION_KEY=$(cat keys/public_key.hex) ``` ### 2.3 Key Storage After Generation @@ -94,52 +97,52 @@ python3 tools/sign_image.py --genkey \ ### 3.1 Compiled-In Key (Default) -The public key is compiled directly into the stage-1 bootloader binary. This is the default for devices without OTP/eFuse capability. +The public key is compiled into the bootloader. `eos_keystore_init()` +(`core/keystore.c`) uses it only when the board has no OTP at all — when +`eos_hal_otp_read()` returns `EOS_ERR_NOT_SUPPORTED` because the board port +provides no `otp_read` hook. Today that is every board under `boards/`, so +on every shipped board the compiled-in key *is* the trust anchor. -**Generated header (`include/eos_signing_key.h`):** +**Which key gets compiled in is a configure-time decision:** -```c -// SPDX-License-Identifier: MIT -// Auto-generated by sign_image.py — do not edit manually - -#ifndef EOS_SIGNING_KEY_H -#define EOS_SIGNING_KEY_H - -#include - -/* Primary signing key (slot 0) */ -static const uint8_t eos_signing_pubkey_0[32] = { - 0x3b, 0x6a, 0x27, 0xbc, /* ... 28 more bytes ... */ -}; +```bash +# A real key: the raw 32-byte Ed25519 public key as 64 hex characters, +# e.g. the `.pub.raw` file from §2.2 step 3, hex-encoded. +cmake -B build -DEBLDR_BOARD=stm32f4 -DCMAKE_BUILD_TYPE=Release \ + -DEBLDR_PRODUCTION_KEY=3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c +``` -/* Backup signing key (slot 1) */ -static const uint8_t eos_signing_pubkey_1[32] = { - 0x9d, 0x61, 0xb1, 0x9d, /* ... 28 more bytes ... */ -}; +`cmake/ProductionKey.cmake` checks the value (exactly 64 hex characters, and +not the development key), generates `build/generated/production_key.c` +defining `ebldr_production_key[]` (declared in `include/eos_production_key.h`), +compiles it into `eboot_core`, and defines `EBLDR_PRODUCTION_KEY` so +`core/keystore.c` uses that symbol in place of its development key. -#define EOS_SIGNING_KEY_COUNT 2 +**Without `EBLDR_PRODUCTION_KEY` the compiled-in key is the development key** +(§7.4), and the build says so with a `#warning` on every compile of +`core/keystore.c`. A **Release build of a real board refuses to configure** +in that state: -#endif /* EOS_SIGNING_KEY_H */ ``` - -**Verification logic:** - -```c -int eos_image_verify_signature(const eos_image_header_t *hdr) { - for (int i = 0; i < EOS_SIGNING_KEY_COUNT; i++) { - const uint8_t *pubkey = (i == 0) - ? eos_signing_pubkey_0 - : eos_signing_pubkey_1; - int rc = eos_crypto_verify_signature( - hdr->hash, EOS_SHA256_DIGEST_SIZE, - hdr->signature, hdr->sig_len, - pubkey, 32); - if (rc == EOS_OK) return EOS_OK; - } - return EOS_ERR_SIGNATURE; -} +CMake Error at CMakeLists.txt:56 (message): + EBLDR_PRODUCTION_KEY is not set: a Release build of board 'stm32f4' would + compile in the RFC 8032 test key as its trust anchor, and anyone can sign + for that key. Pass -DEBLDR_PRODUCTION_KEY=<64 hex characters> (the raw + Ed25519 public key), or, for a bring-up or CI build that will never reach a + device, -DEBLDR_ALLOW_DEV_KEY=ON. ``` +`-DEBLDR_ALLOW_DEV_KEY=ON` is the one way past the gate. It exists for +bring-up on a bench and for CI cross-compiles that only check the tree +builds; it is never passed by `.github/workflows/release.yml`, and +`tests/unit/test_release_workflow_production_key.py` fails if it ever is. +`tests/unit/test_production_key_gate.py` runs real configures against the +tree and pins each of the behaviours above. + +**Verification** uses whatever the keystore selected: `eos_image_verify_signature()` +takes the active key from `eos_keystore_get_active_key()` and checks the +Ed25519 signature over the signed header prefix (`EOS_IMG_SIGNED_LEN`). + ### 3.2 OTP/eFuse Key Storage For devices with one-time-programmable memory: @@ -319,41 +322,53 @@ Device OTP: security_version = 5 → ACCEPT (6 ≥ 5) ### 7.2 Enforcement Mechanism -```c -#if defined(EOS_BUILD_PRODUCTION) - /* Production keys — compiled from HSM-exported header */ - #include "eos_signing_key_production.h" -#elif defined(EOS_BUILD_STAGING) - #include "eos_signing_key_staging.h" -#else - /* Development key — well-known test key */ - #include "eos_signing_key_dev.h" -#endif -``` +There is one switch, and it is structural rather than advisory: + +- `core/keystore.c` compiles the development key under `#ifndef + EBLDR_PRODUCTION_KEY` and the generated `ebldr_production_key[]` under + `#else`. The two never coexist in one object. +- `CMakeLists.txt` defines `EBLDR_PRODUCTION_KEY` only when a key was given, + and refuses a Release build of a real board that gives none (§3.1). +- `.github/workflows/release.yml` passes the `EBLDR_PRODUCTION_KEY_HEX` + repository secret on every board configure; with the secret unset the value + is empty and the configure fails closed. After every firmware build it scans + each `.elf`, `.bin`, `.a` and `.o` under `build/` for the development key's + 32 bytes and fails the job on a hit. + +Staging keys are not modelled: a staging build is a production-shaped build +configured with a staging public key. ### 7.3 Development Key Policy | Rule | Rationale | |---|---| | Development private key is **committed** to the repository | Enables any developer to build and test signed images locally | -| Development key is **never** used in production | Production builds fail if development key header is detected | +| Development key does not reach a production artifact **built through the gate** | Any build of a real board that is not `CMAKE_BUILD_TYPE=Debug` -- `Release`, `MinSizeRel`, `RelWithDebInfo`, any spelling, or no build type at all -- refuses to configure without `EBLDR_PRODUCTION_KEY` unless `EBLDR_ALLOW_DEV_KEY=ON` is passed explicitly; the key it accepts is checked to be a point in the prime-order subgroup (`tools/check_production_key.py`); and the release workflow validates the secret before any board is configured and scans every artifact for the development key's bytes (§3.1, §7.2). What the gate cannot see: a Debug build flashed to a device, a build that passes `EBLDR_ALLOW_DEV_KEY=ON`, or a fork that removes the gate. Those are policy, not mechanism. | | Production key **never** appears in source control | Only the public key is embedded; private key stays in HSM | +| The stored `EBLDR_PRODUCTION_KEY_HEX` is checked against the generated `.pub` **before** it is stored | The configure-time and release-time checks refuse a key that is off the curve, of low order, or outside the prime-order subgroup -- but about **one in sixteen** single-hex-digit typos of a key lands on a *different valid key* inside the subgroup, which no check on the key alone can distinguish from the real one. That rate is derivable, not measured: a mutated encoding decodes to a curve point with probability about 1/2 and lands in the prime-order subgroup with probability 1/8. Measured counts for specific keys vary around it -- 52/960 for the development key, 64/960 for the RFC 8032 TEST 2 key, 75/960 for TEST 3. A device built on it refuses every image, with a green build. The only control for that case is comparing the secret to the `.pub` the key generator wrote, by eye or by `cmp`, before it enters the secret store. | | CI pipeline uses **staging** key for integration tests | Tests signature verification without exposing production key | ### 7.4 Well-Known Development Key -The development key is intentionally public and must never be used for production: +The development key is the RFC 8032 section 7.1 TEST 1 key pair. Its secret +scalar is printed in the RFC, so images signed with it are not authenticated +by anyone in particular: ``` -Development Private Key (Ed25519, PEM): - MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikQ2Ig35R6DIxQ/BV1Cxz - -Development Public Key SHA-256: - 9f836af87a484a954e6e74c5b4c0e842291d8883be51e832314bf055d42c73... +Public key (compiled in by core/keystore.c without EBLDR_PRODUCTION_KEY): + d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a +Secret (RFC 8032 §7.1 TEST 1): + 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60 ⚠️ THIS KEY IS PUBLIC. Images signed with this key are NOT authenticated. ``` +`tools/gen_fw_update_test_sigs.py` signs the unit-test fixtures with it, and +`tests/unit/test_keystore.c` checks that the compiled-in bytes are exactly +this key (an earlier revision of the array was off the curve, so nothing +could verify against it at all). `cmake/ProductionKey.cmake` refuses it as a +value for `EBLDR_PRODUCTION_KEY`. + --- ## 8. Emergency Key Compromise Response diff --git a/docs/quickstart.md b/docs/quickstart.md index 528d90d..638b448 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -83,9 +83,17 @@ This produces: cd EoS/eboot/tools # Generate an Ed25519 keypair (first time only). -# Writes keys/private.pem, keys/public.pem and keys/public_key.h. +# Writes keys/private.pem, keys/public.pem and keys/public_key.hex -- the +# 64-hex-character value the bootloader build takes as its trust anchor. python3 sign_image.py --genkey --output keys/ +# Build the bootloader with THAT key as its trust anchor. Without +# EBLDR_PRODUCTION_KEY a board build keeps the RFC 8032 test key, whose +# private half is public -- and a release-shaped build refuses to configure +# rather than let that happen silently. Full lifecycle: docs/key_lifecycle.md. +cmake -B build -DEBLDR_BOARD= -DCMAKE_BUILD_TYPE=Release \ + -DEBLDR_PRODUCTION_KEY=$(cat keys/public_key.hex) + # Pack a raw firmware binary into an .eimg container. python3 imgpack.py --input firmware.bin --output firmware.eimg \ --load-addr 0x08010000 --entry-addr 0x08010100 --version 1.0.0 diff --git a/include/eos_boot_log.h b/include/eos_boot_log.h index 86024d7..964ebb8 100644 --- a/include/eos_boot_log.h +++ b/include/eos_boot_log.h @@ -53,12 +53,12 @@ void eos_boot_log_init(uint32_t head); void eos_boot_log_append(uint32_t event, uint32_t slot, uint32_t detail); /** - * @brief Read one boot log entry by index. - * @param index Entry index (0 to EOS_BOOT_LOG_MAX - 1). - * @param out Receives the entry at @p index. - * @return EOS_OK on success, EOS_ERR_INVALID on a bad index or null @p out. + * @brief Current ring-buffer write position. + * + * Persisted into the boot control block on handoff so the log survives a + * reset. @return Head index in [0, EOS_BOOT_LOG_MAX). */ -int eos_boot_log_read(uint32_t index, eos_boot_log_entry_t *out); +uint32_t eos_boot_log_get_head(void); /** * @brief Read one log entry by ring index. diff --git a/include/eos_production_key.h b/include/eos_production_key.h new file mode 100644 index 0000000..40f906a --- /dev/null +++ b/include/eos_production_key.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EoS Project + +/** + * @file eos_production_key.h + * @brief The compiled-in production trust anchor. + * + * Defined by a translation unit that cmake/ProductionKey.cmake generates from + * EBLDR_PRODUCTION_KEY, and compiled into eboot_core only when that option is + * set. core/keystore.c uses it in place of the RFC 8032 development key for a + * board that has no OTP to read a key from. + */ + +#ifndef EOS_PRODUCTION_KEY_H +#define EOS_PRODUCTION_KEY_H + +#include +#include "eos_keystore.h" + +#ifdef __cplusplus +extern "C" { +#endif + +extern const uint8_t ebldr_production_key[EOS_ED25519_PUB_KEY_SIZE]; + +#ifdef __cplusplus +} +#endif + +#endif /* EOS_PRODUCTION_KEY_H */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 11f2f3f..4150393 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,25 +55,30 @@ list(APPEND EBLDR_UNIT_TESTS test_fw_transport) add_executable(eboot_test_fw_update_sig unit/test_fw_update_sig.c) target_link_libraries(eboot_test_fw_update_sig PRIVATE eboot_core) add_test(NAME test_fw_update_sig COMMAND eboot_test_fw_update_sig) +list(APPEND EBLDR_UNIT_TESTS test_fw_update_sig) # --- test_fw_update: firmware update finalize / anti-rollback wiring --- add_executable(eboot_test_fw_update unit/test_fw_update.c) target_link_libraries(eboot_test_fw_update PRIVATE eboot_core) add_test(NAME test_fw_update COMMAND eboot_test_fw_update) +list(APPEND EBLDR_UNIT_TESTS test_fw_update) # --- test_jump_app: stage-1 jump uses the authenticated TLV counter --- add_executable(eboot_test_jump_app unit/test_jump_app.c) target_link_libraries(eboot_test_jump_app PRIVATE eboot_stage1) add_test(NAME test_jump_app COMMAND eboot_test_jump_app) +list(APPEND EBLDR_UNIT_TESTS test_jump_app) # --- test_slot_size_bounds: verify_slot() must reject image_size > slot capacity --- add_executable(eboot_test_slot_size_bounds unit/test_slot_size_bounds.c) target_link_libraries(eboot_test_slot_size_bounds PRIVATE eboot_core) add_test(NAME test_slot_size_bounds COMMAND eboot_test_slot_size_bounds) +list(APPEND EBLDR_UNIT_TESTS test_slot_size_bounds) add_executable(eboot_test_jump_app_bounds unit/test_jump_app_bounds.c) target_link_libraries(eboot_test_jump_app_bounds PRIVATE eboot_core eboot_stage1) add_test(NAME test_jump_app_bounds COMMAND eboot_test_jump_app_bounds) +list(APPEND EBLDR_UNIT_TESTS test_jump_app_bounds) # --- test_device_table: UEFI-style device table --- add_executable(eboot_test_device_table unit/test_device_table.c) @@ -105,6 +110,7 @@ target_include_directories(eboot_test_qemu_arm64_timer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../boards/qemu_arm64 ) add_test(NAME test_qemu_arm64_timer COMMAND eboot_test_qemu_arm64_timer) +list(APPEND EBLDR_UNIT_TESTS test_qemu_arm64_timer) # --- test_board_registry: Runtime board selection --- add_executable(eboot_test_board_registry unit/test_board_registry.c) @@ -148,6 +154,7 @@ target_link_libraries(eboot_test_ed25519_contract PRIVATE eboot_core) target_include_directories(eboot_test_ed25519_contract PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME test_ed25519_contract COMMAND eboot_test_ed25519_contract) +list(APPEND EBLDR_UNIT_TESTS test_ed25519_contract) # --- test_keystore: Key management --- add_executable(eboot_test_keystore unit/test_keystore.c) @@ -159,6 +166,7 @@ list(APPEND EBLDR_UNIT_TESTS test_keystore) add_executable(eboot_test_secure_boot_policy unit/test_secure_boot_policy.c) target_link_libraries(eboot_test_secure_boot_policy PRIVATE eboot_core) add_test(NAME test_secure_boot_policy COMMAND eboot_test_secure_boot_policy) +list(APPEND EBLDR_UNIT_TESTS test_secure_boot_policy) # --- test_rollback: Anti-rollback security counter --- add_executable(eboot_test_rollback unit/test_rollback.c) @@ -192,18 +200,41 @@ list(APPEND EBLDR_UNIT_TESTS test_ecc) add_executable(eboot_test_fw_decrypt unit/test_fw_decrypt.c) target_link_libraries(eboot_test_fw_decrypt PRIVATE eboot_core) add_test(NAME test_fw_decrypt COMMAND eboot_test_fw_decrypt) +list(APPEND EBLDR_UNIT_TESTS test_fw_decrypt) # --- test_fdt_loader: device tree parsing against malformed blobs --- # core/fdt_loader.c was in no source list, so it had never been compiled. add_executable(eboot_test_fdt_loader unit/test_fdt_loader.c) target_link_libraries(eboot_test_fdt_loader PRIVATE eboot_core) add_test(NAME test_fdt_loader COMMAND eboot_test_fdt_loader) +list(APPEND EBLDR_UNIT_TESTS test_fdt_loader) # --- test_eos_sign_boot_path: the tool's real output through the real parser --- add_executable(eboot_test_eos_sign_boot_path unit/test_eos_sign_boot_path.c) target_link_libraries(eboot_test_eos_sign_boot_path PRIVATE eboot_core) target_include_directories(eboot_test_eos_sign_boot_path PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) add_test(NAME test_eos_sign_boot_path COMMAND eboot_test_eos_sign_boot_path) +list(APPEND EBLDR_UNIT_TESTS test_eos_sign_boot_path) + +# --- test_keystore_production: core/keystore.c with EBLDR_PRODUCTION_KEY --- +# eboot_core carries the development-key keystore, so this suite compiles +# core/keystore.c itself, with the definition a Release board build gets and +# a fixture key generated the same way (cmake/ProductionKey.cmake). It still +# links eboot_core for eos_crypto_hash(): the suite's own keystore.o already +# defines every keystore symbol, so the archive's copy is never pulled in and +# the object under test is the one compiled here. The key is RFC 8032 +# section 7.1 TEST 2's public key. +ebldr_write_production_key_source( + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" + "${CMAKE_CURRENT_BINARY_DIR}/generated/test_production_key.c") +add_executable(eboot_test_keystore_production + unit/test_keystore_production.c + ../core/keystore.c + ${CMAKE_CURRENT_BINARY_DIR}/generated/test_production_key.c) +target_compile_definitions(eboot_test_keystore_production PRIVATE EBLDR_PRODUCTION_KEY=1) +target_link_libraries(eboot_test_keystore_production PRIVATE eboot_core) +add_test(NAME test_keystore_production COMMAND eboot_test_keystore_production) +list(APPEND EBLDR_UNIT_TESTS test_keystore_production) # --- Valgrind test targets --- # @@ -218,12 +249,7 @@ add_test(NAME test_eos_sign_boot_path COMMAND eboot_test_eos_sign_boot_path) find_program(VALGRIND valgrind) if(VALGRIND) set(VALGRIND_OPTS --leak-check=full --error-exitcode=1 --quiet) - foreach(TEST_NAME test_bootctl test_crypto test_ed25519 test_ed25519_contract test_keystore - test_device_table test_runtime_svc test_board_config - test_multicore test_board_registry test_slot_manager - test_boot_log test_image_verify test_image_abi - test_recovery test_slot_size_bounds test_fw_transport - test_fw_update test_jump_app test_tlv_auth) + foreach(TEST_NAME ${EBLDR_UNIT_TESTS}) add_test( NAME valgrind_${TEST_NAME} COMMAND ${VALGRIND} ${VALGRIND_OPTS} $ diff --git a/tests/unit/test_check_production_key.py b/tests/unit/test_check_production_key.py new file mode 100644 index 0000000..56133aa --- /dev/null +++ b/tests/unit/test_check_production_key.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""tools/check_production_key.py applies the verifier's acceptance rule to a +candidate production key before it is compiled into anything. These vectors +pin each branch of that rule; every refusal is checked for its *reason*, so a +key refused for the wrong reason (a mistyped vector, say) is a failure here +rather than a pass. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) +from check_production_key import check_production_key_hex, main # noqa: E402 + +# RFC 8032 section 7.1 TEST 2 and TEST 3 public keys: real curve points. +TEST2 = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" +TEST3 = "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025" +DEV = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" +# What core/keystore.c compiled in before eBoot#116: decodes to no point. +OFF_CURVE = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18c42c47684377725" + + +@pytest.mark.parametrize("key", [TEST2, TEST3, TEST2.upper()]) +def test_a_real_key_is_accepted(key): + check_production_key_hex(key) + + +def test_mixed_order_is_not_reported_as_low_order(): + """Both classes are outside the prime-order subgroup and both are refused, + but they fail in opposite directions: a genuine low-order point makes every + signature verify, a mixed-order one makes the verifier refuse every image. + The message must say which, because the operator's next action differs.""" + with pytest.raises(ValueError) as low: + check_production_key_hex("ec" + "ff" * 30 + "7f") # order 2 + with pytest.raises(ValueError) as mixed: + check_production_key_hex(DEV[:-1] + "b") # order 8L + assert "low order" in str(low.value) + assert "every signature would verify" in str(low.value) + assert "low order" not in str(mixed.value) + assert "prime-order subgroup" in str(mixed.value) + assert "mistyped" in str(mixed.value) + + +@pytest.mark.parametrize("key,reason", [ + (DEV, "development key"), + (DEV.upper(), "development key"), + (OFF_CURVE, "no point on edwards25519"), + ("01" + "00" * 31, "identity"), # the identity point + ("ec" + "ff" * 30 + "7f", "low order"), # order 2 + ("00" * 31 + "80", "low order"), # order 4 + ("c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", + "low order"), # order 8 + # On the curve, outside the prime-order subgroup, NOT low order: order 8L. + # This is where a mistyped hex digit lands about half the time, and it is + # the case an operator will actually hit, so it must not be reported as + # "low order" -- that names an attack vector when the cause is a typo. + ("03" + "00" * 31, "prime-order subgroup"), + (DEV[:-1] + "b", "prime-order subgroup"), # dev key, last digit mistyped + ("ff" * 31 + "7f", "not below p"), # y = 2^255 - 1 + ("ed" + "ff" * 30 + "7f", "not below p"), # y = p exactly + (TEST2[:-2], "exactly 64"), + (TEST2 + "00", "exactly 64"), + ("zz" + TEST2[2:], "non-hexadecimal"), +]) +def test_an_unusable_key_is_refused_for_the_stated_reason(key, reason): + with pytest.raises(ValueError) as exc: + check_production_key_hex(key) + assert reason in str(exc.value), str(exc.value) + + +def test_cli_exit_codes(capsys): + assert main(["check", TEST2]) == 0 + assert main(["check", OFF_CURVE]) == 1 + assert "refused" in capsys.readouterr().err + assert main(["check"]) == 2 diff --git a/tests/unit/test_ed25519.c b/tests/unit/test_ed25519.c index 6c22666..9ea837c 100644 --- a/tests/unit/test_ed25519.c +++ b/tests/unit/test_ed25519.c @@ -90,46 +90,6 @@ static const struct rfc_vector k_vectors[] = { #define N_VECTORS (sizeof(k_vectors) / sizeof(k_vectors[0])) -/* All eight compressed encodings of edwards25519's order-8 torsion subgroup - * (the cyclic group for any order-8 point G, i.e. {1G, 2G, ..., 8G=O}). - * Order 8/gcd(k,8) for kG: orders present are 1 (identity), 2, 4, 4, 8, 8, 8, 8. - * - * Not hand-transcribed: derived by decoding an order-8 generator from this - * file's own unpackneg()/point_add()/point_is_identity(), then enumerating - * 1G..8G with point_add() and re-encoding with point_pack() -- so their - * correctness rests on the same curve arithmetic this file already uses for - * real verification, not on a separately-copied constant that could carry a - * transcription error. Cross-checked: point k and point (8-k) differ only in - * the sign bit (byte 31), as required since (8-k)G = -(kG); the order-2 - * element (k=4) is its own negation, as required since 2P = O implies P = -P. - */ -static const uint8_t k_low_order[8][32] = { - {0x26,0xe8,0x95,0x8f,0xc2,0xb2,0x27,0xb0,0x45,0xc3,0xf4,0x89,0xf2,0xef,0x98,0xf0, - 0xd5,0xdf,0xac,0x05,0xd3,0xc6,0x33,0x39,0xb1,0x38,0x02,0x88,0x6d,0x53,0xfc,0x85}, - {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x80}, - {0xc7,0x17,0x6a,0x70,0x3d,0x4d,0xd8,0x4f,0xba,0x3c,0x0b,0x76,0x0d,0x10,0x67,0x0f, - 0x2a,0x20,0x53,0xfa,0x2c,0x39,0xcc,0xc6,0x4e,0xc7,0xfd,0x77,0x92,0xac,0x03,0xfa}, - {0xec,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff, - 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0x7f}, - {0xc7,0x17,0x6a,0x70,0x3d,0x4d,0xd8,0x4f,0xba,0x3c,0x0b,0x76,0x0d,0x10,0x67,0x0f, - 0x2a,0x20,0x53,0xfa,0x2c,0x39,0xcc,0xc6,0x4e,0xc7,0xfd,0x77,0x92,0xac,0x03,0x7a}, - {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, - {0x26,0xe8,0x95,0x8f,0xc2,0xb2,0x27,0xb0,0x45,0xc3,0xf4,0x89,0xf2,0xef,0x98,0xf0, - 0xd5,0xdf,0xac,0x05,0xd3,0xc6,0x33,0x39,0xb1,0x38,0x02,0x88,0x6d,0x53,0xfc,0x05}, - {0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, - 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, -}; - -/* A handful of distinct messages, so a low-order-R rejection isn't pinned - * against only one k = SHA-512(R || A || M) value. */ -static const char *messages[] = { - "", - "a", - "untrusted firmware", -}; - /* ---- positive tests: a conforming signature MUST be accepted ---- */ TEST(test_ed25519_rfc8032_vectors_accepted) diff --git a/tests/unit/test_fw_decrypt.c b/tests/unit/test_fw_decrypt.c index ca3c358..ac1b26a 100644 --- a/tests/unit/test_fw_decrypt.c +++ b/tests/unit/test_fw_decrypt.c @@ -27,6 +27,7 @@ static int tests_passed = 0; static void name(void); \ static void run_##name(void) { \ printf(" %-52s ", #name); \ + tests_run++; \ name(); \ tests_passed++; \ printf("[PASS]\n"); \ @@ -265,7 +266,6 @@ int main(void) run_test_init_rejects_bad_arguments_and_unprovisioned_keys(); run_test_update_and_final_reject_uninitialised_contexts(); - tests_run = 8; printf("\n%d/%d tests passed\n", tests_passed, tests_run); return (tests_passed == tests_run) ? 0 : 1; } diff --git a/tests/unit/test_fw_transport.c b/tests/unit/test_fw_transport.c index b5ce142..92d51d3 100644 --- a/tests/unit/test_fw_transport.c +++ b/tests/unit/test_fw_transport.c @@ -16,6 +16,7 @@ #include "eos_image.h" #include "eos_image_tlv.h" #include "eos_hal.h" +#include "../vectors/fw_update_test_sigs.h" #include #include #include @@ -102,6 +103,23 @@ static eos_reset_reason_t sim_reset_reason(void) { return EOS_RESET_POWER_ON; } static bool sim_recovery_pin(void) { return false; } static void sim_system_reset(void) {} +/* Since #104 finalize verifies the image signature unconditionally, so the + * container the XMODEM test finalizes has to be genuinely signed. The keystore + * takes its anchor from OTP slot 0 when the board has OTP at all; serve the + * public half of the key the fixture signature was made under. */ +#define OTP_KEY_OFFSET_SLOT0 0x100u + +static int sim_otp_read(uint32_t offset, void *buf, size_t len) +{ + if (!buf) return EOS_ERR_INVALID; + if (offset == OTP_KEY_OFFSET_SLOT0 && len == sizeof(eos_test_sig_pubkey)) { + memcpy(buf, eos_test_sig_pubkey, len); + return EOS_OK; + } + memset(buf, 0, len); /* slot 1 unprovisioned, nothing revoked */ + return EOS_OK; +} + static const eos_board_ops_t sim_ops = { .flash_base = 0, .flash_size = SIM_FLASH_SIZE, @@ -119,6 +137,7 @@ static const eos_board_ops_t sim_ops = { .flash_read = sim_flash_read, .flash_write = sim_flash_write, .flash_erase = sim_flash_erase, + .otp_read = sim_otp_read, .watchdog_init = sim_noop_u32, .watchdog_feed = sim_noop, @@ -325,6 +344,13 @@ static uint32_t crc32_payload(const uint8_t *data, size_t len) return ~crc; } +/* Coupling: the signed prefix (the first EOS_IMG_SIGNED_LEN = 92 bytes of + * the header, plus the TLV area whose hash sits inside it) is built here + * AND in tools/gen_fw_update_test_sigs.py, which signs it. Changing any + * field in it means changing the generator's copy too and re-running + * python3 tools/gen_fw_update_test_sigs.py > tests/vectors/fw_update_test_sigs.h + * tests/unit/test_fw_update_test_sigs.py fails until the committed header + * is regenerated from the generator. */ static void build_container(void) { eos_image_header_t hdr; @@ -354,7 +380,8 @@ static void build_container(void) hdr.load_addr = SIM_SLOT_B_ADDR; hdr.entry_addr = SIM_SLOT_B_ADDR; hdr.flags = 0; /* CRC32 integrity path */ - hdr.sig_type = EOS_SIG_NONE; + hdr.sig_type = EOS_SIG_ED25519; + hdr.sig_len = EOS_SIG_MAX_SIZE; uint32_t crc = crc32_payload(payload, CONT_PAYLOAD_LEN); memcpy(hdr.hash, &crc, sizeof(crc)); @@ -365,6 +392,10 @@ static void build_container(void) hdr.tlv_len = CONT_TLV_LEN; memcpy(hdr.tlv_hash, digest, EOS_IMG_TLV_HASH_LEN); + /* Precomputed by tools/gen_fw_update_test_sigs.py for exactly the field + * values above; change any of them and regenerate. */ + memcpy(hdr.signature, eos_test_sig_fw_transport_container, EOS_SIG_MAX_SIZE); + memcpy(container, &hdr, sizeof(hdr)); } @@ -880,7 +911,6 @@ int main(void) run_test_raw_oversized_length_is_rejected(); run_test_raw_zero_length_is_rejected(); - tests_run = 19; printf("\n%d/%d tests passed\n", tests_passed, tests_run); return (tests_passed == tests_run) ? 0 : 1; } diff --git a/tests/unit/test_fw_update.c b/tests/unit/test_fw_update.c index afe54d8..f6a3129 100644 --- a/tests/unit/test_fw_update.c +++ b/tests/unit/test_fw_update.c @@ -18,6 +18,7 @@ #include "eos_crypto_boot.h" #include "eos_bootctl.h" #include "eos_hal.h" +#include "../vectors/fw_update_test_sigs.h" #include #include #include @@ -65,6 +66,24 @@ static int sim_monotonic_read(uint32_t *value) return EOS_OK; } +/* Since #104 finalize verifies the image signature unconditionally, and it + * does so before the anti-rollback check, so an image has to be genuinely + * signed to reach the stage these tests exercise. The keystore takes its + * anchor from OTP slot 0 when the board has OTP at all; serve the public + * half of the key the fixture signatures were made under, and nothing else. */ +#define OTP_KEY_OFFSET_SLOT0 0x100u + +static int sim_otp_read(uint32_t offset, void *buf, size_t len) +{ + if (!buf) return EOS_ERR_INVALID; + if (offset == OTP_KEY_OFFSET_SLOT0 && len == sizeof(eos_test_sig_pubkey)) { + memcpy(buf, eos_test_sig_pubkey, len); + return EOS_OK; + } + memset(buf, 0, len); /* slot 1 unprovisioned, nothing revoked */ + return EOS_OK; +} + static const eos_board_ops_t sim_ops = { .flash_base = 0, .flash_size = SIM_FLASH_SIZE, @@ -79,6 +98,7 @@ static const eos_board_ops_t sim_ops = { .flash_write = sim_flash_write, .flash_erase = sim_flash_erase, .monotonic_read = sim_monotonic_read, + .otp_read = sim_otp_read, }; static int tests_run = 0; @@ -98,6 +118,7 @@ static int tests_passed = 0; sim_counter = 0; \ eos_hal_init(&sim_ops); \ printf(" %-58s ", #name); \ + tests_run++; \ name(); \ tests_passed++; \ printf("[PASS]\n"); \ @@ -118,6 +139,13 @@ static void fill_payload(uint8_t *payload) payload[i] = (uint8_t)(i * 7u + 1u); } +/* Coupling: the signed prefix (the first EOS_IMG_SIGNED_LEN = 92 bytes of + * the header, plus the TLV area whose hash sits inside it) is built here + * AND in tools/gen_fw_update_test_sigs.py, which signs it. Changing any + * field in it means changing the generator's copy too and re-running + * python3 tools/gen_fw_update_test_sigs.py > tests/vectors/fw_update_test_sigs.h + * tests/unit/test_fw_update_test_sigs.py fails until the committed header + * is regenerated from the generator. */ static void build_image(uint8_t *out, uint32_t sec_ver) { uint8_t payload[PAYLOAD_SIZE]; @@ -135,8 +163,8 @@ static void build_image(uint8_t *out, uint32_t sec_ver) hdr.image_version = 0x00010000u; hdr.flags = EOS_IMG_FLAG_HASH_SHA256; eos_sha256(payload, PAYLOAD_SIZE, hdr.hash); - hdr.sig_type = EOS_SIG_NONE; - hdr.sig_len = 0; + hdr.sig_type = EOS_SIG_ED25519; + hdr.sig_len = EOS_SIG_MAX_SIZE; uint8_t tlv[TLV_AREA_LEN]; eos_tlv_info_t info = { EOS_TLV_INFO_MAGIC, TLV_AREA_LEN }; @@ -150,6 +178,15 @@ static void build_image(uint8_t *out, uint32_t sec_ver) hdr.tlv_len = TLV_AREA_LEN; memcpy(hdr.tlv_hash, digest, EOS_IMG_TLV_HASH_LEN); + /* Signature over the prefix, precomputed by tools/gen_fw_update_test_sigs.py + * for exactly the field values above. Every other sec_ver would need its + * own entry there, because tlv_hash is inside the signed prefix. */ + switch (sec_ver) { + case 3: memcpy(hdr.signature, eos_test_sig_fw_update_sec_ver_3, EOS_SIG_MAX_SIZE); break; + case 9: memcpy(hdr.signature, eos_test_sig_fw_update_sec_ver_9, EOS_SIG_MAX_SIZE); break; + default: printf("[FAIL] no fixture signature for sec_ver %u\n", (unsigned)sec_ver); exit(1); + } + memset(out, 0, IMAGE_BUF_LEN); memcpy(out, &hdr, sizeof(hdr)); memcpy(out + sizeof(hdr), payload, PAYLOAD_SIZE); @@ -315,7 +352,6 @@ int main(void) run_test_trailing_byte_is_rejected_the_same_across_chunk_boundaries(); run_test_finalize_accepts_tlv_counter_equal_to_floor(); - tests_run = 6; printf("\n%d/%d passed\n", tests_passed, tests_run); return tests_passed == tests_run ? 0 : 1; } diff --git a/tests/unit/test_fw_update_sig.c b/tests/unit/test_fw_update_sig.c index 42970d6..b510ede 100644 --- a/tests/unit/test_fw_update_sig.c +++ b/tests/unit/test_fw_update_sig.c @@ -129,6 +129,7 @@ static int tests_passed = 0; static void run_##name(void) { \ setup(); \ printf(" %-58s ", #name); \ + tests_run++; \ name(); \ tests_passed++; \ printf("[OK]\n"); \ @@ -249,7 +250,6 @@ int main(void) run_test_sha256_sigtype_is_still_unsigned_and_rejected(); run_test_corrupt_image_is_rejected_at_integrity_stage(); - tests_run = 3; printf("\n%d/%d tests passed\n", tests_passed, tests_run); return (tests_passed == tests_run) ? 0 : 1; } diff --git a/tests/unit/test_fw_update_test_sigs.py b/tests/unit/test_fw_update_test_sigs.py new file mode 100644 index 0000000..ec7b825 --- /dev/null +++ b/tests/unit/test_fw_update_test_sigs.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""tests/vectors/fw_update_test_sigs.h must be what its generator emits. + +tools/gen_fw_update_test_sigs.py signs the header prefixes that +tests/unit/test_fw_update.c build_image() and tests/unit/test_fw_transport.c +build_container() assemble, and the C suites include the committed header. +If the generator changes and the header is not regenerated, the C suites +verify against stale signatures and fail with EOS_ERR_SIGNATURE, and nothing +says why. This pins the committed header to the generator's output, byte for +byte, so line endings count too. +""" + +import difflib +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +TOOLS = REPO_ROOT / "tools" + +# A skip is right for a developer without the signing dependency installed and +# wrong for CI, where "collected 19 tests, ran 0" is a green run that checked +# nothing -- the failure .ai/security.md names directly. EOS_REQUIRE_SIGNING_TESTS +# is set in the workflow, so there a missing dependency is a hard error; locally +# the skip still applies. +if os.environ.get("EOS_REQUIRE_SIGNING_TESTS"): + import cryptography # noqa: F401 -- ImportError here must fail the job +else: + pytest.importorskip( + "cryptography", reason="signing tools require 'cryptography'") + +GENERATOR = TOOLS / "gen_fw_update_test_sigs.py" +HEADER = REPO_ROOT / "tests" / "vectors" / "fw_update_test_sigs.h" +REGENERATE = ("python3 tools/gen_fw_update_test_sigs.py " + "> tests/vectors/fw_update_test_sigs.h") + + +def mismatch_message(expected, actual): + """What a stale header is reported as: the regenerate command, then a + unified diff of generator output against the committed file.""" + diff = "".join(difflib.unified_diff( + expected.decode("ascii", "replace").splitlines(keepends=True), + actual.decode("ascii", "replace").splitlines(keepends=True), + fromfile="tools/gen_fw_update_test_sigs.py (stdout)", + tofile="tests/vectors/fw_update_test_sigs.h (committed)", + )) + return ("tests/vectors/fw_update_test_sigs.h differs from what " + "tools/gen_fw_update_test_sigs.py emits; regenerate it with\n" + " " + REGENERATE + "\n" + diff) + + +def test_committed_header_is_the_generator_output(): + result = subprocess.run( + [sys.executable, str(GENERATOR)], + cwd=REPO_ROOT, capture_output=True, check=True, + ) + expected = result.stdout + actual = HEADER.read_bytes() + + assert actual == expected, mismatch_message(expected, actual) + + +def test_a_stale_header_is_reported_with_the_command_and_the_diff(): + """The failure path of the test above, driven directly: one changed + byte must show up as the changed line, under the command that fixes it.""" + expected = b"static const unsigned char k[4] = {\n 0x67,0xf0,\n};\n" + actual = b"static const unsigned char k[4] = {\n 0x68,0xf0,\n};\n" + + message = mismatch_message(expected, actual) + + assert REGENERATE in message + assert "- 0x67,0xf0," in message + assert "+ 0x68,0xf0," in message + assert "tests/vectors/fw_update_test_sigs.h (committed)" in message diff --git a/tests/unit/test_imgpack.py b/tests/unit/test_imgpack.py index dc0fc32..7cee682 100644 --- a/tests/unit/test_imgpack.py +++ b/tests/unit/test_imgpack.py @@ -32,7 +32,13 @@ def _pack(tmp_path, version): "--input", str(tmp_path / "fw.bin"), "--output", str(tmp_path / "fw.eimg"), "--load-addr", "0x08010000", "--entry-addr", "0x08010100", - "--version", version], + # `--version=X`, not `--version X`: a value such as "-1.0.0" is a + # separate token in the second form, and whether argparse reads it + # as a value or as an unknown option depends on the interpreter -- + # 3.10 refuses it with "expected one argument" before imgpack.py + # runs, 3.13+ accepts it. The test is about imgpack's own check, + # which only the joined form reaches on every version. + f"--version={version}"], capture_output=True, text=True) diff --git a/tests/unit/test_jump_app.c b/tests/unit/test_jump_app.c index e9a07e6..c882119 100644 --- a/tests/unit/test_jump_app.c +++ b/tests/unit/test_jump_app.c @@ -221,6 +221,7 @@ static int tests_passed = 0; eos_hal_init(&sim_ops); \ eos_rollback_clear_staged(); \ printf(" %-58s ", #name); \ + tests_run++; \ name(); \ tests_passed++; \ printf("[PASS]\n"); \ @@ -265,7 +266,6 @@ int main(void) run_test_jump_rejects_tlv_counter_below_hw_floor(); run_test_jump_stages_tlv_counter_above_floor(); - tests_run = 2; printf("\n%d/%d passed\n", tests_passed, tests_run); return tests_passed == tests_run ? 0 : 1; } diff --git a/tests/unit/test_jump_app_bounds.c b/tests/unit/test_jump_app_bounds.c index 5e08b6f..496e161 100644 --- a/tests/unit/test_jump_app_bounds.c +++ b/tests/unit/test_jump_app_bounds.c @@ -115,6 +115,7 @@ static int tests_passed = 0; payload_bytes_read = 0; \ eos_hal_init(&sim_ops); \ printf(" %-55s ", #name); \ + tests_run++; \ name(); \ tests_passed++; \ printf("[PASS]\n"); \ @@ -191,7 +192,6 @@ int main(void) run_test_oversized_image_rejected_before_reading_payload(); run_test_in_bounds_image_reaches_integrity_check(); - tests_run = 2; printf("\n%d/%d tests passed\n", tests_passed, tests_run); diff --git a/tests/unit/test_keystore.c b/tests/unit/test_keystore.c index 1e1960d..7d06aec 100644 --- a/tests/unit/test_keystore.c +++ b/tests/unit/test_keystore.c @@ -8,7 +8,9 @@ */ #include "eos_keystore.h" +#include "eos_crypto_boot.h" #include "eos_hal.h" +#include "../vectors/fw_update_test_sigs.h" #include #include #include @@ -263,6 +265,60 @@ TEST(test_revoke_reports_a_failed_persist) otp_detach(); } +/* The compiled-in anchor claims, in a #warning and in comments, to be the + * RFC 8032 section 7.1 TEST 1 public key. That claim is what makes it + * usable for development at all: the matching secret is printed in the RFC, + * so anyone can sign a test image for a board that falls back to it. + * + * From v0.1.0 the array agreed with the RFC for 21 bytes and then diverged, + * and the bytes it held did not decode to a point on the curve. Every + * signature check against the fallback failed, and after #104 made signature + * verification unconditional at install, firmware update refused every + * image on every board without OTP. Nothing noticed because no test ever + * asked the fallback key to verify anything. + * + * This asks. The vector is RFC 8032 TEST 1 itself: empty message, and the + * signature the RFC prints for it. On the old bytes eos_ed25519_verify() + * returns EOS_ERR_SIGNATURE; the two negative checks after it show the + * accept is discriminating, not a verifier that says yes to everything. */ +TEST(test_compiled_in_anchor_verifies_its_own_rfc_vector) +{ + static const uint8_t rfc8032_test1_sig[64] = { + 0xe5,0x56,0x43,0x00,0xc3,0x60,0xac,0x72,0x90,0x86,0xe2,0xcc,0x80,0x6e,0x82,0x8a, + 0x84,0x87,0x7f,0x1e,0xb8,0xe5,0xd9,0x74,0xd8,0x73,0xe0,0x65,0x22,0x49,0x01,0x55, + 0x5f,0xb8,0x82,0x15,0x90,0xa3,0x3b,0xac,0xc6,0x1e,0x39,0x70,0x1c,0xf9,0xb4,0x6b, + 0xd2,0x5b,0xf5,0xf0,0x59,0x5b,0xbe,0x24,0x65,0x51,0x41,0x43,0x8e,0x7a,0x10,0x0b, + }; + const uint8_t *key = NULL; + size_t key_len = 0; + eos_keystore_t ks; + + otp_detach(); /* no OTP at all: the compiled-in path */ + ASSERT(eos_keystore_init(&ks) == EOS_OK); + ASSERT(eos_keystore_get_active_key(&ks, &key, &key_len) == EOS_OK); + ASSERT(key_len == 32); + + /* The bytes are the RFC's bytes, and they verify the RFC's signature. + * + * The expected key is eos_test_sig_pubkey from the generated fixture + * header, not a third hand-typed copy. That public key is derived from + * the RFC 8032 secret by tools/gen_fw_update_test_sigs.py and pinned to + * that script by tests/unit/test_fw_update_test_sigs.py, so this compares + * the compiled-in anchor against a second, independently derived copy of + * the same key. */ + ASSERT(memcmp(key, eos_test_sig_pubkey, 32) == 0); + ASSERT(eos_ed25519_verify(rfc8032_test1_sig, key, NULL, 0) == EOS_OK); + + /* Discrimination: the same signature must not verify a different message, + * and a bit-flipped signature must not verify the empty one. */ + const uint8_t other[1] = { 'x' }; + ASSERT(eos_ed25519_verify(rfc8032_test1_sig, key, other, 1) != EOS_OK); + uint8_t flipped[64]; + memcpy(flipped, rfc8032_test1_sig, 64); + flipped[0] ^= 0x01; + ASSERT(eos_ed25519_verify(flipped, key, NULL, 0) != EOS_OK); +} + int main(void) { printf("=== eBootloader: Keystore Unit Tests ===\n\n"); @@ -276,6 +332,7 @@ int main(void) run_test_failed_otp_read_does_not_fall_back_to_the_compiled_key(); run_test_revocation_is_persisted_without_clobbering_other_slots(); run_test_revoke_reports_a_failed_persist(); + run_test_compiled_in_anchor_verifies_its_own_rfc_vector(); printf("\n%d/%d tests passed\n", tests_passed, tests_run); return (tests_passed == tests_run) ? 0 : 1; diff --git a/tests/unit/test_keystore_production.c b/tests/unit/test_keystore_production.c new file mode 100644 index 0000000..af7101b --- /dev/null +++ b/tests/unit/test_keystore_production.c @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EoS Project + +/** + * @file test_keystore_production.c + * @brief core/keystore.c compiled with EBLDR_PRODUCTION_KEY. + * + * The production branch of the keystore -- the one a release artifact is + * built from -- had never been compiled: it declared an extern that nothing + * defined, so following the #warning's own instruction produced a link + * error. This suite builds core/keystore.c the way a Release board build + * does (EBLDR_PRODUCTION_KEY defined, ebldr_production_key[] generated by + * cmake/ProductionKey.cmake from a fixture key) and asks the no-OTP path for + * its anchor. The suite's own keystore.o defines every keystore symbol, so + * the copy inside eboot_core is never pulled in: the object under test is + * the one compiled here, not the development-key one. + * + * The fixture key is RFC 8032 section 7.1 TEST 2's public key: a real curve + * point that is not the development key, so the assertions can tell the two + * apart. + */ + +#include "eos_keystore.h" +#include "eos_production_key.h" +#include "eos_hal.h" +#include "../vectors/fw_update_test_sigs.h" +#include +#include +#include + +/* RFC 8032 section 7.1, TEST 2, PUBLIC KEY: what tests/CMakeLists.txt hands + * to ebldr_write_production_key_source() for this suite. */ +static const uint8_t k_fixture_key[EOS_ED25519_PUB_KEY_SIZE] = { + 0x3d,0x40,0x17,0xc3,0xe8,0x43,0x89,0x5a,0x92,0xb7,0x0a,0xa7,0x4d,0x1b,0x7e,0xbc, + 0x9c,0x98,0x2c,0xcf,0x2e,0xc4,0x96,0x8c,0xc0,0xcd,0x55,0xf1,0x2a,0xf4,0x66,0x0c, +}; + +/* A board with no OTP at all: eos_hal_otp_read() reports EOS_ERR_NOT_SUPPORTED + * and the keystore takes the compiled-in path. */ +static const eos_board_ops_t no_otp_board = { 0 }; + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST(name) \ + static void name(void); \ + static void run_##name(void) { \ + printf(" %-62s ", #name); \ + tests_run++; \ + name(); \ + tests_passed++; \ + printf("[PASS]\n"); \ + } \ + static void name(void) + +#define ASSERT(cond) do { \ + if (!(cond)) { \ + printf("[FAIL] %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + exit(1); \ + } \ +} while(0) + +/* The generated translation unit defines the symbol keystore.c consumes, and + * with the bytes the build was configured with. */ +TEST(test_generated_symbol_carries_the_configured_key) +{ + ASSERT(memcmp(ebldr_production_key, k_fixture_key, EOS_ED25519_PUB_KEY_SIZE) == 0); +} + +/* On a board without OTP the active key is the production key, from the + * compiled-in source. */ +TEST(test_no_otp_board_gets_the_production_key) +{ + eos_keystore_t ks; + const uint8_t *key = NULL; + size_t key_len = 0; + + eos_hal_init(&no_otp_board); + ASSERT(eos_keystore_init(&ks) == EOS_OK); + ASSERT(ks.source == EOS_KEY_SOURCE_COMPILED); + ASSERT(eos_keystore_get_active_key(&ks, &key, &key_len) == EOS_OK); + ASSERT(key_len == EOS_ED25519_PUB_KEY_SIZE); + ASSERT(memcmp(key, k_fixture_key, EOS_ED25519_PUB_KEY_SIZE) == 0); +} + +/* And it is not the development key. eos_test_sig_pubkey is the RFC 8032 + * TEST 1 public key, derived from the RFC's secret by + * tools/gen_fw_update_test_sigs.py -- the key the #ifndef branch compiles in + * and the one a release must never carry. */ +TEST(test_no_otp_board_does_not_get_the_development_key) +{ + eos_keystore_t ks; + const uint8_t *key = NULL; + size_t key_len = 0; + + eos_hal_init(&no_otp_board); + ASSERT(eos_keystore_init(&ks) == EOS_OK); + ASSERT(eos_keystore_get_active_key(&ks, &key, &key_len) == EOS_OK); + ASSERT(memcmp(key, eos_test_sig_pubkey, EOS_ED25519_PUB_KEY_SIZE) != 0); +} + +int main(void) +{ + printf("=== eBootloader: Keystore with EBLDR_PRODUCTION_KEY ===\n\n"); + + run_test_generated_symbol_carries_the_configured_key(); + run_test_no_otp_board_gets_the_production_key(); + run_test_no_otp_board_does_not_get_the_development_key(); + + printf("\n%d/%d tests passed\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/tests/unit/test_production_key_gate.py b/tests/unit/test_production_key_gate.py new file mode 100644 index 0000000..5f01a26 --- /dev/null +++ b/tests/unit/test_production_key_gate.py @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""The configure-time gate on the compiled-in trust anchor. + +core/keystore.c falls back to a compiled-in public key on a board without +OTP -- every board under boards/ today. Without EBLDR_PRODUCTION_KEY that key +is the RFC 8032 test key, whose secret is published. These tests run real +CMake configures against the repository and pin what the gate does: + + * a Release build of a real board refuses to configure without a key, + and says why, before anything else goes wrong; + * EBLDR_ALLOW_DEV_KEY=ON is the one way past that, and it is explicit; + * the development key and a malformed value are refused as production keys; + * a real key produces the generated translation unit with those bytes. + +Configure only; nothing is compiled. +""" + +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# RFC 8032 section 7.1 TEST 2 public key: a real curve point that is not the +# development key. +GOOD_KEY = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" +# RFC 8032 section 7.1 TEST 1 public key: core/keystore.c's fallback. +DEV_KEY = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + +GATE_MESSAGE = "EBLDR_PRODUCTION_KEY is not set" + +pytestmark = pytest.mark.skipif( + shutil.which("cmake") is None, reason="cmake is not on PATH") + + +def configure(*defs): + build = Path(tempfile.mkdtemp(prefix="eboot-gate-")) + try: + result = subprocess.run( + ["cmake", "-S", str(REPO_ROOT), "-B", str(build), *defs], + capture_output=True, text=True) + generated = build / "generated" / "production_key.c" + text = generated.read_text() if generated.exists() else None + return result, text + finally: + shutil.rmtree(build, ignore_errors=True) + + +def first_error(stderr): + """The first 'CMake Error' block, so ordering can be asserted.""" + blocks = re.split(r"(?=CMake Error)", stderr) + return next((b for b in blocks if b.startswith("CMake Error")), "") + + +# Every build type that is not Debug is release-shaped: the optimised types +# under every spelling CMake accepts, and no build type at all -- a cross build +# still gets -Os from CMakeLists.txt, and a multi-config generator has no type +# at configure time. The gate used to match the literal "Release" only, and +# the other five configured a real board around it. +RELEASE_SHAPED = ["Release", "RelWithDebInfo", "MinSizeRel", + "release", "RELEASE", None] + + +@pytest.mark.parametrize("build_type", RELEASE_SHAPED, + ids=[t or "unset" for t in RELEASE_SHAPED]) +def test_release_shaped_board_build_refuses_without_a_key(build_type): + defs = ["-DEBLDR_BOARD=stm32f4"] + if build_type is not None: + defs.append("-DCMAKE_BUILD_TYPE=" + build_type) + result, generated = configure(*defs) + assert result.returncode != 0 + assert GATE_MESSAGE in result.stderr + # The gate, not a missing toolchain or a board port, is what stops it. + assert GATE_MESSAGE in first_error(result.stderr), result.stderr + assert "Only a Debug build is exempt" in result.stderr + assert generated is None + + +@pytest.mark.parametrize("build_type", ["Debug", "debug", "DEBUG"]) +def test_debug_board_build_is_the_one_exemption(build_type): + result, _ = configure("-DCMAKE_BUILD_TYPE=" + build_type, + "-DEBLDR_BOARD=stm32f4") + assert result.returncode == 0, result.stderr + assert GATE_MESSAGE not in result.stderr + + +def test_release_board_build_can_say_it_is_not_a_release(): + result, _ = configure("-DCMAKE_BUILD_TYPE=Release", "-DEBLDR_BOARD=stm32f4", + "-DEBLDR_ALLOW_DEV_KEY=ON") + assert GATE_MESSAGE not in result.stderr + + +def test_host_build_is_not_gated(): + # No board, so nothing reaches a device: no key, no opt-out, no gate message. + result, generated = configure("-DCMAKE_BUILD_TYPE=Release") + assert result.returncode == 0, result.stderr + assert GATE_MESSAGE not in result.stderr + assert generated is None, "no key was given, so nothing should be generated" + + +def test_development_key_is_refused_as_a_production_key(): + for spelling in (DEV_KEY, DEV_KEY.upper()): + result, generated = configure("-DEBLDR_PRODUCTION_KEY=" + spelling) + assert result.returncode != 0 + assert "development key" in result.stderr, result.stderr + assert generated is None + + +@pytest.mark.parametrize("bad", ["abc", GOOD_KEY[:-2], GOOD_KEY + "00", + "zz" + GOOD_KEY[2:]]) +def test_malformed_key_is_refused(bad): + result, generated = configure("-DEBLDR_PRODUCTION_KEY=" + bad) + assert result.returncode != 0 + assert "exactly 64" in result.stderr, result.stderr + assert generated is None + + +# The bytes core/keystore.c shipped before eBoot#116: 64 hex characters, not +# the development key, and no point on edwards25519. The length and dev-key +# checks accept them; only the curve check refuses them. +OFF_CURVE_KEY = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18c42c47684377725" +# The order-2 point. On the curve, refused by the verifier's subgroup check. +LOW_ORDER_KEY = "ec" + "ff" * 30 + "7f" + + +@pytest.mark.parametrize("key,reason", [ + (OFF_CURVE_KEY, "no point on edwards25519"), + (LOW_ORDER_KEY, "low order"), + ("01" + "00" * 31, "identity"), +]) +def test_key_the_verifier_would_refuse_is_refused_at_configure(key, reason): + result, generated = configure("-DCMAKE_BUILD_TYPE=Release", + "-DEBLDR_BOARD=stm32f4", + "-DEBLDR_PRODUCTION_KEY=" + key) + assert result.returncode != 0 + assert "not a usable Ed25519 public key" in result.stderr, result.stderr + assert reason in result.stderr, result.stderr + assert generated is None, "an unusable key must not be compiled in" + + +def test_production_key_without_python_refuses_rather_than_skipping_the_check(): + """The point check runs in Python. A production-key configure on a machine + with no python3 must stop, not warn and compile an unchecked key in: this + gate is the only control for a build made outside release.yml, and a + warning scrolls past. CMAKE_DISABLE_FIND_PACKAGE_Python3 is how CMake + itself simulates the interpreter being absent.""" + result, generated = configure("-DCMAKE_BUILD_TYPE=Release", "-DEBLDR_BOARD=stm32f4", + "-DEBLDR_PRODUCTION_KEY=" + GOOD_KEY, + "-DCMAKE_DISABLE_FIND_PACKAGE_Python3=TRUE") + assert result.returncode != 0, "a production key was compiled in unchecked" + assert "python3 was not found" in result.stderr, result.stderr + assert "CMake Warning" not in result.stderr, "it must refuse, not warn" + assert generated is None + # A development build never runs the check, so it is unaffected. + result, _ = configure("-DCMAKE_BUILD_TYPE=Debug", "-DEBLDR_BOARD=stm32f4", + "-DCMAKE_DISABLE_FIND_PACKAGE_Python3=TRUE") + assert result.returncode == 0, result.stderr + + +def test_real_key_generates_the_anchor_source(): + result, generated = configure("-DCMAKE_BUILD_TYPE=Release", + "-DEBLDR_BOARD=stm32f4", + "-DEBLDR_PRODUCTION_KEY=" + GOOD_KEY.upper()) + assert GATE_MESSAGE not in result.stderr + assert generated is not None, result.stderr + assert "ebldr_production_key[EOS_ED25519_PUB_KEY_SIZE]" in generated + emitted = "".join(re.findall(r"0x([0-9a-f]{2})", generated)) + assert emitted == GOOD_KEY, "the generated bytes must be the configured key" diff --git a/tests/unit/test_release_workflow_production_key.py b/tests/unit/test_release_workflow_production_key.py new file mode 100644 index 0000000..a7f9f90 --- /dev/null +++ b/tests/unit/test_release_workflow_production_key.py @@ -0,0 +1,340 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Every release-shaped CMake configure in the workflows carries a trust +anchor, and the release scans what it built for the development key. + +The gate in CMakeLists.txt is what makes a Release board build refuse the +development key. This pins the other half: that release.yml actually passes +EBLDR_PRODUCTION_KEY on every board configure (so an unset secret fails +closed instead of being worked around), never passes EBLDR_ALLOW_DEV_KEY, and +scans every firmware build for the development key's bytes -- and that the +bytes it scans for are the ones core/keystore.c and cmake/ProductionKey.cmake +name. +""" + +import re +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = REPO_ROOT / ".github" / "workflows" +SCAN_STEP = "Refuse an artifact that embeds the development anchor" + + +def load(name): + return yaml.safe_load((WORKFLOWS / name).read_text(encoding="utf-8")) + + +def run_steps(job): + return [s for s in job.get("steps", []) if isinstance(s.get("run"), str)] + + +def configure_lines(run): + """Each `cmake -B` configure in a run block, with its backslash + continuation lines. A continuation that itself starts another + `cmake -B` (the esp32 jobs chain two with `||`) is its own configure.""" + lines = run.splitlines() + blocks, i = [], 0 + while i < len(lines): + if "cmake -B" in lines[i]: + block = lines[i] + while block.rstrip().endswith("\\") and i + 1 < len(lines): + i += 1 + block += "\n" + lines[i] + blocks.append(block) + i += 1 + out = [] + for block in blocks: + out.extend(part for part in re.split(r"(?=cmake -B)", block) if "cmake -B" in part) + return out + + +def board_of(block): + m = re.search(r"-DEBLDR_BOARD=(\S+)", block) + return m.group(1) if m else None + + +# Mirrors the gate in CMakeLists.txt: any optimised build type, case-insensitive, +# or none named at all. The gate test parametrises the same spellings, so if +# the two drift it fails there rather than going quiet here. +RELEASE_SHAPED = re.compile( + r"-DCMAKE_BUILD_TYPE=(Release|RelWithDebInfo|MinSizeRel)\b", re.IGNORECASE) + +def is_release_shaped(block): + if "$BUILD_TYPE" in block or RELEASE_SHAPED.search(block): + return True + return "-DCMAKE_BUILD_TYPE=" not in block + + +def dev_key_from_keystore(): + src = (REPO_ROOT / "core" / "keystore.c").read_text(encoding="utf-8") + m = re.search(r"default_dev_key\[EOS_ED25519_PUB_KEY_SIZE\]\s*=\s*\{([^}]*)\}", src) + assert m, "default_dev_key[] not found in core/keystore.c" + return "".join(f"{int(x, 16):02x}" for x in re.findall(r"0x([0-9a-fA-F]{2})", m.group(1))) + + +def dev_key_from_cmake(): + src = (REPO_ROOT / "cmake" / "ProductionKey.cmake").read_text(encoding="utf-8") + m = re.search(r'set\(EBLDR_DEV_KEY_HEX\s*"([0-9a-f]{64})"\)', src) + assert m, "EBLDR_DEV_KEY_HEX not found in cmake/ProductionKey.cmake" + return m.group(1) + + +def test_every_release_board_configure_passes_the_production_key(): + doc = load("release.yml") + seen = 0 + for job_id, job in doc["jobs"].items(): + for step in run_steps(job): + for block in configure_lines(step["run"]): + if board_of(block) in (None, "none"): + continue + seen += 1 + assert "-DEBLDR_PRODUCTION_KEY=" in block, ( + f"release.yml job {job_id!r}: a board configure without " + f"EBLDR_PRODUCTION_KEY would ship the development key:\n{block}") + assert "secrets.EBLDR_PRODUCTION_KEY_HEX" in block, ( + f"release.yml job {job_id!r}: the key must come from the " + f"EBLDR_PRODUCTION_KEY_HEX secret, not a literal:\n{block}") + assert "EBLDR_ALLOW_DEV_KEY" not in block, ( + f"release.yml job {job_id!r} opts into the development key") + assert seen == 8, f"expected the 8 board configures release.yml had, found {seen}" + + +def test_every_release_firmware_job_scans_for_the_development_key(): + doc = load("release.yml") + firmware_jobs = [ + (job_id, job) for job_id, job in doc["jobs"].items() + if any(board_of(b) not in (None, "none") + for s in run_steps(job) for b in configure_lines(s["run"]))] + assert len(firmware_jobs) == 6, [j for j, _ in firmware_jobs] + for job_id, job in firmware_jobs: + names = [s.get("name") for s in job["steps"]] + assert SCAN_STEP in names, f"release.yml job {job_id!r} has no scan step" + build = next(i for i, s in enumerate(job["steps"]) + if isinstance(s.get("run"), str) and "cmake --build" in s["run"]) + scan = names.index(SCAN_STEP) + collect = names.index("Collect artifacts") + assert build < scan < collect, ( + f"release.yml job {job_id!r}: the scan must run after the build and " + f"before artifacts are collected") + + +SCAN_TOOL = "tools/check_no_dev_anchor.py" + + +def scan_tool(): + import importlib.util + spec = importlib.util.spec_from_file_location("check_no_dev_anchor", REPO_ROOT / SCAN_TOOL) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_every_scan_step_calls_the_one_scanner(): + """Six jobs used to carry the scan inline, each a copy, and the copies + drifted from the artifact list. One tool, called six times, cannot.""" + doc = load("release.yml") + scans = [s["run"].strip() for job in doc["jobs"].values() for s in run_steps(job) + if s.get("name") == SCAN_STEP] + assert len(scans) == 6, scans + for run in scans: + assert run == f"python3 {SCAN_TOOL} build", run + + +def test_the_scanner_looks_for_the_key_keystore_actually_compiles_in(): + keystore = dev_key_from_keystore() + cmake = dev_key_from_cmake() + assert keystore == cmake, "core/keystore.c and cmake/ProductionKey.cmake disagree on the development key" + assert scan_tool().DEV_KEY.hex() == keystore, ( + "the scanner does not name the development key core/keystore.c compiles in") + + +def collected_suffixes(doc): + """Every `-name \"*.ext\"` glob in every Collect artifacts step.""" + found = set() + for job in doc["jobs"].values(): + for s in run_steps(job): + if s.get("name") == "Collect artifacts": + found |= set(re.findall(r'-name "\*(\.[a-z0-9]+)"', s["run"])) + return found + + +def test_the_scanner_covers_every_suffix_the_workflow_ships(): + """The scan list and the artifact list were maintained by hand beside each + other and disagreed: the scan read .elf .bin .a .o while Collect shipped + .hex .uf2 .efi too. This pins the one against the other.""" + shipped = collected_suffixes(load("release.yml")) + assert shipped >= {".elf", ".bin", ".a", ".hex", ".uf2", ".efi"}, shipped + missing = shipped - scan_tool().SUFFIXES + assert not missing, f"release.yml ships {sorted(missing)} but the scanner never opens them" + + +def _intel_hex(data, base=0x08000000): + def rec(t, addr, payload): + body = bytes([len(payload), (addr >> 8) & 0xFF, addr & 0xFF, t]) + payload + return ":" + (body + bytes([(-sum(body)) & 0xFF])).hex().upper() + lines = [rec(0x04, 0, (base >> 16).to_bytes(2, "big"))] + lines += [rec(0x00, (base & 0xFFFF) + off, data[off:off + 16]) for off in range(0, len(data), 16)] + return "\n".join(lines + [rec(0x01, 0, b"")]) + "\n" + + +def _uf2(data, base=0x2000): + import struct + blocks = [data[i:i + 256] for i in range(0, len(data), 256)] + return b"".join( + struct.pack("; that is what + must be written, and it must be the key in public.pem.""" + keys = tmp_path / "keys" + r = _run(TOOLS / "sign_image.py", "--genkey", "--output", keys) + assert r.returncode == 0, r.stderr + + assert not (keys / "public_key.h").exists(), "the dead header is back" + hex_path = keys / "public_key.hex" + assert hex_path.exists(), "public_key.hex was not written" + hex_key = hex_path.read_text().strip() + assert len(hex_key) == 64 and all(c in "0123456789abcdef" for c in hex_key) + assert bytes.fromhex(hex_key) == _raw_pubkey_from_pem(keys / "public.pem") + # the tool tells the developer exactly how to use it + assert "-DEBLDR_PRODUCTION_KEY=" in r.stdout + assert "key_lifecycle.md" in r.stdout + + +def test_genkey_output_passes_the_configure_time_key_check(tmp_path): + """The gate runs tools/check_production_key.py on EBLDR_PRODUCTION_KEY. A + freshly generated key is a real curve point in the prime-order subgroup, + so the value --genkey writes must pass that check as-is -- otherwise the + documented path produces a key the build refuses.""" + keys = tmp_path / "keys" + assert _run(TOOLS / "sign_image.py", "--genkey", "--output", keys).returncode == 0 + hex_key = (keys / "public_key.hex").read_text().strip() + r = _run(TOOLS / "check_production_key.py", hex_key) + assert r.returncode == 0, r.stderr + + +def test_extract_pubkey_writes_hex_too(tmp_path): + keys = tmp_path / "keys" + assert _run(TOOLS / "sign_image.py", "--genkey", "--output", keys).returncode == 0 + out = tmp_path / "anchor.hex" + r = _run(TOOLS / "sign_image.py", "--extract-pubkey", keys / "private.pem", "--output", out) + assert r.returncode == 0, r.stderr + assert out.read_text().strip() == (keys / "public_key.hex").read_text().strip() + assert "ebldr_default_pubkey" not in out.read_text() + + +def _sign_image_flags(): + """Every long option sign_image.py actually defines.""" + import re + src = (TOOLS / "sign_image.py").read_text(encoding="utf-8", errors="replace") + return set(re.findall(r"add_argument\(\s*'(--[a-z-]+)'", src)) + + +@pytest.mark.parametrize("rel", ["docs/quickstart.md", "docs/key_lifecycle.md"]) +def test_docs_name_the_key_path_the_build_reads(rel): + """Both documents told developers about a header. quickstart named + public_key.h; key_lifecycle showed --key-out and --pub-header, two flags + sign_image.py has never defined. Each must now name the file --genkey + writes and the flag that consumes it, and no header.""" + doc = (REPO_ROOT / rel).read_text(encoding="utf-8") + assert "public_key.hex" in doc, rel + assert "-DEBLDR_PRODUCTION_KEY=" in doc, rel + assert "public_key.h\n" not in doc and "public_key.h." not in doc and "public_key.h " not in doc, rel + + +@pytest.mark.parametrize("rel", ["docs/quickstart.md", "docs/key_lifecycle.md", "README.md"]) +def test_docs_only_show_sign_image_flags_that_exist(rel): + """A documented flag the tool does not have is the same defect as a + documented header nothing reads: the reader follows it and gets an + argparse error at best, or a silently-wrong build at worst.""" + import re + path = REPO_ROOT / rel + if not path.exists(): + pytest.skip(f"{rel} absent") + doc = path.read_text(encoding="utf-8") + shown = set() + # Lines ending in a backslash continue the invocation. [^\n]* alone + # swallows that backslash, so the first version of this never read a + # continuation line and could not fail on the flags it was written for. + for block in re.findall(r"sign_image\.py(?:[^\n]*\\\n)*[^\n]*", doc): + shown |= set(re.findall(r"(--[a-z-]+)", block)) + phantom = sorted(shown - _sign_image_flags()) + assert not phantom, f"{rel} documents sign_image.py flags that do not exist: {phantom}" diff --git a/tests/unit/test_suite_bookkeeping.py b/tests/unit/test_suite_bookkeeping.py index afa59bb..699cca7 100644 --- a/tests/unit/test_suite_bookkeeping.py +++ b/tests/unit/test_suite_bookkeeping.py @@ -37,6 +37,14 @@ "test_boot_log.c": "prints its own summary and has no TEST() macro", "test_ecc.c": "single-scenario suite; no per-test harness", "test_image_abi.c": "compile-time _Static_asserts; nothing runs per test", + "test_ed25519_contract.c": "one loop over the generated vector table; " + "counts accepted/refused/wrong per vector", + "test_eos_sign_boot_path.c": "CHECK() counts failures, not tests; the " + "exit code is the failure count", + "test_fdt_loader.c": "RUN() macro with exit(1) on the first failed " + "ASSERT; tests_passed is the count", + "test_qemu_arm64_timer.c": "four inline ASSERT_EQ calls that exit(1) on " + "failure; no per-test harness", } diff --git a/tests/unit/test_tlv_auth.c b/tests/unit/test_tlv_auth.c index cd4c9c2..2046a37 100644 --- a/tests/unit/test_tlv_auth.c +++ b/tests/unit/test_tlv_auth.c @@ -362,7 +362,6 @@ int main(void) run_test_tlv_area_must_fit_in_slot(); run_test_hw_floor_uses_tlv_counter_not_image_version(); - tests_run = 9; printf("\n%d/%d passed\n", tests_passed, tests_run); return tests_passed == tests_run ? 0 : 1; } diff --git a/tests/vectors/fw_update_test_sigs.h b/tests/vectors/fw_update_test_sigs.h new file mode 100644 index 0000000..14ea295 --- /dev/null +++ b/tests/vectors/fw_update_test_sigs.h @@ -0,0 +1,43 @@ +/* Generated by tools/gen_fw_update_test_sigs.py -- do not edit. + * + * Ed25519 signatures over the 92-byte signed header prefix of the + * images tests/unit/test_fw_update.c and test_fw_transport.c build, + * under the RFC 8032 section 7.1 TEST 1 key. Regenerate with: + * + * python3 tools/gen_fw_update_test_sigs.py > tests/vectors/fw_update_test_sigs.h + */ +#ifndef EOS_FW_UPDATE_TEST_SIGS_H +#define EOS_FW_UPDATE_TEST_SIGS_H + +/* The public half. Tests serve it from simulated OTP slot 0 so the + * keystore selects it the way a provisioned board would. */ +static const unsigned char eos_test_sig_pubkey[32] = { + 0xd7,0x5a,0x98,0x01,0x82,0xb1,0x0a,0xb7,0xd5,0x4b,0xfe,0xd3,0xc9,0x64,0x07,0x3a, + 0x0e,0xe1,0x72,0xf3,0xda,0xa6,0x23,0x25,0xaf,0x02,0x1a,0x68,0xf7,0x07,0x51,0x1a, +}; + +/* test_fw_update.c build_image(out, 3) */ +static const unsigned char eos_test_sig_fw_update_sec_ver_3[64] = { + 0x67,0xf0,0x58,0x37,0x33,0x61,0x23,0x1e,0xa7,0x6e,0x04,0x59,0x35,0xc7,0x5e,0x84, + 0xfc,0xac,0xce,0x67,0x64,0x7c,0x9b,0xbf,0x17,0xed,0x0a,0xcb,0xc3,0x97,0x13,0xeb, + 0xa4,0xd9,0x1c,0x60,0xc3,0x08,0xa0,0xdf,0xd9,0x06,0x7f,0xf6,0x62,0xd4,0x98,0x3f, + 0x23,0xa6,0x54,0x32,0x48,0x1a,0xc5,0xb3,0xb6,0xe0,0xac,0xf4,0xf1,0x47,0x99,0x07, +}; + +/* test_fw_update.c build_image(out, 9) */ +static const unsigned char eos_test_sig_fw_update_sec_ver_9[64] = { + 0x33,0xcd,0x40,0x50,0x31,0x62,0x91,0x2c,0x9a,0x2a,0x22,0x09,0x0b,0x4a,0xb6,0x69, + 0x2d,0x9c,0x0c,0x20,0x49,0x91,0x9b,0xe0,0x15,0x5d,0x74,0xe1,0x94,0x7e,0x34,0xe5, + 0x41,0x96,0x5c,0x2f,0x3c,0x17,0xa8,0x1b,0x34,0xae,0x6b,0x76,0x7b,0x19,0xbf,0xc0, + 0x91,0xe7,0xc9,0xd8,0x65,0x2f,0x1f,0xa8,0x10,0x74,0x77,0xcb,0x64,0x71,0xe7,0x02, +}; + +/* test_fw_transport.c build_container() */ +static const unsigned char eos_test_sig_fw_transport_container[64] = { + 0x25,0xbb,0xa5,0x30,0x94,0xbe,0xaa,0x7b,0xe5,0xaf,0x2a,0xf6,0x27,0x09,0xa1,0xcd, + 0x42,0x4d,0x20,0xdb,0xde,0xa1,0x28,0x65,0x1b,0x03,0xf8,0xdb,0x77,0x59,0x09,0xf2, + 0x56,0x6f,0xe8,0x84,0x7f,0x2c,0xa1,0x32,0xf0,0xbc,0xd3,0x05,0x7c,0xd8,0xeb,0x84, + 0x08,0x43,0x3a,0xd9,0xf9,0x1a,0xd1,0x47,0xfd,0x5b,0x21,0x48,0x01,0x58,0xcc,0x05, +}; + +#endif /* EOS_FW_UPDATE_TEST_SIGS_H */ diff --git a/tools/check_no_dev_anchor.py b/tools/check_no_dev_anchor.py new file mode 100755 index 0000000..6befe3f --- /dev/null +++ b/tools/check_no_dev_anchor.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project +"""Refuse any release artifact that embeds the development trust anchor. + +core/keystore.c compiles in the RFC 8032 section 7.1 TEST 1 public key when +EBLDR_PRODUCTION_KEY is unset. Its secret is printed in the RFC, so an image +built on it trusts a key anyone can sign for. The configure-time gate refuses +that for release-shaped board builds; this is the second, independent check, +run over what the release workflow actually ships. + +Six release jobs used to carry this scan inline, each a copy, and the copies +read only .elf .bin .a .o while the artifacts step also shipped .hex, .uf2 and +.efi. Two lists maintained by hand beside each other drift; this is the one +copy, and tests/unit/test_release_workflow_production_key.py asserts that its +suffix set covers every suffix the workflow collects. + +Two of those formats are not raw bytes. Intel HEX is ASCII, and a UF2 file is +512-byte blocks with headers, so grepping either for the key's raw bytes finds +nothing even when the key is there -- a scan that opened them and reported +clean would be worse than no scan. Both are decoded to the image they encode +before being searched. + +usage: check_no_dev_anchor.py [BUILD_DIR ...] (default: build) + +Exit 0 and print one line when nothing under the directories embeds the key. +Print one ::error per hit and exit 1 otherwise. +""" + +import pathlib +import struct +import sys + +# RFC 8032 section 7.1 TEST 1 public key, as compiled in by core/keystore.c. +DEV_KEY = bytes.fromhex( + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a") + +# Everything the release workflow collects, plus the intermediates the key +# actually lives in. Keep RAW and DECODED disjoint and complete: the test +# checks their union against release.yml's `find ... -name` globs. +# Both .o and .obj: CMake names objects .obj under CMAKE_SYSTEM_NAME=Generic, +# which is every cross target, so an .o-only entry scanned nothing on exactly +# the builds whose objects matter most. +RAW_SUFFIXES = {".elf", ".bin", ".a", ".o", ".obj", ".efi"} +DECODED_SUFFIXES = {".hex", ".uf2"} +SUFFIXES = RAW_SUFFIXES | DECODED_SUFFIXES + +UF2_MAGIC0 = 0x0A324655 +UF2_MAGIC1 = 0x9E5D5157 +UF2_BLOCK = 512 + + +def decode_intel_hex(text): + """Return the contiguous byte runs an Intel HEX file describes. + + Only the record types a firmware image uses: 00 data, 01 EOF, 02 + extended segment, 04 extended linear. Anything else stops the decode + rather than being skipped, so an unexpected format is a failure, not a + quiet gap in coverage. + """ + upper = 0 + chunks = {} + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + if not line.startswith(":"): + raise ValueError(f"not an Intel HEX record: {line[:20]!r}") + rec = bytes.fromhex(line[1:]) + # Byte count, two address bytes, type, checksum: five bytes minimum. + # Indexing before checking turned a truncated record into an + # IndexError traceback instead of the ::error line this tool promises. + if len(rec) < 5: + raise ValueError(f"Intel HEX record is truncated: {line[:20]!r}") + length, addr, rtype = rec[0], (rec[1] << 8) | rec[2], rec[3] + if len(rec) != 5 + length: + raise ValueError(f"Intel HEX record length field disagrees with the record: {line[:20]!r}") + data = rec[4:4 + length] + if (sum(rec) & 0xFF) != 0: + raise ValueError("Intel HEX checksum mismatch") + if rtype == 0x00: + chunks[upper + addr] = data + elif rtype == 0x01: + break + elif rtype == 0x02: + upper = ((data[0] << 8) | data[1]) << 4 + elif rtype == 0x04: + upper = ((data[0] << 8) | data[1]) << 16 + else: + raise ValueError(f"unsupported Intel HEX record type {rtype:#04x}") + return _runs(chunks) + + +def decode_uf2(blob): + """Return the contiguous byte runs a UF2 file's payload blocks describe.""" + if len(blob) % UF2_BLOCK: + raise ValueError("UF2 file is not a whole number of 512-byte blocks") + chunks = {} + for off in range(0, len(blob), UF2_BLOCK): + m0, m1, _flags, addr, size = struct.unpack_from(" 476: + raise ValueError(f"UF2 payload size {size} exceeds the block") + chunks[addr] = blob[off + 32:off + 32 + size] + return _runs(chunks) + + +def _runs(chunks): + """Merge address->bytes chunks into contiguous runs, so a key that + straddles two records or two blocks is still one search.""" + runs, cur, cur_addr = [], bytearray(), None + for addr in sorted(chunks): + data = chunks[addr] + if cur_addr is not None and addr == cur_addr + len(cur): + cur += data + else: + if cur: + runs.append(bytes(cur)) + cur, cur_addr = bytearray(data), addr + if cur: + runs.append(bytes(cur)) + return runs + + +def images_of(path): + """The byte images to search for a given artifact.""" + suffix = path.suffix.lower() + if suffix == ".hex": + return decode_intel_hex(path.read_text(encoding="ascii", errors="strict")) + if suffix == ".uf2": + return decode_uf2(path.read_bytes()) + return [path.read_bytes()] + + +def scan(roots): + hits, undecodable = [], [] + for root in roots: + for p in sorted(pathlib.Path(root).rglob("*")): + # Case-folded: a .BIN or .HEX artifact is the same artifact, and + # skipping it would report it clean without having read it. + if not p.is_file() or p.suffix.lower() not in SUFFIXES: + continue + try: + if any(DEV_KEY in img for img in images_of(p)): + hits.append(str(p)) + except (ValueError, UnicodeDecodeError, IndexError, struct.error) as e: + undecodable.append((str(p), f"{type(e).__name__}: {e}")) + return hits, undecodable + + +def main(argv): + roots = argv[1:] or ["build"] + hits, undecodable = scan(roots) + for h in hits: + print(f"::error file={h}::embeds the development trust anchor") + for path, why in undecodable: + # A file the scan could not read is a file the scan did not check. + print(f"::error file={path}::could not be decoded for the anchor scan: {why}") + if hits or undecodable: + return 1 + print(f"no artifact under {', '.join(roots)} contains the development key " + f"(suffixes searched: {' '.join(sorted(SUFFIXES))})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/check_production_key.py b/tools/check_production_key.py new file mode 100755 index 0000000..5f4ff57 --- /dev/null +++ b/tools/check_production_key.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project +"""Refuse a production trust anchor that the bootloader could not use. + +cmake/ProductionKey.cmake checks that EBLDR_PRODUCTION_KEY is 64 hexadecimal +characters and is not the development key. Neither check asks whether the bytes +are a public key at all. They can fail to be one in two ways that matter: + + * they decode to no point on edwards25519 -- the exact defect the shipped + development key had until it was fixed, and one mistyped hex digit in a + release secret reproduces it; + * they decode to a point of low order (every signature verifies) or one + outside the prime-order subgroup (the verifier refuses every image) + (core/ed25519_verify.c, public_key_is_valid_subgroup). + +A device built with such an anchor refuses every firmware image it is ever +offered, with a green build and a status line saying the production key is in +place. That fails closed, so it is not a compromise; it is still unrecoverable +in the field. This tool applies the verifier's own acceptance rule before the +key is compiled into anything: the encoding decodes to a point, [L]P is the +identity and P is not. Pure Python, no dependencies, so it can run anywhere a +release is cut. + +Usage: check_production_key.py <64 hex chars> exit 0 if acceptable +""" + +import sys + +P = 2**255 - 19 +D = (-121665 * pow(121666, P - 2, P)) % P +# The order of the prime-order subgroup: 2^252 + 27742317777372353535851937790883648493. +L = 2**252 + 27742317777372353535851937790883648493 +# sqrt(-1) mod p, used when recovering x. +SQRT_M1 = pow(2, (P - 1) // 4, P) + +# RFC 8032 section 7.1 TEST 1 public key: what core/keystore.c compiles in when +# no production key is given. Its secret is printed in the RFC. +DEV_KEY_HEX = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + +IDENTITY = (0, 1, 1, 0) # extended coordinates (X, Y, Z, T) + + +def _decode(key: bytes): + """RFC 8032 section 5.1.3. Returns (x, y) or raises ValueError.""" + if len(key) != 32: + raise ValueError("a public key is exactly 32 bytes") + y = int.from_bytes(key, "little") + sign = y >> 255 + y &= (1 << 255) - 1 + if y >= P: + raise ValueError("y coordinate is not below p (non-canonical encoding)") + u = (y * y - 1) % P + v = (D * y * y + 1) % P + x = (u * pow(v, 3, P) * pow(u * pow(v, 7, P), (P - 5) // 8, P)) % P + vx2 = (v * x * x) % P + if vx2 == u: + pass + elif vx2 == (-u) % P: + x = (x * SQRT_M1) % P + else: + raise ValueError("the bytes decode to no point on edwards25519") + if x == 0 and sign == 1: + raise ValueError("x is zero but the sign bit is set (invalid encoding)") + if (x & 1) != sign: + x = P - x + return x, y + + +def _add(p, q): + x1, y1, z1, t1 = p + x2, y2, z2, t2 = q + a = (y1 - x1) * (y2 - x2) % P + b = (y1 + x1) * (y2 + x2) % P + c = 2 * t1 * t2 * D % P + d = 2 * z1 * z2 % P + e, f, g, h = b - a, d - c, d + c, b + a + return (e * f % P, g * h % P, f * g % P, e * h % P) + + +def _mul(point, n): + result, addend = IDENTITY, point + while n: + if n & 1: + result = _add(result, addend) + addend = _add(addend, addend) + n >>= 1 + return result + + +def _is_identity(p): + x, y, z, _ = p + return x % P == 0 and (y - z) % P == 0 + + +def check_production_key_hex(hex_key: str) -> None: + """Raise ValueError with a one-line reason if hex_key is not acceptable.""" + hex_key = hex_key.strip() + if len(hex_key) != 64: + raise ValueError( + f"EBLDR_PRODUCTION_KEY must be exactly 64 hexadecimal characters; " + f"got {len(hex_key)}") + try: + key = bytes.fromhex(hex_key) + except ValueError: + raise ValueError("EBLDR_PRODUCTION_KEY contains a non-hexadecimal character") + if hex_key.lower() == DEV_KEY_HEX: + raise ValueError( + "EBLDR_PRODUCTION_KEY is the RFC 8032 section 7.1 TEST 1 public key -- " + "the development key, whose secret is published") + x, y = _decode(key) + point = (x, y, 1, x * y % P) + if _is_identity(point): + raise ValueError("the key is the identity point; any signature verifies " + "against it") + if not _is_identity(_mul(point, L)): + # Outside the prime-order subgroup. Two very different things land + # here and an operator needs to be told which: + # [8]P == identity: P has order 2, 4 or 8. Every signature verifies + # against it -- fails open. Somebody handed over an attack vector. + # otherwise: P has order 2L, 4L or 8L. That is where a mistyped hex + # digit lands about half the time. The verifier refuses it and every + # image is rejected -- fails closed. Somebody typed the secret wrong. + if _is_identity(_mul(point, 8)): + raise ValueError("the key is a point of low order (order 2, 4 or 8); " + "every signature would verify against it") + raise ValueError("the key is on the curve but not in the prime-order " + "subgroup -- a mistyped hex digit usually lands here; " + "the verifier refuses it and every image would be rejected. " + "Check the secret against the key that was generated") + + +def main(argv) -> int: + if len(argv) != 2: + print(__doc__.strip().splitlines()[-1], file=sys.stderr) + return 2 + try: + check_production_key_hex(argv[1]) + except ValueError as e: + print(f"production key refused: {e}", file=sys.stderr) + return 1 + print("production key accepted: a point in the prime-order subgroup of " + "edwards25519, and not the development key") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/gen_fw_update_test_sigs.py b/tools/gen_fw_update_test_sigs.py new file mode 100644 index 0000000..86f18d1 --- /dev/null +++ b/tools/gen_fw_update_test_sigs.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project +"""Sign the image headers that tests/unit/test_fw_update.c and +tests/unit/test_fw_transport.c stream through eos_fw_update_finalize(). + +Since #104 finalize verifies the Ed25519 signature unconditionally, and it +does so *before* the anti-rollback check, so an unsigned test image can no +longer reach the rollback stage -- it is refused as EOS_ERR_SIGNATURE first. +The images those suites build have to carry a real signature. eBoot has no +Ed25519 signer in C (only a verifier), so the signatures are computed here and +committed as tests/vectors/fw_update_test_sigs.h. + +The signing key is the RFC 8032 section 7.1 TEST 1 key. Its secret half is +printed in the RFC, so nothing here is a secret. The tests provision the +public half through their simulated OTP (slot 0), which is the path +eos_keystore_init() takes on a real provisioned board. + +core/keystore.c's compiled-in default_dev_key is described as this same key +but is not: it differs from byte 21 on and does not decode to a point on the +curve, so no signature can verify against it. That is a defect in its own +right and is not what this generator works around -- the OTP route is used +because it is the production path, not because the fallback is broken. + +Each header prefix below must be byte-identical to what the C test builds; +the field values are copied from the tests, and the layout is the one +tests/unit/test_image_header_abi.c pins. + + python3 tools/gen_fw_update_test_sigs.py > tests/vectors/fw_update_test_sigs.h +""" + +import hashlib +import struct +import sys + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +# RFC 8032 section 7.1, TEST 1. +RFC8032_TEST1_SECRET = bytes.fromhex( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") + +EOS_IMG_MAGIC = 0x454F5349 +EOS_IMAGE_HDR_VERSION = 2 +EOS_IMG_STRUCT_SIZE = 156 +EOS_IMG_SIGNED_LEN = 92 +EOS_IMG_FLAG_HASH_SHA256 = 1 << 6 +EOS_SIG_ED25519 = 3 +EOS_SIG_MAX_SIZE = 64 +EOS_IMG_TLV_HASH_LEN = 28 +EOS_TLV_INFO_MAGIC = 0x6907 +EOS_TLV_MIN_SEC_VER = 0x50 + + +def crc32_payload(data: bytes) -> int: + """update_crc() in core/fw_update.c, as test_fw_transport.c mirrors it.""" + crc = 0xFFFFFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0xEDB88320 if crc & 1 else crc >> 1 + return (~crc) & 0xFFFFFFFF + + +def tlv_area(sec_ver: int) -> bytes: + """[tlv_info(4)][entry_hdr(4)][uint32 value] -- the shape both suites build.""" + total = 4 + 4 + 4 + return (struct.pack(" bytes: + # build_image() in tests/unit/test_fw_update.c + payload = bytes((i * 7 + 1) & 0xFF for i in range(256)) + return signed_prefix(image_size=256, load_addr=0, entry_addr=0, + version=0x00010000, flags=EOS_IMG_FLAG_HASH_SHA256, + hash32=hashlib.sha256(payload).digest(), + tlv=tlv_area(sec_ver)) + + +def fw_transport_prefix() -> bytes: + # build_container() in tests/unit/test_fw_transport.c: CRC32 integrity + # path (flags = 0), the CRC in the first four bytes of hash[]. + payload = bytes(0x5A + (i & 0x1F) for i in range(256)) + hash32 = struct.pack(" str: + lines = [] + for i in range(0, len(b), 16): + lines.append(indent + ",".join("0x%02x" % x for x in b[i:i + 16]) + ",") + return "\n".join(lines) + + +def main() -> int: + key = Ed25519PrivateKey.from_private_bytes(RFC8032_TEST1_SECRET) + pub = key.public_key().public_bytes(serialization.Encoding.Raw, + serialization.PublicFormat.Raw) + + vectors = [ + ("fw_update_sec_ver_3", fw_update_prefix(3), + "test_fw_update.c build_image(out, 3)"), + ("fw_update_sec_ver_9", fw_update_prefix(9), + "test_fw_update.c build_image(out, 9)"), + ("fw_transport_container", fw_transport_prefix(), + "test_fw_transport.c build_container()"), + ] + + out = [] + out.append("/* Generated by tools/gen_fw_update_test_sigs.py -- do not edit.") + out.append(" *") + out.append(" * Ed25519 signatures over the 92-byte signed header prefix of the") + out.append(" * images tests/unit/test_fw_update.c and test_fw_transport.c build,") + out.append(" * under the RFC 8032 section 7.1 TEST 1 key. Regenerate with:") + out.append(" *") + out.append(" * python3 tools/gen_fw_update_test_sigs.py > tests/vectors/fw_update_test_sigs.h") + out.append(" */") + out.append("#ifndef EOS_FW_UPDATE_TEST_SIGS_H") + out.append("#define EOS_FW_UPDATE_TEST_SIGS_H") + out.append("") + out.append("/* The public half. Tests serve it from simulated OTP slot 0 so the") + out.append(" * keystore selects it the way a provisioned board would. */") + out.append("static const unsigned char eos_test_sig_pubkey[32] = {") + out.append(carr(pub)) + out.append("};") + for name, prefix, origin in vectors: + sig = key.sign(prefix) + out.append("") + out.append("/* %s */" % origin) + out.append("static const unsigned char eos_test_sig_%s[64] = {" % name) + out.append(carr(sig)) + out.append("};") + out.append("") + out.append("#endif /* EOS_FW_UPDATE_TEST_SIGS_H */") + sys.stdout.write("\n".join(out) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/sign_image.py b/tools/sign_image.py index 84e8e52..4beca25 100644 --- a/tools/sign_image.py +++ b/tools/sign_image.py @@ -18,7 +18,7 @@ Key Generation: python sign_image.py --genkey --output keys/ Creates keys/private.pem and keys/public.pem (Ed25519 keypair) - Also generates keys/public_key.h for embedding in bootloader. + Also writes keys/public_key.hex, the value for -DEBLDR_PRODUCTION_KEY. """ import argparse @@ -327,28 +327,28 @@ def generate_keypair(output_dir: Path): format=serialization.PublicFormat.Raw ) - # Generate C header for embedding - hdr_path = output_dir / "public_key.h" - hex_bytes = ', '.join(f'0x{b:02x}' for b in pub_raw) - c_header = f"""\ -// SPDX-License-Identifier: MIT -// Auto-generated by sign_image.py --genkey -// DO NOT EDIT — regenerate with: python sign_image.py --genkey - -#ifndef EBLDR_PUBLIC_KEY_H -#define EBLDR_PUBLIC_KEY_H - -#include - -static const uint8_t ebldr_default_pubkey[32] = {{ - {hex_bytes} -}}; - -#endif /* EBLDR_PUBLIC_KEY_H */ -""" - hdr_path.write_text(c_header) - print(f"C header saved: {hdr_path}") - print(f"Public key (hex): {pub_raw.hex()}") + # The one artefact the build consumes. This used to write public_key.h + # defining ebldr_default_pubkey[] -- a symbol nothing includes or links, + # so a developer who followed the quickstart built firmware whose trust + # anchor was still the RFC 8032 test key, with no error to say so. The + # bootloader takes its anchor from -DEBLDR_PRODUCTION_KEY=<64 hex> at + # configure time (cmake/ProductionKey.cmake, docs/key_lifecycle.md), and + # that is what is written here. + write_public_key_hex(pub_raw, output_dir / "public_key.hex") + + +def write_public_key_hex(pub_raw: bytes, path: Path): + """Write the raw Ed25519 public key as 64 lowercase hex characters -- the + exact value EBLDR_PRODUCTION_KEY takes -- and print the configure flag.""" + hex_key = pub_raw.hex() + assert len(hex_key) == 64, len(hex_key) + path.write_text(hex_key + "\n") + print(f"Public key (hex) saved: {path}") + print(f"Public key (hex): {hex_key}") + print("Build the bootloader with this key as its trust anchor:") + print(" cmake -B build -DEBLDR_BOARD= -DCMAKE_BUILD_TYPE=Release \\") + print(f" -DEBLDR_PRODUCTION_KEY=$(cat {path})") + print("See docs/key_lifecycle.md; compare this value against the .pub before storing it.") def extract_pubkey(key_path: Path, output_path: Path): @@ -368,25 +368,7 @@ def extract_pubkey(key_path: Path, output_path: Path): format=serialization.PublicFormat.Raw ) - hex_bytes = ', '.join(f'0x{b:02x}' for b in pub_raw) - c_header = f"""\ -// SPDX-License-Identifier: MIT -// Auto-generated by sign_image.py --extract-pubkey - -#ifndef EBLDR_PUBLIC_KEY_H -#define EBLDR_PUBLIC_KEY_H - -#include - -static const uint8_t ebldr_default_pubkey[32] = {{ - {hex_bytes} -}}; - -#endif /* EBLDR_PUBLIC_KEY_H */ -""" - output_path.write_text(c_header) - print(f"Public key header saved: {output_path}") - print(f"Public key (hex): {pub_raw.hex()}") + write_public_key_hex(pub_raw, output_path) def main(): @@ -410,7 +392,7 @@ def main(): if args.extract_pubkey: key_path = Path(args.extract_pubkey) - output_path = Path(args.output) if args.output else Path('public_key.h') + output_path = Path(args.output) if args.output else Path('public_key.hex') extract_pubkey(key_path, output_path) return