Skip to content

fix(crypto): reject Ed25519 public keys outside the prime-order subgroup - #99

Merged
srpatcha merged 1 commit into
masterfrom
fix/ed25519-reject-low-order-keys
Sep 1, 2026
Merged

srpatcha merged 1 commit into
masterfrom
fix/ed25519-reject-low-order-keys

Conversation

@srpatcha

Copy link
Copy Markdown
Member

Closes the verifier half of #98.

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.

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.

The check

Multiply the key by L, require the identity. Chosen over comparing against a
table 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 = identity and it passes. My first version still
accepted 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 subgroup
check.

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:

untrusted firmware           -> rejected
malicious package payload    -> ACCEPTED
AB                           -> ACCEPTED
ABC                          -> rejected

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:

unfixed   test_all_zero_key_is_rejected  [FAIL] at line 112
fixed     5/5 tests passed

Verified

0 build errors
ctest   28/29 -> 29/29 passed

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 every
key 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_pkg should refuse
to 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 policy
call rather than a bug fix, so it is deliberately not in this PR.

Base

Branches from #82, which repairs master's four compile breakages — on master
today 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.

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>
@srpatcha

Copy link
Copy Markdown
Member Author

Rebased onto current master, which is now green — the four compile breakages
this originally branched around have landed, so the base commits were dropped as
already-upstream. This is now a single commit on top of master.

The vulnerability is still live on master. Re-verified against a clean
git clone --depth 1 a moment ago:

subgroup checks in services/crypto/src/ed25519_verify.c: 0
all-zero key: 2 of 256 arbitrary signatures ACCEPTED

Worth noting when this became dangerous. master's ed25519_verify.c was last
touched by

feat: transition package verification from stubs to production-grade crypto (#57)

which added the real ed25519_fe.c / ed25519_ge.c / ed25519_sc.c
implementation — 1491 lines of field arithmetic — and left two things untouched:

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
self-evidently a stub. After it, the code reads as production crypto and is
described that way in the commit title, while accepting an all-zero signature for
roughly one message in four. The stub-to-real transition is exactly the point at
which this stops being a known gap and starts being a false assurance.

That is not a criticism of #57 — putting a real implementation in is the right
move and had to happen first. It does mean the trust anchor and the subgroup
check are now the two things standing between "production-grade" in the commit
message and production-grade in fact.

Re-verified after the rebase:

0 build errors
29/29 ctest passed
5/5 test_crypto_ed25519_loworder

I also dropped the two comment-only commits that were riding along here. They
were adopted from #93 and #94; those PRs are still open and should land their own
wording under their own authorship rather than having me carry it.

@srpatcha
srpatcha force-pushed the fix/ed25519-reject-low-order-keys branch from 141999c to be033eb Compare August 31, 2026 00:33
@srpatcha
srpatcha merged commit 1d4af45 into master Sep 1, 2026
23 of 26 checks passed
@srpatcha
srpatcha deleted the fix/ed25519-reject-low-order-keys branch September 1, 2026 10:58
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 1, 2026
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.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 1, 2026
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.
srpatcha added a commit that referenced this pull request Sep 1, 2026
* 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>
srpatcha added a commit to embeddedos-org/eBoot that referenced this pull request Sep 3, 2026
…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>
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 14, 2026
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.
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.

1 participant