Skip to content

Ed25519 accepts a forged signature over any message when the key is a low-order point #73

Closed

Description

@srpatcha

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

  1. srpatcha commented on Aug 30, 2026

    @srpatcha
    MemberAuthor

    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 = identity and a
    plain "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 subgroup
    test — 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 term
    collapses 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 on
    k mod 4 — on the message. In eos that came out at roughly one in four:

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

    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_rejected is sound as written — the identity
    case 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, plus
    RFC 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions