Skip to content

Harden x25519 XEdDSA verification - #1158

Open
maraino wants to merge 1 commit into
masterfrom
mariano/x25519-fixes
Open

maraino wants to merge 1 commit into
masterfrom
mariano/x25519-fixes

Conversation

@maraino

@maraino maraino commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Name of feature:

Harden x25519 XEdDSA verification.

Pain or issue this feature alleviates:

x25519.Verify accepted public keys that the XEdDSA specification rejects, and keys for which signatures can be forged without the private key:

  • Non-canonical public keys (u >= p, including encodings with the most significant bit set) were accepted. The specification rejects them.
  • Small order public keys were accepted, and for those keys anyone can forge a valid signature without the private key. The specification doesn't reject them, so Verify is now stricter than the specification in this case.

Other fixes:

  • PrivateKey.Equal now compares private keys in constant time.
  • PrivateKey.Public returns an untyped nil for invalid keys, instead of a typed nil that compares != nil.
  • GenerateKey and Sign use crypto/rand.Reader when random is nil, instead of panicking.
  • calculate_key_pair selects the private scalar in constant time, as the specification recommends, instead of branching on the sign bit of the twisted Edwards public key.

Why is this important to the project (if not answered above):

step-ca's Nebula provisioner verifies XEdDSA tokens with jose.Verify, which calls x25519.Verify.

Is there documentation on how to use this feature? If so, where?

The Sign and Verify doc comments now document the differences with the specification:

  • Verify rejects s >= q; the specification only rejects s >= 2^253. This prevents creating a second valid signature for the same message without the private key. This behavior is unchanged from master.
  • Verify rejects small order public keys.
  • Sign derives the nonce like xed25519_sign in libsignal-protocol-c: it hashes the clamped bytes of k when the key is not negated, instead of the encoding of k (mod q). The nonce is not visible to verifiers and both choices are equally secure, but this one generates the same signatures as libsignal-protocol-c, so its test vectors can be used.
  • The signature format used by libsignal, and by curve25519_sign in libsignal-protocol-c, is not part of the specification and is not accepted. That format keeps the sign bit of the twisted Edwards public key in the most significant bit of s.

In what environments or workflows is this feature supported?

All. Signatures still use the format of the specification, so step and step-ca interoperate across versions in both directions. Keys generated by GenerateKey, or by any X25519 implementation, are canonical and never of small order, so they're not affected by the new checks.

In what environments or workflows is this feature explicitly NOT supported (if any)?

  • Public keys that are not canonical, or that are of small order, are now rejected by Verify. Honest keys never have this form.
  • Sign generates different, still valid, signature bytes than before for about half of the keys. This only matters to tests that compare signature bytes generated with a fixed random reader. There are none in this repository, smallstep/cli, or smallstep/certificates.
  • ToEd25519 is unchanged and still accepts non-canonical and small order keys.

Supporting links/other PRs/issues:

Verification

New tests:

  • The libsignal-protocol-c XEdDSA pseudorandom tests: two chains of 10000 sign, verify, and tamper iterations, which must reproduce the expected signatures. They're skipped in short mode.
  • Tests that the libsignal signature format is rejected.
  • Regression tests for non-canonical keys, and for signatures forged for small order keys.

These checks were run locally and are not part of this PR:

  • Compared with libsignal-protocol-c: this code was cross-checked against the unmodified C implementation of the specification, xed25519_sign and xed25519_verify.
    • Sign matched xed25519_sign byte for byte for 3000 random keys.
    • 15000 Verify results, from valid, tampered, libsignal format, and random signatures, matched xed25519_verify. The only differences are the two intended ones: s + q, and signatures forged for small order keys.
  • Compared with ed25519.Verify: for canonical keys that are not of small order, including keys with a small order component, Verify matched ToEd25519 followed by ed25519.Verify in 27000 cases.

🤖 Generated with Claude Code

Verification:

- Reject public keys that are not canonical field elements, u >= p,
  which includes the encodings with the most significant bit set, as
  the XEdDSA specification requires.
- Reject public keys of small order, signatures for those keys can be
  forged without the private key. The specification does not reject
  them.
- Keep rejecting signatures with s >= q, stricter than the specification,
  to prevent malleable signatures.
- Document that the signature format used by libsignal, and by the
  curve25519_sign function of libsignal-protocol-c, is not accepted. It
  is not part of the specification.

Signing:

- Derive the nonce like the xed25519_sign function of
  libsignal-protocol-c, using the clamped bytes of the private key when
  it is not negated, instead of the encoding of k (mod q). The nonce is
  not visible to verifiers, and both choices are equally secure, but this
  one generates the same signatures as xed25519_sign, so its test vectors
  can be used.
- Select the private scalar and the nonce key in constant time in
  calculate_key_pair, as the specification recommends, instead of
  branching on the sign bit of the twisted Edwards public key.
- Use crypto/rand.Reader in GenerateKey and Sign when rand is nil.
- Encode R only once.

Keys:

- Compare private keys in constant time in PrivateKey.Equal.
- Return an untyped nil from PrivateKey.Public on invalid keys.

Document the differences with the specification in Sign and Verify, fix
doc comments, and remove a redundant sign bit mask.

Add the libsignal-protocol-c XEdDSA test vectors, including the 10000
iterations pseudorandom tests, which are skipped in short mode, tests
that the libsignal signature format is rejected, and regression tests
for non-canonical and small order public keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8VgknTCaG4zTDd1oBuC3T
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant