fix(fw_decrypt): drop a HW-crypto shortcut that cannot express streaming GCM - #80
Conversation
|
Now stacked on #77 — merge that first. The three red checks here were Rather than leave this PR permanently red on someone else's breakage, it is rebased onto #77 (the one-character fix). Once #77 merges this collapses back to the two files it actually changes. On the stacked branch, the whole suite builds and runs:
|
00d6ee8 to
8d19ae2
Compare
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
28baa86 to
c657bff
Compare
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#80 "fix(fw_decrypt): drop a HW-crypto shortcut that cannot express streaming GCM"
head: c657bff author: Kartikey1306 ci: 24 checks pass
Verdict: Correct, well-evidenced, and the test design is the best thing in it. Two
loose ends the removal leaves behind, both in files the PR did not touch.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium | include/eos_hal.h:90-92 |
After this merges, hw_aes_decrypt has zero call sites — git grep hw_aes_decrypt returns only this header, the removed block, and the stale doc comment in finding 2. The header still advertises it under /* HW-accelerated crypto (optional, software fallback used if NULL) */, which is now false in the other direction: a board author who implements the hook gets nothing, silently, and no diagnostic tells them. That is the same class of failure the PR is fixing. |
Either delete the member, or keep it and change the comment to say it is reserved and currently unconsumed pending a streaming-capable contract, pointing at the rationale block this PR added. |
| 2 | Medium | core/fw_decrypt.c:11 |
The file header still reads "Falls back to HAL hw_aes_decrypt if available." The PR's diff starts at line 192, so this line survives and directly contradicts the twenty-line comment the PR installs below it. | Update line 11 in this PR — it is one line, and leaving the file self-contradictory is worse than the original. |
| 3 | Low | tests/CMakeLists.txt:86 |
Textual conflict with #82. Both PRs insert a new add_executable/add_test triple immediately after add_test(NAME test_keystore ...), at the same anchor. Same author, both open. |
Whichever lands second, move the insertion point. Trivial, but it will surprise someone at merge time. |
On the analysis
Both failure modes check out against the code being removed. The hook signature at
include/eos_hal.h:92 is (const void *key, size_t key_len, const void *iv, ...) with no
block offset and no output state, so:
ctx->ivwas passed unchanged on everyeos_fw_decrypt_update()call andctx->bytes_processed
was never communicated — a second chunk restarts the CTR keystream at block 0. Reusing a
CTR keystream across two plaintexts is the failure the mode cannot survive.- The hook returns plaintext only, so
ctx->ghash_accis never fed and
eos_fw_decrypt_final()computes the tag over an empty accumulator.
The second point is why this is a latent defect rather than a live one: it fails closed,
and no in-tree board provides the hook. The body says this plainly instead of inflating
the severity, which is the right call. The reasoning that it cannot be repaired by also
feeding GHASH — because the plaintext is already wrong past the first chunk — is correct.
The test file is the strongest part. Vectors from OpenSSL rather than from this code
pin behaviour rather than record it; the chunk-boundary matrix ([32], [16,16],
[10,22], [1,31], [5,15]) is the right way to test a streaming AEAD; and the note
that a simulated engine returning EOS_ERR_NOT_SUPPORTED made the test pass against
broken code — so it had to return EOS_OK — is exactly the kind of negative result that
usually goes unrecorded. test_board_with_aes_engine_still_accepts_a_genuine_image is
the assertion that carries the PR.
Architecture conformance
Deviates, pre-existingly, and this PR narrows the deviation.
Master design §14.1: "Use reviewed cryptographic libraries; do not invent cryptographic
primitives." core/fw_decrypt.c is a hand-written AES-256-GCM in the boot path, which
the PR body itself names. This PR does not introduce that and cannot resolve it; it adds
the first vector-based tests the file has ever had, which is the right move short of
replacement.
§14.1 also says "Keep target-specific hardware security adapters behind stable
interfaces." That is the rule eos_board_ops_t::hw_aes_decrypt broke — the interface was
stable and simply could not express the operation. The master design gives no contract
for what a hardware crypto adapter must be able to carry, which is why a one-shot
signature was a plausible thing to write. Proposal appended to
.ai/autoreview/proposals/2026-09.md.
§21 Tier 1 (eBoot, Foundation) — correct repo. §5.1 untouched; removing a call site adds
no dependency edge.
Proposed changes
core/fw_decrypt.c:11 - " * Falls back to HAL hw_aes_decrypt if available."
+ " * Software-only: see eos_fw_decrypt_update() for why the
+ HAL hw_aes_decrypt hook is not used."
include/eos_hal.h:90-92 mark hw_aes_decrypt reserved/unconsumed, or remove it
tests/CMakeLists.txt re-anchor whichever of #80/#82 lands second
Merge after #77 as the author stacked it. Nothing here should hold it up.
Not checked
- Nothing was built or run.
8/8,20/20and16 passed, 1 skippedare taken from the
PR body and the author's comment; I did not reproduce them, and I did not re-derive the
OpenSSL vectors intests/unit/test_fw_decrypt.c. checks.txtshows 24 green checks on this head, which is CI's signal, not mine.- I did not audit the software AES-GCM implementation itself. The PR asserts it is
correct against independent vectors; that assertion is untested here, and §14.1's
objection to a hand-written primitive in the TCB stands regardless of whether these
particular vectors pass.
Automated architecture review of c657bff65f79 — 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.
…rged broken master (22d8f8b) does not compile. Two independent double-merges, both the same shape: two PRs fixing adjacent things landed on stale bases, each was green on its own branch, and the result was never rebuilt. 1. include/eos_image.h — embeddedos-org#93 replaced reserved[30] with tlv_len (2) + tlv_hash[28], preserving every offset. embeddedos-org#87 merged afterwards carrying asserts written against the older struct: error: no member named 'reserved' in 'eos_image_header_t' (x2) embeddedos-org#93 already asserts tlv_len at 62 and tlv_hash at 64, so the offset assert was a duplicate; the width assert had no replacement and is restored as two asserts covering both halves of the same 30-byte span. No offset moves and the wire format is unchanged. 2. core/ed25519_verify.c — embeddedos-org#86 and embeddedos-org#57 both landed a subgroup guard, so the file carried two byte-identical point_is_identity() definitions: error: redefinition of 'point_is_identity' Only embeddedos-org#57's public_key_is_valid_subgroup() is wired to the call site, so embeddedos-org#86's key_has_prime_order() was dead. Kept the live function, folded embeddedos-org#86's fuller rationale onto it, deleted the duplicate. 3. tests/unit/test_ed25519.c — collateral from the same merge. Two copies of test_ed25519_identity_key_forgery_rejected, main() calling it twice and two tests not at all, and test_ed25519_low_order_R_with_a_valid_key_is_not_a_forgery referencing k_low_order[] and messages[] that the merge had dropped. While restoring the corpus, corrected it (review finding on embeddedos-org#86): the array claimed to hold "the eight low-order point encodings" and held five. Every order here was computed rather than copied — decode y, recover x, add the point to itself until it reaches the identity — giving 1, 2, 4, 4, 8, 8, 8, 8. Missing before: y=0 with the sign bit set, and both sign-flipped order-8 encodings. D9FF..FF was in the array and is not a low-order point at all — no x satisfies the curve equation for that y — so it moves to a separate k_non_canonical[], with EDFF..FF7F (y=p) and EEFF..FF7F (y=p+1). tests_run was assigned a literal (11) in main() and never incremented, which is how the duplicate call and the two unregistered tests went unnoticed. The TEST macro now increments it, so the total cannot drift. Verified: cmake -DEBLDR_BUILD_TESTS=ON on master FAILS to build, 3 errors same with this commit builds clean ctest 21/21 PASS ctest -DEBLDR_SANITIZE=ON (ASan+UBSan) 21/21 PASS pytest tests/ 24 passed, 1 skipped test_ed25519 14/14 PASS (was 11 claimed, 12 run) discrimination, with `public_key_is_valid_subgroup` disabled: test_ed25519_low_order_keys_rejected FAILS, as it must test_ed25519_non_canonical_... still PASSES — those are refused by unpackneg() on canonicality, a different mechanism, which is the reason they are held in a separate array rather than counted among the eight.
…ing GCM
core/fw_decrypt.c is a hand-written AES-256-GCM sitting in the secure boot
path with no tests. It has an opt-in fast path:
if (ops && ops->hw_aes_decrypt) {
int rc = ops->hw_aes_decrypt(ctx->key, EOS_AES_KEY_SIZE,
ctx->iv, data, data, len);
if (rc == EOS_OK) { ctx->bytes_processed += len; return EOS_OK; }
}
The hook is (key, key_len, iv, in, out, len). That signature cannot carry
streaming GCM, and taking the path broke decryption two ways:
1. No counter position. ctx->iv is passed unchanged on every call, so a second
chunk restarts the CTR keystream at block 0 and is decrypted against the
same keystream as the first. Reusing a CTR keystream across two plaintexts
is the one thing the mode must never do.
2. No GHASH. The hook returns plaintext only, so ctx->ghash_acc is never fed.
eos_fw_decrypt_final() computes the tag over an empty accumulator and
rejects the image -- a board with an AES engine could not install a
correctly encrypted firmware update at all.
(2) is why this was never noticed: it fails closed, and no board in-tree
implements the hook yet. (1) is why it cannot be patched by also feeding
GHASH: the plaintext would still be wrong past the first chunk. Re-enabling
needs a hook that takes a block offset and either exposes GHASH state or does
the whole GCM operation including the tag. Removed, with that written down
where the next person will look.
Also adds tests/unit/test_fw_decrypt.c -- the first tests this file has had.
Vectors come from an independent implementation (Python cryptography, i.e.
OpenSSL) rather than from this code, so they pin behaviour rather than
recording it:
- whole blocks, and a 20-byte payload with a partial trailing block
- the same ciphertext split [32] / [16,16] / [10,22] / [1,31] / [5,15]:
GCM is a stream, so the result must not depend on how the caller sliced it
- a board advertising a working AES engine must reach the same answer as one
without -- this is the case that fails on master
- every single-bit flip in the tag (128 of them), and in each ciphertext
byte, must be rejected
- unprovisioned/unreadable OTP keys and uninitialised contexts are refused
Verified: 8/8 pass with this change; against the unmodified fw_decrypt.c the
suite fails on
test_board_with_aes_engine_still_accepts_a_genuine_image, the assertion it
exists to make. The software GCM itself is correct -- I checked it against the
reference vectors before changing anything, including every chunk split above.
Note: the full test build on master is currently broken by test_image_verify.c
(fixed in embeddedos-org#77), so this target was built directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… clash Three findings from the review on embeddedos-org#80, all in files this branch already owns. Finding 2 (Medium) -- core/fw_decrypt.c's file header still read "Falls back to HAL hw_aes_decrypt if available." The diff started at line 192, so that line survived and directly contradicted the twenty-line rationale this PR installs below it. A self-contradictory file is worse than the original. Finding 1 (Medium) -- after the removal, hw_aes_decrypt has zero call sites while eos_hal.h still advertises it under "software fallback used if NULL", which is now false in the other direction: a board author who implements the hook gets nothing, silently, with no diagnostic. That is the same class of failure this PR is fixing. Kept the member rather than deleting it -- removing it from a public struct is an ABI change for out-of-tree boards, and the hook is worth having once it can express the operation -- and documented it as reserved and currently unconsumed, with the reason and with what a streaming-capable replacement would need. Finding 3 (Low) -- this PR and embeddedos-org#82 both inserted their add_executable/add_test triple immediately after add_test(NAME test_keystore ...), so whichever landed second would have conflicted for no reason but placement. Re-anchored this one to the end of the registrations, with a comment saying why. Also rebased: this branch was 7 commits behind and its diff against current master would have reverted the test_recovery link-line fix. It is now stacked on embeddedos-org#94, which repairs master -- without that, every PR that builds the test suite is red on include/eos_image.h and core/ed25519_verify.c. Verified: cmake --build (EBLDR_BUILD_TESTS=ON) clean ctest 22/22 PASS test_fw_decrypt 8/8 PASS git grep hw_aes_decrypt header + this file's comment only Refs embeddedos-org#80
c657bff to
04b7b74
Compare
… it calls Answers finding 1 (Medium) from the review on embeddedos-org#82. All three tests called eos_secure_boot_lock_debug() directly and asserted its return value. None drove eos_secure_boot() with cfg.lock_debug = true, so the step-7 early return this PR adds -- the attest_record + return EOS_SBOOT_ERR_POLICY -- never executed under test. The file's docblock says "the debug-lock policy must be enforced, not merely attempted"; what it covered was the attempted half. Adds three end-to-end cases through eos_secure_boot(): - lock_debug: true on a board with no otp_write -> EOS_SBOOT_ERR_POLICY - the same image with lock_debug: false -> EOS_SBOOT_OK, and the recorded entry point is the header's, which is the counter-check: without it the first test would also pass if steps 1-6 were failing for an unrelated reason and never reaching step 7 - lock_debug: true with a working fuse -> EOS_SBOOT_OK, one write Reaching step 7 needs an image that clears steps 1, 2 and 5, so the fixture gains a simulated flash and stages an unsigned, unencrypted image whose SHA-256 matches. EOS_IMG_FLAG_HASH_SHA256 is load-bearing there: without it verify_integrity takes the CRC32 branch, reads a CRC out of hash[], and step 2 fails before the policy step is ever reached. Finding 4 (Low), the tests/CMakeLists.txt anchor shared with embeddedos-org#80, is resolved on embeddedos-org#80's side -- its block moved to the end of the registrations, so this one keeps its position and the two no longer collide. Verified: ctest 22/22 PASS test_secure_boot_policy 6/6 PASS discrimination: with step 7 reverted to `(void)eos_secure_boot_lock_debug();` test_secure_boot_refuses_when_the_debug_lock_cannot_be_taken FAILS the three original helper tests still PASS which is the gap the finding described, reproduced. Refs embeddedos-org#82
srpatcha
left a comment
There was a problem hiding this comment.
Review — eBoot#80 "fix(fw_decrypt): drop a HW-crypto shortcut that cannot express streaming GCM"
head: 04b7b74 author: Kartikey1306 ci: 24 checks pass
Verdict: Conforms. All three findings from the review of c657bff6 are addressed at
04b7b74, and this time I built and ran it rather than taking the numbers on trust.
Two new points, both Low, both in the test scaffolding rather than the fix.
What I ran
Built refs/pull/80/head from a clean export of the tree, host toolchain, gcc
-DEBLDR_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug:
cmake exit 0
cmake --build exit 0
ctest --no-tests=error --output-on-failure --timeout 120
exit 0 22/22 tests passed
#22 test_fw_decrypt Passed
For contrast, the same procedure on origin/master (22d8f8b):
cmake --build exit 2
include/eos_image.h:135: error: 'eos_image_header_t' has no member named 'reserved'
core/ed25519_verify.c:338: error: redefinition of 'point_is_identity'
ctest exit 8 0/21 tests run — every one "Not Run"
So the green CI on this PR is real and it is not master's. That is because this branch is
stacked on #94, which is what repairs those two. gh pr diff shows #94's commit as
part of this PR because the base is master; this PR's own delta is the two commits
fb3afdb + 04b7b74, touching core/fw_decrypt.c, include/eos_hal.h,
tests/CMakeLists.txt and tests/unit/test_fw_decrypt.c. Nothing below refers to the
ed25519_verify.c / eos_image.h / test_ed25519.c hunks — those are #94's and are
reviewed there.
Previous findings
| Was | Now |
|---|---|
M1 eos_hal.h:90-92 — hw_aes_decrypt left advertised with zero consumers |
Fixed. include/eos_hal.h:92-107 now marks it reserved and unconsumed, says a board implementing it gets nothing, and names the contract change required before a caller may be added. |
M2 core/fw_decrypt.c:11 — file header still said "Falls back to HAL hw_aes_decrypt" |
Fixed at core/fw_decrypt.c:11-14. |
L3 tests/CMakeLists.txt — same insertion anchor as #82 |
Fixed: re-anchored after test_ecc at tests/CMakeLists.txt:113, with the reason in a comment. Confirmed against #82's head — it inserts after test_keystore, so the two no longer collide. |
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Low | tests/CMakeLists.txt:121-131 |
test_fw_decrypt is not in the Valgrind foreach(TEST_NAME ...) list, so the one new test in the boot-crypto path does not get the memory-safety run the others do — and it is the test with a fixed uint8_t buf[64], a caller-driven chunk splitter, and 128 tag-mutation iterations, i.e. the one most likely to reward it. The list is hand-maintained and has already drifted: 5 of the 22 registered tests are absent from it (test_ecc, test_rollback, test_secure_boot, test_storage, and now test_fw_decrypt). |
Add test_fw_decrypt to the foreach list. Separately — not this PR's job — the list should be derived from the registered tests rather than retyped, since nothing fails when a name is forgotten. |
| 2 | Low | tests/unit/test_fw_decrypt.c:24-32, 262 |
The new file hardcodes tests_run = 8; in main() and its TEST() macro does not increment tests_run. A test function added to this file but never wired into main() leaves the count at 8, tests_passed == tests_run, and the binary exits 0 having silently skipped it. The base commit of this very branch — #94, f704d87 — removed exactly this pattern from tests/unit/test_ed25519.c, where a hardcoded tests_run = 11 had been masking two tests that main() never called. |
Copy the fixed form: put tests_run++; in the TEST() macro before name(); and delete the tests_run = 8; line, matching tests/unit/test_ed25519.c:29-36 on this same branch. |
On the fix itself
The reasoning holds and I re-checked it against the removed code rather than the PR body.
eos_board_ops_t::hw_aes_decrypt is (key, key_len, iv, in, out, len) — no counter
position and no output state — so the removed block passed ctx->iv unchanged on every
eos_fw_decrypt_update() call and never fed ctx->ghash_acc. Keystream restart at block 0
on the second chunk, and a tag computed over an empty accumulator. The claim that it cannot
be repaired by feeding GHASH alone is right: the plaintext is already wrong past the first
chunk, so there is nothing correct to hash.
The chunk-boundary matrix is the part that earns its keep, and I confirmed it is doing real
work rather than passing trivially. [5,15] on the 20-byte vector splits inside a block and
still has to produce OpenSSL's tag — that only works if the implementation buffers the
partial block across calls instead of zero-padding each chunk, which is the single most
common way a hand-rolled streaming GCM is wrong. It passes.
test_board_with_aes_engine_still_accepts_a_genuine_image now passes because the call site
is gone rather than because the engine is exercised — sim_hw_aes_decrypt is installed and
never invoked. That is fine and is the point: it is a tripwire against reintroduction, and
it fails to compile if the struct member is deleted. Worth knowing when reading a green run.
Architecture conformance
Deviates pre-existingly; this PR narrows the deviation. Repo placement is correct.
- §14.1 "Use reviewed cryptographic libraries; do not invent cryptographic primitives."
core/fw_decrypt.cremains a hand-written AES-256-GCM in the TCB. This PR neither
introduces nor can resolve that; it gives the file its first vector-based tests, which is
the right move short of replacement. - §14.1 "Keep target-specific hardware security adapters behind stable interfaces."
The interface was stable and could not express the operation. Proposal already appended
forc657bff6("Hardware security adapters need a stated contract, not just a stable
interface",proposals/2026-09.md); the header comment this PR adds at
include/eos_hal.h:92-107is a good local statement of the same gap and nothing further
is proposed here. - §21 Tier 1 (Foundation) —
eBootis the right repo. §5.1 untouched: removing a
call site adds no dependency edge, and the new test linkseboot_coreonly.
Proposed changes
tests/CMakeLists.txt:126 add test_fw_decrypt to the valgrind foreach list
tests/unit/test_fw_decrypt.c:24-32
#define TEST(name) \
static void name(void); \
static void run_##name(void) { \
printf(" %-52s ", #name); \
+ tests_run++; \
name(); \
tests/unit/test_fw_decrypt.c:262
- tests_run = 8;
Both are one-liners and neither blocks the merge. Merge order: #94 first, since this is
stacked on it and master does not build without it.
Not checked
- I did not audit the software AES-256-GCM implementation itself. The eight tests pass
against OpenSSL-derived vectors for 16, 20 and 32 bytes; that says nothing about lengths
past 32 bytes, non-empty AAD, IV lengths other than 96 bits, or the length-block encoding
near 2^32 blocks. §14.1's objection to a hand-written primitive in the TCB stands
regardless. - I did not re-derive the vectors in
tests/unit/test_fw_decrypt.cfrom an independent
implementation; I confirmed the suite passes, not that the constants are OpenSSL's. - Host build and
ctestonly — gcc, x86-64, Debug. I did not run the cross builds, the
sanitizer build, valgrind, orpytest tests/. The 24 green checks inchecks.txtare
CI's evidence for those, not mine. ctx->bytes_processedisuint32_t; I did not check what happens to an image at or past
4 GiB, which is out of scope for the targets inconfigs/.
Automated architecture review of 04b7b74c75ed — 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.
…ul boot (#82) * fix: repair master — the ABI asserts and the Ed25519 verifier both merged broken master (22d8f8b) does not compile. Two independent double-merges, both the same shape: two PRs fixing adjacent things landed on stale bases, each was green on its own branch, and the result was never rebuilt. 1. include/eos_image.h — #93 replaced reserved[30] with tlv_len (2) + tlv_hash[28], preserving every offset. #87 merged afterwards carrying asserts written against the older struct: error: no member named 'reserved' in 'eos_image_header_t' (x2) #93 already asserts tlv_len at 62 and tlv_hash at 64, so the offset assert was a duplicate; the width assert had no replacement and is restored as two asserts covering both halves of the same 30-byte span. No offset moves and the wire format is unchanged. 2. core/ed25519_verify.c — #86 and #57 both landed a subgroup guard, so the file carried two byte-identical point_is_identity() definitions: error: redefinition of 'point_is_identity' Only #57's public_key_is_valid_subgroup() is wired to the call site, so #86's key_has_prime_order() was dead. Kept the live function, folded #86's fuller rationale onto it, deleted the duplicate. 3. tests/unit/test_ed25519.c — collateral from the same merge. Two copies of test_ed25519_identity_key_forgery_rejected, main() calling it twice and two tests not at all, and test_ed25519_low_order_R_with_a_valid_key_is_not_a_forgery referencing k_low_order[] and messages[] that the merge had dropped. While restoring the corpus, corrected it (review finding on #86): the array claimed to hold "the eight low-order point encodings" and held five. Every order here was computed rather than copied — decode y, recover x, add the point to itself until it reaches the identity — giving 1, 2, 4, 4, 8, 8, 8, 8. Missing before: y=0 with the sign bit set, and both sign-flipped order-8 encodings. D9FF..FF was in the array and is not a low-order point at all — no x satisfies the curve equation for that y — so it moves to a separate k_non_canonical[], with EDFF..FF7F (y=p) and EEFF..FF7F (y=p+1). tests_run was assigned a literal (11) in main() and never incremented, which is how the duplicate call and the two unregistered tests went unnoticed. The TEST macro now increments it, so the total cannot drift. Verified: cmake -DEBLDR_BUILD_TESTS=ON on master FAILS to build, 3 errors same with this commit builds clean ctest 21/21 PASS ctest -DEBLDR_SANITIZE=ON (ASan+UBSan) 21/21 PASS pytest tests/ 24 passed, 1 skipped test_ed25519 14/14 PASS (was 11 claimed, 12 run) discrimination, with `public_key_is_valid_subgroup` disabled: test_ed25519_low_order_keys_rejected FAILS, as it must test_ed25519_non_canonical_... still PASSES — those are refused by unpackneg() on canonicality, a different mechanism, which is the reason they are held in a separate array rather than counted among the eight. * fix(secure-boot): a debug lock that failed must not report a successful boot cfg.lock_debug asks for SWD/JTAG to be closed before the verified image runs. Step 7 of eos_secure_boot() honoured that request like this: if (cfg->lock_debug) { eos_secure_boot_lock_debug(); } /* ---- Step 8: Record successful attestation ---- */ attest_record(2, hdr.image_version, hdr.hash, NULL, EOS_SBOOT_OK); return EOS_SBOOT_OK; eos_secure_boot_lock_debug() returned void and discarded the result of the OTP write that actually blows the fuse. So when the write failed, boot continued, attestation recorded EOS_SBOOT_OK, and the device ran the image with its debug port open -- the exact condition the policy existed to prevent, reported as a clean secure boot. The interesting case is not a flaky fuse. eos_hal_otp_write() returns EOS_ERR_NOT_SUPPORTED when the board provides no otp_write hook at all, so on every such board `lock_debug: true` was silently a no-op. That is the default configuration, not an edge case. eos_secure_boot_lock_debug() now returns int, and a caller that asked for the lock and did not get it fails with EOS_SBOOT_ERR_POLICY -- a code that already existed for exactly this ("Boot policy violation") -- with the failure recorded in the attestation log rather than a success. Why this was never observable: core/secure_boot.c is not in CMakeLists.txt. The module has never been compiled, so this path could not run and could not be tested. Added the one line that builds it -- the same line #72 adds, written identically so whichever lands first leaves the other a trivial rebase. tests/unit/test_secure_boot_policy.c covers the three outcomes: the fuse written, the write failing, and a board with no otp_write. Kept in its own file so it does not collide with the test_secure_boot.c #72 introduces. Against master the new test does not compile -- `invalid operands to binary expression ('void' and 'int')` -- because there is no result to check. That is the defect stated as a compile error. Verified on this branch: build clean, ctest 20/20, pytest 30 passed. Stacked on #77 (master's test suite does not compile without it). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secure-boot): test the policy the PR changed, not only the helper it calls Answers finding 1 (Medium) from the review on #82. All three tests called eos_secure_boot_lock_debug() directly and asserted its return value. None drove eos_secure_boot() with cfg.lock_debug = true, so the step-7 early return this PR adds -- the attest_record + return EOS_SBOOT_ERR_POLICY -- never executed under test. The file's docblock says "the debug-lock policy must be enforced, not merely attempted"; what it covered was the attempted half. Adds three end-to-end cases through eos_secure_boot(): - lock_debug: true on a board with no otp_write -> EOS_SBOOT_ERR_POLICY - the same image with lock_debug: false -> EOS_SBOOT_OK, and the recorded entry point is the header's, which is the counter-check: without it the first test would also pass if steps 1-6 were failing for an unrelated reason and never reaching step 7 - lock_debug: true with a working fuse -> EOS_SBOOT_OK, one write Reaching step 7 needs an image that clears steps 1, 2 and 5, so the fixture gains a simulated flash and stages an unsigned, unencrypted image whose SHA-256 matches. EOS_IMG_FLAG_HASH_SHA256 is load-bearing there: without it verify_integrity takes the CRC32 branch, reads a CRC out of hash[], and step 2 fails before the policy step is ever reached. Finding 4 (Low), the tests/CMakeLists.txt anchor shared with #80, is resolved on #80's side -- its block moved to the end of the registrations, so this one keeps its position and the two no longer collide. Verified: ctest 22/22 PASS test_secure_boot_policy 6/6 PASS discrimination: with step 7 reverted to `(void)eos_secure_boot_lock_debug();` test_secure_boot_refuses_when_the_debug_lock_cannot_be_taken FAILS the three original helper tests still PASS which is the gap the finding described, reproduced. Refs #82 * fix(secure-boot): step 4 must not report success for a check it never makes Answers the second review on #82. Finding 1 (High) -- step 4, "Verify signing key against OTP root-of-trust", was the same fail-open this PR removes from step 7, two steps earlier. An `eos_hal_otp_read()` failure was discarded and boot continued; and when the read succeeded and the anchor was provisioned, the body of the `if` was `/* In a full implementation, extract key hash from TLV and compare */`. So a device whose root of trust *is* provisioned booted an image signed by any key the image carried, and step 8 recorded EOS_SBOOT_OK. Implementing the TLV comparison is out of scope, as the review says. The two things that are in scope are done: a non-EOS_OK otp_read now fails EOS_SBOOT_ERR_SIGNATURE, and a provisioned anchor that nothing compares against refuses the boot rather than proceeding. An unprovisioned board (all-zero anchor) is deliberately unchanged -- there is nothing to check against, and refusing would brick every board that has not been provisioned. The comment now states plainly that the step is planned rather than implemented, which §8.1 asks for and a comment inside an `if` was not. No test for those two refusals, deliberately, and the file says why rather than leaving it to be discovered. Reaching step 4 requires passing step 3 -- a real Ed25519 signature checked against the keystore. I wrote the obvious test first and it was worthless: with require_signature = true and an unsigned fixture the boot fails at step 3 and returns the same EOS_SBOOT_ERR_SIGNATURE step 4 returns, so it passed against the unfixed code too. I confirmed that by reverting step 4 and watching it still pass. A test that cannot fail is worse than none. What is there instead is the counter-check that the change does not refuse a boot it should allow. The fixture is buildable -- the keystore ships RFC 8032 TEST 1's public key and the matching private key is in the RFC -- but that machinery is #88's (tools/gen_signed_image_fixture.py). Worth doing once #88 lands. Finding 3 (Low) -- `TEST()` did not increment `tests_run` and `main()` hardcoded `tests_run = 6`, so a test added to the file but not wired into `main()` would have been skipped with a zero exit. #94 removed exactly this from tests/unit/test_ed25519.c, where a hardcoded 11 was masking two uncalled tests. Same fix here. Finding 4 (Low) -- the Valgrind `foreach` is hand-maintained and missed 6 of 22 registered tests. Added test_secure_boot_policy, test_fdt_loader and test_fw_decrypt. The list being hand-maintained at all is the real defect and is not fixed here -- it is the same class as the hardcoded count, one level up. Finding 2 (Medium), that eos_secure_boot() has no production caller, stands and is not addressed here; finding 1 makes it sharper rather than resolving it. Finding 5 (Low) is a PR-body correction. Verified: ctest 22/22 PASS test_secure_boot_policy 7/7 PASS step 7 discrimination, still: reverting the step-7 branch fails test_secure_boot_refuses_when_the_debug_lock_cannot_be_taken Refs #82 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing them (#95) * fix: repair master — the ABI asserts and the Ed25519 verifier both merged broken master (22d8f8b) does not compile. Two independent double-merges, both the same shape: two PRs fixing adjacent things landed on stale bases, each was green on its own branch, and the result was never rebuilt. 1. include/eos_image.h — #93 replaced reserved[30] with tlv_len (2) + tlv_hash[28], preserving every offset. #87 merged afterwards carrying asserts written against the older struct: error: no member named 'reserved' in 'eos_image_header_t' (x2) #93 already asserts tlv_len at 62 and tlv_hash at 64, so the offset assert was a duplicate; the width assert had no replacement and is restored as two asserts covering both halves of the same 30-byte span. No offset moves and the wire format is unchanged. 2. core/ed25519_verify.c — #86 and #57 both landed a subgroup guard, so the file carried two byte-identical point_is_identity() definitions: error: redefinition of 'point_is_identity' Only #57's public_key_is_valid_subgroup() is wired to the call site, so #86's key_has_prime_order() was dead. Kept the live function, folded #86's fuller rationale onto it, deleted the duplicate. 3. tests/unit/test_ed25519.c — collateral from the same merge. Two copies of test_ed25519_identity_key_forgery_rejected, main() calling it twice and two tests not at all, and test_ed25519_low_order_R_with_a_valid_key_is_not_a_forgery referencing k_low_order[] and messages[] that the merge had dropped. While restoring the corpus, corrected it (review finding on #86): the array claimed to hold "the eight low-order point encodings" and held five. Every order here was computed rather than copied — decode y, recover x, add the point to itself until it reaches the identity — giving 1, 2, 4, 4, 8, 8, 8, 8. Missing before: y=0 with the sign bit set, and both sign-flipped order-8 encodings. D9FF..FF was in the array and is not a low-order point at all — no x satisfies the curve equation for that y — so it moves to a separate k_non_canonical[], with EDFF..FF7F (y=p) and EEFF..FF7F (y=p+1). tests_run was assigned a literal (11) in main() and never incremented, which is how the duplicate call and the two unregistered tests went unnoticed. The TEST macro now increments it, so the total cannot drift. Verified: cmake -DEBLDR_BUILD_TESTS=ON on master FAILS to build, 3 errors same with this commit builds clean ctest 21/21 PASS ctest -DEBLDR_SANITIZE=ON (ASan+UBSan) 21/21 PASS pytest tests/ 24 passed, 1 skipped test_ed25519 14/14 PASS (was 11 claimed, 12 run) discrimination, with `public_key_is_valid_subgroup` disabled: test_ed25519_low_order_keys_rejected FAILS, as it must test_ed25519_non_canonical_... still PASSES — those are refused by unpackneg() on canonicality, a different mechanism, which is the reason they are held in a separate array rather than counted among the eight. * test: derive the suite totals and the Valgrind list instead of restating them Two findings arrived one after another in review, on different PRs, with the same shape underneath: a number or a list that describes what the tests do, written out separately from the thing it describes, with nothing checking the two agree. Fixing them one instance at a time was going to keep producing the same finding, so this fixes the class and adds the guard. **1. Suites stated a total instead of counting one.** The `TEST()` macro incremented `tests_passed`; `main()` assigned `tests_run = <literal>`. A test defined but never wired into `main()` was skipped with a zero exit, and the suite still reported "N/N passed" for an N that was a claim. 18 of 21 suites carried it. It was not theoretical: - `test_ed25519.c` had a hardcoded 11 that masked one test called twice and two never called at all (repaired in #94, which is where this started). - `test_tlv_auth.c` assigns `tests_run` twice in one function -- 8, then 7. The stale 8 survives only because the later assignment wins. Its suite reports 7/7 today by luck. - `test_secure_boot.c` counted nothing and ended `return 0`, so a suite that ran none of its cases still reported success. The ASSERT macro exits on failure, so that return could only ever have signalled the one case it ignored. `TEST()` now increments `tests_run` where it invokes the test, every literal assignment and every `%d/<literal>` in a summary is gone, and `test_secure_boot.c` compares and returns accordingly. **2. The Valgrind list named its suites again by hand**, and had drifted to 17 of 21 -- `test_ecc`, `test_rollback`, `test_secure_boot` and `test_storage` got no memory-safety run, and nothing failed when a name was forgotten. Each `add_test()` now appends to `EBLDR_UNIT_TESTS` and the `foreach` iterates that, so a suite added without touching the block still gets a Valgrind target. Confirmed with a stub `valgrind` on PATH: 21 `valgrind_*` tests are generated, up from 17. **3. tests/unit/test_suite_bookkeeping.py** keeps both from returning. Four checks, no C toolchain needed: no suite hardcodes its own total; every `TEST()` macro counts the test it runs; every defined test is actually called; and the Valgrind list is derived rather than repeated. Three suites with no `TEST()` macro are listed in `NO_TEST_MACRO` with a reason each. Verified: ctest 21/21 PASS ctest -DEBLDR_SANITIZE=ON (ASan+UBSan) 21/21 PASS pytest tests/ 42 passed valgrind targets, with a stub on PATH 21 (was 17) every suite's reported total now equals what it ran -- e.g. test_bootctl 12/12, test_tlv_auth 7/7, test_secure_boot 4/4 Each of the four guards was probed against the defect it is written for: reintroduce `tests_run = 12` -> test_bootctl.c: tests_run = 12 remove `tests_run++` from the macro -> FAIL stop calling a defined TEST() -> FAIL drop a suite from EBLDR_UNIT_TESTS -> FAIL and all four pass again on restore. Refs #94, #80, #82 --------- Co-authored-by: Srikanth Patchava <srikanth.patchava@outlook.com>
core/fw_decrypt.cis a hand-written AES-256-GCM sitting in the secure boot path, with no tests at all. It has an opt-in fast path:The hook is
(key, key_len, iv, in, out, len). That signature cannot carry streaming GCM, and taking the path breaks decryption two ways.1. No counter position → CTR keystream reuse
ctx->ivis passed unchanged on every call, and nothing communicatesbytes_processed. A second chunk restarts the keystream at block 0 and is decrypted against the same keystream as the first. Reusing a CTR keystream across two plaintexts is the one thing the mode must never do.2. No GHASH → genuine images rejected
The hook returns plaintext only, so
ctx->ghash_accis never fed.eos_fw_decrypt_final()then computes the tag over an empty accumulator and rejects the image. A board with an AES engine could not install a correctly encrypted firmware update at all.(2) is why this was never noticed: it fails closed, and no board in-tree implements the hook yet. (1) is why it cannot simply be patched by also feeding GHASH — the plaintext would still be wrong past the first chunk.
Re-enabling this needs a hook that takes a block offset and either exposes the GHASH state or performs the whole GCM operation including the tag. Removed for now, with that reasoning written where the next person will look.
The software GCM itself is correct
I checked before changing anything, against vectors from an independent implementation (Python
cryptography, i.e. OpenSSL):That is a clean negative result and worth stating: the arithmetic is fine, the integration is not.
First tests for this file
tests/unit/test_fw_decrypt.c— vectors come from OpenSSL rather than from this code, so they pin behaviour rather than record it:[32]/[16,16]/[10,22]/[1,31]/[5,15]— GCM is a stream, so the result must not depend on how the caller sliced itA detail that matters: the simulated engine succeeds. My first version returned
EOS_ERR_NOT_SUPPORTED, which made the caller fall through to the software path — so the test passed against the broken code and proved nothing. The bug only appears when the engine returnsEOS_OK.Verification
fw_decrypt.ctest_board_with_aes_engine_still_accepts_a_genuine_image, the assertion it exists to makeNote: the full test build on
masteris currently broken bytest_image_verify.c(a lost line continuation — fixed in #77), so this target was built directly. This PR is independent of #77 and does not touch that file.core/fw_decrypt.cis CRLF; the diff is 26/10 lines with endings preserved, not a whole-file rewrite.🤖 Generated with Claude Code