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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ jobs:
ci-gate:
name: CI Gate
runs-on: ubuntu-22.04
needs: [test, build-arm, static-analysis]
needs: [test, build-arm, fuzz-build, static-analysis]
if: always()
steps:
- uses: actions/checkout@v4
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/eosim-sanity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ jobs:
install-validate:
name: Install & Validate (${{ matrix.os }}, Python ${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
# Every step here is written in bash: `$(...)`, `|| { ... }`, a heredoc,
# and /tmp. Without this the Windows legs ran them under PowerShell, where
# `SITE_PACKAGES=$(...)` is an unknown command and the job went red, while
# the `|| { exit 1 }` guard in "Verify installation" parsed as an unexecuted
# script block and could never fail.
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ossf/[email protected].0
- uses: ossf/[email protected].3
with:
results_file: results.sarif
results_format: sarif
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## [Unreleased]

### Security
- **UART recovery issues no challenge on a board without an entropy source.** When `eos_hal_rng_get()` failed, `recovery_handle_auth()` filled the 32-byte challenge from a linear congruential generator seeded with `eos_hal_get_tick_ms()`. The challenge is the only thing that stops a captured `(challenge, response)` pair from being replayed, and with that fallback it was a function of the millisecond at which the AUTH command was handled -- a few thousand values on a freshly reset board -- which the client sees before it has to answer and can retry after a reset at no cost in failure count. The AUTH is now refused outright (NACK, event `EOS_LOG_AUTH_NO_ENTROPY` = `0x23`, which `tools/uart_recovery.py` decodes; counted like any other failure so the backoff applies). No board port provides `rng_get` (0 of the 83 ops tables under `boards/`), so this was the challenge on every board; none provides `otp_read` either, so no board authenticates anyone today and the defect was latent. Pinned by `test_auth_refuses_when_the_board_has_no_entropy_source` in `tests/unit/test_recovery.c`.
- **UART recovery refuses an unprovisioned secret, and a write into an unmapped slot.** `recovery_handle_auth()` compared the client's response against `SHA-256(challenge || secret)` for whatever the OTP held at the secret offset -- and unprogrammed fuses read back as all zeros or all ones, both public, so a device whose recovery secret was never provisioned authenticated any client that knew that. Both patterns are now refused before the comparison (logged as event `0x22`), the way the keystore already refuses an all-zero key. `recovery_handle_write()` had stopped calling `eos_recovery_write_in_range()` -- the rule the unit tests exercise -- for an inline check that never looked at the slot base, so a slot the board leaves unmapped (base 0) turned "write at offset" into "write at flash address". The handler uses the shared rule again -- which also refuses a `WRITE` with `len == 0`, where the inline check had ACKed it and written nothing; a recovery client sees a NACK there now. No shipped board port provides `otp_read` today, so the first defect was latent (authentication always failed); both are pinned by `tests/unit/test_recovery.c`. The authentication outcomes are logged as `EOS_LOG_AUTH_SUCCESS` / `EOS_LOG_AUTH_FAIL` / `EOS_LOG_AUTH_UNPROVISIONED` (`0x20`-`0x22`, now defined in `include/eos_types.h` beside the other `EOS_LOG_*` codes instead of as bare hex), `tools/uart_recovery.py` decodes all three, and `tests/unit/test_boot_log_event_names.py` keeps the firmware, the header and the client's table in step.
- **Image header is now authenticated (header format v2).** `eos_image_verify_signature()` signed `hdr->hash` only — 32 of the header's 156 bytes. Everything else (`image_size`, `load_addr`, `entry_addr`, `flags`, `sig_type`, `image_version`) sat outside the signature, so an attacker holding a legitimately signed image could relocate it, move its entry point, or clear `EOS_IMG_FLAG_HASH_SHA256` to downgrade integrity checking from SHA-256 to forgeable CRC32 — all while keeping the signature valid. The signature now covers `EOS_IMG_SIGNED_LEN` (92) bytes: the whole header except `signature[]` itself. **Existing signed images must be re-signed.**
- **`eos_image_parse_header`:** validates `hdr_version`, rejecting 0 and anything newer than this build understands.
- **`tools/eos_sign.py`:** `SIG_TYPE_ED25519` was `1` — that is `EOS_SIG_CRC32` in `eos_types.h`, which `eos_image_verify_signature()` rejects outright — and `IMG_FLAG_SIGNED` was `1 << 2`, which is `EOS_IMG_FLAG_DEBUG`. It also never set `EOS_IMG_FLAG_HASH_SHA256`, so the bootloader read the stored SHA-256 as a CRC32. Constants now match `include/eos_types.h`.
Expand All @@ -13,6 +15,13 @@
- **`image_verify.c`:** `eos_image_verify_integrity` rejects a zero `image_size`, and an `addr + hdr_size` that wraps `uint32_t`, instead of computing a payload address that is not the payload.

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

if (auth_state == RCVR_AUTH_NONE) {
/* Generate challenge using RNG */
/* The challenge is the only thing that stops a captured response
* from being replayed, so it has to come from an entropy source.
* A board without one gets no challenge at all: a fallback seeded
* from the millisecond tick lets a client reset the board and
* retry, at no cost, until a challenge it already holds an answer
* for comes back. Refusing here is fail-closed in the same way as
* an unreadable or unprovisioned secret below. */
int rc = eos_hal_rng_get(challenge, RCVR_CHALLENGE_SIZE);
if (rc != EOS_OK) {
/* Fallback: use tick-based pseudo-random */
uint32_t seed = eos_hal_get_tick_ms();
for (int i = 0; i < RCVR_CHALLENGE_SIZE; i++) {
seed = seed * 1103515245 + 12345;
challenge[i] = (uint8_t)(seed >> 16);
}
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(EOS_LOG_AUTH_NO_ENTROPY, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}

/* Send challenge to client */
Expand Down Expand Up @@ -182,7 +186,24 @@ static int recovery_handle_auth(void)
/* Fail authentication if OTP secret is unreadable */
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(0x21, EOS_SLOT_NONE, auth_fail_count); /* AUTH_FAIL */
eos_boot_log_append(EOS_LOG_AUTH_FAIL, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}

/* Unprogrammed fuses read back as all zeros or all ones, and both
* are public. A board whose recovery secret was never provisioned
* must not authenticate anyone: the keystore already refuses an
* all-zero key for the same reason. Branch-free so the check does
* not leak which value the fuses hold. */
uint8_t all_zero = 0, all_ones = 0xFF;
for (size_t i = 0; i < sizeof(shared_secret); i++) {
all_zero |= shared_secret[i];
all_ones &= shared_secret[i];
}
if (all_zero == 0 || all_ones == 0xFF) {
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(EOS_LOG_AUTH_UNPROVISIONED, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}

Expand All @@ -199,12 +220,12 @@ static int recovery_handle_auth(void)
EOS_SHA256_DIGEST_SIZE) == 0) {
auth_state = RCVR_AUTH_AUTHENTICATED;
auth_fail_count = 0;
eos_boot_log_append(0x20, EOS_SLOT_NONE, 0); /* AUTH_SUCCESS */
eos_boot_log_append(EOS_LOG_AUTH_SUCCESS, EOS_SLOT_NONE, 0);
return recovery_send_ack();
} else {
auth_fail_count++;
auth_state = RCVR_AUTH_NONE;
eos_boot_log_append(0x21, EOS_SLOT_NONE, auth_fail_count); /* AUTH_FAIL */
eos_boot_log_append(EOS_LOG_AUTH_FAIL, EOS_SLOT_NONE, auth_fail_count);
return recovery_send_nack();
}
}
Expand Down Expand Up @@ -286,8 +307,11 @@ static int recovery_handle_write(eos_slot_t slot, uint32_t offset, uint16_t len)

/* offset/len come straight from the wire; without this check a
* recovery client can write past the slot boundary into the other
* slot, boot-control blocks, or the boot log. */
if (slot_size == 0 || (uint64_t)offset + len > (uint64_t)slot_size)
* slot, boot-control blocks, or the boot log. The rule lives in
* eos_recovery_write_in_range() -- the function the unit tests drive --
* and it also refuses a slot the board leaves unmapped (base 0), which
* would otherwise turn "write at offset" into "write at address". */
if (eos_recovery_write_in_range(base, slot_size, offset, len) != EOS_OK)
return recovery_send_nack();

recovery_send_ack();
Expand Down
Loading
Loading