Repository navigation
fix(crypto): reject Ed25519 public keys outside the prime-order subgroup - #99
Conversation
ed25519_verify() decoded the public key and never checked which subgroup it
was in. Ed25519 has eight low-order points, and for any of them every term
of the verification equation collapses regardless of the message, so a
signature of all zeros verifies against arbitrary content.
This was not hypothetical. eos_pkg.c:94 ships
static const uint8_t eos_pkg_public_key[32] = {0};
and the all-zero encoding is one of the eight. Measured against the built
library, before this change:
all-zero key: 2 of 256 arbitrary signatures ACCEPTED
Package installation authenticated nothing. The SHA-256 check two lines
above is no help — it compares a hash of the payload against a field in the
same attacker-supplied file, which detects corruption, not origin. See #98.
The check multiplies the key by L and requires the identity, rather than
comparing against a table of the eight encodings. Both are correct; this one
cannot be wrong in a way that silently rejects valid keys because a constant
was transcribed incorrectly.
Two things this took to get right, both found by testing rather than
reasoning:
The subgroup test alone is insufficient. The identity has order 1, which
divides L, so [L]identity = identity and it passes. The first version of
this check still accepted an identity key. It is now rejected explicitly,
which is why the helper has two parts.
The regression test's first version passed against the unfixed code. The
all-zero key is order 4, so with S = 0 the equation reduces to [k](-A) and
whether that lands on R depends on k mod 4 — on the message. The forgery
succeeds for roughly one message in four, and "untrusted firmware" happened
to be one of the three that do not verify. It looked like a regression test
and was not one. It now sweeps messages verified to be accepted by the
unfixed implementation.
unfixed test_all_zero_key_is_rejected [FAIL] at line 112
fixed 5/5 tests passed
The suite covers all 64 combinations of the eight low-order encodings as key
and as R, plus RFC 8032 vector 1 — without which a check that rejected every
key would also pass.
ctest 28/29 -> 29/29 passed, 0 build errors
This does not make package signing work. A correct verifier with an all-zero
trust anchor now rejects everything instead of accepting everything, which is
the safer failure and still not a functioning signature check. #98 covers
supplying a real key, and whether eos_pkg should refuse to run at all while
its anchor is unset — the shape #84 uses for stub RSA/ECC.
Refs #98
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Rebased onto current The vulnerability is still live on Worth noting when this became dangerous.
which added the real services/pkg/eos_pkg.c:94
static const uint8_t eos_pkg_public_key[32] = {0};and the absent subgroup check. Before that PR, nobody would have relied on package verification: it was That is not a criticism of #57 — putting a real implementation in is the right Re-verified after the rebase: I also dropped the two comment-only commits that were riding along here. They |
141999c to
be033eb
Compare
services/pkg/eos_pkg.c checked package signatures against
static const uint8_t eos_pkg_public_key[32] = {0};
embeddedos-org#99 stopped that key accepting every package. It did not make package
signing work: the anchor is still all zeros, so eos_pkg now rejects every
package, including correctly signed ones, and reports "signature
verification failed" while doing it -- blaming the package for a key that
was never provisioned.
The key comes from the platform now. eos_pkg_set_trust_anchor() is the
seam a keystore or a provisioning step fills, EOS_PKG_TRUST_ANCHOR_HEX is
the build-time form, and with neither configured verification refuses and
names the reason. EOS_ALLOW_UNSIGNED_PKG is the opt-in for tests and
bring-up, matching EOS_ALLOW_STUB_CRYPTO in services/crypto and
EOS_ALLOW_UNSIGNED_OTA in services/ota.
An anchor is validated when it is installed rather than once per package,
so an unusable key is reported as an unusable key. That needed embeddedos-org#99's
checks to be reachable, so ed25519_public_key_is_usable() exports them
rather than duplicating the constants.
services/pkg was in no CMakeLists.txt, so nothing compiled this file and
no test could reach it -- which is why the all-zero anchor survived. It
is now the eos_eapp library (pkg/ already owns the name eos_pkg for the
build-recipe registry).
Closes the second half of embeddedos-org#98.
services/pkg/eos_pkg.c checked package signatures against
static const uint8_t eos_pkg_public_key[32] = {0};
embeddedos-org#99 stopped that key accepting every package. It did not make package
signing work: the anchor is still all zeros, so eos_pkg now rejects every
package, including correctly signed ones, and reports "signature
verification failed" while doing it -- blaming the package for a key that
was never provisioned.
The key comes from the platform now. eos_pkg_set_trust_anchor() is the
seam a keystore or a provisioning step fills, EOS_PKG_TRUST_ANCHOR_HEX is
the build-time form, and with neither configured verification refuses and
names the reason. EOS_ALLOW_UNSIGNED_PKG is the opt-in for tests and
bring-up, matching EOS_ALLOW_STUB_CRYPTO in services/crypto and
EOS_ALLOW_UNSIGNED_OTA in services/ota.
An anchor is validated when it is installed rather than once per package,
so an unusable key is reported as an unusable key. That needed embeddedos-org#99's
checks to be reachable, so ed25519_public_key_is_usable() exports them
rather than duplicating the constants.
services/pkg was in no CMakeLists.txt, so nothing compiled this file and
no test could reach it -- which is why the all-zero anchor survived. It
is now the eos_eapp library (pkg/ already owns the name eos_pkg for the
build-recipe registry).
Closes the second half of embeddedos-org#98.
* fix: repair master — the default configuration and the test link Two independent breakages, both in "CI — eos" on master (9861a3b). net/src/net_posix.c did not compile in the default configuration. net.h wraps its declarations in `#if EOS_ENABLE_NET`, and nothing sets that flag without a product profile or a test build. CMake, however, adds net_posix.c to eos_net whenever the build is not cross-compiling — the flag never enters that decision. So the header expanded to nothing while the file still defined the API against it: net/src/net_posix.c:43:30: error: unknown type name 'eos_net_addr_t' net.c already guards its body the same way and survives; the POSIX mapping was the half that did not. Guarded so the two translation units appear and disappear together. This is the exact gap the "Default configuration" job was added to catch, and it caught it. tests/test_net.c failed to link: undefined reference to `assert' The file defines CHECK() precisely because CI builds Release, NDEBUG deletes assert() along with the expression inside it, and half these checks wrap the call under test — the comment above the macro says so. Ten call sites in the POSIX section still used bare assert(), including assert(eos_net_connect(...) == 0), the exact pattern named there. They were invisible until #104 added -UNDEBUG and stopped assert() being a no-op; the file never includes <assert.h>, so it resolved to an implicit function and failed at link. Converted to CHECK(), which the file already establishes as the right macro here. Verified locally on macOS, all three configurations CI builds: cmake -B build/host -DCMAKE_BUILD_TYPE=Release builds (fails on master) -DEOS_PRODUCT=medical / aerospace both build -DEOS_BUILD_TESTS=ON -DEOS_PRODUCT=vbox_test + ctest 34/34 passed Negative control: the same default-configuration build against unpatched origin/master reproduces the unknown-type errors verbatim. * test: build four suites that existed but were never compiled, and guard it Nine test_*.c files in tests/ are named by no add_executable(), so they are compiled by nothing. Nothing reports that: the file stays in the tree, ctest counts one fewer test, and the run is green. test_mpu_validate.c is the one that matters. It landed with the MPU fix it guards (#109) and a later merge kept the file while dropping the target that built it, so its six cases -- regions the MPU cannot program, and the divide-before-check that preceded them -- have not run since. Four of the nine build and pass as they are: test_mpu_validate 6 cases, registration restored test_hal_stm32f4 21 cases; it needed only the board header directory on the include path, which is the whole reason it never ran test_e2e kernel boot-to-task path test_net_mock eNet socket API against a mock transport ctest goes from 34 to 38. The other five stay out, each for a reason recorded next to its name in the new guard: one hangs because it asserts stub semantics against the real POSIX implementation; one cannot link because backends/ is in no add_subdirectory(); and three would pass without testing anything -- test_profiles reports 0/0 because its directory allowlist admits only "products", which holds no *.yaml; test_performance_benchmarks asserts a wall-clock threshold; test_emulation_simulation asserts a literal against itself one line after assigning it. tests/unit/test_cmake_test_registration.py makes the omission fail. It parses tests/CMakeLists.txt statically -- no cmake, no compiler -- and asserts every suite is either built or listed with a reason, that every built target is registered with ctest, that the exclusion list has no stale or non-existent entries, and, by name, that test_mpu_validate is built. Dropping that registration again fails two of the five. The eBoot repo has carried the same guard since a merge dropped test_fw_transport there; this is the same file adapted to this layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pkg): give eos_pkg a real trust anchor, or refuse to verify services/pkg/eos_pkg.c checked package signatures against static const uint8_t eos_pkg_public_key[32] = {0}; #99 stopped that key accepting every package. It did not make package signing work: the anchor is still all zeros, so eos_pkg now rejects every package, including correctly signed ones, and reports "signature verification failed" while doing it -- blaming the package for a key that was never provisioned. The key comes from the platform now. eos_pkg_set_trust_anchor() is the seam a keystore or a provisioning step fills, EOS_PKG_TRUST_ANCHOR_HEX is the build-time form, and with neither configured verification refuses and names the reason. EOS_ALLOW_UNSIGNED_PKG is the opt-in for tests and bring-up, matching EOS_ALLOW_STUB_CRYPTO in services/crypto and EOS_ALLOW_UNSIGNED_OTA in services/ota. An anchor is validated when it is installed rather than once per package, so an unusable key is reported as an unusable key. That needed #99's checks to be reachable, so ed25519_public_key_is_usable() exports them rather than duplicating the constants. services/pkg was in no CMakeLists.txt, so nothing compiled this file and no test could reach it -- which is why the all-zero anchor survived. It is now the eos_eapp library (pkg/ already owns the name eos_pkg for the build-recipe registry). Closes the second half of #98. * build: keep services/pkg out of cross-compiled builds Adding services/pkg to the build put it in the ARM Cortex-M4 leg too, where it does not belong and does not compile: eos_pkg.c lists directories, spawns processes and waits on them, so it needs <dirent.h>, <unistd.h> and <sys/wait.h>. Bare-metal newlib turns the first of those into a hard #error. Gated on CMAKE_CROSSCOMPILING, which is the switch net_posix.c already uses for the same reason, and for the same stated reason: EOS_PLATFORM defaults to "linux" and a cross toolchain file does not change it, so that condition is true even for a Cortex-M build. Verified with a toolchain file that sets CMAKE_SYSTEM_NAME while keeping the host compiler, so the CMake logic can be checked without arm-none-eabi installed: eos_eapp and test_pkg_trust_anchor are not generated under it, and both are present in the host build. Host ctest 39/39. * fix(tests): create the .eapp fixture 0600, not world-writable CodeQL flags this PR with one new high-severity alert: tests/test_pkg_trust_anchor.c:128 File created without restricting permissions A file may be created here with mode 0666, which would make it world-writable. fopen(path, "wb") creates with 0666 & ~umask, so on a permissive umask the fixture is writable by anyone on the machine. These fixtures are the signed packages the verifier is about to trust, so a world-writable one lets anything on the box rewrite the payload between the write here and the read under test -- which is the very substitution the suite exists to detect. Create through open(O_CREAT|O_WRONLY|O_TRUNC, S_IRUSR|S_IWUSR) and fdopen the descriptor. Verified the call yields 0600. 39/39. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Srikanth Patchava <srikanth.patchava@outlook.com>
…oup (#92) eos_ed25519_verify() decoded the public key and never checked which subgroup it was in. Ed25519 has eight low-order points, and for any of them every term of the verification equation collapses regardless of the message, so a signature of all zeros verifies against arbitrary firmware: identity_pub[32] = {1} /* 01 00..00, the identity */ identity_sig[64] = {1} /* R = identity, S = 0 */ master: rc=0 -> ACCEPTED (forgery works) fixed: rc=-4 -> rejected A complete secure-boot bypass: anyone able to set the trusted key can boot arbitrary firmware. The implementation is muhammadburhandevv-hub's, from #57, carried across because that PR has been unmergeable for four days — it branched before test_secure_boot.c and test_stage0_reset_entry.py landed, so merging it now would delete them. Only core/ed25519_verify.c is taken; the build-repair half of #57 is already upstream. Credit is theirs. Their formulation is better than the one I wrote for the same defect in eos (#99 there): return point_is_identity(multiple) && !point_is_identity(public_key); One expression covering both required checks. The subgroup test alone is insufficient — the identity has order 1, which divides L, so [L]identity = identity and it passes. My eos version needed two separate guards to say this; theirs says it once. The regression tests are mine. test_ed25519_zero_pubkey_rejected already existed and looks like it covers this, but does not: the identity encodes as 01 00..00, not 00 00..00, and unlike the all-zero encoding it decodes to a valid curve point. Two tests are added — the identity case, and all sixteen combinations of four low-order encodings used as key and as R. Verified the tests fail against the unfixed code rather than merely passing with it: unfixed test_ed25519_identity_key_forgery_rejected [FAIL] at line 210 fixed 12/12 tests passed full 20/20 ctest, 0 build errors eos had the identical gap in an independent implementation and was fixed in embeddedos-org/eos#99, which has merged. This was the last one. Refs #73, #57 Co-authored-by: muhammadburhandevv-hub <muhammadburhandevv-hub@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
services/pkg/eos_pkg.c checked package signatures against
static const uint8_t eos_pkg_public_key[32] = {0};
embeddedos-org#99 stopped that key accepting every package. It did not make package
signing work: the anchor is still all zeros, so eos_pkg now rejects every
package, including correctly signed ones, and reports "signature
verification failed" while doing it -- blaming the package for a key that
was never provisioned.
The key comes from the platform now. eos_pkg_set_trust_anchor() is the
seam a keystore or a provisioning step fills, EOS_PKG_TRUST_ANCHOR_HEX is
the build-time form, and with neither configured verification refuses and
names the reason. EOS_ALLOW_UNSIGNED_PKG is the opt-in for tests and
bring-up, matching EOS_ALLOW_STUB_CRYPTO in services/crypto and
EOS_ALLOW_UNSIGNED_OTA in services/ota.
An anchor is validated when it is installed rather than once per package,
so an unusable key is reported as an unusable key. That needed embeddedos-org#99's
checks to be reachable, so ed25519_public_key_is_usable() exports them
rather than duplicating the constants.
services/pkg was in no CMakeLists.txt, so nothing compiled this file and
no test could reach it -- which is why the all-zero anchor survived. It
is now the eos_eapp library (pkg/ already owns the name eos_pkg for the
build-recipe registry).
Closes the second half of embeddedos-org#98.
Closes the verifier half of #98.
ed25519_verify()decoded the public key and never checked which subgroup it wasin. Ed25519 has eight low-order points, and for any of them every term of the
verification equation collapses regardless of the message — so a signature of all
zeros verifies against arbitrary content.
Not hypothetical.
eos_pkg.c:94shipsand the all-zero encoding is one of the eight. Measured against the built
library, before this change:
Package installation authenticated nothing. The SHA-256 check two lines above is
no help — it compares a hash of the payload against a field in the same
attacker-supplied file, which detects corruption, not origin.
The check
Multiply the key by
L, require the identity. Chosen over comparing against atable of the eight encodings because both are correct but this one cannot be
wrong in a way that silently rejects valid keys because a constant was
transcribed incorrectly.
Two things this took to get right, both found by testing rather than reasoning
The subgroup test alone is insufficient. The identity has order 1, which
divides L, so
[L]identity = identityand it passes. My first version stillaccepted an identity key. It is now rejected explicitly — which is why the helper
has two parts, and why eBoot#57 has both a
point_is_identity()and a subgroupcheck.
The regression test's first version passed against the unfixed code. The
all-zero key is order 4, so with
S = 0the equation reduces to[k](-A), andwhether that lands on
Rdepends onk mod 4— on the message:The forgery succeeds for roughly one message in four, and an attacker simply
varies padding until one takes. My test asserted on a single message that
happened to be one of the three-in-four that do not verify. It looked like a
regression test and was not one — the exact defect I have been flagging on other
PRs this week.
It now sweeps messages verified to be accepted by the unfixed implementation:
Verified
The suite covers all 64 combinations of the eight low-order encodings used as key
and as
R, plus RFC 8032 vector 1 — without which a check that rejected everykey would also pass every test above.
What this does not do
It does not make package signing work. A correct verifier with an all-zero trust
anchor now rejects everything instead of accepting everything: the safer failure,
still not a functioning signature check.
#98 covers the rest — supplying a real key, and whether
eos_pkgshould refuseto run at all while its anchor is unset rather than appearing to verify. That is
the shape #84 uses for stub RSA/ECC (
EOS_ALLOW_STUB_CRYPTO), and it is a policycall rather than a bug fix, so it is deliberately not in this PR.
Base
Branches from #82, which repairs
master's four compile breakages — onmastertoday CMake cannot generate a single test target, so none of the above could be
run there. Merge #82 first.
Related
eBoot#73 is the same missing check in eBoot's independent Ed25519
implementation, reached with the identity point. eBoot#57 fixes that one. Two
implementations in the organisation, the same gap in both.