fix(stage1): give stage-1 an entry point, and refuse to verify an image that is not there - #138
Kartikey1306 wants to merge 11 commits into
Conversation
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#138 "fix(stage1): give stage-1 an entry point, and refuse to verify an image that is not there"
head: 7f23993 author: Kartikey1306 ci: pass (26 green, 0 red; Create GitHub Release and assign skipped)
Verdict: First look. This is the answer to #129's Critical finding, and on stm32f4 it
works — I cross-compiled it: eboot_firmware.bin goes from 0 bytes to 13408, the embedded
hash is a real digest instead of the SHA-256 of the empty string, and stage1_vector_table sits at
the FLASH origin with the correct _estack/Reset_Handler pair. The belt-and-braces structure is
right — the tool refuses, the linker anchors, and stage-0 refuses again at runtime. The SysTick
vector-table extension is a real second bug found along the way.
On cortex_r5 it does not work, and the new guards report that it does. I built that board too,
because #129's review had marked it Inferred. Two Critical findings below. One is pre-existing —
ebldr_stage0.bin is 0 bytes on that board, the root-of-trust image does not exist, and did not
before this PR either. The other this PR introduces: the stage-1 image is now 18 KiB, clears the new
4096-byte floor, carries a real hash, passes the new linker-script guard — and has no vector table
at all, with memcpy at the address stage-0 branches to. Both come from one mismatch I measured
rather than inferred.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Critical (P0) — pre-existing, not introduced here | boards/cortex_r5/cortex_r5_stage0.ld:21-24 |
ebldr_stage0.bin is 0 bytes on cortex_r5: there is no bootloader image for that board, and there never has been. Built it: cmake -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-none-eabi-r5.cmake -DEBLDR_BOARD=cortex_r5 → configure rc 0, build rc 0, Generating stage0 binary for cortex_r5 printed as a normal build line, build-r5/ebldr_stage0.bin 0 bytes. arm-none-eabi-objdump -h build-r5/ebldr_stage0.elf → .vectors 00000000, .text 00000000, .data 00000000, .bss 00000000. The whole image is empty. Cause, and it is one line: the script does KEEP(*(.vectors)) and has no ENTRY(), while stage0/reset_entry.c:98 puts its table in .isr_vector. I enumerated every section any translation unit emits — grep -rhn 'section("\.[a-z_]*")' stage0/ stage1/ boards/ returns .isr_vector and nothing else. So the KEEP names a section that does not exist in this repository, --gc-sections retains nothing, and the link succeeds with no output and no warning. Confirmed pre-existing: the identical build at #129's head (949775b3) also gives 0 bytes. It survives because no CI job builds cortex_r5 — grep -rn cortex_r5 .github/workflows/ is empty. stm32f4_stage0.ld also has no ENTRY(), and is fine at 12968 bytes, because its KEEP names .isr_vector correctly — so the KEEP is the load-bearing half here, not the ENTRY. |
Point the KEEP at a section that exists. But not as a one-word rename: toolchains/arm-none-eabi-r5.cmake builds -mcpu=cortex-r5 in ARM mode, and boards/cortex_r5/board_cortex_r5.c:116-126 states the handover explicitly — "Cortex-R5 jump differs from Cortex-M: No vector table with MSP at offset 0; Direct branch to entry point address". stage0/reset_entry.c's table is Cortex-M shaped (_estack first). The port needs an entry appropriate to how it actually resets, which is a maintainer's decision, not a mechanical edit. Whatever the shape, the durable fix is finding 3's: assert the kept section is one something emits. And add a cortex_r5 cross leg to build.yml — it will go red the day it lands, which is the point. |
| 2 | Critical (P0) — introduced by this PR | boards/cortex_r5/cortex_r5_stage1.ld:19-28 |
On cortex_r5 the new stage-1 image has no vector table, and stage-0 branches into memcpy — while every new guard reports success. The KEEP(*(.vectors)) this PR adds has the same mismatch as finding 1, so stage1_vector_table is garbage-collected: .isr_vector does not appear in build-r5/eboot_firmware.elf at all, and the .vectors output section is 00000000. The image is non-empty only because the new ENTRY(Reset_Handler) roots the code. Measured consequences, all at this head: eboot_firmware.bin is 18576 bytes, so it clears embed_stage1_hash.py's new 4096-byte floor; stage1_expected_size = 18576u with a real SHA-256; test_every_stage1_linker_script_is_anchored_like_its_stage0 passes. And arm-none-eabi-nm -n build-r5/eboot_firmware.elf puts memcpy at 0x00204000, the FLASH origin, with Reset_Handler at 0x00204260. Since r5_jump() branches directly to the image base, the stage-1 entry point is memcpy. The board has gone from "empty image, measurement vacuous" to "18 KiB image, measurement real, entry point is the wrong function" — and the second state is the one that produces a green build, a real hash, and a positive IMAGE_VALID record. .ai/security.md is explicit that I must not round this down because the path looks unreachable, so I am not: nothing can reach it today only because finding 1 leaves that board with no stage-0 at all. Fix finding 1 alone and this becomes live. For contrast, stm32f4 is correct in every respect: stage1_vector_table at 0x08004000 = FLASH origin, first word 0x20020000, second 0x080042ad = Reset_Handler+1. |
Same decision as finding 1 and it should be made once for both scripts. Until it is, the honest options are to leave cortex_r5_stage1.ld alone — an empty image that fails the new size floor is a louder failure than a wrong entry point that passes it — or to fix both scripts together. What must not ship is the current middle state, where the board's images satisfy every check the PR adds and neither is bootable. |
| 3 | Medium (P2) | tests/unit/test_stage1_image_is_verified.py:125-140 |
The new guard anchors each stage-1 script to its stage-0 counterpart, so it propagates a broken reference instead of catching it. test_every_stage1_linker_script_is_anchored_like_its_stage0 computes kept0 and kept1 and asserts kept0 <= kept1. For cortex_r5 both are {.vectors}, the assertion holds, and the test is green — it has verified that the two scripts agree with each other, and neither agrees with any source file in the tree. That is exactly findings 1 and 2, sitting inside the test written to prevent them. The ENTRY(Reset_Handler) half of the same test is sound and does real work. |
Add the assertion that closes both: every section name a stage-0 or stage-1 script KEEPs must be a section some .c under stage0/, stage1/ or boards/ actually emits. That is the same grep -rhn 'section("...")' I ran, six lines in Python, and it fails today on cortex_r5 for both scripts — which is the correct outcome and the reason to add it. Worth pairing with an assertion that the stage-0 script keeps a section too, since the current assert kept0 only checks the set is non-empty, not that it means anything. |
What this PR gets right
#129's Critical finding is closed on stm32f4, verified rather than read. Built it with the
real cross toolchain: eboot_firmware.bin 0 → 13408 bytes; stage1_hash.c carries
stage1_expected_size = 13408u and SHA-256: 1a63338f…2ee6 instead of 0u and e3b0c442…b855;
xxd of the first 32 bytes gives 20020000 080042ad 080042a9 … — initial stack pointer, reset
vector with the Thumb bit set, then the fault handlers. That is the handover contract in
stm32f4_stage1.ld's own comment, satisfied literally.
The defence is layered rather than single-point, which is right for a defect that produced a green
build for months. tools/embed_stage1_hash.py refuses an empty input and anything below a
4096-byte floor, with the floor's reasoning written down (~13 KiB at -Os, versus 64 bytes for a
vector table alone) rather than picked. stage0/jump_stage1.c:72-81 refuses
stage1_expected_size == 0 before the hash loop, with the comment saying why a bootloader should not
trust its own build — that is the correct instinct and it is what would have caught this on a device.
EBLDR_FAIL_STAGE1_NO_IMAGE 0xBAD3 is named beside the other two rather than added as bare hex,
consistent with what #129 landed.
The SysTick extension is a real second bug, found rather than inherited. stage0/reset_entry.c's
table stopped at UsageFault; board_early_init() on stm32f4 enables the SysTick interrupt, whose
vector is entry 15, so the first tick — one millisecond in — had the core fetch its handler address
from whatever .text followed the table. Both tables now run through SysTick with the reserved slots
zeroed in the right places, and two tests pin the position of entry 15. Nothing asked for this.
Verification performed for this review
Detached worktree at .ai/autoreview/state/scratch/eBoot-138 on 7f239931. The user's eBoot
checkout is on fix/ed25519-low-order-keys; it was clean before and is clean after, and was not
touched. Nothing was committed or pushed. This review covers 949775b3..7f239931 (one commit); the
branch is stacked on #129, #127's sibling line and #115.
| Check | Result |
|---|---|
cmake -B build/host -DEBLDR_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug → cmake --build --parallel 4 |
PASS |
ctest --test-dir build/host --output-on-failure --no-tests=error |
PASS — 31/31, 11.02s |
pytest tests/ -q |
PASS — 96 passed, 2.01s (85 at #129's head, +11) |
pytest tests/unit/test_stage1_image_is_verified.py -q |
PASS — 11 passed |
stm32f4 cross-compile — toolchains/arm-none-eabi.cmake, real arm-none-eabi-gcc |
configure rc 0, build rc 0. eboot_firmware.bin 13408 bytes (0 at #129's head); ebldr_stage0.bin 12968 bytes |
stm32f4 embedded measurement |
stage1_expected_size = 13408u; SHA-256: 1a63338f…2ee6 — a real digest, not the empty string's |
stm32f4 image head, xxd -e -g4 -l 32 |
20020000 080042ad 080042a9 080042a9 … — SP, reset vector (+1 Thumb), faults. nm: stage1_vector_table at 0x08004000 = FLASH origin; Reset_Handler at 0x080042ac. Contract satisfied. |
cortex_r5 cross-compile — toolchains/arm-none-eabi-r5.cmake |
configure rc 0, build rc 0, Generating stage0 binary for cortex_r5. eboot_firmware.bin 18576 bytes; ebldr_stage0.bin 0 bytes |
cortex_r5 section tables, objdump -h |
ebldr_stage0.elf: .vectors 0, .text 0, .data 0, .bss 0 — finding 1. eboot_firmware.elf: .vectors 0, .text 0x4884, no .isr_vector section present — finding 2 |
cortex_r5 stage-1 entry, nm -n |
0x00204000 T memcpy · 0x002040ec T memset · 0x00204188 T eboot_main · 0x00204260 T Reset_Handler. stage1_vector_table absent. xxd first word 0xea414684 — a branch instruction, not _estack. |
Is finding 1 pre-existing? Same cortex_r5 build at #129's head (949775b3) |
ebldr_stage0.bin 0 bytes, eboot_firmware.bin 0 bytes. Finding 1 unchanged by this PR; finding 2's image went 0 → 18576. |
| KEEP-vs-emitted audit across all four linker scripts | cortex_r5_stage0.ld KEEP(.vectors) ENTRY=0 · cortex_r5_stage1.ld KEEP(.vectors) ENTRY=1 · stm32f4_stage0.ld KEEP(.isr_vector) ENTRY=0 · stm32f4_stage1.ld KEEP(.isr_vector) ENTRY=1. Sections emitted anywhere in stage0/, stage1/, boards/: .isr_vector only. |
| CI board coverage | grep -rn cortex_r5 .github/workflows/ empty. Boards named in workflows: none, stm32f4, rpi4, riscv64_virt, esp32, esp32c3, x86_64_efi. |
test_every_stage1_linker_script_is_anchored_like_its_stage0 on cortex_r5 |
passes (kept0 == kept1 == {.vectors}) — finding 3 |
r5_jump() handover shape |
boards/cortex_r5/board_cortex_r5.c:116-126 — "No vector table with MSP at offset 0; Direct branch to entry point address". Establishes what finding 2 means in practice. |
Architecture conformance
The stm32f4 half conforms and closes a §5.1 violation; the cortex_r5 half does not. §21: eBoot is
Tier 1 Foundation, every touched file is inside the owning repo, and stage1/reset_entry.c lands in
stage1/ where .ai/architect.md's target shape puts stage-1 code — §21.1 is not engaged. §5.1
dependency direction is untouched: stage1/reset_entry.c includes <stdint.h> and declares
eboot_main(), nothing else, and it is the leaf of the stage-1 link. The clause this PR exists to
serve is §5.1's "eBoot keeps the trusted computing base minimal and auditable" together with §8's
boot flow: a Verify Image step that hashes zero bytes and a Transfer Control into an image that
does not exist are not the chain §8 draws, and on stm32f4 they now are. §8.1's "explicit separation
between implemented, experimental and planned security features" is what findings 1 and 2 offend
against from the opposite direction — after this PR, cortex_r5's stage-1 measurement is
Implemented by every signal the repository produces (real size, real digest, green guard) and
measures an image whose entry point is memcpy. §22 is the section with the actual gap: it grades
support tiers and has no state for a board that builds cleanly and emits nothing, which is why
neither defect was ever a rule violation. The 2026-09-04 proposal already covers "a board that has
never compiled"; cortex_r5 does compile, so I have appended an addendum to
.ai/autoreview/proposals/2026-09.md raising the floor from it compiles to it produces the
artifact it names, in the handover shape its own HAL documents.
Proposed changes
In this PR, before merge -- pick one, do not ship the middle state:
(a) revert boards/cortex_r5/cortex_r5_stage1.ld to leave that board's image empty,
so it fails the new size floor loudly, and fix the board in its own PR; or
(b) fix both cortex_r5 scripts together, with an entry shape appropriate to a
board that branches directly to the image base rather than resetting through
a Cortex-M vector table (findings 1, 2)
tests/unit/test_stage1_image_is_verified.py
assert every KEEP'd section name is emitted by some .c under stage0/,
stage1/ or boards/ -- it fails on cortex_r5 today, which is the point (finding 3)
Follow-up, needs sequencing by a maintainer:
.github/workflows/build.yml a cortex_r5 cross leg; it goes red on day one
tools/embed_stage1_hash.py the non-empty/floor check it now applies to stage-1
is the right check for ebldr_stage0.bin too
This branch is stacked on #129 and #115 and cannot land before them.
No fix PR opened. Both blocking findings are Critical, and the brief's autofix rule is High
only, small and provable — neither applies. Finding 1's fix is on origin/master and would qualify
on location, but the right repair for an R-profile port is not a section rename: the board's own HAL
says it does not reset through a vector table, so choosing the entry shape is a design decision I
cannot verify by running anything short of booting the board, and there is no cortex_r5 hardware or
emulator here. Finding 2's line is new on this branch, so a branch cut from the default branch —
what fix-start.sh produces — would have nothing to patch. Finding 3 is a test-only change and the
kind I would normally open, but it is Medium, and landing a guard that immediately reddens a board
the maintainers have not yet decided how to fix is a sequencing call that belongs to them.
Not checked
- No hardware and no emulation. Nothing was booted. Every statement about device behaviour is
from cross-compiled artifacts inspected withobjdump,nmandxxd. Thatstm32f4stage-0
successfully enters this stage-1 is Inferred from the image beginning with the documented
handover pair, not observed. - Finding 2's consequence is stated from the symbol table, not from execution. That
memcpysits
at the addressr5_jump()branches to is Verified; what a Cortex-R5 does on entering it is not,
and I make no claim about the specific failure mode. - Only
stm32f4andcortex_r5were built. They are the only two boards with a*_stage1.ld,
which is what gates this whole code path (CMakeLists.txt:387), so I believe that is the full blast
radius — but I did not build the other boards to prove no third path exists. TheKEEP-vs-emitted
audit above covers all four linker scripts in the tree. release.yml's board builds were NOT run — they need a tag. Whether the released firmware
artifacts contain a stage-1 hash at all remains the open question #129's review raised, still
Inferred.- The 4096-byte floor was not stress-tested. I confirmed it admits the real 13408-byte
stm32f4
image and the tests pin it above a bare vector table, but I did not check it against a legitimately
smaller board — and on this evidencecortex_r5's 18576-byte image shows the floor admits a wrong
image as readily as a right one, which is finding 2. - ASan/UBSan, Valgrind, fuzz, CodeQL, cppcheck/clang-tidy — NOT RUN locally. CI green on this
head; logs not read. Note that CI's green says nothing about either finding, since no job builds
cortex_r5. - The #115/#127/#129 portions of the bundle diff were not re-reviewed here. This review covers
949775b3..7f239931only.
Automated architecture review of 7f2399312de2 — 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.
|
Finding 2 was mine and it was exactly the middle state you describe; taken option (a), in Findings 1 and 2 — cortex_r5. Finding 3 — the guard propagated the broken reference. Agreed, and it is the part I should have seen: two scripts that agree with each other and with no source file is the defect itself.
|
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.
… -1.0.0 as an option test_out_of_range_version_is_rejected[-1.0.0] passed locally (Python 3.14) and failed in CI (ubuntu-22.04, Python 3.10) with argparse's own "expected one argument": the older negative-number matcher does not accept -1.0.0, so the token was read as an unknown option and imgpack.py's range check -- the thing under test -- never ran. The joined form is unambiguous on every interpreter and the test now reaches the tool's message.
tests/vectors/fw_update_test_sigs.h is the output of tools/gen_fw_update_test_sigs.py, committed because eBoot has no Ed25519 signer in C. Nothing checked that the two agree: a generator edit without a regeneration leaves test_fw_update and test_fw_transport verifying against stale signatures, failing with EOS_ERR_SIGNATURE and nothing to say why. tests/unit/test_fw_update_test_sigs.py runs the generator with the test's own interpreter and compares its stdout to the committed header byte for byte, so a line-ending change counts too. It follows the same dependency rule as test_eos_sign_payload_offset.py: with EOS_REQUIRE_SIGNING_TESTS set (the CI workflow sets it before the pytest step) a missing cryptography module fails the job instead of skipping. Negative control: one flipped hex byte in the header fails the test with a unified diff naming the line. build_image() and build_container() now carry a comment naming the coupling: the signed prefix is assembled both there and in the generator, and changing any field in it means changing the generator's copy and regenerating the header.
…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.
The regeneration test's failure path -- the unified diff under the regenerate command -- ran only when the header was stale, so a green run never executed it and the coverage report said so. The report is now a helper the match asserts with, and a second test drives the helper with two byte strings that differ in one byte and checks the command and both sides of the changed line appear.
The stage-1 hash loop in stage0/jump_stage1.c called eos_hal_flash_read() and hashed the buffer whatever the call returned. A read that failed left the buffer holding the previous chunk, or whatever the stack held, and that was hashed as if it were stage-1. The mismatch that followed sent the device to recovery, so the outcome was fail-closed -- by accident, and logged as 0xBAD1, a hash mismatch, which is not what happened. core/crypto_boot.c's eos_crypto_verify_image() does the same job and refuses a failed read; stage-0 now does too, entering recovery with its own reason (0xBAD2) and returning so the loop can never fall through to the jump. stage0/ is only compiled by a cross build, so the test is a source-level guard in the style of test_stage0_reset_entry.py: every call to a HAL read or write in stage0/*.c must assign or test its result, and the stage-1 loop in particular must test the read, enter recovery on failure with the 0xBAD2 reason, and return. Against the unfixed file both tests fail.
…des have names The hash-mismatch path in ebldr_stage0_main() entered recovery and fell through: no return, so control reached the EOS_LOG_IMAGE_VALID record and ops->jump(). The only thing holding it back was eos_recovery_enter() never returning, which its own `int` return type does not promise. The recovery-trigger path eight lines earlier had the same shape. Both return now, and the positive record is written only when the hash matched. 0xBAD1 and 0xBAD2 were bare magic values defined in no header, so a boot log read in the field gave `event=BOOT_FAIL detail=0xBAD2` and no way to learn what that meant. EBLDR_FAIL_STAGE1_HASH and EBLDR_FAIL_STAGE1_READ sit beside the EOS_LOG_* codes in include/eos_types.h and jump_stage1.c uses them by name. tests/unit/test_stage0_hal_results.py had two demonstrated false negatives: `int rd = read(...); (void)rd;` passed because a bare `=` in the statement head counted as examined, and a discarded call placed after a `#ifdef` line passed because "#ifdef" contains "if". Preprocessor lines are now stripped with the comments, and a result counts as examined only when the call sits in an if/while condition or a return, or is assigned to a name that a later if/while/return in the same block reads. A new test asserts every eos_recovery_enter() in ebldr_stage0_main() is followed by a return (a closing brace counts only when it closes the function), that the mismatch block returns and carries no IMAGE_VALID, and that both bypass probes are refused while the two legitimate shapes are accepted. Against 4b42626 the fall-through test fails on the mismatch call.
ec2f49b to
2048de3
Compare
…ndition
The guard counted a statement as examined when its head started with
"if (" or "while (". The head of `if (need) eos_hal_flash_read(...)`
does, and that call is the body, its result discarded -- reproduced in
review against the module's own helpers. The call is inside the
condition only when the head has an unclosed parenthesis; that is the
whole fix, and the two bypasses join the probe test beside the two from
the previous round, with `if (ok && read(...) != EOS_OK)` as the control
that a call deeper in a condition still counts.
…ge that is not there On every board build with a stage-1 linker script, eboot_firmware.bin was zero bytes. stage1/ exported eboot_main() only; the script had no ENTRY() and kept no section; with -nostartfiles and --gc-sections the linker had nothing to anchor, and arm-none-eabi-objdump -h reported .text 00000000. tools/embed_stage1_hash.py took the size verbatim and emitted stage1_expected_size = 0u with the SHA-256 of the empty string, printed as a normal build line. On the device, the hash loop in stage0/jump_stage1.c ran zero iterations, the digest of nothing matched the digest of nothing, IMAGE_VALID was recorded, and stage-0 jumped to whatever was in flash. The first link of the secure boot chain reported success without having measured anything, on 100% of the builds where it was live. Three independent fixes, each of which alone would have caught it: stage1/reset_entry.c owns the vector table and the reset handler for stage-1, in the shape of stage0/reset_entry.c: initial stack pointer, Reset_Handler that copies .data, zeros .bss and enters eboot_main(), the core faults, and the system exceptions through SysTick. boards/stm32f4/stm32f4_stage1.ld gains ENTRY(Reset_Handler) and a kept .isr_vector section placed first, as its stage-0 script already had, and CMakeLists.txt builds eboot_firmware from the new file. The stm32f4 stage-1 now links to 13,416 bytes (ed25519_verify, sha256/512, the keystore, rollback and recovery are all in it), its first two words are 0x20020000 and 0x08004211 -- the stack top and Reset_Handler with the Thumb bit -- and the digest stage-0 embeds is that image's. tools/embed_stage1_hash.py refuses an empty image outright and one below a 4096-byte floor (--min-size); a stage-1 that verifies an Ed25519 signature cannot be smaller than the verifier, and 64 bytes is what a vector table alone links to. stage0/jump_stage1.c refuses stage1_expected_size == 0 before the loop, with its own reason (EBLDR_FAIL_STAGE1_NO_IMAGE, 0xBAD3), because a bootloader does not trust its own build to have been correct. Found alongside and fixed here: stage-0's vector table stopped at UsageFault, while board_early_init() on stm32f4 enables the SysTick interrupt (entry 15). The first tick, one millisecond in, had the core fetch its handler address from the code that followed the table. Both tables now run through SysTick. boards/cortex_r5/cortex_r5_stage1.ld is anchored the same way (ENTRY plus the .vectors section its stage-0 script keeps) and that stage-1 now links to 18,576 bytes, but nothing in the tree emits .vectors, so the cortex_r5 ebldr_stage0.bin is still 0 bytes; that port is filed separately and no workflow builds it. tests/unit/test_stage1_image_is_verified.py pins the tool (empty, below-floor, and a real image whose emitted digest is checked), the boot-time refusal and its named code, ENTRY plus the kept section on every stage-1 script matching its stage-0 script, the stage-1 entry file's table (stack, Reset_Handler, SysTick at entry 15), stage-0's table reaching SysTick, and the CMake source list. Against 949775b, 10 of its 11 tests fail.
…against emitted ones The previous commit gave cortex_r5_stage1.ld ENTRY(Reset_Handler) and a kept .vectors section, mirroring that board's stage-0 script. .vectors is a section no source file emits, so the KEEP kept nothing; the ENTRY alone rooted the code, the stage-1 image went from 0 to 18,576 bytes, cleared the new size floor, got a real digest, passed the new guard -- and had no vector table at all, with memcpy at the address r5_jump() branches to. Every check this branch adds reported success for an image that could not boot. That middle state must not ship. cortex_r5_stage1.ld is back to what it was, so that board's stage-1 links to zero bytes and tools/embed_stage1_hash.py stops the build there, loudly. Its entry shape (a direct branch to the image base, per board_cortex_r5.c) is a port decision, tracked as embeddedos-org#137; its stage-0 has been empty for the same reason all along. The guard no longer anchors a stage-1 script to its stage-0 script -- two scripts that agree with each other and with no source file is exactly the defect -- and instead asserts, per board, that both scripts keep a section some .c under stage0/, stage1/ or boards/ places code in (today: .isr_vector, and nothing else), plus ENTRY on the stage-1 script. cortex_r5 is a strict expected failure naming embeddedos-org#137: the parametrized case turns into a failure the day the port is fixed, so the mark has to come off with the fix, and every other board is held to the rule now.
2048de3 to
2482df0
Compare
Fixes #136
Stacked on #129 (itself on #115); review
e018474..2482df0for this change alone -- two commits (restacked twice with #129, last onto itse018474onto #115'se152d8e;949775b..ec2f49bwas the same content on the previous base).4784b96(was7f23993) is the fix (ten files);2048de3(wasec2f49b) answers the review:cortex_r5_stage1.ldis back to what it was, and the guard checks kept sections against emitted ones (withcortex_r5a strict expected failure naming #137). Merge order: #115, #129, then this. This is #129's review finding 1 (Critical, pre-existing), done as the separate PR that review asked for, with the SysTick vector-table defect found on the way.The defect
On every board build with a stage-1 linker script,
eboot_firmware.binwas 0 bytes.stage1/exportedeboot_main()only;boards/stm32f4/stm32f4_stage1.ldhad noENTRY()and kept no section; with-nostartfilesand--gc-sectionsthe linker had nothing to anchor (objdump -h→.text 00000000).tools/embed_stage1_hash.pytook the size verbatim and emittedstage1_expected_size = 0uwithe3b0c442…b855, the SHA-256 of the empty string, printed as a normal build line. On the device the hash loop instage0/jump_stage1.cran zero iterations, the digest of nothing matched the digest of nothing,IMAGE_VALIDwas recorded, and stage-0 jumped to whatever was in flash. Reproduced here withbuild.yml's flags and withtoolchains/arm-none-eabi.cmake(Arm GNU Toolchain 14.2.Rel1) before the change:eboot_firmware.bin0 bytes,stage1_hash.ccarrying the empty-string digest.The fix -- three guards, each sufficient alone
stage1/reset_entry.c(new) owns the stage-1 vector table and reset handler in the shape ofstage0/reset_entry.c: initial stack pointer,Reset_Handler(copy.data, zero.bss, entereboot_main()), the core faults, and the system exceptions through SysTick.stm32f4_stage1.ldgainsENTRY(Reset_Handler)and a kept.isr_vectorsection placed first, as its stage-0 script already had;CMakeLists.txtbuildseboot_firmwarefrom the new file.embed_stage1_hash.pyexits 1 for an empty input whatever the floor, and for one below--min-size(default 4096: a stage-1 that verifies an Ed25519 signature cannot be smaller than the verifier, and a vector table alone links to 64 bytes).jump_stage1.crefusesstage1_expected_size == 0before the loop, with its own reasonEBLDR_FAIL_STAGE1_NO_IMAGE(0xBAD3, named beside the other two ininclude/eos_types.h).Found alongside:
stage0/reset_entry.c's table stopped at UsageFault (7 entries) whileboard_early_init()on stm32f4 enables the SysTick interrupt (vector 15), so one millisecond after reset the core fetched its handler address from the code after the table. Both stage tables now run through SysTick (16 entries;SysTick_Handlerweak, andboard_stm32f4.c's strong one links in).Measured, stm32f4
eboot_firmware.binobjdump -h eboot_firmware.elf.text 00000000.isr_vector 0x40 @ 0x08004000,.text 0x341c,.data 4,.bss 0x1800x20020000(stack top),0x08004211(Reset_Handler, Thumb bit set) -- whatstm32f4_jump()loadseos_ed25519_verify,sha256/sha512_transform,eos_keystore_init,eos_rollback_read_image_counter,eos_recovery_enter,eboot_main,Reset_Handler(72Tsymbols)stage1_hash.csize = 0u, digest of""size = 13416u, digest05dc8850…7bf6=shasum -a 256 eboot_firmware.binebldr_stage0.bin.isr_vector0x1c → 0x40)Both CI invocations were run locally:
build.yml's (-DCMAKE_SYSTEM_NAME=Generic -DCMAKE_C_COMPILER=arm-none-eabi-gcc -DEBLDR_BOARD=stm32f4 -DCMAKE_C_FLAGS="-mcpu=cortex-m4 …") andci.yml's (-G Ninja -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-none-eabi.cmake -DEBLDR_BOARD=stm32f4); both link, both embed the digest above.cortex_r5 (review findings 1-3)
Left as it was. The first commit had anchored
cortex_r5_stage1.ldthe way its stage-0 script is anchored --ENTRYplusKEEP(*(.vectors))-- and the review measured what that produced:.vectorsis a section no source file emits, so theKEEPkept nothing,ENTRYalone rooted the code, the stage-1 image went 0 → 18,576 bytes, cleared the floor, got a real digest, passed the guard, and hadmemcpyat the addressr5_jump()branches to. Every check reported success for an image that could not boot.ec2f49breverts that file, so the cortex_r5 build now stops at the embed step --eboot_firmware.bin is empty: there is no stage-1 image to verify-- which is the louder of the two honest states. The port's entry shape (a direct branch to the image base, perboard_cortex_r5.c) is a decision for the port: #137, which also covers its 0-byte stage-0 (pre-existing; same cause).The guard no longer anchors a stage-1 script to its stage-0 script (two scripts that agree with each other and with no source file is the defect itself).
test_stage1_linker_script_is_anchored_on_a_section_the_tree_emitsis parametrized per board and assertsENTRY(Reset_Handler)on the stage-1 script and that both of the board's scripts keep a section some.cunderstage0/,stage1/orboards/places code in (_emitted_sections()greps__attribute__((section("..."))); today that is.isr_vectorand nothing else).cortex_r5isxfail(strict=True)with the reason#137: cortex_r5 keeps .vectors, which no source file emits: the case fails today for the right reason, and the day the port is fixed the strict mark turns into a failure so it has to come off with the fix. Acortex_r5leg inbuild.ymlis deliberately not added: it would be red on every PR until #137 lands, which is noise rather than a gate; the xfail pins the state instead.Verification
cmake -DEBLDR_BUILD_TESTS=ON→ctestpytest tests -qtests/unit/test_stage1_image_is_verified.py, of whichcortex_r5is the expected failure)949775b, the new test kept7f23993(the eleventh is the real-image control, which the old tool also handled); withec2f49b's guard, thecortex_r5case fails at949775b, at7f23993and here -- it is the expected failure, and thestm32f4case passes only hereis empty: there is no stage-1 image to verify), exit 1 (below the 4096-byte floor), exit 0 with the emitted digest equal tohashlib.sha256of the payloadcc -fsyntax-only -DEBLDR_VERIFY_STAGE1 stage0/jump_stage1.cCMakeLists.txt464→467,include/eos_types.h174→175,stage0/reset_entry.c98→112,CHANGELOG.md136→137 CR bytes (one per added line); the linker scripts,jump_stage1.c, the tool, the new C file and the test are LF as their neighbours areThe new test pins: the tool's three behaviours; the boot-time refusal and its named code; per board,
ENTRY(Reset_Handler)on the stage-1 script and a kept section that a source file emits on both scripts;stage1/reset_entry.c's table (entry 0_estack, 1Reset_Handler, 15SysTick_Handler); stage-0's table reaching SysTick; andstage1/reset_entry.cinadd_executable(eboot_firmware …).Not done: nothing was run on hardware or under QEMU. The claim is that the image exists, is anchored the way
stm32f4_jump()expects, and is what stage-0 measures -- not that stage-1 has been observed to boot.