Skip to content

fix(stage1): give stage-1 an entry point, and refuse to verify an image that is not there - #138

Open
Kartikey1306 wants to merge 11 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/stage1-image-is-not-empty
Open

Kartikey1306 wants to merge 11 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/stage1-image-is-not-empty

Conversation

@Kartikey1306

@Kartikey1306 Kartikey1306 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #136

Stacked on #129 (itself on #115); review e018474..2482df0 for this change alone -- two commits (restacked twice with #129, last onto its e018474 onto #115's e152d8e; 949775b..ec2f49b was the same content on the previous base). 4784b96 (was 7f23993) is the fix (ten files); 2048de3 (was ec2f49b) answers the review: cortex_r5_stage1.ld is back to what it was, and the guard checks kept sections against emitted ones (with cortex_r5 a 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.bin was 0 bytes. stage1/ exported eboot_main() only; boards/stm32f4/stm32f4_stage1.ld had no ENTRY() and kept no section; with -nostartfiles and --gc-sections the linker had nothing to anchor (objdump -h.text 00000000). tools/embed_stage1_hash.py took the size verbatim and emitted stage1_expected_size = 0u with e3b0c442…b855, 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. Reproduced here with build.yml's flags and with toolchains/arm-none-eabi.cmake (Arm GNU Toolchain 14.2.Rel1) before the change: eboot_firmware.bin 0 bytes, stage1_hash.c carrying the empty-string digest.

The fix -- three guards, each sufficient alone

  1. The link is anchored. stage1/reset_entry.c (new) owns the stage-1 vector table and reset handler in the shape of stage0/reset_entry.c: initial stack pointer, Reset_Handler (copy .data, zero .bss, enter eboot_main()), the core faults, and the system exceptions through SysTick. stm32f4_stage1.ld gains ENTRY(Reset_Handler) and a kept .isr_vector section placed first, as its stage-0 script already had; CMakeLists.txt builds eboot_firmware from the new file.
  2. The tool refuses an image that is not there. embed_stage1_hash.py exits 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).
  3. Stage-0 does not trust its build. jump_stage1.c refuses stage1_expected_size == 0 before the loop, with its own reason EBLDR_FAIL_STAGE1_NO_IMAGE (0xBAD3, named beside the other two in include/eos_types.h).

Found alongside: stage0/reset_entry.c's table stopped at UsageFault (7 entries) while board_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_Handler weak, and board_stm32f4.c's strong one links in).

Measured, stm32f4

before after
eboot_firmware.bin 0 bytes 13,416 bytes
objdump -h eboot_firmware.elf .text 00000000 .isr_vector 0x40 @ 0x08004000, .text 0x341c, .data 4, .bss 0x180
first two words of the image -- 0x20020000 (stack top), 0x08004211 (Reset_Handler, Thumb bit set) -- what stm32f4_jump() loads
symbols in the image none eos_ed25519_verify, sha256/sha512_transform, eos_keystore_init, eos_rollback_read_image_counter, eos_recovery_enter, eboot_main, Reset_Handler (72 T symbols)
stage1_hash.c size = 0u, digest of "" size = 13416u, digest 05dc8850…7bf6 = shasum -a 256 eboot_firmware.bin
ebldr_stage0.bin 12,904 12,960 (.isr_vector 0x1c → 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 …") and ci.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.ld the way its stage-0 script is anchored -- ENTRY plus KEEP(*(.vectors)) -- and the review measured what that produced: .vectors is a section no source file emits, so the KEEP kept nothing, ENTRY alone rooted the code, the stage-1 image went 0 → 18,576 bytes, cleared the floor, got a real digest, passed the guard, and had memcpy at the address r5_jump() branches to. Every check reported success for an image that could not boot. ec2f49b reverts 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, per board_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_emits is parametrized per board and asserts ENTRY(Reset_Handler) on the stage-1 script and that both of the board's scripts keep a section some .c under stage0/, stage1/ or boards/ places code in (_emitted_sections() greps __attribute__((section("..."))); today that is .isr_vector and nothing else). cortex_r5 is xfail(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. A cortex_r5 leg in build.yml is 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

Check Result
host cmake -DEBLDR_BUILD_TESTS=ONctest 31/31
pytest tests -q 97 passed, 1 xfailed (85 + the 13 cases in tests/unit/test_stage1_image_is_verified.py, of which cortex_r5 is the expected failure)
Negative control: all touched files restored to 949775b, the new test kept 10 of 11 fail at 7f23993 (the eleventh is the real-image control, which the old tool also handled); with ec2f49b's guard, the cortex_r5 case fails at 949775b, at 7f23993 and here -- it is the expected failure, and the stm32f4 case passes only here
the tool on an empty file / 64 bytes / 8 KiB exit 1 (is empty: there is no stage-1 image to verify), exit 1 (below the 4096-byte floor), exit 0 with the emitted digest equal to hashlib.sha256 of the payload
cc -fsyntax-only -DEBLDR_VERIFY_STAGE1 stage0/jump_stage1.c PASS
Line endings CMakeLists.txt 464→467, include/eos_types.h 174→175, stage0/reset_entry.c 98→112, CHANGELOG.md 136→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 are

The 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, 1 Reset_Handler, 15 SysTick_Handler); stage-0's table reaching SysTick; and stage1/reset_entry.c in add_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.

