Repository navigation
Description
eos_ed25519_verify() accepts a forged signature over any message when the
public key is a low-order point. This is a complete secure-boot bypass: an
attacker who can set the trusted key can boot arbitrary firmware.
Reproduction
Built against core/ed25519_verify.c as it stands on #58 — the approved build
repair, and the base of the current eBoot PR chain:
uint8_t identity_pub[32] = {1}; /* identity point, compressed: 01 00...00 */
uint8_t identity_sig[64] = {1}; /* R = identity, S = 0 */
const uint8_t msg[] = "untrusted firmware";
int rc = eos_ed25519_verify(identity_sig, identity_pub, msg, sizeof(msg) - 1);identity-key verify: rc=0 -> ACCEPTED (forgery works)
rc == 0 is EOS_OK. The message is arbitrary; nothing about it is signed.
Why it verifies
The implementation is a real Ed25519 — canonical-S check, unpackneg, SHA-512,
scalarmult, constant-time compare. It is not a stub. What it lacks is any
check that the public key lies in the prime-order subgroup.
With A = identity:
s_is_canonical(0…0) passes, 0 < L
unpackneg(identity) succeeds, the identity is on the curve
k = SHA512(R ‖ A ‖ M) mod L
lhs = [k](-A) = [k](identity) = identity
rhs = [S]B = [0]B = identity
lhs + rhs = identity
point_pack(identity) = 01 00…00 = R ✓ equation holds
The verification equation is satisfied for every M, because every term
collapses to the identity regardless of the message.
What is and is not already covered
tests/unit/test_ed25519.c on #58 has ten tests, including
test_ed25519_zero_pubkey_rejected and test_ed25519_zero_signature_rejected.
Those catch an all-zero key, which is a different encoding — the identity is
01 00…00, not 00 00…00, and it decodes to a valid curve point where the
all-zero encoding does not. Ed25519 has eight low-order points; the existing
tests catch one edge case and miss the family.
RFC 8032 §5.1.7 permits but does not require cofactored verification, and
explicitly notes that implementations rejecting small-order keys are the ones
resistant to this. libsodium's crypto_sign_verify_detached rejects low-order
public keys for exactly this reason.
Fix
#57 already implements it: point_is_identity() plus a subgroup check that
multiplies the decoded key by L and requires the identity, rejecting both the
identity and torsion keys.
Verified against the same reproduction:
#58 (current base) rc=0 ACCEPTED
#57 rc=-4 rejected
and it does not break valid signatures — the RFC 8032 vectors still pass:
10/10 tests passed (test_ed25519)
16/16 tests passed (full ctest)
Priority
This should go in ahead of the rest of the eBoot queue. The bootloader's entire
trust model rests on this function, and every other PR in the chain is about
build repair or coverage. #57 is currently unmergeable for the same
signed-commit reason as the rest of the queue (#68), which is worth weighing when
deciding that policy.
Activity
srpatcha commentedon Aug 30, 2026
Two things learned while fixing the same class in eos (#98 / eos#99) that apply
directly to this issue and to #57.
A subgroup check alone is not sufficient
The identity has order 1, which divides L, so
[L]identity = identityand aplain "multiply by L, require the identity" test admits it. My first attempt at
the eos fix did exactly that and still accepted an identity key.
#57 gets this right — it carries
point_is_identity()alongside the subgrouptest — but it is worth stating explicitly here, because the obvious one-line
reading of "reject low-order keys" is the subgroup multiply, and anyone
reimplementing this from the issue title would ship the same hole I did.
The forgery is probabilistic for some of the eight points
The identity case in this issue is universal: with
A= identity every termcollapses regardless of the message, so it verifies for any input.
Not all low-order points behave that way. The all-zero encoding is an order-4
point, and there the equation reduces to
[k](-A), so success depends onk mod 4— on the message. In eos that came out at roughly one in four:Relevant here because a test written against eBoot's zero-key case with one
fixed message could pass on unfixed code and look like a regression test. Mine
did, in eos, until I checked it against the unfixed build.
Suggestion for #57's test
test_ed25519_identity_key_forgery_rejectedis sound as written — the identitycase is message-independent, so a single message is enough there.
If a zero-key or other low-order case is added later, it needs either several
messages or one verified to be accepted by the unfixed implementation. eos#99
sweeps all 64 combinations of the eight encodings used as key and as
R, plusRFC 8032 vector 1 so that a check which rejected every key cannot pass.
Nothing here changes the priority: #57 fixes the bypass, and it is still blocked
behind #68.
test(ed25519): hold both repos' verifiers to one shared contract
fix(crypto): reject Ed25519 public keys outside the prime-order subgr…
test(ed25519): hold both repos' verifiers to one shared contract
test(ed25519): hold both repos' verifiers to one shared contract (#89)