When I use Ed25519 in production systems I’m not “just calling a library”. I want to know exactly:
- how nonces are derived,
- what bytes are being signed,
- how curve arithmetic behaves under real constraints,
- and why signatures from my implementation match every other correct implementation.
If you ever have to debug a consensus issue, a “signature mismatch” with another stack, or an HD wallet bug, this is not optional.
This article is a practical walkthrough of Ed25519 as defined in RFC 8032: the maths, the signature equations, and how I structure an implementation example in Kotlin on the JVM. No Kotlin code yet; just the architecture, invariants, and failure modes you must understand before writing a line.
1. Overview
Ed25519 is an instance of EdDSA (Edwards‑curve Digital Signature Algorithm) over the twisted Edwards curve edwards25519, using SHA‑512 as its hash.
Roughly:
Curve: edwards25519 (over F_q, q = 2^255 - 19)
Scheme: EdDSA (Schnorr-style signatures)
Hash: SHA-512
Security: ~128-bit
Secret: 32-byte seed
Public: 32-byte compressed point
Signature: 64 bytes (32-byte R || 32-byte s)
It’s fast, widely deployed (SSH, TLS 1.3, Signal, Tor, many blockchains), and easier to implement in constant time than classic ECDSA.
In Cardano, Ed25519 underpins payment/stake keys and HD derivation (BIP32‑Ed25519 / CIP‑1852), so a correct Ed25519 core is a prerequisite for wallet and ledger tooling.
2. Prerequisites
You’ll be more comfortable with this article if you know:
- basic elliptic‑curve cryptography: scalar multiplication, base points, prime fields;
- Schnorr‑style signatures at the equation level;
- SHA‑512 usage and byte encodings;
- Kotlin/JVM integer and array semantics (
Long,Int,ByteArray).
The two primary specs:
- RFC 8032 – protocol, encodings, test vectors;
- “High‑speed high‑security signatures” – the Ed25519 paper, for formulas and performance details.
3. What Ed25519 Actually Is
3.1 Curve and group
Ed25519 uses the twisted Edwards curve edwards25519 over the field ( \mathbb{F}_q ) with ( q = 2^{255} - 19 ).
Parameters, in abbreviated form:
Field prime:
q = 2^255 - 19
Curve equation:
-x^2 + y^2 = 1 + d x^2 y^2 (mod q)
where d = -121665 / 121666 (mod q)
Group order:
L = 2^252 + 27742317777372353535851937790883648493
Cofactor:
h = 8
The base point ( B ) is a specific point of order ( L ) on this curve. Ed25519 uses the prime‑order subgroup generated by ( B ); the cofactor 8 is the source of the “small‑subgroup issues” you see in Ed25519 quirks posts.
3.2 Encodings
Points are encoded in 32 bytes:
bytes[0..30] = little-endian y-coordinate
bytes[31] = high bit = sign of x (0 = "positive", 1 = "negative")
low 7 bits = upper bits of y
This is the compressed point format used for public keys and the R component of signatures. Scalars (elements of ( \mathbb{Z}_L)) are also encoded as 32‑byte little‑endian integers.
4. Key Generation
Ed25519 key generation is deterministic and purely hash‑based. Starting point: a 32‑byte random seed. RFC 8032 defines:
seed (32 bytes, uniform random)
-> h = SHA-512(seed) // 64 bytes
Split h:
h[0..31] -> used to derive secret scalar a (after "clamping")
h[32..63] -> nonce prefix z (used for r)
“Clamping” the lower half means:
h[0] &= 0b1111_1000 // clear lowest 3 bits
h[31] &= 0b0111_1111 // clear highest bit
h[31] |= 0b0100_0000 // set second highest bit
Interpret h[0..31] as a little‑endian integer a, then reduce mod ( L ). That a is your secret scalar.
The public key is:
A = a * B // scalar multiply in the group
A_enc = encode(A) // 32-byte compressed point
In most APIs the secret key is “just the seed”, plus optionally the public key for convenience; the public key
is A_enc alone.
From the trenches. Cardano’s BIP32‑Ed25519 derivation hands you seeds. The Ed25519 core must then follow the RFC procedure exactly. If you clamp the wrong half of
h, or interpret in big‑endian, you will generate keys that no hardware wallet or reference implementation recognises. I’ve debugged exactly this bug in a JVM stack once.
5. Signing: The Ed25519 Equation
Ed25519 is Schnorr‑style: the signature proves knowledge of a by satisfying a simple group equation.
Given:
Secret scalar: a
Nonce prefix: z // h[32..63] from keygen
Public key: A = a * B, encoded as A_enc
Hash: H = SHA-512
To sign message M (pure Ed25519, no context or prehash):
1) Nonce scalar r:
r = H( z || M ) mod L
2) R = r * B
R_enc = encode(R) // 32 bytes
3) Challenge scalar k:
k = H( R_enc || A_enc || M ) mod L
4) s = (r + k * a) mod L
5) Signature = R_enc || s_enc
(s_enc is 32-byte little-endian encoding of s)
No RNG calls. The nonce r is deterministic given (z, M). This avoids ECDSA‑style disasters where a bad RNG leaks
private keys.
On JVM/Kotlin this translates into two places you must be precise:
randkare 512‑bit SHA outputs reduced mod ( L ) in little‑endian order.- All concatenations (
z || M,R_enc || A_enc || M) are byte‑exact.
6. Verification
Given:
Public key: A_enc
Message: M
Signature: sig = R_enc || s_enc
Verification:
1) Decode A_enc -> A
- check encoding is canonical
- check point is on the curve
- check point is in the correct subgroup
2) Decode R_enc -> R, with the same checks.
3) Decode s_enc -> s (integer), and check 0 <= s < L.
4) Compute k = H( R_enc || A_enc || M ) mod L.
5) Check group equation:
[s]B == R + [k]A
If equality holds, accept; otherwise reject.
This single equation is the heart of Ed25519. Most “Ed25519 quirks” and security issues boil down to:
- accepting non‑canonical encodings;
- not enforcing subgroup membership;
- not checking the scalar range.
7. Implementation Structure in Kotlin/JVM
I like to structure a pure implementation in four layers:
+-------------------------------------------+
| Ed25519 API (keygen, sign, verify) |
+-------------------------------------------+
| Group operations on edwards25519 |
+-------------------------------------------+
| Field arithmetic mod q = 2^255 - 19 |
+-------------------------------------------+
| SHA-512 + byte/endianness utilities |
+-------------------------------------------+
Each layer has a clean responsibility and can be tested separately.
7.1 Field arithmetic (mod q)
The field is ( \mathbb{F}_q ) with ( q = 2^{255} - 19 ). You need:
- add/sub modulo q
- multiply modulo q
- square
- invert (for encoding/decoding)
A naive BigInteger implementation is fine for understanding, but too slow for production. Serious Ed25519
implementations use limb representations (e.g. 10×25.5‑bit or 5×51‑bit limbs).
On the JVM:
- 5×51 using
Longis a good fit: each limb fits in 64 bits with headroom for carries; - everything stays in primitive arrays (
LongArray) to avoid boxing; - arithmetic is constant‑time: no branches based on secret data.
7.2 Group operations (points on edwards25519)
Use extended coordinates ((X : Y : Z : T)) with the usual invariant XY = ZT. This avoids special cases for point
addition and doubling. The Ed25519 paper gives explicit formulas; most libraries reuse them.
You implement:
- point_add(P, Q)
- point_double(P)
- scalar_mul(a, P)
- scalar_mul_base(a) // precomputed tables for B
- encode(P) // -> 32 bytes
- decode(bytes) // -> P or failure
scalar_mul_base uses a precomputed table for speed; verification uses a more general scalar multiplication for [k]A.
Encoding/decoding include:
- little‑endian y‑coordinate + sign bit of x;
- reject non‑canonical encodings (high bits set where they shouldn’t be);
- ensure subgroup membership or cofactor‑clear as per RFC 8032 guidance.
7.3 API layer (keygen, sign, verify)
On top of SHA‑512 + group operations:
- Keygen: seed →
h = SHA512(seed)→ clamp →aandz→A = aB→A_enc. - Sign: as in section 5.
- Verify: as in section 6.
On the JVM, I usually expose an API like:
data class Ed25519KeyPair(val seed: ByteArray, val publicKey: ByteArray)
object Ed25519 {
fun generateKeyPair(): Ed25519KeyPair
fun sign(seed: ByteArray, message: ByteArray): ByteArray // 64-byte sig
fun verify(publicKey: ByteArray, message: ByteArray, signature: ByteArray): Boolean
}
If you want deeper integration, you can wrap this in a JCA‑style SignatureSpi, but I tend to keep the pure
implementation separate and simple.
From the trenches. The nastiest JVM bug I’ve seen in Ed25519 wasn’t in the curve math at all. It was a mismatch in how SHA‑512 output was sliced: the wrong 32 bytes used for clamping vs nonce prefix. Our implementation was “self‑consistent”, but signatures didn’t match any external wallet. Re‑reading RFC 8032 and the Ed25519 paper fixed it immediately.
8. Testing a Kotlin Ed25519
I don’t trust an Ed25519 implementation until it’s survived three categories of tests.
8.1 RFC 8032 test vectors
RFC 8032 provides comprehensive vectors for Ed25519, Ed25519ph, and Ed25519ctx: seeds, public keys, messages, signatures.
At minimum:
- implement all Ed25519 (pure) vectors;
- test one‑shot and incremental hashing of the message (feed in chunks);
- confirm that flipping any bit in
R,s, orMcauses verification to fail.
8.2 Cross‑implementation tests
Cross‑check against at least one widely used library:
libsodium/ NaCl in C,- Rust’s
ed25519-dalek, - Python’s
pynaclorcryptography.
Generate random seeds and messages, then:
- sign in Kotlin, verify in the other implementation;
- sign in the other implementation, verify in Kotlin.
Any mismatch is a bug until proven otherwise.
8.3 Edge cases and “quirks”
The “Ed25519 quirks” article collects typical pitfalls: small‑order points, non‑canonical encodings, etc.
I like targeted tests:
- A_enc with invalid high bits → must be rejected.
- R_enc that doesn’t decode to a valid point → reject.
- s >= L → reject.
- points in small subgroups → reject or handle safely.
These are what distinguish a robust verifier from a “works on happy path” implementation.
9. Production Considerations on the JVM
9.1 Constant‑time behaviour
Ed25519 is designed for constant‑time implementations: no secret‑dependent branches, no secret‑dependent memory lookups.
On the JVM that translates to:
- fixed‑time scalar multiplication, no early exits based on secret bits;
- table lookups indexed by public values or fixed patterns, not by secret scalars;
- avoiding micro‑optimisations that introduce data‑dependent branches.
The ETH Zurich work on Ed25519’s provable security is useful here: it shows that the scheme is sound; most real‑world issues are in implementations.
9.2 Variants: pure, ph, ctx
Ed25519 has three variants:
- Ed25519 (pure; H(M))
- Ed25519ph (prehashed; H(H(M)))
- Ed25519ctx (with a context string)
Different libraries pick different defaults. On the JVM:
- be explicit: name the variant in your API;
- don’t silently switch to prehashing for “convenience”;
- for blockchain/protocol use, stick to the variant the spec mandates (usually pure Ed25519).
9.3 Domain separation and key reuse
If you reuse keys across domains (ideally you shouldn’t), you must separate domains via:
- distinct context strings (Ed25519ctx),
- or distinct preimage formats.
In Cardano‑style systems it’s cleaner to use separate key hierarchies for ledger, staking, application‑level auth, etc., rather than overload the same keypair for everything.
9.4 Interop pitfalls
Common JVM‑level gotchas:
- Confusing Ed25519 with X25519: same curve family, different protocol, different encodings. Keys are not interchangeable.
- Confusing “seed”, “expanded secret key”, and “scalar a”: different libraries expose different things as the “secret”. You must know which representation you’re dealing with.
- Mixed variants: one side uses pure Ed25519, the other Ed25519ph; signatures don’t match.
From the trenches. I’ve seen “signature mismatch” tickets where the root cause was simply: library A used Ed25519ph by default, library B used pure Ed25519. Both sides were perfectly correct — just not doing the same thing.
10. Conclusion
Ed25519 looks deceptively simple from the outside: 32‑byte keys, 64‑byte signatures, fast operations. Under the hood it’s a well‑engineered combination of:
- carefully chosen curve and field parameters,
- constant‑time group arithmetic,
- deterministic Schnorr‑style signing,
- and strict encoding rules.
If you’re implementing it in Kotlin on the JVM for wallets, node tooling, or consensus‑adjacent services, you need to:
- understand the curve/group and encoding rules;
- implement field and group arithmetic correctly and in constant time;
- follow RFC 8032 exactly for keygen, signing, and verification;
- test against official vectors and at least one reference implementation;
- be explicit about variants and domain separation.
Once that foundation is in place, your Kotlin Ed25519 becomes a reliable building block you can reuse across Cardano and multi‑chain infrastructure without fearing that a one‑bit bug is silently corrupting signatures.
References & Further Reading
- RFC 8032 – Edwards‑Curve Digital Signature Algorithm (EdDSA)
- EdDSA / Ed25519 overview
- Curve25519 and edwards25519 background
- High‑speed high‑security signatures (Ed25519 paper)
- Ed25519 quirks – edge cases and verification pitfalls
- CIP‑1852 | HD wallets for Cardano (BIP32‑Ed25519)
- The Provable Security of Ed25519: Theory and Practice