@codecov-commenter

codecov-commenter commented Sep 14, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 95.10204% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
tests/unit/test_stage0_hal_results.py 90.00% 5 Missing and 5 partials ⚠️
tests/unit/test_fw_update_test_sigs.py 93.33% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@srpatcha srpatcha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_r5grep -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=Debugcmake --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-compiletoolchains/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-compiletoolchains/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 0finding 1. eboot_firmware.elf: .vectors 0, .text 0x4884, no .isr_vector section presentfinding 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 with objdump, nm and xxd. That stm32f4 stage-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 memcpy sits
    at the address r5_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 stm32f4 and cortex_r5 were 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. The KEEP-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 evidence cortex_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..7f239931 only.

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.

@Kartikey1306

Copy link
Copy Markdown
Contributor Author

Finding 2 was mine and it was exactly the middle state you describe; taken option (a), in ec2f49b (range now 949775b..ec2f49b; the body's cortex_r5 section is rewritten around what you measured).

Findings 1 and 2 — cortex_r5. boards/cortex_r5/cortex_r5_stage1.ld is back to what it was at 949775b. Rebuilt with toolchains/arm-none-eabi-r5.cmake: the stage-1 link is empty again and the build now stops at the embed step — Error: …/eboot_firmware.bin is empty: there is no stage-1 image to verify. The stage-1 link produced nothing -- check that the board's stage-1 linker script has ENTRY() and keeps the vector section. — exit 2 from cmake --build, instead of an 18 KiB image with memcpy at the entry and a green guard. I had read r5_jump()'s direct branch and still mirrored the stage-0 script; you are right that the entry shape for a core that branches to the image base is a port decision, not a mechanical edit, and it is #137 (which also carries the 0-byte stage-0, same cause, pre-existing). Not adding a cortex_r5 leg to build.yml: it would be red on every PR until #137 lands, which is noise rather than a gate; the guard below pins the state instead.

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. test_stage1_linker_script_is_anchored_on_a_section_the_tree_emits is now parametrized per board and asserts ENTRY(Reset_Handler) on the stage-1 script and that both of the board's scripts keep a section some .c under stage0/, stage1/ or boards/ emits — your grep -rhn 'section("...")', as _emitted_sections(); today that set is {.isr_vector} and nothing else — with the non-empty check on the stage-0 script you asked for. It fails for cortex_r5 at 949775b, at 7f23993 and here, which is the correct outcome; rather than leave the PR red on a pre-existing port defect, that one case is xfail(strict=True) with the reason #137: cortex_r5 keeps .vectors, which no source file emits. Strict means the day the port is fixed the case turns into a failure and the mark has to come off with the fix. stm32f4 passes only on this branch.

pytest tests/ on ec2f49b: 97 passed, 1 xfailed. stm32f4 unchanged from what you measured; the two-word contract at the FLASH origin and the embedded digest are the same as 7f23993.

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.
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stage-0 verifies an empty stage-1: eboot_firmware.bin links to 0 bytes and the embedded hash is SHA-256("")

3 participants