Skip to content

fix(recovery): issue no challenge on a board without an entropy source - #131

Open
Kartikey1306 wants to merge 10 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/recovery-challenge-needs-entropy
Open

Kartikey1306 wants to merge 10 commits into
embeddedos-org:masterfrom
Kartikey1306:fix/recovery-challenge-needs-entropy

Conversation

@Kartikey1306

@Kartikey1306 Kartikey1306 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #130

Stacked on #127 (itself on #115); review a9c6a2e..00283e8 for this change alone (restacked onto #127's a9c6a2e, itself on #115's restacked e152d8e; corrected from 184f431..e3335f6, earlier from 89e2242..c3c8506; content unchanged throughout) -- one commit, five files: core/recovery.c, include/eos_types.h (EOS_LOG_AUTH_NO_ENTROPY 0x23), tools/uart_recovery.py (decodes it), tests/unit/test_recovery.c, CHANGELOG.md. Merge order: #115, #127, then this. Rebased once, onto #127's review follow-up 89e2242, which named the authentication events; this PR's event is named the same way and is covered by that commit's tests/unit/test_boot_log_event_names.py guard. This closes #127's review finding 2.

The defect

recovery_handle_auth() fell back to a linear congruential generator seeded with eos_hal_get_tick_ms() whenever eos_hal_rng_get() failed. The challenge is what stops a captured (challenge, response) pair from being replayed, and with that fallback it was a deterministic function of the millisecond at which the AUTH command was handled: a few thousand reachable values on a freshly reset board. The protocol shows the client the challenge before asking for the response, and a reset clears auth_fail_count, so a client holding one captured pair can reset, send AUTH, compare, and reset again at no cost until the captured challenge comes back.

Measured, not assumed:

  • rng_get is provided by 0 of the 83 board ops tables under boards/ (git grep -l rng_get boards/ is empty), so the fallback was the challenge on every board in the tree.
  • otp_read is provided by 0 of 83 as well, so no board can read a recovery secret today and the defect is latent -- the same status fix(recovery): refuse an unprovisioned secret, and a write into an unmapped slot #127 records for the unprovisioned-secret refusal. A port that adds OTP without an RNG would have gone live with it.

The fix

Without an entropy source there is no challenge worth sending, so the AUTH is refused outright: NACK, boot-log event EOS_LOG_AUTH_NO_ENTROPY (0x23, alongside EOS_LOG_AUTH_FAIL 0x21 for an unreadable secret and EOS_LOG_AUTH_UNPROVISIONED 0x22; tools/uart_recovery.py prints all of them), and counted as a failure so the existing backoff and the RCVR_MAX_AUTH_FAILS cap apply. A port that wants recovery authentication has to provide rng_get; the alternative -- deriving a challenge from the secret and a counter -- needs a monotonic counter no board provides either (also 0 of 83), and is not attempted here.

Verification

