Skip to content

fix(image): pin the whole .efw header, not four fields of it - #87

Merged
srpatcha merged 1 commit into
embeddedos-org:masterfrom
Kartikey1306:fix/pin-efw-wire-format
Sep 3, 2026
Merged

fix(image): pin the whole .efw header, not four fields of it#87
srpatcha merged 1 commit into
embeddedos-org:masterfrom
Kartikey1306:fix/pin-efw-wire-format

Conversation

@Kartikey1306

Copy link
Copy Markdown
Contributor

Closes #66, parts 1 and 2.

eos_image_header_t is a wire format: eFirmware writes these bytes, eBoot reads them, and the signing tools address fields by absolute offset. Four of its fourteen fields were pinned — sizeof, hash, sig_type, signature.

A field swap passed every existing assert

Transposing two adjacent same-width fields moves neither sizeof nor any of those four offsets:

$ # load_addr and entry_addr transposed
$ cc -std=c11 -I include -c abi_tu.c
   (on master: compiles clean, 0 asserts fire)

The bootloader would then load an image at its entry point and jump to its load address. With this change:

include/eos_image.h:99:  static assertion failed: load_addr must stay at offset 12
include/eos_image.h:101: static assertion failed: entry_addr must stay at offset 16

No constant's value was pinned on this side

EOS_IMG_MAGIC, EOS_HASH_SIZE, EOS_SIG_MAX_SIZE, EOS_IMG_SIGNED_LEN, EOS_IMAGE_HDR_VERSION and the five eos_sig_type_t values all travel inside the image, so they are wire format too, and an offset assert says nothing about them.

$ # EOS_SIG_ED25519 = 3 -> 4
   master:    compiles clean, 0 asserts fire
   this PR:   static assertion failed: EOS_SIG_ED25519 is 3 on the wire

That change would make eBoot misread the signature type of every image already in the field. eFirmware pins EFW_IMAGE_MAGIC == 0x454F5349u and EFW_SIG_ED25519 == 3 on its side; these are the matching half, so the two definitions can no longer drift apart in silence.

What is added

count
missing field offsets 10
field widths 3
constant values 11
total asserts 30 (was 4)

The three width asserts are there because an offset assert cannot see a field growing into padding that happens to keep every later offset — reserved[30] absorbs exactly that.

Header only. No struct member, constant, or line of logic changes, so every image on disk today parses exactly as it did before. The asserts are all EOS_IMG_STATIC_ASSERT, which already degrades to nothing before C11.

Validation

compiles clean, all 30 pass
load_addr/entry_addr transposed  ->  2 asserts fire   (master: 0)
EOS_SIG_ED25519 = 3 -> 4         ->  1 assert fires   (master: 0)
cmake -DEBLDR_BUILD_TESTS=ON + ctest  ->  20/20 passed

Not touched

Part 3 of #66 — eFirmware stamping hdr_version = 1 while eBoot supports only v2, so core/image_verify.c admits a v1 image at the parser and then fails it at the signature — is a security-policy call (reject at parse, branch the verifier, or change what efwtool stamps). It fails closed, so it is not an escalation, and it wants a decision rather than a patch. Deliberately left out.

🤖 Generated with Claude Code

Closes embeddedos-org#66 (parts 1 and 2).

eos_image_header_t is a wire format — eFirmware writes these bytes, eBoot
reads them, and the signing tools address fields by absolute offset.
Four of its fourteen fields were pinned: sizeof, hash, sig_type,
signature.

Transposing two adjacent same-width fields moves neither sizeof nor any
of those four offsets. With load_addr and entry_addr swapped, all four
existing asserts still pass and the header compiles clean; the
bootloader then loads an image at its entry point and jumps to its load
address.

No constant's *value* was pinned on this side either. EOS_IMG_MAGIC,
EOS_HASH_SIZE, EOS_SIG_MAX_SIZE, EOS_IMG_SIGNED_LEN, EOS_IMAGE_HDR_
VERSION and the five eos_sig_type_t values all travel inside the image.
Changing EOS_SIG_ED25519 from 3 to 4 compiled cleanly, passed every
assert, and would make eBoot misread the signature type of every image
already in the field. eFirmware pins EFW_IMAGE_MAGIC and EFW_SIG_ED25519
on its side; this is the matching half, so the two definitions can no
longer drift apart in silence.

Adds the ten missing offsets, three field widths (an offset assert
cannot see a field growing into padding that keeps every later offset —
reserved[] absorbs exactly that), and eleven constant values. Thirty
asserts total.

Header only. No struct member, constant, or line of logic changes, so
every image on disk today parses exactly as before.

Verified:
  compiles clean, all 30 pass
  load_addr/entry_addr transposed  -> 2 asserts fire (master: 0)
  EOS_SIG_ED25519 = 3 -> 4         -> 1 assert fires (master: 0)
  cmake -DEBLDR_BUILD_TESTS=ON + ctest -> 20/20 passed

