Skip to content

fix(okp): refuse the Ed25519 public keys of small order and report the invalid ones - #827

Open
lav45 wants to merge 2 commits into
web-token:4.3.xfrom
lav45:fix/okp-ed25519-point-validation
Open

lav45 wants to merge 2 commits into
web-token:4.3.xfrom
lav45:fix/okp-ed25519-point-validation

Conversation

@lav45

@lav45 lav45 commented Nov 10, 2026

Copy link
Copy Markdown

Target branch: 4.3.x

  • It is a Bug fix
  • It is a New feature
  • It is related to dependencies

Includes:

  • Breaks BC
  • Deprecations

The defect

Both defects are in code that is on 4.3.x only and not released yet: the OpenSSL backend of the Edwards curves came with
2bd3f62.

  • Signature. Without ext-sodium, AbstractEdDSA::verify() goes through OKPKey::verifyWithOpenSSL(). OpenSSL loads
    an Ed25519 public key whose point is of small order, and with the neutral point as key (x = 01 00…00) it verifies
    the signature R = the neutral point, S = 0 for any input. Ed25519 and EdDSA accepted such a forged signature. With
    sodium, sodium_crypto_sign_verify_detached() refuses it. Only a key that its owner registered with such a point is
    concerned: a generated key is never of small order.
  • Analyzer. OKPKeyAnalyzer only checked the size of x and d and the alg, so an Ed25519 key of small order,
    or whose x decodes to no point of the curve, raised no message.

The fix

  • OKPKey::isSmallOrderEd25519Point() compares x, sign bit cleared, with the 7 encodings libsodium refuses
    (ge25519_has_small_order()): the 8 points of order 1, 2, 4 and 8 and the non-canonical encodings of y = 0 and y = 1.
    It needs no extension. verifyWithOpenSSL() returns false for such a key.
  • OKPKey::isValidEd25519PublicKey() uses sodium_crypto_sign_ed25519_pk_to_curve25519() when sodium is loaded, which
    also requires the point to be in the prime-order subgroup. Without sodium, OKPKey::isEd25519Point() decodes x as
    RFC 8032 section 5.1.3 does, with brick/math: y lower than p and (y² − 1) / (d·y² + 1) a square modulo p. A point
    outside the prime-order subgroup is therefore reported with sodium only.
  • OKPKeyAnalyzer reports a key of small order with
    Invalid key. The point of the Ed25519 public key is of small order., and an x that is no valid public key with
    Invalid key. The point is not a valid Ed25519 public key.

Without sodium, isEd25519Point() takes 0.7 ms with GMP and about 1.5 s in pure PHP (two modPow on 255-bit numbers).
Its input is bounded: the analyzer checks that x is 32 bytes long before, and isEd25519Point() returns false for any
other length first.

Tests

  • OKPKeyTest: the 7 encodings and their sign-bit variants are of small order, the RFC 8037 key is not; the decoding of
    x (neutral point, y = 0, y = p − 1, y = 2, y = p, x = 0 with the sign bit set, a short string); a mixed-order point
    is valid without sodium only; verifyWithOpenSSL() refuses the forged signature.
  • FullySpecifiedEdDSATest: EdDSA and Ed25519 return false for the forged signature, whichever backend is loaded.
  • OKPKeyAnalyzerTest: the messages for small-order points, for y = 2 and for the mixed-order point. The helper of the
    well-formed keys takes the x of the RFC 8037 key for Ed25519, as random bytes are a point of the curve only half of
    the time.

Without the fix, the new tests fail; without sodium, the FullySpecifiedEdDSATest one shows the forged signature
accepted. With ghcr.io/spomky-labs/phpqa:8.4:

  • PHPUnit (2986 tests), PHPStan, ECS, Rector and Deptrac pass; the OKP and EdDSA tests also pass with sodium disabled.
  • Infection on the lines of this branch, as the CI runs it (--git-diff-lines --git-diff-base=origin/4.3.x): 82
    mutants, 82 killed, covered code MSI 100%.

OpenSSL loads an Ed25519 public key whose point is of small order, and with
the neutral point as key it verifies the signature R = the neutral point,
S = 0 for any input. Without ext-sodium, AbstractEdDSA::verify() goes through
OKPKey::verifyWithOpenSSL(), so Ed25519 and EdDSA accepted such a forged
signature. With sodium, sodium_crypto_sign_verify_detached() refuses it.

OKPKey::isSmallOrderEd25519Point() compares "x", sign bit cleared, with the 7
encodings libsodium refuses (ge25519_has_small_order()): the 8 points of order
1, 2, 4 and 8 and the non-canonical encodings of y = 0 and y = 1. It needs no
extension. verifyWithOpenSSL() returns false for such a key, and
OKPKeyAnalyzer reports it whichever backend is loaded.
OKPKeyAnalyzer only checked the size of "x" and the points of small order, so
an Ed25519 key whose "x" decodes to no point of the curve raised no message,
while every signature verification with it fails.

OKPKey::isValidEd25519PublicKey() uses sodium when it is loaded:
sodium_crypto_sign_ed25519_pk_to_curve25519() also requires the point to be in
the prime-order subgroup. Without sodium, OKPKey::isEd25519Point() decodes
"x" as RFC 8032 section 5.1.3 does, with brick/math: y lower than p and
(y^2 - 1) / (d y^2 + 1) a square modulo p. A point outside the prime-order
subgroup is then only reported with sodium.

The tests build the "x" of a well-formed Ed25519 key from the RFC 8037 key, as
random bytes are a point of the curve only half of the time.
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