Check Result
cmake -B build -DEBLDR_BUILD_TESTS=ONcmake --build PASS, no new warnings
ctest --test-dir build --output-on-failure 31/31
tests/eboot_test_recovery 7/7, including the new test_auth_refuses_when_the_board_has_no_entropy_source
Negative control: git show a9c6a2e:core/recovery.c > core/recovery.c (184f431 and 89e2242 before the two restacks; same file each time), rebuild, rerun new test fails at tests/unit/test_recovery.c:483: out_buf[0] == RCVR_NACK -- the old code ACKs and sends a tick-seeded challenge
pytest tests -q 88 passed at 00283e8 (86 at e3335f6, 84 at c3c8506; the base's count grows with #115's and #127's restacks) (the boot-log event-name guard from #127's 89e2242 covers the new code: it is named in the header, used by name in core/, and decoded by the client)
Line endings core/recovery.c, include/eos_types.h and CHANGELOG.md stay CRLF (545→549, 175→176 and 136→137 CR bytes, one per added line); tests/unit/test_recovery.c and tools/uart_recovery.py stay LF

The test copies sim_ops with rng_get = NULL -- the shape of every real board -- scripts one AUTH and one WRITE, and asserts the reply is NACK NACK with no 32-byte challenge in between, the slot untouched, and event 0x23 in the boot log.

Not done here

  • docs/security.md:102 still lists recovery authentication as "Planned / Not started" although core/recovery.c implements it; a status correction, not a code change, so left for a docs PR.
  • No board port is given an rng_get; that is per-silicon work.

@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 96.00000% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
tests/unit/test_boot_log_event_names.py 97.14% 1 Missing and 1 partial ⚠️
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#131 "fix(recovery): issue no challenge on a board without an entropy source"

head: c3c8506 author: Kartikey1306 ci: pass (26 green, 0 red; Create GitHub Release skipped)

Verdict: First look. This is the fix for the High finding raised on #127, and it is the right
one: the tick-seeded LCG is gone, the AUTH is refused outright, the refusal is logged under a named
event the client decodes, and the failure is counted so the existing backoff applies. I reverted it
and the new test went red, so the test catches the defect it claims to. The fix itself needs
nothing. Two findings from the code around it, neither introduced by this PR: recovery_handle_info()
transmits three bytes of uninitialised stack on an unauthenticated command and its wire format
does not match this repo's own client — I reproduced both — and the diagnostic this PR adds is
unreadable over the channel that produces it.

Findings

# Severity File:line Finding Recommended fix
1 High (P1) — pre-existing, not introduced here core/recovery.c:283-305 (recovery_handle_info); tools/uart_recovery.py:121-129 RCVR_CMD_INFO sends three bytes of uninitialised bootloader stack to an unauthenticated caller, and the repo's own client prints them as the flash size. The response struct is { uint8_t ack; uint32_t flash_size; … }not __attribute__((packed)), unlike rcvr_packet_t fifteen lines above it, and never memset. Only the six named members are assigned, then eos_hal_uart_send(&info, sizeof(info)) transmits the whole object. Measured, not argued: sizeof(info) == 24, offsetof(flash_size) == 4, so bytes 1-3 are padding the code never writes. cmd_requires_auth() at :103-116 does not list RCVR_CMD_INFO, so any client on the UART can ask, repeatedly, before authenticating. Two consequences, both reproduced with the struct poisoned to 0xA5 and filled exactly as the firmware fills it: (a) the wire carries AA A5 A5 A5 … — three bytes of whatever the previous call left on the stack; (b) the client reads 1 + 4*5 = 21 bytes and unpacks '<IIIII' from response[1:21], i.e. it expects a packed 21-byte layout, so it decodes flash_size = 0x00A5A5A5literally the leaked stack bytes, printed to the operator's console as "Flash size" — and slot addresses of 0x00001000/0x00000400 against actual values of 0x08010000/0x08050000. The three surplus bytes stay in the serial buffer and desynchronise whatever command follows. RCVR_CMD_INFO has no test anywhere (grep -rn "CMD_INFO|handle_info" tests/ is empty), which is why this survived. Two lines in the firmware and nothing in the client: mark the struct __attribute__((packed)) the way rcvr_packet_t already is, and memset(&info, 0, sizeof(info)) before filling it so no future field leaves a hole. That makes the firmware match the 21-byte layout uart_recovery.py has always parsed, so the fix is a repair rather than a format change. Then add a test that drives RCVR_CMD_INFO through the sim_ops harness and asserts out_len == 21 and the five decoded values — tests/unit/test_recovery.c already has everything needed to script it. Separate PR, not this one.
2 Medium (P2) core/recovery.c:149-152 vs :103-116; tests/unit/test_recovery.c:490 EOS_LOG_AUTH_NO_ENTROPY cannot be read over the channel that emits it. The new test's comment — and the intent behind naming the event at all — is "so a field log tells the integrator what is missing". The only way to read the boot log over UART recovery is RCVR_CMD_LOG, and cmd_requires_auth() lists it, so it needs RCVR_AUTH_AUTHENTICATED. On a board with no rng_get that state is now unreachable by construction — which is the whole point of the fix — so the event that says "this board has no entropy source" is written to a log the integrator cannot fetch without the authentication the missing entropy source prevents. core/fw_services.c:116 eos_fw_read_boot_log() is the other reader, but that runs in booted firmware, and recovery is where you are precisely because the device is not booting normally. What the integrator actually observes is a bare NACK — indistinguishable from a wrong secret, an unreadable OTP, or an unprovisioned one, all of which also NACK. Meanwhile the cost of finding out is real: RCVR_BACKOFF_BASE_MS is 1000 and RCVR_MAX_AUTH_FAILS is 5, so five AUTH attempts on a board that can never answer burn 0+1+2+4+8 = 15s of watchdog-feeding busy-wait before the session refuses permanently. auth_fail_count is RAM-only and reset at :483, so nothing is bricked — I checked that before writing this. The unauthenticated RCVR_CMD_INFO response is the natural place: add a capability byte alongside the geometry (has_rng, has_otp, derived from ops->rng_get != NULL / ops->otp_read != NULL). It answers the question before the 15 seconds are spent, it leaks nothing an attacker cannot determine by trying, and it folds into finding 1's fix — the struct has to be touched anyway. Failing that, one sentence in docs/security.md's Recovery Authorization section saying a board without rng_get has no authenticated recovery and what that looks like on the wire.

What this PR gets right

The refusal is placed before the challenge is sent rather than after, so nothing derived from a weak
source ever reaches the client. It reuses the existing failure accounting instead of inventing a
parallel path, which means the backoff and the RCVR_MAX_AUTH_FAILS cap apply without a second
mechanism to keep in step. no_rng.rng_get = NULL copies sim_ops and nulls one member — the shape
of every real board port, rather than a synthetic error injection. And the test asserts the absence
of the 32-byte challenge (out_buf[0] and out_buf[1] are both verdicts, with nothing between them)
as well as the NACKs, which is what distinguishes "refused" from "answered then rejected". The
CHANGELOG entry states the replay mechanism concretely — a client sees each challenge before it
must answer, and a reset costs nothing — rather than asserting weakness in the abstract.

Worth recording because it bears on my finding on #127: EOS_LOG_AUTH_NO_ENTROPY 0x23 is written
without a trailing comment, so test_boot_log_event_names.py does collect it and the
header/client cross-check does apply here — 4 passed, 16 codes collected, AUTH_NO_ENTROPY among
them. That is the guard working by luck rather than by construction; the #127 finding stands.

§28 is honoured on the documentation side and I checked rather than assumed: docs/security.md:102
and docs/threat_model.md:139,215 both carry recovery authentication as Planned, so making it
structurally unreachable on every current board contradicts no claim in the tree.

Verification performed for this review

Detached worktree at .ai/autoreview/state/scratch/eBoot-131 on c3c85061. 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. The PR head was fetched into refs/autoreview/scratch131
read-only to isolate 89e2242c..c3c85061 (one commit) — the rest of the 30-file bundle diff belongs
to #115 and #127, which this branch is stacked on.

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.05s
eboot_test_recovery PASS — 7/7 (6 at #127's head)
pytest tests/ -q PASS — 84 passed, 1.95s
Negative control — the tick-seeded LCG fallback restored verbatim, rebuilt test_auth_refuses_when_the_board_has_no_entropy_source FAILS at test_recovery.c:483 (out_buf[0] == RCVR_NACK). Restored; git status --short empty.
grep -rl rng_get boards/ · grep -rl otp_read boards/ both empty — no board port provides either. The author's "0 of 83" claim is directionally right; I confirmed the count is zero, not the denominator.
Finding 1 reproduction — the INFO struct compiled and poisoned, filled as the firmware fills it sizeof == 24, offsetof(flash_size) == 4; wire AA A5 A5 A5 00 00 10 00 …; client's '<IIIII' decode → flash=0x00A5A5A5, slotA=0x00001000/524544, slotB=0x00000400/525568 against intended 0x08010000/262144, 0x08050000/262144
cmd_requires_auth() membership ERASE, WRITE, VERIFY, BOOT, FACTORY, LOG require auth; PING, INFO, RESET, AUTH do not — establishes both findings
RCVR_CMD_INFO test coverage nonegrep -rn "CMD_INFO|handle_info" tests/ is empty
auth_fail_count lifetime static in RAM, zeroed at core/recovery.c:483 on recovery entry — no persistent lockout, nothing is bricked
Backoff cost on a no-entropy board RCVR_BACKOFF_BASE_MS=1000, RCVR_MAX_AUTH_FAILS=5 → 0+1+2+4+8 = 15s of busy-wait across five attempts, then permanent NACK for the session
test_boot_log_event_names.py covers the new 0x23 PASS — 4 passed; 16 codes collected, AUTH_NO_ENTROPY included
Test isolation of eos_hal_init(&no_rng) Not a leaksetup() at test_recovery.c:242-251 re-installs sim_ops before every test. I checked because the test that runs next expects a real challenge.
docs/security.md, docs/threat_model.md recovery-auth status Planned in both — no documentation is made wrong by this change

Architecture conformance

Conforms. §21: eBoot is Tier 1 Foundation; core/, include/, tools/ and tests/unit/ are all
inside the owning repo, and §21.1 is not engaged. §5.1 dependency direction is untouched —
core/recovery.c calls down into hal/ and include/, and the new event code is defined in
include/eos_types.h, which depends on nothing. §14.1's hardware-root-of-trust clause is the one
this PR exists to satisfy: "target-specific hardware security adapters stay behind stable
interfaces"
, and .ai/security.md's reading of it — a target without hardware support degrades to
a documented, weaker posture; it does not silently pretend to have the strong one
— is exactly the
transition from the LCG fallback to a refusal. .ai/security.md's fail closed rule is satisfied
literally: "A verification step that cannot run must fail, not pass. A HAL returning
EOS_ERR_NOT_SUPPORTED is not success."
§8.1's "crash/health information available to update
logic"
is where finding 2 sits — an event written to a log no one in that situation can read is not
available in any useful sense, which is the same clause #127's finding 1 was measured against.
Finding 1 is measured against .ai/security.md's "no device secrets or firmware plaintext in logs or
trace output" read in its obvious generalisation: uninitialised TCB stack must not leave the device,
least of all before authentication.

Proposed changes

This PR: mergeable on its own merits once #115 and #127 land. The fix is correct,
minimal, and pinned by a test I verified catches its absence. Nothing to change here.

Separate PR, priority order:

  P1  core/recovery.c  recovery_handle_info():
        __attribute__((packed)) on the response struct, as rcvr_packet_t already has
        memset(&info, 0, sizeof(info)) before filling
      tests/unit/test_recovery.c
        drive RCVR_CMD_INFO; assert out_len == 21 and the five decoded values
      -> this makes the firmware match tools/uart_recovery.py's existing parser;
         no client change, and it closes the unauthenticated stack leak   (finding 1)

  P2  core/recovery.c  a capability byte in the same INFO response
        (ops->rng_get != NULL, ops->otp_read != NULL), so an integrator learns
        why AUTH refuses without spending 15s to be told nothing      (finding 2)
      docs/security.md  one sentence under Recovery Authorization

Merge order: this branch is stacked on #127, which is stacked on #115. It cannot land before
them.

No fix PR opened. Finding 1 is High and the change is small, so it is the kind the brief allows —
but it alters what a bootloader puts on a recovery UART, and fix-verify.sh can only prove the host
suite still passes, not that a real client on a real board reads the new framing correctly. There is
no board port and no hardware here to check that against. Changing a TCB wire format unattended is
not a call I should make; the finding names the two lines and the test that would prove them, and it
belongs to a human. Finding 2 is Medium and outside the autofix rule regardless.

Not checked

  • No hardware. Everything about recovery comes from the host simulation in
    tests/unit/test_recovery.c (sim_ops, sim_flash). Real UART framing and timing were NOT
    exercised; finding 1's wire layout is Verified against a host compiler with the same struct, and
    that an ARM target lays it out identically is Inferred from the AAPCS alignment rules, not
    measured — though any padding at all reproduces the leak, and the client/firmware size mismatch
    needs only sizeof != 21.
  • tools/uart_recovery.py was not executed against a device, only read. That its info command
    misreports is Inferred from the parser and the measured layout, not observed on a wire.
  • The replay this PR prevents was not demonstrated. I did not build a replay against the old LCG
    or measure how many distinct challenges it yields across realistic tick_ms values. The claim I
    am endorsing is the narrow one — the fallback was deterministic given the tick, and it was the only
    path any board took.
  • tests/fuzz/fuzz_recovery_protocol.c — NOT built or run. It drives the same protocol and would
    be the natural place to reach RCVR_CMD_INFO; I did not check whether it does.
  • Cross-compile, ASan/UBSan, Valgrind, CodeQL, cppcheck/clang-tidy — NOT RUN locally. CI green on
    this head; logs not read. Note that a host build cannot show finding 1's leak as a failure, and
    neither ASan nor Valgrind would flag it — the padding is addressable, merely uninitialised, so
    MemorySanitizer is the tool that would, and it is not in the matrix.
  • The #115 and #127 halves of the 30-file diff were not re-reviewed here. This review covers
    89e2242c..c3c85061 only. If either changes, the suite numbers above stop describing what merges.

Automated architecture review of c3c850619735 — 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.

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.
@Kartikey1306
Kartikey1306 force-pushed the fix/recovery-challenge-needs-entropy branch from c3c8506 to e3335f6 Compare September 15, 2026 06:27
@Kartikey1306

Copy link
Copy Markdown
Contributor Author

Restacked onto #127's 184f431 (its 22:38Z finding), head now e3335f6. Content unchanged — one commit, same five files; git range-diff 89e2242..c3c8506 184f431..e3335f6 is empty. At e3335f6: ctest 31/31, pytest 86, and #127's event-name guard (which now reads commented defines) sees EOS_LOG_AUTH_NO_ENTROPY — 6 passed.

Nothing to change in this PR, as the review says. Both findings are taken up separately, as asked:

Finding 1 (P1, pre-existing) and finding 2 (P2) go in one PR stacked on this one, because they touch the same struct — recovery_handle_info's response gets __attribute__((packed)) + memset (the three leaked padding bytes; makes the firmware match the 21-byte layout uart_recovery.py has always parsed) and the capability byte (has_rng, has_otp from the ops table), so an integrator on a board with no entropy source learns it from the unauthenticated INFO response instead of after 15 s of backoff. Test drives RCVR_CMD_INFO through sim_ops, asserts out_len and every decoded value, with the struct poisoned to 0xA5 first so a hole would show. I'll link it here when it's open.

Merge order: #115#127#131 → that one. Range in the body corrected to 184f431..e3335f6. No approval existed at c3c8506 to dismiss.

…mapped slot

Two wire-facing checks in core/recovery.c were weaker than the code around
them said.

recovery_handle_auth() read the shared secret from OTP and compared the
client's response against SHA-256(challenge || secret) with no look at
what the secret was. Unprogrammed fuses read back as all zeros or all
ones, and both are public: a device whose recovery secret was never
provisioned authenticated any client that sent SHA-256(challenge || 00..00).
The keystore already refuses an all-zero key for exactly this reason; the
recovery path now refuses both patterns before comparing, branch-free so
the check does not leak which value the fuses hold, and logs event 0x22.
No shipped board port provides otp_read today, so on current boards
authentication always failed and this was latent; it is the first board
with OTP that would have shipped it.

recovery_handle_write() had stopped calling eos_recovery_write_in_range()
-- the rule tests/unit/test_recovery.c exercises, which refuses a slot the
board leaves unmapped -- and checked offset + len against the slot size
inline. embeddedos-org#55's replay did that while repairing a duplicate declaration. For
a slot whose base is 0 the write then landed at flash address `offset`,
which in the test layout is between the boot-control block and its backup.
The handler uses the shared rule again, so the wire has one definition.

Tests: an all-zero and an all-ones secret are refused and the write that
follows is refused too; the same exchange with a provisioned secret still
authenticates and writes (the control); a write into an unmapped slot B is
refused and flash at the would-be address is untouched. Each new test was
run against the unfixed handler: the all-zero secret authenticated
(out_buf[33] was ACK) and the unmapped write was accepted (out_buf[34] was
ACK).
…ode them

core/recovery.c logged its authentication outcomes as bare 0x20, 0x21
and 0x22 with a comment for a name, while include/eos_types.h defines
EOS_LOG_* for every other event and tools/uart_recovery.py maps codes to
names for its `log` command. That table ended at 0x21, so the event this
branch adds -- "this device was never provisioned", the one condition
the fix exists to distinguish from a guessed secret -- printed as
UNKNOWN(0x22) on the client that exists to read it.

EOS_LOG_AUTH_SUCCESS, EOS_LOG_AUTH_FAIL and EOS_LOG_AUTH_UNPROVISIONED
now sit beside the other EOS_LOG_* codes and recovery.c uses them by
name; the client's BOOT_LOG_EVENT_NAMES gains 0x22. The new guard
tests/unit/test_boot_log_event_names.py holds the three lists together:
every eos_boot_log_append() in core/, stage0/ and stage1/ passes an
EOS_LOG_* name, every code the header defines is in the client's table
under the same name, and the client names nothing the header does not
define. Against the previous commit it fails on the four bare literals
and on the two codes the client named without a definition.

The CHANGELOG entry also records the wire change the shared range rule
brings with it: a WRITE with len == 0 is refused where the inline check
had ACKed it and written nothing.
…omment

header_events() anchored its pattern on end of line, so an EOS_LOG_*
define written in this header's own style -- a trailing comment, as
EOS_LOG_MAGIC already has -- dropped out of the client cross-check
without a trace. Demonstrated by the review with the very next code:
EOS_LOG_AUTH_NO_ENTROPY 0x23 with a comment, no client entry, 4 passed.
Under-collection is the failure mode a parser cannot report on its own.

The match now stops at the value. Two tests pin it: a sample header
with C and C++ trailing comments parses to the expected map, and the
strict pattern is cross-checked against a looser count of the same
lines in the real header, so a style it misses shows up as a mismatch.
With the review's probe re-applied, the guard now fails where it
should: "BOOT_LOG_EVENT_NAMES lacks: ['EOS_LOG_AUTH_NO_ENTROPY = 0x23']".
recovery_handle_auth() fell back to a linear congruential generator
seeded with eos_hal_get_tick_ms() whenever eos_hal_rng_get() failed.
The challenge is what 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
reachable values on a freshly reset board, shown to the client before
it has to answer, retryable after a reset at no cost in failure count.

No board port provides rng_get (0 of the 83 ops tables under boards/),
so the fallback was the challenge on every board. None provides
otp_read either, so no board authenticates anyone today and the defect
was latent; a port that adds OTP without an RNG would have gone live
with it.

The AUTH is now refused outright: NACK, boot-log event
EOS_LOG_AUTH_NO_ENTROPY (0x23, defined beside the other EOS_LOG_* codes
and decoded by tools/uart_recovery.py), and counted as a failure so the
existing backoff and the RCVR_MAX_AUTH_FAILS cap apply. Pinned by
test_auth_refuses_when_the_board_has_no_entropy_source, which fails
against the old code at the first byte of the reply.
@Kartikey1306

Copy link
Copy Markdown
Contributor Author

Restacked again onto #127's a9c6a2e (which is on #115's e152d8e, the master-682d005 restack). Head 00283e8; git range-diff against e3335f6 is empty. ctest 31/31, pytest 88 at this head.

The separate PR promised above is open: #139 — packed + zeroed INFO struct (the three leaked padding bytes), the caps byte for finding 2, client reads 22 and refuses 21, five tests with negative controls on both files. Stacked on this at 00283e8.

@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#131 "fix(recovery): issue no challenge on a board without an entropy source"

head: 00283e8 author: Kartikey1306 ci: pass (26 green, 0 red; only Create GitHub Release skipped)

Verdict: Follow-up review. This head is a pure re-stack — the PR's single commit is
byte-identical to the reviewed c3c85061 by git patch-id, after two hops as #127 and then #115
rebased beneath it. Both previous findings were pre-existing and were correctly scoped out; #139
is now open and covers both
, which is what the previous review asked for. Nothing to change here
and no new findings. One thing improved without this branch touching anything: the caveat the last
review attached to its own evidence — that the event-name guard covered 0x23 by luck — is now
closed by #127's parser fix in the base, and I demonstrated it.

Status of the previous findings (eBoot-131-c3c85061.md)

# Prev. severity Status Evidence
1 High (P1), pre-existing — RCVR_CMD_INFO transmits three bytes of uninitialised stack to an unauthenticated caller, and the repo's own client decodes them as the flash size Open here, correctly. Moved to #139. Untouched at this head, as intended — the commit is patch-identical and changes no INFO code. #139 is OPEN, MERGEABLE, titled "fix(recovery): INFO sent three bytes of stack to whoever asked, and the client printed them as the flash size", and stacked on 00283e8. The author's description says it does both the __attribute__((packed)) + memset repair and the caps byte, with negative controls on both files. I have not reviewed #139's contents — that is a separate PR and a separate review.
2 Medium (P2) — EOS_LOG_AUTH_NO_ENTROPY cannot be read over the channel that emits it, because RCVR_CMD_LOG requires the authentication the missing entropy prevents Open here, correctly. Moved to #139. Same: untouched at this head, folded into #139's INFO response as the recommended capability byte.

Findings

# Severity File:line Finding Recommended fix
None.

The previous review's conclusion on the diff itself — "the fix is correct, minimal, and pinned by a
test I verified catches its absence; nothing to change here"
— stands unchanged, because the diff is
unchanged. Per the brief I am not manufacturing a finding to justify commenting again.

Forward note for whoever reviews #139, not a finding against this PR. The previous review
recommended making the firmware match the 21-byte layout tools/uart_recovery.py has always parsed,
so the fix would be a repair with no client change. The author's description of #139 says the
response instead grows a caps byte and the client "reads 22 and refuses 21" — a deliberate and
defensible choice, since it closes finding 2 in the same place, but it is a wire-format change
rather than a repair, and brief item 8 asks for the compatibility impact and migration path to be
stated for exactly that. Worth checking there that the PR body says what happens to a client
speaking the old framing.

Verification performed for this review

Detached worktree at .ai/autoreview/state/scratch/eBoot-131 off refs/pull/131/head. The user's
eBoot checkout was not touched; nothing was committed or pushed. Both probes below were reverted
and git diff --stat confirmed empty afterwards.

Check Result
Is this head new work or a re-stack? RE-STACK, confirmed. One own commit at each head; git patch-id --stable of c3c85061 and 00283e82 are identical. The parent moved 89e2242a9c6a2e (#127's own follow-up and rebase), which itself sits on e152d8e (#115's rebase). The author's two "range-diff is empty" claims are consistent with this.
cmake -B build/host -DEBLDR_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debugcmake --build --parallel 4 PASS, no errors
ctest --test-dir build/host --output-on-failure --no-tests=error PASS — 31/31, 11.92s
eboot_test_recovery PASS — 7/7
EOS_REQUIRE_SIGNING_TESTS=1 pytest tests/ -q PASS — 88 passed, 2.49s. The author's comment says 88 at this head; that matches exactly.
pytest tests/unit/test_boot_log_event_names.py -q PASS — 6 passed (4 at the previous review; #127's two parser tests arrived in the base)
The previous review's "by luck" caveat, now tested Closed by construction. Last time I recorded that EOS_LOG_AUTH_NO_ENTROPY 0x23 is collected only because it happens to carry no trailing comment. Probe at this head: added /* board provides no rng_get */ to the define → guard still 6 passed (the client entry exists); then also deleted 0x23: "AUTH_NO_ENTROPY" from tools/uart_recovery.py1 failed, 5 passed, failing test_client_names_every_event_the_header_defines at :101. Under the old parser the commented define would have dropped out and both probes would have passed. #127's fix, which this branch is stacked on, is what makes the coverage real.
#139 state OPEN, MERGEABLE.
Was anything weakened? No. No new source since the last review; ctest 31/31, eboot_test_recovery 7/7 and the guard module all unchanged or improved.

Architecture conformance

Conforms; re-checked rather than recalled. §21: eBoot is Tier 1 Foundation; core/, include/,
tools/ and tests/unit/ are all inside the owning repo, and §21.1 is not engaged. §5.1 dependency
direction is untouched — core/recovery.c calls down into hal/ and include/, and the event code
is defined in include/eos_types.h, which depends on nothing. §14.1's hardware-root-of-trust clause
is what this PR satisfies, read through .ai/security.md: a target without hardware support
degrades to a documented, weaker posture; it does not silently pretend to have the strong one
— the
transition from a tick-seeded LCG fallback to an outright refusal is exactly that. .ai/security.md's
fail closed rule is satisfied literally. §8.1's "crash/health information available to update
logic"
is where the previous finding 2 sits, and it moves to #139. §28 is honoured on the
documentation side: docs/security.md:102 and docs/threat_model.md:139,215 carry recovery
authentication as Planned, so making it structurally unreachable on every current board
contradicts no claim in the tree. No new proposal appended — nothing at this head reveals a gap
in the master design that is not already filed.

Blocked / stale status

Green, correct, and waiting on its stack: this branch is #115#127#131, and cannot land
before both. No finding against its own diff is open. That is the whole of what blocks it.

Proposed changes

Nothing to change in this PR.

Merge order:  #115 -> #127 -> #131 -> #139

Both previous findings are carried by #139 and are reviewed there, not here:
  P1  packed + memset the RCVR_CMD_INFO response (unauthenticated stack leak)
  P2  capability byte so an integrator is told, not left to spend 15s of backoff

Not checked

  • The negative control was NOT re-run at this head. Restoring the tick-seeded LCG and watching
    test_auth_refuses_when_the_board_has_no_entropy_source go red was done at c3c85061; the commit
    is patch-identical here, so the result carries — but that is Inferred from patch-id, not
    re-observed. The passing eboot_test_recovery 7/7 is the direct evidence I have at this SHA.
  • #139's contents were NOT reviewed — only its state, mergeability and title. The forward note
    above is drawn from the author's own description of it, not from its diff.
  • No hardware. Everything about recovery comes from the host simulation in
    tests/unit/test_recovery.c (sim_ops, sim_flash). Real UART framing and timing were NOT
    exercised.
  • tools/uart_recovery.py was not executed against a device, only read and mutated for the guard
    probe.
  • The replay this PR prevents was not demonstrated. Unchanged from the previous review: I did not
    build a replay against the old LCG.
  • tests/fuzz/fuzz_recovery_protocol.c — NOT built or run.
  • Cross-compile, ASan/UBSan, Valgrind, CodeQL, cppcheck/clang-tidy — NOT RUN locally. CI green on
    this head; job logs not read.
  • The #115 and #127 halves of the 30-file bundle diff were not re-reviewed here. See
    reports/eBoot-115-e152d8ed.md and reports/eBoot-127-a9c6a2e3.md.

Automated architecture review of 00283e82dd82 — 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

Ack for the 06:58Z re-review at 00283e8 — pure re-stack, single commit byte-identical after two hops, as you found. Both carried findings are no longer only scoped out; they are taken, in #139 (fix/recovery-info-leaks-stack-padding, e14456f, stacked on this head, closes #140):

  • Finding 1 (P1) — the INFO struct is packed and zeroed; the wire is the 21-byte layout the client always parsed, plus one byte. Test drives RCVR_CMD_INFO through sim_ops with out_buf poisoned 0xA5 first, asserts out_len == 22, every field at the client's offsets, and the byte after untouched. Old recovery.c with the test kept fails at out_len == INFO_WIRE_LEN (sends 24).
  • Finding 2 (P2) — that extra byte is caps (RCVR_CAP_RNG | RCVR_CAP_OTP, from the ops table), so a board with no entropy source says so in the unauthenticated INFO response, before the 15 s of backoff. Client reads 22, decodes it, refuses 21.

Nothing to change here; merge order #115#127#131#139.

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.

UART recovery: without an RNG the auth challenge is a function of the millisecond tick, so a captured response replays

3 participants