Part 3 of embeddedos-org#66 — eFirmware stamping v1 while eBoot supports only v2 — is
a security-policy call and is deliberately not touched here.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@srpatcha
srpatcha merged commit bbd997a into embeddedos-org:master Sep 3, 2026
27 checks passed

@srpatcha srpatcha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — eBoot#87 "fix(image): pin the whole .efw header, not four fields of it"

head: 837fed8 author: Kartikey1306 ci: pass

Verdict: Header-only, no member or logic change, and every one of the 23 new asserts is correct — I recomputed all 10 offsets against natural alignment and checked all 10 constants against their definitions. The substance is right and the "every remaining field is now pinned" claim is true. Two things are not: the arithmetic in the PR body, and the drift-protection argument the constant asserts are justified by, which does not hold for the one constant where the two repos already disagree.

Findings

# Severity File:line Finding Recommended fix
1 Medium include/eos_image.h, comment above the constant asserts: "eFirmware pins the same numbers on its side … so the two definitions can no longer drift apart in silence" The mechanism described does not exist. Each repo asserts its own literal against itself; nothing compares the two. eBoot's _Static_assert cannot see eFirmware's headers, and eFirmware's equivalents are runtime checks in eFirmware/tests/test_abi.c:84,89-93, run by a different suite in a different repo. Two independent self-consistency checks do not detect divergence — they only make each side's own value harder to change by accident, which is worth having but is a different property. And the counter-example is already in the tree: this PR adds EOS_IMG_STATIC_ASSERT(EOS_IMAGE_HDR_VERSION == 2, …) while eFirmware/include/efw/efw_image.h:56 defines EFW_IMAGE_HDR_VERSION 1u. The two sides have already drifted on hdr_version, both now assert their own value, and neither assert fires. The new assert pins the disagreement rather than surfacing it. Two parts, and only the first belongs in this PR: (a) reword the comment to what the asserts actually do — "each side pins its own values, so neither changes by accident; this is not a cross-repo check and does not detect divergence" — and drop "can no longer drift apart in silence"; (b) separately, the real check is a CI job that compiles both headers in one translation unit and static-asserts EOS_IMG_MAGIC == EFW_IMAGE_MAGIC, the five signature enumerators pairwise, and offsetof equality field by field. That is the thing that would have caught the hdr_version split, and it is worth its own issue.
2 Low PR body, the counts table and "Four of its fourteen fields were pinned"; same wording in the added comment in include/eos_image.h eos_image_header_t has 13 fields (include/eos_image.h:25-39: magic, hdr_version, hdr_size, image_size, load_addr, entry_addr, image_version, flags, hash, sig_type, sig_len, reserved, signature), not fourteen, and three of them had pinned offsets, not four — the fourth pre-existing assert is sizeof, which is not a field. The table is also off: the diff adds 10 offset asserts, 3 width asserts and 10 constant asserts, not 11, for 23 new and 27 total, not 30. The 59-line diff corroborates 23 (23 × 2 lines + 13 lines of comment). Small, but this PR's whole subject is that a stated invariant should match the artefact, and per the run brief an unsupported count is the finding. Correct both to "three of the thirteen field offsets were pinned; all thirteen now are" and "10 + 3 + 10 = 23 new, 27 total (was 4)".
3 Low include/eos_image.h, the pinned block as a whole Byte order is not pinned and not documented, on either side of the file. eFirmware's mirror header states it — "Multi-byte fields are little-endian. That matches the in-memory struct layout on every currently supported target (ARM and x86, both LE), which is how eBoot reads the header straight out of flash" (eFirmware/include/efw/efw_image.h:32-34) — and eBoot, which is the side that reads the header straight out of flash, says nothing. Offsets and widths do not constrain byte order, so a big-endian port would compile with all 27 asserts passing and silently misread magic, image_size, load_addr and entry_addr. §5 lists ARM/ARM64, RISC-V, x86-64 and Xtensa as approved ports; ARM and RISC-V both have big-endian configurations. Unlike the cross-repo case, this one is a compile-time property. Add the constraint the format actually has: EOS_IMG_STATIC_ASSERT(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__, "the .efw header is little-endian on the wire") (guarded on __BYTE_ORDER__ being defined), and copy eFirmware's endianness paragraph into this header's doc comment so the contract is stated where it is read.
4 Low include/eos_image.h:66-72, the EOS_IMG_STATIC_ASSERT fallback #else #define EOS_IMG_STATIC_ASSERT(cond, msg) /* unavailable before C11 */ — all 27 checks silently become nothing on a pre-C11 compiler, with no diagnostic. eBoot's own build is safe: CMakeLists.txt:8-9 sets CMAKE_C_STANDARD 11 with CMAKE_C_STANDARD_REQUIRED ON. But this is a file in include/, which .ai/architect.md calls "the contract other repos compile against", and the signing tools address these fields by absolute offset. A consumer building at -std=c99 loses the entire wire-format guard and is told nothing. .ai/security.md puts it directly: "A verification step that cannot run must fail, not pass"; the PR body treats the degradation as a feature ("already degrades to nothing before C11"). Pre-existing macro, but this PR is what makes the whole format depend on it, taking it from 4 asserts to 27. Make the fallback work instead of vanish: #define EOS_IMG_STATIC_ASSERT(cond, msg) typedef char eos_img_assert_##__LINE__[(cond) ? 1 : -1] (or an extern array declaration), which is C89-portable. If the no-op is kept deliberately, emit a #warning so a consumer at least learns the guard is off.

