- UART recovery issues no challenge on a board without an entropy source. When
eos_hal_rng_get()failed,recovery_handle_auth()filled the 32-byte challenge from a linear congruential generator seeded witheos_hal_get_tick_ms(). The challenge is the only thing that stops a captured(challenge, response)pair from being replayed, and with that fallback it was a function of the millisecond at which the AUTH command was handled -- a few thousand values on a freshly reset board -- which the client sees before it has to answer and can retry after a reset at no cost in failure count. The AUTH is now refused outright (NACK, eventEOS_LOG_AUTH_NO_ENTROPY=0x23, whichtools/uart_recovery.pydecodes; counted like any other failure so the backoff applies). No board port providesrng_get(0 of the 83 ops tables underboards/), so this was the challenge on every board; none providesotp_readeither, so no board authenticates anyone today and the defect was latent. Pinned bytest_auth_refuses_when_the_board_has_no_entropy_sourceintests/unit/test_recovery.c. - UART recovery refuses an unprovisioned secret, and a write into an unmapped slot.
recovery_handle_auth()compared the client's response againstSHA-256(challenge || secret)for whatever the OTP held at the secret offset -- and unprogrammed fuses read back as all zeros or all ones, both public, so a device whose recovery secret was never provisioned authenticated any client that knew that. Both patterns are now refused before the comparison (logged as event0x22), the way the keystore already refuses an all-zero key.recovery_handle_write()had stopped callingeos_recovery_write_in_range()-- the rule the unit tests exercise -- for an inline check that never looked at the slot base, so a slot the board leaves unmapped (base 0) turned "write at offset" into "write at flash address". The handler uses the shared rule again -- which also refuses aWRITEwithlen == 0, where the inline check had ACKed it and written nothing; a recovery client sees a NACK there now. No shipped board port providesotp_readtoday, so the first defect was latent (authentication always failed); both are pinned bytests/unit/test_recovery.c. The authentication outcomes are logged asEOS_LOG_AUTH_SUCCESS/EOS_LOG_AUTH_FAIL/EOS_LOG_AUTH_UNPROVISIONED(0x20-0x22, now defined ininclude/eos_types.hbeside the otherEOS_LOG_*codes instead of as bare hex),tools/uart_recovery.pydecodes all three, andtests/unit/test_boot_log_event_names.pykeeps the firmware, the header and the client's table in step. - The production-key gate covers every release-shaped build, and the key it accepts is checked against the curve. The gate matched the literal string
Releaseand nothing else, soMinSizeRel,RelWithDebInfo,release,RELEASEand an unset build type all configured a real board on the development anchor. It now exempts onlyDebug. Separately,EBLDR_PRODUCTION_KEYwas checked for length and hex-ness but never for being a point on edwards25519 — the defect the development key itself had — so one mistyped hex digit in the release secret would have shipped a fleet that refuses every image, with a green build.tools/check_production_key.pyapplies the verifier's own rule ([L]P == identity,P != identity, not the development key) in pure Python;cmake/ProductionKey.cmakeruns it at configure when python3 is present and warns in so many words when it is not, andrelease.ymlruns it on the secret before any board is configured. - A release can no longer be built on the development trust anchor.
EBLDR_PRODUCTION_KEYhad no CMake option, nothing set it, and its#elsebranch declared anexternnothing defined -- so every firmware job inrelease.ymlcompiled the#ifndefbranch and shipped the RFC 8032 test key as the anchor for every board without OTP, which is every board underboards/. The only guard was a#warning. Now:-DEBLDR_PRODUCTION_KEY=<64 hex characters>generatesebldr_production_key[](cmake/ProductionKey.cmake, declared ininclude/eos_production_key.h) and selects the production branch ofcore/keystore.c; a Release build of a real board refuses to configure without it unless it says-DEBLDR_ALLOW_DEV_KEY=ON; the development key and malformed values are refused as production keys;release.ymlpasses theEBLDR_PRODUCTION_KEY_HEXsecret on every board configure (empty until a maintainer provides it, so a release fails closed) and scans every built.elf/.bin/.a/.ofor the development key's bytes.tests/unit/test_keystore_production.ccompiles the production branch with a fixture key on every host build;tests/unit/test_production_key_gate.pyandtest_release_workflow_production_key.pypin the gate and the workflow. - The compiled-in development trust anchor was not a point on the curve.
core/keystore.cdefault_dev_keywas described as the RFC 8032 section 7.1 TEST 1 public key, but it diverged from that key at byte 21 and the bytes it held did not decode to a point on the curve, so no signature could ever verify against it. It is now the RFC key, andtests/unit/test_keystore.cchecks that the anchor verifies the RFC's own signature. Behaviour change: a board with no OTP and noEBLDR_PRODUCTION_KEYgoes from refusing every image (since #104 made signature verification unconditional at install) to accepting images signed with the public RFC test key -- the documented development intent of the#warningincore/keystore.c, and a key that must never reach a device. #120 tracks making that structural. - Image header is now authenticated (header format v2).
eos_image_verify_signature()signedhdr->hashonly — 32 of the header's 156 bytes. Everything else (image_size,load_addr,entry_addr,flags,sig_type,image_version) sat outside the signature, so an attacker holding a legitimately signed image could relocate it, move its entry point, or clearEOS_IMG_FLAG_HASH_SHA256to downgrade integrity checking from SHA-256 to forgeable CRC32 — all while keeping the signature valid. The signature now coversEOS_IMG_SIGNED_LEN(92) bytes: the whole header exceptsignature[]itself. Existing signed images must be re-signed. eos_image_parse_header: validateshdr_version, rejecting 0 and anything newer than this build understands.tools/eos_sign.py:SIG_TYPE_ED25519was1— that isEOS_SIG_CRC32ineos_types.h, whicheos_image_verify_signature()rejects outright — andIMG_FLAG_SIGNEDwas1 << 2, which isEOS_IMG_FLAG_DEBUG. It also never setEOS_IMG_FLAG_HASH_SHA256, so the bootloader read the stored SHA-256 as a CRC32. Constants now matchinclude/eos_types.h.recovery.c: UART recovery writes now reject offsets and lengths that leave the target slot, including wrap ofbase + offset.recovery.c: UART recovery VERIFY rejects image headers whose payload exceeds the slot capacity before streaming integrity reads past the slot boundary.image_verify.c:eos_image_parse_headerrejects aload_addr + image_sizethat overflowsuint32_tinstead of wrapping the runtime end address.image_verify.c: The CRC32 integrity path now fails closed on a flash read error.eos_crc32()returned0wheneos_hal_flash_read()failed, which is indistinguishable from a region that genuinely hashes to0, so an image whose payload could not be read passedeos_image_verify_integrity()when the stored CRC was0. The stored CRC lives in the unauthenticated header, so setting it to0is trivial. The SHA-256 path already propagated the read error; the two now behave the same.image_verify.c:eos_image_verify_integrityrejects a zeroimage_size, and anaddr + hdr_sizethat wrapsuint32_t, instead of computing a payload address that is not the payload.
- Stage-0 refuses a stage-1 image it could not read. The stage-1 hash loop in
stage0/jump_stage1.cdiscardedeos_hal_flash_read()'s result, so a failed read hashed the previous chunk (or the stack) as if it were stage-1; the mismatch that followed reached recovery with the wrong reason (0xBAD1, hash mismatch), and only by luck. A failed read now enters recovery with its own reason (EBLDR_FAIL_STAGE1_READ,0xBAD2) and never falls through to the jump, matching whateos_crypto_verify_image()incore/already does. The hash-mismatch path (EBLDR_FAIL_STAGE1_HASH,0xBAD1) and the recovery-trigger path had the same fall-through --eos_recovery_enter()with nothing after it, so a failed verification reached theIMAGE_VALIDrecord and the jump, held back only by recovery never returning -- and both now return. The two detail codes are named ininclude/eos_types.hbeside theEOS_LOG_*events.tests/unit/test_stage0_hal_results.pyis a source-level guard that every HAL read or write instage0/has its result tested (not merely assigned; preprocessor lines are ignored), and that every entry into recovery inebldr_stage0_main()is followed by a return. - The stage-1 image was empty, and stage-0 verified it anyway. On every board build with a stage-1 linker script,
eboot_firmware.binwas 0 bytes:stage1/exportedeboot_main()only, the script had noENTRY()and kept no section, and with-nostartfilesand--gc-sectionsthe linker had nothing to anchor.tools/embed_stage1_hash.pytook the size verbatim and emittedstage1_expected_size = 0uwith the SHA-256 of the empty string;stage0/jump_stage1.cran its hash loop zero times, matched nothing against nothing, recordedIMAGE_VALIDand jumped. Three independent fixes:stage1/reset_entry.cnow owns the vector table and the reset handler (stack pointer,Reset_Handlerintoeboot_main(), faults and the system exceptions through SysTick) andboards/stm32f4/stm32f4_stage1.ldhasENTRY(Reset_Handler)and keeps.isr_vector, so the stm32f4 stage-1 links to 13,416 bytes and the embedded digest is that image's; the embed tool refuses an empty image outright and one below a 4096-byte floor (--min-size); and stage-0 refusesstage1_expected_size == 0before the loop (EBLDR_FAIL_STAGE1_NO_IMAGE,0xBAD3), because a bootloader does not trust its own build. Stage-0's own vector table stopped at UsageFault whileboard_early_init()enables the SysTick interrupt, so the first tick fetched a handler address from the code after the table; both tables now run through SysTick.cortex_r5is left as it was: both of its linker scripts keep.vectors, which nothing in the tree emits, so both of its images link to zero bytes and the embed tool now stops that board's build at the empty stage-1 instead of describing it; the port needs an entry shape of its own (#137), and the guard names it as a strict expected failure until then. The guard also checks that every section a stage script keeps is one some source file emits, which is the mismatch that produced both empty images.tests/unit/test_stage1_image_is_verified.pypins all of it. - The tree did not configure, compile or link after the 09-07 batch merge.
tests/CMakeLists.txtregisteredeboot_test_fdt_loadertwice;core/sha512.chad been replaced by a version predating thebitlen[2]/buffer_lencontext;core/boot_log.c,core/secure_boot.candcore/fdt_loader.chad been dropped fromeboot_core;scalarbase()andk_low_order[]were defined twice; and theeos_boot_log_get_head()declaration was lost. All restored. - Install-path verification order settled: signature before anti-rollback.
eos_fw_update_finalize()verifies the Ed25519 signature over the signed header prefix first and reads the TLV security counter only after the prefix that binds it is authenticated (seedocs/adr/ADR-020). Thefw_updateandfw_transportsuites now stream genuinely signed images;tools/gen_fw_update_test_sigs.pyemits their signatures astests/vectors/fw_update_test_sigs.h, andtests/unit/test_fw_update_test_sigs.pypins the committed header to the generator's output. tests/CMakeLists.txt: the Valgrind list is derived from the registered suites again; a hand-written copy had replaced it, eleven registered suites were missing fromEBLDR_UNIT_TESTS, and seven of those (test_eos_sign_boot_path,test_fdt_loader,test_fw_decrypt,test_fw_update_sig,test_jump_app_bounds,test_qemu_arm64_timer,test_secure_boot_policy) had no Valgrind run at all; the other four were only in the hand-written list. All eleven are appended..github/workflows/ci.yml:fuzz-buildis in the CI gate. It was added after the gate job and the gate never waited for it.- Unit suites count
tests_runas each test executes instead of assigning it a literal that the summary line then trusted. .github/workflows/eosim-sanity.yml: the install-validate job's steps are bash and now run undershell: bashon the Windows legs, where PowerShell rejectedSITE_PACKAGES=$(...)and parsed|| { exit 1 }as an unexecuted script block..github/workflows/scorecard.yml:ossf/scorecard-actionmoved to v2.4.3, the release hosted on ghcr.io; v2.4.0 pulls from gcr.io, which now requires GCP billing.- The tree did not compile.
include/eos_image.hdeclaredeos_crc32()asint eos_crc32(uint32_t, size_t, uint32_t *)whilecore/image_verify.cdefined it asuint32_t eos_crc32(uint32_t, size_t)-- a conflicting-types error that stopped the build at the first core source file. The declaration now matches the definition and the documented behaviour. ed25519_verify.c:eos_ed25519_verify()never performed the verification. Two merged copies of the challenge-hash step had been left in the function, the second referring to identifiers that do not exist (sha512_ctx_t,sc_reduce), and RFC 8032 step 4 -- the[S]B == R + [k]Acheck -- was absent entirely, leaving the function returning an undeclareddiff. The duplicate is removed and the group-equation check restored; the function now passes the RFC 8032 test vectors and rejects tampered messages, every single-bit signature flip, wrong keys and malleated signatures.recovery.c:recovery_handle_write()declaredslot_sizetwice, which does not compile. The bounds check now callseos_recovery_write_in_range()-- the helper the unit tests already exercise -- so the wire-input rule has one definition, and an unmapped slot (base == 0) is rejected too.- SHA-512 had two incompatible declarations.
include/eos_sha512.hdeclaredsha512_init/update/finalover asha512_ctx_t, whileinclude/eos_crypto_boot.hdeclaredeos_sha512_init/update/finalplus a one-shoteos_sha512()over aneos_sha512_ctx_t.core/sha512.cimplemented the first set;core/ed25519_verify.cand the unit tests called the second, which nothing defined.core/sha512.cnow implements theeos_-prefixed API (including the missing one-shot),eos_sha512_ctx_tcarries the 128-bit length counter FIPS 180-4 requires, and the duplicateinclude/eos_sha512.his removed. CMakeLists.txt:core/sha512.candcore/rollback.cwere never compiled, soeboot_corecould not resolveeos_sha512_*oreos_rollback_*and eleven unit-test executables failed to link.core/boot_log.cwas listed twice. Both fixed.CMakeLists.txt: theEBLDR_BOARDdispatch chain was duplicated fromcortex_m3onwards, and a straymessage(FATAL_ERROR ...)sat inside thekalimbabranch, socmake -DEBLDR_BOARD=kalimbaaborted configuration for a supported board and 110 later branches were unreachable. The duplicate is removed. (This is the regressiontests/unit/test_cmake_board_dispatch.pywas written to catch; it had returned.)tests/unit/test_slot_manager.c: the file was two different test files spliced together mid-function -- stub definitions cut in half,main()calling twenty functions that do not exist. Rebuilt as one suite that exercises the realcore/slot_manager.cthrough scriptable per-slot verification mocks.tests/unit/test_recovery.c: local stand-ins for the boot-log API conflicted withinclude/eos_boot_log.hand duplicated symbols now linked fromcore/boot_log.c. Removed.tests/CMakeLists.txt:unit/test_fw_transport.cexisted but was never built or run. It is now registered.EBLDR_REQUIRE_SIGNATURESandEBLDR_RECOVERY_AUTHwere switches wired to nothing.CMakeLists.txtoffered both as options, forwarded each as a compile definition, and the README listed both asONby default -- but no source file tested either macro, so a configure with oneOFFproduced byte-for-byte the same objects asON(41 of 41 non-test objects identical with the definitions forced back in). Signature verification and recovery authentication are unconditional; the two options are gone, passing either asOFFfails the configure, the README says so, anddocs/book/book.mdno longer listsEBLDR_SECURE_BOOT,EBLDR_MULTICORE,EBLDR_RECOVERYandEBLDR_BOOT_MENU, none of which ever existed.tests/unit/test_build_options_are_read.pyrequires every forwardedEBLDR_definition to be read by a source file and every documented option to be declared.
eos_crc32_checked()— CRC32 over a flash region that reports read failures through its return value.eos_crc32()is retained for API compatibility and documented as unsuitable for verification decisions.
This patch release resolves 8 real bugs discovered during a line-by-line static code audit of the core bootloader logic. All issues were verified and fixed.
image_verify.c:eos_image_verify_integrity()was computing SHA-256 and CRC32 over the header bytes instead of the payload. Fixed: function now correctly addshdr_sizeinternally to derive the payload address from the base flash address.tools/eos_sign.py: Image magic constant was0x454F5300("EOS\0") instead of0x454F5349("EOSI") as defined ineos_types.h. Signed images would fail the magic check in the bootloader. Fixed to matchEOS_IMG_MAGICineos_types.h.
secure_boot.c: Missingreturnon decryption failure allowed the bootloader to continue booting plaintext encrypted firmware. Fixed: explicitreturn EOS_SBOOT_ERR_DECRYPTadded.image_verify.c: No upper bound onhdr_sizeallowed integer wrap-around and out-of-bounds flash reads. Fixed:hdr_size > 4096returnsEOS_ERR_INVALID.image_verify.c: No upper bound onimage_sizeallowed oversized flash reads. Fixed:image_size > 16MBreturnsEOS_ERR_INVALID.image_verify.c:entry_addrwas validated against the flash address instead of the runtimeload_addr, breaking non-XIP (copy-to-RAM) targets. Fixed: check now usesload_addras the runtime base.
image_verify.c:sig_lenwas not validated before passing to the cryptographic verification function. Fixed:sig_len == 0 || sig_len > EOS_SIG_MAX_SIZEreturnsEOS_ERR_SIGNATURE.fw_update.c: Integer overflow possible in progress calculation. Fixed: uses__builtin_add_overflow()and 64-bit arithmetic.
stage1/jump_app.c,core/slot_manager.c,core/recovery.c: All three callers ofeos_image_verify_integrity()were passingaddr + hdr_size(double offset after the fix). Fixed: all callers now pass the base flash address.
tests/run_comprehensive_tests.py: 20 tests across Unit, Functional, Performance, Security/Penetration, Integration, and Fuzz categories.tests/run_extended_tests.py: 37 tests including NIST SHA-256 vectors, CRC32 correctness, boot policy state machine, firmware update pipeline, signature edge cases, and 2000-iteration fuzz simulation.- Total: 57/57 tests passing (100% coverage).
This is the synchronized production release across all 18 EmbeddedOS-org repos.
- Refreshed governance: LICENSE, NOTICE, CITATION.cff, SECURITY.md
- CI/CD pipelines hardened: release.yml, book-build.yml, video-build.yml, deploy-pages.yml
- Release artifacts produced for: Linux x64/arm64, macOS x64/arm64, Windows x64, Docker, plus per-repo embedded/mobile/extension targets
- mdBook documentation built and deployed to GitHub Pages
- Promo video rendered and attached as a release asset
This is the synchronized production release across all 18 EmbeddedOS-org repos.
- Refreshed governance: LICENSE, NOTICE, CITATION.cff, SECURITY.md
- CI/CD pipelines hardened: release.yml, book-build.yml, video-build.yml, deploy-pages.yml
- Release artifacts produced for: Linux x64/arm64, macOS x64/arm64, Windows x64, Docker, plus per-repo embedded/mobile/extension targets
- mdBook documentation built and deployed to GitHub Pages
- Promo video rendered and attached as a release asset
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.2.0 - 2026-04-05
- eFlash unified flashing tool (
tools/eflash.py) — wraps 25 board-vendor flash tools behind a single CLI - Flash configuration for all 25 boards (
configs/flash_tools.yaml) - CMake
flash,flash_stage0, andflash_infotargets for one-command flashing - eFlash documentation (
docs/eflash.md) - Custom handlers for RPi4 (SD card copy) and QEMU (emulator launch)
eflash doctorcommand for system-wide tool availability audit--dry-runflag for all flash operations
0.1.0 - 2026-03-31
- Initial release of eboot
- Complete CI/CD pipeline with nightly, weekly, and QEMU sanity runs
- Full cross-platform support (Linux, Windows, macOS)
- ISO/IEC standards compliance documentation
- MIT license