fix(fw_decrypt): drop a HW-crypto shortcut that cannot express streaming GCM - #80
fix(fw_decrypt): drop a HW-crypto shortcut that cannot express streaming GCM#80Kartikey1306 wants to merge 3 commits into
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.
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