Skip to content

perf: faster Ed25519 verification with 0.3.1's exact behaviour - #4

Open
satran004 wants to merge 6 commits into
maintain-0.3.0from
perf/inplace-verify
Open

satran004 wants to merge 6 commits into
maintain-0.3.0from
perf/inplace-verify

Conversation

@satran004

Copy link
Copy Markdown
Member

Summary

This makes Ed25519 verification faster and cuts its allocation. Behaviour is unchanged: every verdict, every exception and serialization all match 0.3.1. There is no API change and no new checks; libsodium-style input checks stay in callers such as cardano-client-lib (#718, and bloxbean/cardano-client-lib#751). Version 0.3.2-SNAPSHOT. Nothing is released.

How it works

  • EdDSAEngine is byte-identical to v0.3.1, so the S check, the cached getNegativeA(), the base point and every exception path are all 0.3.1's.
  • The only behavioural change is inside GroupElement.doubleScalarMultiplyVariableTime:
    • After 0.3.1's own slide and getZero(P2), it runs an in-place replica of the same loop. It uses the same steps (dbl, toP3, madd/msub, toP2), the same operand order and the same field kernels, so the limbs are bit-identical.
    • The replica runs only when every object it reads is exactly GroupElement / Ed25519FieldElement (getClass() ==), the field matches and the precomputed tables are well-formed. Anything else runs 0.3.1's loop unchanged.
  • Ed25519FieldElement:
    • multiply, square and squareAndDouble were moved verbatim into package-private static kernels.
    • invert and pow22523 use in-place chains only for the exact class. Subclasses get the verbatim 0.3.1 chains, which call square() and multiply() virtually.
    • serialVersionUID is pinned to 0.3.1's computed value.
  • Ed25519VerifyEquation is a new internal helper: not a stable API, and public only because of package layout.

Performance (JDK 25, 20k verifies):

0.3.1 this branch
fresh key from bytes 126 µs, 632 KB 93 µs, 36 KB
fresh key from X.509 127 µs, 632 KB 93 µs, 36 KB
one key reused 70 µs, 388 KB 46 µs, 3.5 KB

A fresh key still builds 0.3.1's getNegativeA() precomputation, with one inversion per entry. That is the cost of reading exactly 0.3.1's snapshot.

Review history

Five Codex adversarial rounds and an independent Claude review. Each round's counterexample is now a regression test:

  1. a caller-built point with inconsistent T or off-curve coordinates, and dispatch on spec equals;
  2. out-of-range limbs, and a foreign Field encoding;
  3. a caller mutating limbs after getNegativeA() is cached, and field-element subclasses;
  4. a GroupElement subclass overriding dbl(), and virtual dispatch in invert/pow22523;
  5. the computed serialVersionUID changing. It is now pinned and checked across versions against the Central 0.3.1 jar in both directions.

Rounds 1–2 added guards that later rounds replaced with the snapshot and exact-class design, so the intermediate commit messages describe superseded approaches. Squash merge is suggested.

Testing

  • Tests: 147/147 on JDK 11, including the 2,600 verify and 4,000 signature replays.

  • Differentials against the Central 0.3.1 jar:

    Harness Result
    Hand-built keys: every review counterexample, mutations, subclasses in each position, 2,000 random limb/field variants 0 / 2,019 differences
    Byte-decoded comparisons: badT, T0, off-curve, decoded, 16-thread concurrency 0 / 239,716 mismatches
    Corpus verdicts identical on 100,444 cases

🤖 Generated with Claude Code

https://claude.ai/code/session_01MJrQU97MHKeQpEH7XcJT9A

satran004 and others added 6 commits November 11, 2026 09:37
EdDSAEngine verifies Ed25519 keys with the same computation as before
(ref10's double scalar multiplication, sliding windows and tables, then the
encoding of R' compared with R), on int[10] field elements updated in place
instead of a new object per field operation. multiply, square,
squareAndDouble, invert and pow22523 share in-place forms with the same
arithmetic. Verification outcomes and signatures are unchanged: the tests
replay 2,600 verification outcomes and 4,000 signatures made by 0.3.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQU97MHKeQpEH7XcJT9A
The in-place path now runs only for the standard spec instance
(EdDSANamedCurveTable.ED_25519_CURVE_SPEC, whose base point carries the
precomputed tables) and for key points that Ed25519VerifyEquation.supports:
Ed25519FieldElement coordinates on the standard curve, Z != 0, TZ = XY and
the curve equation. A key point built through GroupElement.p3 with another T,
or off the curve, and a spec equal to the standard one without precomputed
tables keep the 0.3.1 computation, with its outcomes and exceptions.

Ed25519VerifyEquation.holds returns false for S >= L and for a k or signature
of the wrong length, and its class documents that it is internal to
EdDSAEngine. The version is 0.3.2-SNAPSHOT.

Tests: a key point with an inconsistent T, points off the curve or with
Z = 0, an equal spec without precomputed tables (NullPointerException, as in
0.3.1), streaming input, and the preconditions of holds.

Keys deserialized from this version's EdDSAPublicKey no longer carry a
computed Aneg: the in-place path does not use it, and getNegativeA computes
it on demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQU97MHKeQpEH7XcJT9A
…coordinates

Ed25519VerifyEquation.supports now requires, before any arithmetic, that the
point is on the standard curve instance and that every coordinate is an
Ed25519FieldElement of the standard field instance with limbs within
1.1 * 2^25 (even) and 1.1 * 2^24 (odd), the input bounds of the field
arithmetic. A point built through GroupElement.p3 with an out-of-bound limb or
with a coordinate of another Field (for which 0.3.1 accepts the identity
signature, or throws) keeps the 0.3.1 computation; supports neither throws
nor overflows.

Tests: both hand-built points, against 0.3.1's outcomes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQU97MHKeQpEH7XcJT9A
Instead of a separate equation chosen by guards, the in-place code is now the
loop of GroupElement.doubleScalarMultiplyVariableTime itself: the same steps
(dbl, toP3, madd, msub, toP2), the same operand order and the same field
kernels, over the same inputs 0.3.1 reads: the cached negated key's
dblPrecmp table, the base point's dblPrecmp table and curve.getZero(P2). It
runs when every element it reads is exactly an Ed25519FieldElement (not a
subclass) in a full PRECOMP table, which holds for every table the library
builds; otherwise the 0.3.1 loop runs. EdDSAEngine is again 0.3.1's, so the
key's cached getNegativeA() snapshot, the spec's base point and every
exception are 0.3.1's, and a caller changing their own limb arrays or
subclassing a field element sees 0.3.1's outcome.

Ed25519VerifyEquation now only holds that loop (public because GroupElement
is in another package; documented as internal). The guard methods supports
and holds are gone.

Tests: a key point whose caller-owned limbs change after the negation was
cached, and a field-element subclass whose isNonZero throws, both with
0.3.1's outcomes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQU97MHKeQpEH7XcJT9A
- GroupElement.doubleScalarMultiplyVariableTime takes the in-place loop only
  when this, A, the starting zero point r and every table entry are exactly
  GroupElement; a subclass (for example a zero point whose dbl() is
  overridden) keeps the 0.3.1 loop, which calls its methods. The result
  carries the curve of r, as the 0.3.1 loop's result does.
- Ed25519FieldElement.invert and pow22523 use the in-place chains only for
  exactly Ed25519FieldElement; a subclass gets 0.3.1's chain of square() and
  multiply() calls, so its overrides run as before.

Tests: a curve whose P2 zero point overrides dbl(), and subclasses
overriding square() or multiply() (invert, pow22523, encoding a P3 point).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQU97MHKeQpEH7XcJT9A
0.3.1 did not declare one, so the new package-private kernels changed the
computed value (8932514744815372482 -> 2707052793834647740) and elements
serialized by 0.3.1 failed to deserialize. Declare the 0.3.1 value and test
it, plus a round trip on a serializable field. Checked across versions
against the Central 0.3.1 jar in both directions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJrQU97MHKeQpEH7XcJT9A
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