Architecture conformance

Conforms. §21 Tier 1 — Foundation. include/ is the correct location per .ai/architect.md's eBoot layout, and the rule that "include/ depends on nothing" holds: the file's only include is eos_types.h, unchanged, and the added asserts introduce no new dependency in any direction. No new link line or target_link_libraries entry. §8.1 "signed manifests and images" is served — pinning the offsets is what keeps EOS_IMG_SIGNED_LEN addressing the bytes the signer signed. §5.1 minimal TCB: 59 lines of compile-time assertion, zero runtime code, zero flash.

Placement of the format itself is worth stating since two repos define it. eBoot is Tier 1 and eFirmware is the producer side; the format lives in both as mirrored declarations with no shared source of truth. §21.1's split policy is about repositories, not headers, and does not speak to a duplicated wire format — the closest the design comes is §10.1's component contract, which is about manifests rather than image headers. This is the same shape as the duplicated Ed25519 verifier in #86/#89, and I am recording the design gap once, against #89, rather than three times.

The deferral of #66 part 3 is correctly described and I verified the "fails closed" claim: core/image_verify.c:89 reads if (out->hdr_version == 0 || out->hdr_version > EOS_IMAGE_HDR_VERSION), so a v1 image passes the parser (1 <= 2), and the signature is then checked over EOS_IMG_SIGNED_LEN at core/image_verify.c:202,208 — the v2 prefix — which a v1 signature over hash[] alone cannot satisfy. Rejected, not admitted. Leaving that to a policy decision is the right call.

Proposed changes

  1. Reword the drift comment (finding 1a) and correct the counts in both the body and the comment (finding 2). Comment-only; no code moves.
  2. Add the endianness assert and doc paragraph (finding 3).
  3. Make the pre-C11 fallback loud or portable (finding 4).
  4. Open a separate issue for the real cross-repo ABI check (finding 1b), which is the only thing that would catch the hdr_version split and belongs with #66 part 3 rather than here.

All four are independent. None of them argues against merging the asserts themselves.

Verified clean, for the record: all 13 field offsets recomputed from the declaration at include/eos_image.h:25-39 under natural alignment — 0, 4, 6, 8, 12, 16, 20, 24, 28, 60, 61, 62, 92, total 156, struct alignment 4, no trailing padding — every offset assert matches. All three width asserts match. All ten constant asserts match their definitions: EOS_IMG_MAGIC 0x454F5349 (include/eos_types.h:49), EOS_HASH_SIZE 32 and EOS_SIG_MAX_SIZE 64 (:150-151), the enum 0..4 (:98-102), EOS_IMAGE_HDR_VERSION 2 (include/eos_image.h:46), and EOS_IMG_SIGNED_LEN = offsetof(…, signature) = 92. After this PR every field offset in the struct is pinned, so no same-width transposition and no scalar widening or narrowing can pass unnoticed — a narrowing that would preserve later offsets is prevented by alignment, and a widening absorbed by reserved[] is caught by its width assert. The completeness claim holds.

Not checked

  • Nothing was compiled. The three negative probes in the body — transposing load_addr/entry_addr fires 2 asserts, EOS_SIG_ED25519 = 4 fires 1, both firing 0 on master — were not reproduced; that needs the PR head checked out, which the run brief forbids. My confirmation of the asserts is by recomputing offsets and reading the constant definitions, which is independent of the author's numbers but is not the compiler's answer. ctest 20/20 likewise not run.
  • Finding 1's hdr_version divergence is read from the working tree, not from this PR's base. eFirmware is at whatever commit the sync step left it; I did not check its branch or whether an unmerged PR there moves EFW_IMAGE_HDR_VERSION to 2. If one does, finding 1's example weakens — the mechanism point stands regardless.
  • eFirmware's own offset pinning not audited. I confirmed it pins the magic and the five enumerators (tests/test_abi.c:84,89-93) and that its struct mirrors eBoot's field for field with an identical documented offset table (include/efw/efw_image.h:15-29,84-98). Whether it pins every offset, and whether EFW_SHA256_DIGEST_LEN is 32, I did not verify.
  • No cross-target compile. Whether all 27 asserts hold on every §22 target — in particular the Xtensa and RISC-V ports and any big-endian configuration — is exactly what finding 3 is about and is not answered by checks.txt, whose cross legs are arm-none-eabi Cortex-M4 and STM32F4 only. Both little-endian.
  • 22 of 23 checks pass; Create GitHub Release and assign report skipping, expected on a PR. No required check failing. mergeStateStatus: BLOCKED, mergeable: MERGEABLE; no merge attempted.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pin the .efw wire format in eBoot — a field swap and every constant value drift undetected

3 participants