Repository navigation
fix(fw-update): verify the image signature unconditionally at install - #104
Conversation
eos_fw_update_finalize() gated signature verification on the image
header's own sig_type field:
if (ctx->header.sig_type >= EOS_SIG_ED25519) {
int sig_rc = eos_image_verify_signature(&ctx->header);
...
}
sig_type travels inside the image and is fully controlled by whoever
streams it over the update transport (UART raw / XMODEM / YMODEM). An
image that declares EOS_SIG_NONE (0), EOS_SIG_CRC32 (1) or EOS_SIG_SHA256
(2) fails the `>= EOS_SIG_ED25519 (3)` test, so the signature check was
skipped entirely and the image was written to the target slot and marked
bootable with no authentication. The integrity check that does run
(SHA-256 or CRC32) only proves the payload matches a hash the same sender
placed in the header; it is not a signature.
This is the one call site in the tree that gates the check this way.
eos_image_verify_signature() is the intended single policy point — it
rejects NONE/CRC32/SHA256 and unknown types with EOS_ERR_SIGNATURE and
accepts only a valid Ed25519 signature over the signed header prefix —
and every other verification path calls it unconditionally:
slot_manager.c, recovery.c and stage1/jump_app.c on the boot side, and
secure_boot.c when its trusted require_signature policy is set. Only
fw_update.c keyed the decision off untrusted input.
Call eos_image_verify_signature() unconditionally in finalize and fail
closed on anything but EOS_OK, matching the rest of the trusted computing
base. Signed images are unaffected — they run the same check the boot
path already applies — while an unsigned image can no longer be committed
to a slot.
Add tests/unit/test_fw_update_sig.c, which drives the real
begin -> write -> finalize pipeline over a simulated flash:
- an unsigned image (EOS_SIG_NONE) with a valid CRC is rejected with
EOS_ERR_SIGNATURE (fails before this change, passes after);
- EOS_SIG_SHA256 as sig_type is likewise rejected, covering the class;
- a corrupted image is still rejected earlier with EOS_ERR_CRC, proving
the rejection above is the signature gate rather than the integrity
gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBdBqtYFBgP5uCc8ft6ZKQ
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#104 "fix(fw-update): verify the image signature unconditionally at install"
head: 3553670 author: KhajaRaheelAhmedMohiuddin ci: pending (no checks reported on the branch)
Verdict: Correct, in-scope TCB fix — the install path was keying signature verification off header.sig_type, an attacker-supplied field, and this makes it unconditional and fail-closed, matching every other verify call site. Conforms to master design §8.1 and §14.1. I reproduced both the vulnerability and the fix locally; the findings below are about the verification claim and about pre-existing gaps around the code being changed, not about the change itself.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium | core/fw_update.c (whole PR) |
The head as pushed does not compile, so the ctest --test-dir build -E valgrind → 22/22 and cmake --build build → 0 errors results in the PR body cannot be reproduced from this commit. Verified: include/eos_image.h:135 and :142 assert on eos_image_header_t.reserved, which no longer exists in the struct (include/eos_image.h:53-54 replaced it with tlv_len/tlv_hash); core/ed25519_verify.c:338 redefines point_is_identity; tests/unit/test_ed25519.c:31,263 redefine run_test_ed25519_identity_key_forgery_rejected / test_ed25519_identity_key_forgery_rejected. All three are inherited from origin/master (22d8f8b), which fails identically — 12 errors — so none is introduced here. |
Do not fix it here. State in the Verification block which commits or patches were applied on top of this head to get those numbers (the body discloses stacking on a build fix further down, but the command block reads as run against this head). Rebase once #94 — "repair master — the ABI asserts and the Ed25519 verifier both merged broken" — lands, then re-post the real output. |
| 2 | Medium | tests/fuzz/fuzz_fw_update.c:18-20 |
Pre-existing, directly adjacent: the fuzz harness for the very parser this PR hardens declares eos_fw_update_init(), eos_fw_update_process_chunk() and eos_fw_update_finalize(void). Verified: none of the first two exists anywhere in the tree, and the real eos_fw_update_finalize takes two arguments (include/eos_fw_update.h:100). The target cannot link, and EBLDR_BUILD_FUZZ defaults to OFF (CMakeLists.txt:29), so nothing notices. .ai/security.md ("Input validation") requires fuzz coverage, not just unit tests, for externally reachable parsers — the UART update stream is one. |
Already covered by #99 / #100 / #101 — do not duplicate. Reference one of them here so the unit test added in this PR is not read as closing the fuzz gap. |
| 3 | Low | core/fw_update.c:199-204 |
Pre-existing, same function, one gate below the change: eos_image_check_rollback() returning EOS_ERR_NOT_SUPPORTED is accepted as a pass. .ai/security.md ("Fail closed") states a HAL returning EOS_ERR_NOT_SUPPORTED is not success; master design §8.1 only requires rollback protection "where hardware/policy supports it" and does not require a target that lacks it to declare so. The two do not agree. |
Out of scope for this PR. Adjacent to #103 (anti-rollback via authenticated TLV counter). Raised as a design proposal (see below) rather than as a change request here. |
| 4 | Low | tests/unit/test_fw_update_sig.c:252 |
tests_run = 3 is a hand-maintained literal. Adding a TEST() and forgetting the matching run_*() call in main() leaves tests_passed == tests_run == 3 and the suite green with a case that never ran — the same silent-drop failure mode tests/unit/test_cmake_test_registration.py exists to catch one level up. Forgetting to bump the literal after adding a run_*() call fails closed, so only this direction leaks. |
Increment tests_run inside the TEST macro's run_##name alongside tests_passed, so the count is derived from the calls rather than restated. (#95 is doing the same de-duplication for suite totals repo-wide — align with whatever lands there.) |
Nothing Critical or High against this diff. The vulnerability it closes would have been Critical.
Verification I ran
Detached worktree at the PR head, host GCC, C11:
origin/master(22d8f8b) alone:cmake --build→ 12 errors, first atinclude/eos_image.h:135. Master's host build is broken independently of this PR.- PR head with those pre-existing merge artifacts patched out locally (not committed, verification only):
cmake --build build --target eboot_test_fw_update_sig→ 0 errors./build/tests/eboot_test_fw_update_sig→ 3/3 passed, exit 0- Reverting only the
core/fw_update.chunk and rerunning →[FAIL] test_unsigned_image_with_valid_crc_is_rejectedattest_fw_update_sig.c:212, exit 1. The regression test is genuine andctestwould catch it.
- The bypass is real:
core/fw_update.c:175on master gated onctx->header.sig_type >= EOS_SIG_ED25519, andsig_typeis read straight out of the streamed header (core/fw_update.c,EOS_FW_STATE_HEADERpath). eos_image_verify_signature()(core/image_verify.c:157-219) does fail closed forNONE/CRC32/SHA256(line 163) and for unknown types (line 219), and resolves the key through the keystore so revocation applies (lines 182-190). The PR's claim that it is the single policy point is accurate.- The call-site table in the PR body checks out:
core/slot_manager.c:68,core/recovery.c:328,stage1/jump_app.c:43are unconditional;core/secure_boot.c:97-98gates on the trustedcfg->require_signature.fw_update.cwas the only one keyed off untrusted input. - The
EBLDR_REQUIRE_SIGNATURESclaim checks out:CMakeLists.txt:24,57-58defines the macro, and no.c/.hunderstage0/ stage1/ core/ hal/ include/ tests/references it. Making verification unconditional removes no working opt-out. - No existing suite regresses:
core/fw_transport_uart.c:43is the only non-test caller offinalize, andtests/unit/test_fw_transport.cexercisesbegin/writeonly — it never reaches the signature gate. - The new suite is registered in
tests/CMakeLists.txt:43-46, sotest_cmake_test_registration.pyis satisfied and the target will actually be built and run.
Architecture conformance
Conforms.
- §8.1 "Required boot concepts" — "Signed manifests and images." The install path was committing an unsigned image to an A/B slot and marking it bootable; this restores the requirement at the last step that can still refuse it.
- §14.1 "Security principles" — "Integrate key management across eBoot, eSec, eOTA and release signing." Routing through
eos_image_verify_signature()rather than a local decision keeps keystore selection and revocation in one place, as §14.1 requires them to be reviewed as one system. - §5.1 "Architectural law" — no new dependency in either direction.
core/fw_update.calready includedeos_fw_update.h/eos_hal.h/eos_fwsvc.h; the change adds no include, no link entry, no manifest edit. eBoot's TCB gets smaller in surface, not larger: one attacker-controlled input is removed from a trust decision. - §21 tier placement — Tier 1 Foundation,
eBoot, boot/update trust. Correct repo; the code is incore/(shared boot logic) per the.ai/architect.mdtarget layout, and the test is undertests/unit/. - Split policy §21.1 — not engaged; nothing moves between repos.
Proposed changes
Smallest sequence that keeps things working:
- Land #94 to restore the host build on
master. - Rebase this branch onto it (no shared files — verified: this PR touches
core/fw_update.c,tests/CMakeLists.txt,tests/unit/test_fw_update_sig.c; #94 touchesinclude/eos_image.handcore/ed25519_verify.c), then replace the Verification block with output actually produced by this head. - Optional, in this PR: derive
tests_runin theTESTmacro (finding 4) — two lines, no behaviour change. - Separately: nothing to do about findings 2 and 3 here; they belong to #99/#100/#101 and #103.
CI cannot confirm any of this until step 1: gh pr checks 104 reports no checks at all on this branch, and mergeStateStatus is BLOCKED. A required-check gate is what #90 is for; while there is none, a red or absent build does not block a merge, which is worth saying out loud on a TCB change.
Not checked
- CI.
checks.txtin the bundle is empty andgh pr checks 104reports no checks on the branch. I have no CI evidence for or against this PR; the local build result above is mine, not CI's. - The full
ctestsuite (22 tests). I could not run it:tests/unit/test_ed25519.cdoes not compile at this head for the pre-existing reason in finding 1, and I would not commit a patch to it to find out. Onlyeboot_test_fw_update_sigwas built and run. The PR's "100% tests passed, 22/22" is unconfirmed. - The valgrind suite. Not run (the PR excludes it too).
- Cross builds / on-target. No cross toolchain and no hardware exercised. Whether
finalizestill behaves on a real target after this change is untested by me; the change is target-independent C, so the risk is low but not zero. - Signed-image happy path. Neither the PR nor I test that a validly signed image still installs through
finalize— the three new cases are all rejections.eos_image_verify_signature()needs a keystore key and a real Ed25519 signature to exercise that, which the simulated-flash harness does not set up. This is the one coverage gap in an otherwise good test: the fix could in principle reject everything and all three tests would still pass. I confirmed by readingcore/image_verify.c:175-215that the accept path is unchanged and shared withslot_manager/recovery/jump_app, which do have coverage — but it is inference, not a test. - Whether unsigned images are legitimately installed anywhere today (factory provisioning, developer flashing, a recovery flow). I checked every in-tree caller —
core/fw_transport_uart.c:43is the only one — but not any out-of-tree or vendor flow, and nottools/. If an in-house provisioning script relies on installing unsigned images over UART, this change breaks it. That would be the correct outcome, but it should be a conscious one.
Automated architecture review of 355367099a11 — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
|
Thanks @srpatcha — really appreciate the depth here, especially reproducing both the bypass and the fix and checking it against §8.1/§14.1. Point by point: 1 — Verification block reads as if run against this head. Fair, and my wording was misleading. The build/test numbers were produced by applying the #94 build-fix on top of this branch (this PR touches none of the broken files), not from this head, which inherits master's 12 errors. I've reworded the Verification section to say that explicitly. Once a build-fix lands on master I'll rebase and replace that block with output produced directly from the head, with CI confirming it. 2 — fuzz_fw_update.c declares functions that don't exist. Agreed, and it's pre-existing — not touched here. That belongs to the fuzz work in #99/#100/#101, so I'm not duplicating it. To be clear: the unit test I added is a targeted regression for the signature gate, not a claim to close the fuzz-coverage requirement in .ai/security.md — the UART stream still needs the fuzz target those PRs are fixing. 3 — rollback EOS_ERR_NOT_SUPPORTED accepted as pass. Also pre-existing, one gate below my change, and it's a real "fail closed" vs §8.1 tension. Out of scope here; it's adjacent to the authenticated-counter work in #103, so I'll leave it to that line rather than fold a design change into this fix. 4 — tests_run = 3 is a hand-maintained literal. Fixed — On the accept path (your "not checked" note). Correct that all three cases are rejections, so in principle the gate could reject everything and still pass. The accept path is unchanged by this PR and is the same Sequence I'll follow: land #94 → rebase this onto it (no shared files) → repost real, CI-backed output with items 1 and 4 folded in. Thanks again. |
…e curve core/keystore.c's default_dev_key claims, in a #warning and in comments, to be the RFC 8032 section 7.1 TEST 1 public key. From v0.1.0 it agreed with the RFC for 21 bytes and then did not, and the bytes it held do not decode to a point on edwards25519. No signature could ever verify against it, on any board that fell back to it; after embeddedos-org#104 made signature verification unconditional at install, that meant firmware update refused every image on every board without OTP. Nothing noticed because no test ever asked the fallback key to verify anything. The array is now the RFC's 32 bytes. test_keystore.c asks the compiled-in anchor to verify the RFC's own TEST 1 signature over the empty message, and then shows the accept discriminates (wrong message, flipped bit). On the old bytes the first verify returns EOS_ERR_SIGNATURE. Also corrects the note in test_secure_boot_policy.c that repeated the claim.
Twenty PRs were merged into master in ninety minutes on 09-07, each on the base it was written against. Master has not configured, compiled, linked or passed its tests since. Every nightly and every eos simulation run (which builds eBoot master) has been red for the same reason. Configure: - tests/CMakeLists.txt registered eboot_test_fdt_loader twice. embeddedos-org#84 added it, and embeddedos-org#85 -- stacked on embeddedos-org#84 -- re-added it at a different anchor when it was replayed onto a master that already had embeddedos-org#84. Same hunk as embeddedos-org#111. Compile (eboot_core): - embeddedos-org#55 restored a core/sha512.c from 02b7dac that keeps its byte count in ctx->count; the eos_sha512_ctx_t master ships has bitlen[2]/buffer_len (embeddedos-org#69/embeddedos-org#93). Back to the pre-embeddedos-org#55 file, blob-identical (d9aa57c). - embeddedos-org#55's source-list "correction" dropped core/boot_log.c, core/secure_boot.c and core/fdt_loader.c, which embeddedos-org#72/embeddedos-org#84 had added after embeddedos-org#55 was written. CMakeLists.txt is blob-identical to pre-embeddedos-org#55 again (f8fe6eb). - embeddedos-org#55 replaced the eos_boot_log_get_head() declaration with a second copy of eos_boot_log_read(); embeddedos-org#91 had already fixed the prototype it meant to fix. Header restored (964ebb8). - embeddedos-org#94 and embeddedos-org#105 each repaired the Ed25519 verifier and each added an identical static scalarbase(); both merged. One copy removed. - The same pair each added k_low_order[]/messages[] to test_ed25519.c. The embeddedos-org#105 copy is removed; embeddedos-org#94's stays because it also carries k_non_canonical[]. Tests that stopped passing because two merged PRs disagree on behaviour: - embeddedos-org#104 verifies the image signature at install unconditionally, before the anti-rollback check embeddedos-org#103 added, so embeddedos-org#103's unsigned images are refused as EOS_ERR_SIGNATURE before they can be refused as EOS_ERR_ANTI_ROLLBACK, and test_fw_transport's XMODEM install can no longer finalize. Both suites now stream genuinely signed images. eBoot has no Ed25519 signer in C, so tools/gen_fw_update_test_sigs.py signs the exact header prefixes those suites build under the RFC 8032 section 7.1 TEST 1 key and emits tests/vectors/fw_update_test_sigs.h; the suites serve that key from a simulated OTP slot 0. Negative control: one flipped signature byte fails test_write_streams_tlv_then_finalize_rejects_below_floor with EOS_ERR_SIGNATURE. - embeddedos-org#103's step 5b reads the TLV counter through the HAL slot containing the image; test_secure_boot_policy (embeddedos-org#82) declared no slots, so eos_secure_boot() returned EOS_SBOOT_ERR_BAD_HEADER two steps before the one under test. The fixture now places its image in slot A. Guards from embeddedos-org#95 that later merges walked back, never run until now because the C configure step failed first: - embeddedos-org#103 replayed the hand-written Valgrind foreach over the derived one. Restored foreach(TEST_NAME ${EBLDR_UNIT_TESTS}); eleven registered suites had no list(APPEND ...) and so no Valgrind run. - Seven suites assign tests_run = <literal> and their TEST() does not count; four suites have no TEST() macro at all. Counted, and classified. - embeddedos-org#101 added fuzz-build after embeddedos-org#90's gate; the gate did not wait for it. CI plumbing: - eosim-sanity.yml: the install-validate job is written in bash but ran under PowerShell on the Windows legs (no shell:), where SITE_PACKAGES=$(...) is an unknown command and `|| { exit 1 }` is an unexecuted script block. - scorecard.yml: ossf/scorecard-action@v2.4.0 pulls gcr.io, which now demands GCP billing. v2.4.3 pulls ghcr.io; eos already pins it and is green. Verified locally (macOS, clang): Release build clean, 31/31 ctest; the same under -DEBLDR_SANITIZE=ON (ASan+UBSan); 78/78 pytest. Not fixed here, reported separately: core/keystore.c's compiled-in default_dev_key is described as the RFC 8032 TEST 1 public key but differs from byte 21 on and is not a point on the curve, so nothing can verify against it on any board without OTP. With embeddedos-org#104 that makes firmware update refuse every image on such boards.
…epair embeddedos-org#103 and embeddedos-org#104 were both merged and disagree on whether the anti-rollback counter or the signature is checked first in eos_fw_update_finalize(). The master design orders boot as verify image, then version policy (section 8.1), but its update flow (section 15) never places the anti-rollback check, so the order the install path uses existed only in a PR body. ADR-020 records it: the signature over the signed header prefix is verified first, the TLV counter is read only after the prefix that binds it is authenticated, and an image that fails verification is refused as EOS_ERR_SIGNATURE without its counter being consulted. docs/adr/README.md is added in the shape of the eos repository's index; 020 avoids reusing 001 through 019. CHANGELOG.md gains the Unreleased entries for the repair: the configure, compile and link breakage after the 09-07 batch merge, the settled check ordering with the suites streaming signed images, the re-derived Valgrind list, fuzz-build in the CI gate, counted tests_run, the EoSim Windows legs running under bash, and the Scorecard action on its ghcr.io-hosted release.
The changelog said eleven suites had no Valgrind run. Eleven were missing from EBLDR_UNIT_TESTS, but four of those were named in the hand-written foreach and did run; seven had no run at all. Say which. ADR-020: its design-document citation now says where it comes from (the architecture review of embeddedos-org#115), and the sentence about the two PRs' bases is replaced with what the history shows -- embeddedos-org#103's commits predate embeddedos-org#104's merge, and embeddedos-org#104 was written without embeddedos-org#103's check in place.
…e curve core/keystore.c's default_dev_key claims, in a #warning and in comments, to be the RFC 8032 section 7.1 TEST 1 public key. From v0.1.0 it agreed with the RFC for 21 bytes and then did not, and the bytes it held do not decode to a point on edwards25519. No signature could ever verify against it, on any board that fell back to it; after embeddedos-org#104 made signature verification unconditional at install, that meant firmware update refused every image on every board without OTP. Nothing noticed because no test ever asked the fallback key to verify anything. The array is now the RFC's 32 bytes. test_keystore.c asks the compiled-in anchor to verify the RFC's own TEST 1 signature over the empty message, and then shows the accept discriminates (wrong message, flipped bit). On the old bytes the first verify returns EOS_ERR_SIGNATURE. Also corrects the note in test_secure_boot_policy.c that repeated the claim.
… and record the behaviour change The unit test that checks the compiled-in development trust anchor carried its own hand-typed copy of the RFC 8032 TEST 1 public key, the third such copy in the tree. tests/vectors/fw_update_test_sigs.h already holds that key as eos_test_sig_pubkey: it is derived from the RFC secret by tools/gen_fw_update_test_sigs.py and pinned to the generator by tests/unit/test_fw_update_test_sigs.py. The test now includes the fixture and compares against it, so the anchor is checked against a second, independently derived copy of the key instead of bytes that could have been mistyped the same way. The signature constant and the four assertions are unchanged; on the old anchor bytes the memcmp still fails. The CHANGELOG gains a Security entry for the anchor fix and states the consequence plainly: a board with no OTP and no EBLDR_PRODUCTION_KEY moves from refusing every image, as it has since embeddedos-org#104, to accepting images signed with the public RFC test key. That is what the #warning in core/keystore.c has always said a development build does, and the key must never reach a device; embeddedos-org#120 tracks making that structural.
…e curve core/keystore.c's default_dev_key claims, in a #warning and in comments, to be the RFC 8032 section 7.1 TEST 1 public key. From v0.1.0 it agreed with the RFC for 21 bytes and then did not, and the bytes it held do not decode to a point on edwards25519. No signature could ever verify against it, on any board that fell back to it; after embeddedos-org#104 made signature verification unconditional at install, that meant firmware update refused every image on every board without OTP. Nothing noticed because no test ever asked the fallback key to verify anything. The array is now the RFC's 32 bytes. test_keystore.c asks the compiled-in anchor to verify the RFC's own TEST 1 signature over the empty message, and then shows the accept discriminates (wrong message, flipped bit). On the old bytes the first verify returns EOS_ERR_SIGNATURE. Also corrects the note in test_secure_boot_policy.c that repeated the claim.
… and record the behaviour change The unit test that checks the compiled-in development trust anchor carried its own hand-typed copy of the RFC 8032 TEST 1 public key, the third such copy in the tree. tests/vectors/fw_update_test_sigs.h already holds that key as eos_test_sig_pubkey: it is derived from the RFC secret by tools/gen_fw_update_test_sigs.py and pinned to the generator by tests/unit/test_fw_update_test_sigs.py. The test now includes the fixture and compares against it, so the anchor is checked against a second, independently derived copy of the key instead of bytes that could have been mistyped the same way. The signature constant and the four assertions are unchanged; on the old anchor bytes the memcmp still fails. The CHANGELOG gains a Security entry for the anchor fix and states the consequence plainly: a board with no OTP and no EBLDR_PRODUCTION_KEY moves from refusing every image, as it has since embeddedos-org#104, to accepting images signed with the public RFC test key. That is what the #warning in core/keystore.c has always said a development build does, and the key must never reach a device; embeddedos-org#120 tracks making that structural.
Twenty PRs were merged into master in ninety minutes on 09-07, each on the base it was written against. Master has not configured, compiled, linked or passed its tests since. Every nightly and every eos simulation run (which builds eBoot master) has been red for the same reason. Configure: - tests/CMakeLists.txt registered eboot_test_fdt_loader twice. embeddedos-org#84 added it, and embeddedos-org#85 -- stacked on embeddedos-org#84 -- re-added it at a different anchor when it was replayed onto a master that already had embeddedos-org#84. Same hunk as embeddedos-org#111. Compile (eboot_core): - embeddedos-org#55 restored a core/sha512.c from 02b7dac that keeps its byte count in ctx->count; the eos_sha512_ctx_t master ships has bitlen[2]/buffer_len (embeddedos-org#69/embeddedos-org#93). Back to the pre-embeddedos-org#55 file, blob-identical (d9aa57c). - embeddedos-org#55's source-list "correction" dropped core/boot_log.c, core/secure_boot.c and core/fdt_loader.c, which embeddedos-org#72/embeddedos-org#84 had added after embeddedos-org#55 was written. CMakeLists.txt is blob-identical to pre-embeddedos-org#55 again (f8fe6eb). - embeddedos-org#55 replaced the eos_boot_log_get_head() declaration with a second copy of eos_boot_log_read(); embeddedos-org#91 had already fixed the prototype it meant to fix. Header restored (964ebb8). - embeddedos-org#94 and embeddedos-org#105 each repaired the Ed25519 verifier and each added an identical static scalarbase(); both merged. One copy removed. - The same pair each added k_low_order[]/messages[] to test_ed25519.c. The embeddedos-org#105 copy is removed; embeddedos-org#94's stays because it also carries k_non_canonical[]. Tests that stopped passing because two merged PRs disagree on behaviour: - embeddedos-org#104 verifies the image signature at install unconditionally, before the anti-rollback check embeddedos-org#103 added, so embeddedos-org#103's unsigned images are refused as EOS_ERR_SIGNATURE before they can be refused as EOS_ERR_ANTI_ROLLBACK, and test_fw_transport's XMODEM install can no longer finalize. Both suites now stream genuinely signed images. eBoot has no Ed25519 signer in C, so tools/gen_fw_update_test_sigs.py signs the exact header prefixes those suites build under the RFC 8032 section 7.1 TEST 1 key and emits tests/vectors/fw_update_test_sigs.h; the suites serve that key from a simulated OTP slot 0. Negative control: one flipped signature byte fails test_write_streams_tlv_then_finalize_rejects_below_floor with EOS_ERR_SIGNATURE. - embeddedos-org#103's step 5b reads the TLV counter through the HAL slot containing the image; test_secure_boot_policy (embeddedos-org#82) declared no slots, so eos_secure_boot() returned EOS_SBOOT_ERR_BAD_HEADER two steps before the one under test. The fixture now places its image in slot A. Guards from embeddedos-org#95 that later merges walked back, never run until now because the C configure step failed first: - embeddedos-org#103 replayed the hand-written Valgrind foreach over the derived one. Restored foreach(TEST_NAME ${EBLDR_UNIT_TESTS}); eleven registered suites had no list(APPEND ...) and so no Valgrind run. - Seven suites assign tests_run = <literal> and their TEST() does not count; four suites have no TEST() macro at all. Counted, and classified. - embeddedos-org#101 added fuzz-build after embeddedos-org#90's gate; the gate did not wait for it. CI plumbing: - eosim-sanity.yml: the install-validate job is written in bash but ran under PowerShell on the Windows legs (no shell:), where SITE_PACKAGES=$(...) is an unknown command and `|| { exit 1 }` is an unexecuted script block. - scorecard.yml: ossf/scorecard-action@v2.4.0 pulls gcr.io, which now demands GCP billing. v2.4.3 pulls ghcr.io; eos already pins it and is green. Verified locally (macOS, clang): Release build clean, 31/31 ctest; the same under -DEBLDR_SANITIZE=ON (ASan+UBSan); 78/78 pytest. Not fixed here, reported separately: core/keystore.c's compiled-in default_dev_key is described as the RFC 8032 TEST 1 public key but differs from byte 21 on and is not a point on the curve, so nothing can verify against it on any board without OTP. With embeddedos-org#104 that makes firmware update refuse every image on such boards.
…epair embeddedos-org#103 and embeddedos-org#104 were both merged and disagree on whether the anti-rollback counter or the signature is checked first in eos_fw_update_finalize(). The master design orders boot as verify image, then version policy (section 8.1), but its update flow (section 15) never places the anti-rollback check, so the order the install path uses existed only in a PR body. ADR-020 records it: the signature over the signed header prefix is verified first, the TLV counter is read only after the prefix that binds it is authenticated, and an image that fails verification is refused as EOS_ERR_SIGNATURE without its counter being consulted. docs/adr/README.md is added in the shape of the eos repository's index; 020 avoids reusing 001 through 019. CHANGELOG.md gains the Unreleased entries for the repair: the configure, compile and link breakage after the 09-07 batch merge, the settled check ordering with the suites streaming signed images, the re-derived Valgrind list, fuzz-build in the CI gate, counted tests_run, the EoSim Windows legs running under bash, and the Scorecard action on its ghcr.io-hosted release.
The changelog said eleven suites had no Valgrind run. Eleven were missing from EBLDR_UNIT_TESTS, but four of those were named in the hand-written foreach and did run; seven had no run at all. Say which. ADR-020: its design-document citation now says where it comes from (the architecture review of embeddedos-org#115), and the sentence about the two PRs' bases is replaced with what the history shows -- embeddedos-org#103's commits predate embeddedos-org#104's merge, and embeddedos-org#104 was written without embeddedos-org#103's check in place.
…e curve core/keystore.c's default_dev_key claims, in a #warning and in comments, to be the RFC 8032 section 7.1 TEST 1 public key. From v0.1.0 it agreed with the RFC for 21 bytes and then did not, and the bytes it held do not decode to a point on edwards25519. No signature could ever verify against it, on any board that fell back to it; after embeddedos-org#104 made signature verification unconditional at install, that meant firmware update refused every image on every board without OTP. Nothing noticed because no test ever asked the fallback key to verify anything. The array is now the RFC's 32 bytes. test_keystore.c asks the compiled-in anchor to verify the RFC's own TEST 1 signature over the empty message, and then shows the accept discriminates (wrong message, flipped bit). On the old bytes the first verify returns EOS_ERR_SIGNATURE. Also corrects the note in test_secure_boot_policy.c that repeated the claim.
… and record the behaviour change The unit test that checks the compiled-in development trust anchor carried its own hand-typed copy of the RFC 8032 TEST 1 public key, the third such copy in the tree. tests/vectors/fw_update_test_sigs.h already holds that key as eos_test_sig_pubkey: it is derived from the RFC secret by tools/gen_fw_update_test_sigs.py and pinned to the generator by tests/unit/test_fw_update_test_sigs.py. The test now includes the fixture and compares against it, so the anchor is checked against a second, independently derived copy of the key instead of bytes that could have been mistyped the same way. The signature constant and the four assertions are unchanged; on the old anchor bytes the memcmp still fails. The CHANGELOG gains a Security entry for the anchor fix and states the consequence plainly: a board with no OTP and no EBLDR_PRODUCTION_KEY moves from refusing every image, as it has since embeddedos-org#104, to accepting images signed with the public RFC test key. That is what the #warning in core/keystore.c has always said a development build does, and the key must never reach a device; embeddedos-org#120 tracks making that structural.
fix(fw-update): verify the image signature unconditionally at install
Summary
eos_fw_update_finalize()— the last step of installing a firmware imagestreamed over the UART update transports (raw / XMODEM / YMODEM) — decided
whether to check the image's signature based on a field inside the image
itself:
sig_typeis attacker-controlled — it travels in the image header that thesender supplies.
EOS_SIG_NONE = 0,EOS_SIG_CRC32 = 1,EOS_SIG_SHA256 = 2,EOS_SIG_ED25519 = 3. An image that declaressig_type = 0/1/2fails the>= EOS_SIG_ED25519test, so signature verification is skipped entirely andthe image is written to the target slot and marked bootable with no
authentication.
The integrity check that still runs (SHA-256 or CRC32) proves only that the
payload matches a hash the same sender placed in the header. It is not a
signature. So an attacker who can talk to the update transport can install an
arbitrary, unsigned image.
Why this is the wrong call site, specifically
eos_image_verify_signature()is the intended single policy point. It rejectsNONE/CRC32/SHA256and unknown types withEOS_ERR_SIGNATURE, validates theEd25519 signature length, resolves the key through the keystore (applying
revocation), and verifies over the signed header prefix. Every other
verification path calls it unconditionally:
core/slot_manager.ccore/recovery.cstage1/jump_app.ccore/secure_boot.ccfg->require_signaturepolicycore/fw_update.cheader.sig_type← the bugfw_update.cwas the only place keying the decision off untrusted input. Thereis no
EBLDR_REQUIRE_SIGNATURESopt-out involved — that option gates nothing inthe current tree, and the boot-side paths reject unsigned images regardless, so
an unsigned image could never boot anyway. Accepting one at install is pure
downside (an attacker can overwrite an A/B slot and have
finalizereportsuccess).
The fix
Call
eos_image_verify_signature()unconditionally infinalizeand failclosed on anything but
EOS_OK, matching the rest of the trusted computingbase. Signed images are unaffected — they run the exact check the boot path
already applies.
Regression test
tests/unit/test_fw_update_sig.cdrives the realbegin → write → finalizepipeline over a simulated flash (same harness style as
test_fw_transport.c):test_unsigned_image_with_valid_crc_is_rejected— anEOS_SIG_NONEimagewith a correct CRC must return
EOS_ERR_SIGNATURE. On the unpatched codefinalizereturnsEOS_OK(the image is committed) — this is the exploit,and the test fails before the fix and passes after.
test_sha256_sigtype_is_still_unsigned_and_rejected—sig_type = EOS_SIG_SHA256(2) took the same bypass; the wholeNONE/CRC32/SHA256classis now refused.
test_corrupt_image_is_rejected_at_integrity_stage— a control: the sameimage with a corrupted CRC is rejected earlier with
EOS_ERR_CRC, provingthe rejection above is the signature gate acting on an otherwise-valid
image, not the integrity gate.
Verification
Important:
master(22d8f8b) does not compile — an unrelated merge leftinclude/eos_image.h,core/ed25519_verify.candtests/unit/test_ed25519.cbroken (12 errors), fixed in #94/#95. This PR touches none of those files, so
the numbers below were not produced from this head alone. They were produced
by applying the #94 build-fix on top of this branch first (verification only —
not part of this commit). GCC 13, C11:
The
fw_update.chunk and the new test are self-contained C that do not dependon the broken files. Once a build-fix lands on
master, I will rebase this andreplace the block above with output produced directly from the PR head (with CI
confirming it).
Scope and independence
This change touches only
core/fw_update.c, a new test, and one line oftests/CMakeLists.txt. It is independent of themasterbuild breakage andshares no files with #94/#95/#98/#99–101 — so it rebases cleanly onto
whichever build-fix lands. Because
masterdoes not currently compile, CI herewill be red until then; that is the pre-existing breakage, not this change.
The new test covers the rejection property (unsigned images refused). The
accept path — a validly signed image still installing — is not exercised
here because it needs a real Ed25519 signature under the keystore's active key;
that path is unchanged by this PR and is the same
eos_image_verify_signature()already covered on the boot side byslot_manager,recoveryandjump_app. I'm happy to add a signed-imagehappy-path case as a follow-up if you'd like it in this suite.