fix(recovery): refuse an unprovisioned secret, and a write into an unmapped slot - #127
Conversation
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#127 "fix(recovery): refuse an unprovisioned secret, and a write into an unmapped slot"
head: 26ac2d4 author: Kartikey1306 ci: pass (26 green, 0 red; Create GitHub Release and assign skipped)
Verdict: Both fixes are correct, minimal, and land in the right place — and unusually for a
security fix, the tests provably catch the defects: I reverted each change independently and the
matching test failed each time, then passed again when restored. Reviewed a276016..26ac2d4
(one commit) as the body asks; the rest of the 27-file diff belongs to #115. Three findings, none
of them in the fix itself: a new boot-log event code that the shipped recovery client cannot
decode, a fail-open RNG path eight lines above the fix that the body's "Not changed" list omits,
and an unannounced wire behaviour change for a zero-length WRITE.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium (P2) | core/recovery.c:202; tools/uart_recovery.py:62-77 |
Event 0x22 is emitted but nothing can decode it — including this repo's own recovery client. The boot log is a serialized record read off the device (§8.1: crash/health information available to update logic), and tools/uart_recovery.py maps codes to names at :86, printing the result in the log command at :268. Its table ends at 0x21: "AUTH_FAIL". I ran the lookup: 0x20 → AUTH_SUCCESS, 0x21 → AUTH_FAIL, 0x22 → UNKNOWN(0x22). So the one signal that distinguishes "someone is guessing the secret" from "this device was never provisioned" — the exact condition this PR exists to detect — is the one a field engineer reading the boot log cannot name. It is also the only event code with no symbolic definition anywhere: include/eos_types.h:134-145 defines EOS_LOG_BOOT_START … EOS_LOG_BOOT_FAIL, and core/recovery.c bypasses that namespace for 0x20/0x21/0x22 with bare hex and a trailing comment. The 0x20/0x21 precedent is pre-existing; this PR adds the third instance, and tests/unit/test_uart_recovery.py:76 pins 0x21 without pinning the table against core/, so the drift is silent in both directions. |
Two lines and a test: 0x22: "AUTH_UNPROVISIONED" in BOOT_LOG_EVENT_NAMES, and #define EOS_LOG_AUTH_UNPROVISIONED 0x22 (with 0x20/0x21) beside the existing EOS_LOG_* codes in include/eos_types.h, used by name at core/recovery.c:202. Then a test that asserts every eos_boot_log_append( literal in core/ appears in BOOT_LOG_EVENT_NAMES — the same shape as the suffix-coverage test in #122, and the thing that stops the next code from drifting too. |
| 2 | High (P1) — pre-existing, not introduced here; do not block this merge on it | core/recovery.c:141-149 |
When the RNG is unavailable the challenge is fabricated from a tick-seeded LCG, and that is the only path any current board takes. eos_hal_rng_get() failing falls through to seed = eos_hal_get_tick_ms(); seed = seed*1103515245 + 12345; challenge[i] = seed >> 16. hal/hal_core.c:172 returns EOS_ERR_NOT_SUPPORTED when the board provides no rng_get, and git grep rng_get boards/ returns nothing — no shipped board port provides one. A challenge exists for freshness, not secrecy; a predictable one makes a captured (challenge, response) pair replayable by an attacker who can reset the device and re-enter recovery, which is the recovery threat model exactly. This is the same failure shape as the bug the PR fixes, and .ai/security.md names it first: "A verification step that cannot run must fail, not pass. A HAL returning EOS_ERR_NOT_SUPPORTED is not success." I am recording it at its severity rather than rounding down, but flagging scope honestly: it is untouched by this commit, it is latent for the same reason the author documents for OTP (no board provides otp_read either, so authentication fails outright today), and closing it belongs in its own change. The reason it is here at all is that the body's "Not changed" section enumerates the RESET/INFO exemptions and the SHA-256(challenge ‖ secret) construction and correctly argues neither is a bypass — and omits this one, which is. |
Refuse: if (rc != EOS_OK) { auth_fail_count++; eos_boot_log_append(<a new code>, EOS_SLOT_NONE, auth_fail_count); return recovery_send_nack(); }. A board with no entropy source has no authenticated recovery, and should say so rather than simulate one. Separate issue and separate PR; add it to the body's "Not changed" list in the meantime so it is recorded rather than implied safe. |
| 3 | Low (P3) | core/recovery.c:310 |
A zero-length WRITE now NACKs where it previously ACKed, and the body does not say so. The replaced inline test was slot_size == 0 || (uint64_t)offset + len > (uint64_t)slot_size, which for len == 0 is false — the handler ACKed, called eos_hal_uart_recv(buf, 0, …) and eos_hal_flash_write(base, buf, 0). eos_recovery_write_in_range() refuses len == 0 at core/recovery.c:281. The direction is safe and the new behaviour is the better one, so this is not a defect — but it is a change in what a recovery client sees on the wire, and brief item 8 is that silence about a behaviour change is itself the finding. Observed by reading both expressions, not executed. |
One sentence in the PR body and the CHANGELOG.md entry: a WRITE with len == 0 is now refused. |
Not a finding, recorded because it reads like one: the new early return at core/recovery.c:196-203
leaves shared_secret on the stack without running the volatile zeroing loop at :211-212. On
that path the buffer holds all zeros or all 0xFF by construction — there is nothing to disclose —
so the omission is harmless. The pre-existing OTP-read-failure return at :187-192 has the same
shape and additionally leaves the buffer uninitialised; also harmless, also untouched here.
What this PR gets right
The unprovisioned check is placed correctly: after the OTP read and before the secret reaches
eos_sha256_update(), so an unprovisioned device never computes a response at all. The accumulator
loop is genuinely branch-free over the secret's bytes and only the aggregate is branched on, which
is the right granularity — the two outcomes are distinguishable anyway. test_auth_accepts_a_provisioned_secret
is a real control rather than decoration: without it, test_auth_refuses_an_unprovisioned_secret
could pass because the scripted exchange was broken. And routing the write bounds back through
eos_recovery_write_in_range() means the wire, the unit tests (tests/unit/test_recovery.c:371)
and the fuzz harness (tests/fuzz/fuzz_recovery_protocol.c:41) now all exercise one definition —
the drift between them is what let #55's replay reintroduce this.
Verification performed for this review
Detached worktree at .ai/autoreview/state/scratch/eBoot-127 on refs/pull/127/head. The user's
eBoot checkout was clean before and after and was not touched; nothing was committed or pushed.
refs/autoreview/pr127 was not present locally, so I fetched refs/pull/127/head read-only into a
scratch ref to isolate a276016..26ac2d4.
| Check | Result |
|---|---|
cmake -B build/host -DEBLDR_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug → cmake --build --parallel 4 |
PASS, no new warnings from this diff |
ctest --test-dir build/host --output-on-failure --no-tests=error |
PASS — 31/31, 10.97s — matches the body's claim |
eboot_test_recovery |
PASS — 6/6 (3 before this commit) |
Negative control 1 — if (all_zero == 0 || all_ones == 0xFF) forced to if (0), rebuilt |
test_auth_refuses_an_unprovisioned_secret FAILS at test_recovery.c:299 (out_buf[33] == RCVR_NACK). The test catches the defect it claims to. |
Negative control 2 — the old inline slot_size == 0 || offset + len > slot_size restored, rebuilt |
test_write_refuses_a_slot_the_board_leaves_unmapped FAILS at test_recovery.c:364 (out_buf[34] == RCVR_NACK); the unprovisioned tests still pass, so the two fixes are independently pinned. |
| Both reverts restored, rebuilt | 6/6 pass; git status clean |
Boot-log decode of the new event, via tools/uart_recovery.py |
0x20 → AUTH_SUCCESS, 0x21 → AUTH_FAIL, 0x22 → UNKNOWN(0x22) — finding 1 |
git grep rng_get boards/, git grep otp_read boards/ |
both empty — confirms the author's latency argument for OTP, and establishes finding 2 |
eos_recovery_write_in_range() read against the removed inline check |
Adds base == 0 (the fix) and len == 0 (finding 3); wrap and underflow handling unchanged |
Placement of the new check relative to eos_sha256_update(&ctx, shared_secret, …) |
Correct — the secret is refused before it is consumed |
Architecture conformance
Conforms. §21: eBoot is Tier 1 Foundation and every file touched is inside the owning repo; no new
repository is implied, so §21.1 is not engaged. §5.1 dependency direction is untouched —
core/recovery.c calls downward into hal/ and include/, and nothing points up a tier. §5.1's
"eBoot keeps the trusted computing base minimal and auditable" is served in both directions here:
the write path now has one definition of its bounds rule instead of two that had already drifted
apart once, which is auditability, and deleting an inline duplicate of a security check is the same
move #122 makes for the artifact scan. §8.1 ("factory/recovery image strategy"; "crash/health
information available to update logic") is what finding 1 is measured against — a health record
whose codes are defined in no header and decoded by no tool is not available to update logic in any
useful sense. §14.1's hardware-root-of-trust clause is what finding 2 is measured against: a target
without hardware support is required to degrade to a documented, weaker posture, not to silently
synthesise the strong one from a tick counter.
Proposed changes
In this PR (small, and the PR is the natural place):
tools/uart_recovery.py 0x22: "AUTH_UNPROVISIONED" in BOOT_LOG_EVENT_NAMES
include/eos_types.h EOS_LOG_AUTH_{SUCCESS,FAIL,UNPROVISIONED} beside the
existing EOS_LOG_* codes; use them in core/recovery.c
CHANGELOG.md / body say that a zero-length WRITE is now refused (finding 3)
Separate issue, not this PR:
core/recovery.c:141-149 refuse when eos_hal_rng_get() fails, instead of
falling back to a tick-seeded LCG (finding 2)
tests/ a test asserting every eos_boot_log_append() literal
in core/ is present in BOOT_LOG_EVENT_NAMES
No fix PR opened. Finding 1's fix has to land alongside the 0x22 emission, which exists only
on this branch, so a branch cut from origin/master — what fix-start.sh produces — would add a
name for an event nothing emits. Finding 2 is on origin/master and would qualify, but it is not
the "small and provable" kind: refusing where the code currently falls back changes recovery
behaviour on every board, and I cannot verify the consequences without hardware. It belongs to a
human.
Not checked
- No hardware. Every statement about recovery is from the host simulation in
tests/unit/test_recovery.c(sim_ops,sim_flash). The UART framing, timing, and the real
eos_hal_otp_read()on a board with fuses were NOT exercised — no board port provides
otp_readat all, so no such path exists to exercise yet. - Finding 2's replay is reasoned, not demonstrated. I did not build a replay against a device
or measure how many distinct challenges the LCG produces across realistictick_msvalues at
reset. The claim I am making is the narrow one: the fallback is deterministic given the tick, and
it is the only path a current board takes. The step from there to a practical replay is
Inferred. tests/fuzz/fuzz_recovery_protocol.cwas NOT run. I read it to confirm it drives the same
helper; I did not build the fuzz harness or run a campaign.- The #115 half of the diff was not reviewed here —
core/sha512.c,core/ed25519_verify.c,
tools/gen_fw_update_test_sigs.pyand the workflow changes are 24 of the 27 files and belong to
that PR. This review coversa276016..26ac2d4only. If #115 changes, this PR'sctestresult
above no longer describes what will merge. - ASan/UBSan, Valgrind, cross-compile, CodeQL, cppcheck/clang-tidy — NOT RUN locally. CI green
on this head; logs not read. mergeStateStatusisBLOCKED—reviewDecision: REVIEW_REQUIREDplus the #115 dependency.
I did not verify #115 is mergeable.
Automated architecture review of 26ac2d445224 — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
|
Thanks — all three taken, in Finding 1 — Finding 2 — the tick-seeded LCG. Agreed on every point, including the scope: it is not in this PR. It is issue #130 and PR #131, stacked on this head, which refuses the AUTH outright when Finding 3 — a zero-length On the not a finding: agreed the early return leaves an all-zero/all-FF buffer, which is nothing to disclose; left as is. Full suite on |
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#127 "fix(recovery): refuse an unprovisioned secret, and a write into an unmapped slot"
head: 89e2242 author: Kartikey1306 ci: pass (26 green, 0 red; Create GitHub Release skipped)
Verdict: Follow-up review of 26ac2d44..89e2242c (one commit). All three findings from the
previous review are resolved, and the guard the author wrote for finding 1 is stricter than the
one I asked for — it pins three directions instead of one. The author's negative control reproduced
exactly. One new finding against that guard, Medium: its header parser anchors on end-of-line, so an
EOS_LOG_* define written in this header's own existing style — with a trailing comment — drops out
of the client cross-check silently. I demonstrated it with EOS_LOG_AUTH_NO_ENTROPY 0x23, which is
the very next code #131 adds on top of this head.
Previous findings — status
| # | Previous finding | Status | Evidence |
|---|---|---|---|
| 1 | Medium (P2) — event 0x22 emitted as a bare literal, decoded by nothing, including this repo's own recovery client |
Resolved in 89e2242 |
include/eos_types.h:146-151 defines EOS_LOG_AUTH_SUCCESS/FAIL/UNPROVISIONED beside the other EOS_LOG_* codes; core/recovery.c:185,202,219,224 log by name at all four sites, including the two pre-existing bare 0x20/0x21; tools/uart_recovery.py:77 gains 0x22: "AUTH_UNPROVISIONED". The guard tests/unit/test_boot_log_event_names.py pins more than I asked: no bare literal at any eos_boot_log_append() in core//stage0//stage1/, every header code named by the client under the same name, and no client entry without a define behind it. Negative control reproduced: restoring the three files to 26ac2d4 gives 3 failed, 1 passed, exactly the count the author reported. |
| 2 | High (P1), pre-existing — eos_hal_rng_get() failure falls back to a tick-seeded LCG, the only path any current board takes |
Correctly scoped out, and now recorded rather than implied safe | The body's Not changed list names the bypass and points at issue #130 / PR #131 instead of omitting it, which is what the finding asked for. The defect itself is untouched here and remains open on origin/master; core/recovery.c:141-149 at this head is byte-identical to 26ac2d4. #131 is in this same review batch and I review it separately — this PR is not the place to close it, and blocking on it would be wrong. |
| 3 | Low (P3) — a zero-length WRITE now NACKs where it previously ACKed, unannounced |
Resolved in 89e2242 |
CHANGELOG.md:6 now states it in the entry itself: "which also refuses a WRITE with len == 0, where the inline check had ACKed it and written nothing; a recovery client sees a NACK there now". Also in the body's Not changed section. |
The not a finding from last time — the early return at core/recovery.c:199-204 skipping the
volatile zeroing loop — is unchanged, still harmless for the same reason, and the author agreed on
the thread. Not restated.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium (P2) | tests/unit/test_boot_log_event_names.py:40 |
The guard's header parser requires the #define to end at the hex value, so any event code written with a trailing comment is invisible to it — and this header's existing style puts comments there. header_events() matches r"^\s*#define\s+EOS_LOG_([A-Z0-9_]+)\s+(0x[0-9A-Fa-f]+)\s*$". EOS_LOG_MAGIC at include/eos_types.h:50 is already written 0x454C4F47 /* "ELOG" */, so the style is in the file the test reads. Demonstrated, not argued: I added #define EOS_LOG_AUTH_NO_ENTROPY 0x23 /* board provides no rng_get */ to eos_types.h, deliberately left tools/uart_recovery.py alone, and ran the module — 4 passed. A new event that the field client decodes as UNKNOWN(0x23) sails through the guard whose entire purpose is to stop that. This is not hypothetical: the author states on the thread that #131 adds exactly EOS_LOG_AUTH_NO_ENTROPY 0x23, and it is in this same batch. The guard catches a commented define today only by accident — core/recovery.c emits EOS_LOG_AUTH_UNPROVISIONED by name, so rule 1 trips on the missing define — and that incidental cover disappears for any code defined but not yet emitted from those three directories. I confirmed the narrower half separately: comment out AUTH_UNPROVISIONED and delete 0x22 from the client table and test_client_names_every_event_the_header_defines still passes; only the two rules that mention the name explicitly fail. |
Drop the end anchor and stop at the value: r"^\s*#define\s+EOS_LOG_([A-Z0-9_]+)\s+(0x[0-9A-Fa-f]+)\b". Then add the case as a test — write a temp header with a commented define and assert header_events() still finds it — because the failure mode here is under-collection, and a parser that silently returns fewer rows than the file contains cannot fail loudly on its own. The existing assert events at :42 only catches finding nothing. Worth doing in this PR: it is one character of regex plus a test, and #131 is the first thing that will hit it. |
Verification performed for this review
Detached worktree at .ai/autoreview/state/scratch/eBoot-127 on 89e2242c. 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. 26ac2d44..89e2242c was not present locally, so the PR
head was fetched into refs/autoreview/scratch127 read-only. Every probe below was reverted and the
worktree confirmed clean (git status --short empty) before the next one.
| Check | Result |
|---|---|
cmake -B build/host -DEBLDR_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug → cmake --build --parallel 4 |
PASS |
ctest --test-dir build/host --output-on-failure --no-tests=error |
PASS — 31/31, 11.07s — matches the body's claim |
eboot_test_recovery |
PASS — 6/6, including both fixes' tests |
pytest tests/ -q |
PASS — 84 passed, 1.84s — matches the body's claim of 84 (80 + 4) |
Negative control, author's claim re-run — include/eos_types.h, core/recovery.c, tools/uart_recovery.py restored to 26ac2d4 |
3 failed, 1 passed — the exact count reported. The four bare literals and the two client-named codes with no define are both caught. |
| Probe — commented define, client not updated (finding 1) | EOS_LOG_AUTH_NO_ENTROPY 0x23 /* … */ added, client table untouched → 4 passed. The guard does not see it. |
Probe — commented define + 0x22 removed from the client table |
test_client_names_every_event_the_header_defines passes; only the two rules naming AUTH_UNPROVISIONED literally fail. Confirms the client-table rule is what goes silent. |
header_events() coverage at this head |
15 of 15 EOS_LOG_* event codes collected; EOS_LOG_MAGIC correctly excluded by name. No event is currently missed. |
Every eos_boot_log_append() call site vs FIRMWARE_DIRS |
All 35 firmware call sites are in top-level core/, stage0/, stage1/ .c files; none in hal/, boards/ or a subdirectory. The glob("*.c") is sufficient for the tree as it stands. |
core/recovery.c:141-149 (the finding-2 RNG fallback) vs 26ac2d4 |
Byte-identical. Untouched by this commit, as the body says. |
New #define values vs existing codes |
0x20-0x22, no collision with 0x01-0x0C or EOS_LOG_MAGIC |
Architecture conformance
Conforms; re-checked against the new commit rather than carried over. §21: eBoot is Tier 1
Foundation, and include/, core/, tools/, tests/unit/ are all inside the owning repo — §21.1
not engaged. §5.1 dependency direction is untouched and, notably, improved in the right direction:
core/recovery.c now depends on include/eos_types.h for its event vocabulary instead of carrying
private literals, and include/ still depends on nothing. §8.1's "crash/health information
available to update logic" is the clause the whole commit serves — three codes that were legible
only to someone reading core/recovery.c with a hex editor beside them are now named in the
contract header and printed by the client. Finding 1 is measured against the same clause: a guard
that lets the next code through silently leaves §8.1 satisfied by luck. §28's evidence policy is
honoured — the author's negative-control count and both suite totals reproduced exactly.
Proposed changes
In this PR (one character and a test, and #131 is the first caller that needs it):
test_boot_log_event_names.py:40 ...(0x[0-9A-Fa-f]+)\b instead of ...\s*$
test_boot_log_event_names.py a case asserting a commented #define is still collected
Already agreed, tracked, not this PR:
core/recovery.c:141-149 refuse when eos_hal_rng_get() fails -> #130 / #131
This PR remains blocked behind #115 (a276016 and below are that PR's commits, 24 of the 27
files in the bundle diff), and mergeStateStatus was BLOCKED at the previous review. I did not
re-check mergeability at this head.
No fix PR opened. tests/unit/test_boot_log_event_names.py exists only on this branch — it is
not on origin/master — so a branch cut from the default branch, which is what fix-start.sh
produces, would have nothing to patch. This belongs to the author.
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, timing, and a real
eos_hal_otp_read()on a board with fuses were NOT exercised — no shipped board port provides
otp_read, so no such path exists to exercise. tools/uart_recovery.pywas not executed against a device, only parsed. Thelogcommand's
rendering ofAUTH_UNPROVISIONEDis Inferred from the table, not observed on a wire.- The #115 half of the diff was not reviewed here —
core/sha512.c,core/ed25519_verify.c,
include/eos_boot_log.h,tools/gen_fw_update_test_sigs.pyand the workflow changes belong to
b7d7cac..a276016. This review covers26ac2d44..89e2242conly. If #115 changes, thectestand
pytestnumbers above stop describing what will merge. tests/fuzz/fuzz_recovery_protocol.c— NOT built or run.- ASan/UBSan, Valgrind, cross-compile, CodeQL, cppcheck/clang-tidy — NOT RUN locally. CI green
on this head; logs not read. - Unrelated pre-existing defect observed, not charged to this PR:
tests/unit/test_boot_log.c:303
isprintf("\n%d/%d tests passed\n", tests_passed)— two conversions, one argument, so the second
reads an indeterminate vararg.-Wformat=flags it on every host build. It is onorigin/master
and in a file this PR does not touch. Recorded here so it is not lost.
Automated architecture review of 89e2242cd3e6 — 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.
|
Taken, in
|
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.
…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']".
184f431 to
a9c6a2e
Compare
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#127 "fix(recovery): refuse an unprovisioned secret, and a write into an unmapped slot"
head: a9c6a2e author: Kartikey1306 ci: pass (26 green, 0 red; only Create GitHub Release skipped)
Verdict: Follow-up review. The one finding from the review of 89e2242c is resolved, and I
closed it with the same probe that opened it: the commented EOS_LOG_AUTH_NO_ENTROPY 0x23 define
that used to sail through the guard now fails it by name. The earlier two commits are patch-identical
after the re-stack onto #115's rebased tip; a9c6a2e is the only new work, it is test-only, and it
is clean. No new findings.
Previous finding — status
| # | Previous finding | Status | Evidence |
|---|---|---|---|
| 1 | Medium (P2) — header_events() anchored its pattern on end of line, so an EOS_LOG_* define written in this header's own style (trailing comment) dropped out of the client cross-check silently |
Resolved in a9c6a2e |
The pattern is now EVENT_DEFINE = re.compile(r"^\s*#define\s+EOS_LOG_([A-Z0-9_]+)\s+(0x[0-9A-Fa-f]+)\b", re.M) — the end anchor is gone, exactly as recommended — and header_events() takes an optional text so it is testable on a sample. Both halves of the recommended fix landed, and the second one (a test for the under-collection case) is done twice over: test_header_parser_sees_a_define_with_a_trailing_comment asserts the exact map for a four-line sample carrying a bare define, a /* */-commented one, a //-commented one and EOS_LOG_MAGIC; test_header_parser_collects_every_event_define_in_the_real_header cross-checks the strict pattern against a looser #define EOS_LOG_* count over the real header, so a style the strict pattern misses surfaces as a mismatch rather than a silent drop. The review's own probe re-applied at this head: added #define EOS_LOG_AUTH_NO_ENTROPY 0x23 /* board provides no rng_get */ to include/eos_types.h, left tools/uart_recovery.py untouched → 1 failed, 5 passed, failing at test_boot_log_event_names.py:101 with tools/uart_recovery.py BOOT_LOG_EVENT_NAMES lacks: ['EOS_LOG_AUTH_NO_ENTROPY = 0x23']. That is a 4 passed → 1 failed flip on the identical probe. Header restored; git diff --stat clean. |
The two findings closed at 89e2242c (event names, zero-length WRITE in the CHANGELOG) and the
one correctly scoped out to #131 (the tick-seeded LCG on eos_hal_rng_get() failure) are unchanged
and are not restated. #131 is in this same batch and is reviewed separately.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| — | — | — | None. | — |
a9c6a2e is +34/−4 in one test file. I went looking for a hole in the new guard — a decimal
value, a parenthesised value, a define the strict pattern would miss — and in each case the
loose-vs-strict cross-check turns it into a failure rather than a silent drop, which is the property
the finding asked for. Not manufacturing a second opinion to fill the table.
Verification performed for this review
Detached worktree at .ai/autoreview/state/scratch/eBoot-127 off refs/pull/127/head. The user's
eBoot checkout was not touched; nothing was committed or pushed. Every probe was reverted and the
worktree confirmed clean before the next step.
| Check | Result |
|---|---|
What is new since 89e2242c? |
One commit. git patch-id --stable over this PR's own commits: 26ac2d4/89e2242 are identical to 42abb8d/72a4b1f; a9c6a2e is the only addition. The parent moved a276016 → e152d8e, which is #115's own rebase. |
cmake -B build/host -DEBLDR_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug → cmake --build --parallel 4 |
PASS, no errors |
ctest --test-dir build/host --output-on-failure --no-tests=error |
PASS — 31/31, 11.65s |
eboot_test_recovery |
PASS — 6/6 |
EOS_REQUIRE_SIGNING_TESTS=1 pytest tests/ -q |
PASS — 88 passed, 2.56s. The comment says 86; the two extra are tests/unit/test_sign_image.py cases that arrived on the new base from #117, not from this branch. |
| Probe from the previous review, re-applied verbatim | 1 failed, 5 passed — test_client_names_every_event_the_header_defines fails with the exact message the author quotes. At 89e2242c the same probe gave 4 passed. This is the evidence the finding is closed. |
| Does the new guard fail loud on styles the strict pattern misses? | Yes, by construction — test_header_parser_collects_every_event_define_in_the_real_header compares sorted(header_events()) against a looser #define EOS_LOG_* name scan, so a decimal, parenthesised or otherwise-unmatched value becomes a mismatch. Checked by reading the assertion, not by mutating the header for each style. |
| Was anything weakened? | No. a9c6a2e is additive: one regex narrowed in scope of anchoring only (it now matches strictly more input), one docstring, one optional parameter, two tests added. No test disabled, no assertion removed, no lint loosened, no permission widened. Suite totals moved up: pytest 84 → 86 on this branch's own count. |
Carried observation, not a finding and not this PR's. The previous review recorded that
tests/unit/test_boot_log.c:303 is printf("\n%d/%d tests passed\n", tests_passed) — two
conversions, one argument. It is still on origin/master, unchanged, and this build warns on it
(test_boot_log.c:303:19: warning: format '%d' expects a matching 'int' argument [-Wformat=]). What
is new is that I ran the binary: it prints 11/762734247 tests passed, and the denominator is an
indeterminate vararg that differs between runs. It is cosmetic rather than dangerous — ASSERT()
calls exit(1), so the suite does fail closed and ctest cannot be fooled by it — which is why it
stays Low and why no fix PR was opened for it here. It belongs to master, not to this PR.
Architecture conformance
Conforms; re-checked against the new commit rather than carried over. §21: eBoot is Tier 1
Foundation and tests/unit/ is inside the owning repo; §21.1 is not engaged. §5.1 dependency
direction is untouched — the commit changes one Python test module and adds no #include, link
line, CMake entry or manifest dependency of any kind. §8.1's "crash/health information available to
update logic" is the clause the PR as a whole serves, and this commit protects it: the guard that
keeps every boot-log event code named in the contract header and decoded by the field client is now
able to see the codes it is supposed to police. §28's evidence policy is honoured — the author's
claimed probe result and both suite totals reproduced, with the pytest count differing only by the
two tests the new base contributed.
Proposed changes
None. Merge after #115.
Already agreed, tracked, not this PR:
core/recovery.c:141-149 refuse when eos_hal_rng_get() fails -> #130 / #131
(#131 is stacked on this head and reviewed separately)
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, timing and a real
eos_hal_otp_read()on a board with fuses were NOT exercised — no shipped board port provides
otp_read, so no such path exists to exercise. tools/uart_recovery.pywas not executed against a device, only parsed. Its rendering of
AUTH_UNPROVISIONEDon a wire remains Inferred from the table.- The
#115half of the bundle diff was not reviewed here; this review covers
89e2242c..a9c6a2e3plus the re-stack check. Seereports/eBoot-115-e152d8ed.md. tests/fuzz/fuzz_recovery_protocol.c— NOT built or run.- ASan/UBSan, Valgrind, cross-compile, CodeQL, cppcheck/clang-tidy — NOT RUN locally. CI green
on this head; job logs not read. - The earlier two commits were not re-read line by line. They are patch-identical to the heads
reviewed ineBoot-127-26ac2d44.mdandeBoot-127-89e2242c.md; that is Inferred frompatch-id,
not re-observed. - Mergeability at this head — NOT re-checked. It was
BLOCKEDat the first review, behind #115.
Automated architecture review of a9c6a2e3ee5e — 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.
Stacked on #115 (master does not build without it). Review
e152d8e..a9c6a2efor this change alone: three commits (restacked once, onto #115'se152d8e;a276016..184f431was the same content on the previous base).42abb8d(was26ac2d4) is the fix --core/recovery.c(+22/−2),tests/unit/test_recovery.c(+3 tests),CHANGELOG.md.a9c6a2e(was184f431) fixes the guard's header parser (a define with a trailing comment was dropped silently; the match now stops at the value, with two tests).72a4b1f(was89e2242) answers the review's findings 1 and 3:include/eos_types.h(EOS_LOG_AUTH_SUCCESS/FAIL/UNPROVISIONED),core/recovery.c(uses them by name),tools/uart_recovery.py(0x22: "AUTH_UNPROVISIONED"),tests/unit/test_boot_log_event_names.py(new guard),CHANGELOG.md. #131 is stacked on this PR and closes finding 2.Two checks in
core/recovery.cthat were weaker than the code around them says1. An unprovisioned recovery secret authenticated anyone
recovery_handle_auth()read the shared secret from OTP offset0x180and compared the client's response againstSHA-256(challenge || secret)with no look at the secret itself. 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 sentSHA-256(challenge || 00…00), and every destructive command (ERASE, WRITE, BOOT, FACTORY) was then open to it.core/keystore.calready refuses an all-zero key for exactly this reason.Fix: both patterns are refused before the comparison — branch-free, so the check does not leak which value the fuses hold — counted as an authentication failure and logged as event
0x22.Blast radius, stated exactly: no shipped board port under
boards/providesotp_read, so on every current boardeos_hal_otp_read()returnsEOS_ERR_NOT_SUPPORTEDand authentication always failed. The defect was latent; the first board port with OTP would have shipped it.2. A write into an unmapped slot landed at a flash address
recovery_handle_write()had stopped callingeos_recovery_write_in_range()— the ruletests/unit/test_recovery.cexercises, which refuses a slot whose base is 0 — and checkedoffset + lenagainst the slot size inline. #55's replay did that while repairing a duplicate declaration. For a slot the board leaves unmapped, "write at offset 0x800 of slot B" became "write at flash address 0x800" — in the test layout, between the boot-control block and its backup.Fix: the handler uses the shared rule again, so the wire has one definition and the tests test the code the wire hits.
Tests (Verified, each run against the unfixed handler first)
test_auth_refuses_an_unprovisioned_secret— all-zero and all-ones secret, correct response for that secret, then a WRITEout_buf[33] == RCVR_ACK)test_auth_accepts_a_provisioned_secret— the control: same exchange, provisioned secrettest_write_refuses_a_slot_the_board_leaves_unmapped— slot B with base 0, WRITE at offset 0x800sim_flash[0x800]overwrittensim_flash[0x800]still0xFFFull suite on this head:
ctest31/31 (test_recovery6/6),pytest86 passed (80 + the 6 in the new guard).Review follow-up (
89e2242)0x22had no name and the client printedUNKNOWN(0x22)include/eos_types.hdefinesEOS_LOG_AUTH_SUCCESS 0x20,EOS_LOG_AUTH_FAIL 0x21,EOS_LOG_AUTH_UNPROVISIONED 0x22beside the otherEOS_LOG_*codes;core/recovery.clogs by name at all four sites;tools/uart_recovery.pydecodes0x22.tests/unit/test_boot_log_event_names.pyholds the three lists together: everyeos_boot_log_append()incore/,stage0/,stage1/passes anEOS_LOG_*name (no literal), every code the header defines is inBOOT_LOG_EVENT_NAMESunder the same name, and the client names nothing the header does not define. Negative control against26ac2d4: 3 of 4 fail -- the four bare literals atcore/recovery.c:170/183/200/205, and0x20/0x21named by the client with no definition.rng_getis absent (pre-existing)EOS_LOG_AUTH_NO_ENTROPY 0x23), with its own test and negative control. Added to Not changed below.WRITEnow NACKsNot changed
RESETandINFOstill need no authentication, andSHA-256(challenge || secret)is still the response construction (the file's own comment calls it a simplification of HMAC); neither is a bypass and both are outside this fix.Two more, recorded rather than implied safe:
eos_hal_rng_get()fails,recovery_handle_auth()synthesises the challenge from an LCG seeded with the millisecond tick, and no board port providesrng_get(0 of 83), so that is the challenge on every board today. It is latent for the same reason as the unprovisioned-secret defect (nootp_readeither), and it is closed in fix(recovery): issue no challenge on a board without an entropy source #131 on top of this PR.WRITEthrougheos_recovery_write_in_range()also refuseslen == 0, where the inline check had ACKed and written nothing. A client that sent empty writes sees a NACK now.Closing issue
Fixes #126