← Back to the tool

Public-key · Under the hood

Ed25519 sign & verify

What each button in the tool actually computes, step by step — with the real formulas.

The key: 32 random bytes, hashed into a scalar

An Ed25519 secret key is just 32 random bytes. It is never used directly as the private scalar: signing first hashes it with SHA-512, clamps the low half into the scalar a (clearing the 3 low bits so a is a multiple of the cofactor 8, clearing the top bit and setting bit 254), and keeps the high half as a secret prefix for deriving nonces. The public key A = a·B is a point on the twisted Edwards curve edwards25519, which is birationally equivalent to the Montgomery curve Curve25519 that X25519 uses.

"Load RFC 8032 TEST 1" imports the secret key from the first test vector in the RFC; the public key the browser derives from it should read d75a9801…f707511a, exactly as published.

The actual WebCrypto calls
// Generate: the browser picks the 32 secret bytes
const keyPair = await crypto.subtle.generateKey(
  { name: "Ed25519" },   // no curve or hash parameters: Ed25519 fixes both
  true,
  ["sign", "verify"]
);
const publicKey = await crypto.subtle.exportKey("raw", keyPair.publicKey); // 32 bytes

// Import an RFC 8032 secret key: Web Crypto takes private keys only as
// PKCS #8 or JWK, so the 32 bytes go inside a 16-byte PKCS #8 prefix
const pkcs8 = concat(hexToBytes("302e020100300506032b657004220420"), secretKey);
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);

// There's no "derive public key" call, but a JWK export carries it as x
const { x } = await crypto.subtle.exportKey("jwk", privateKey);

Ed25519 came to Web Crypto later than ECDSA: Chrome and Edge 137+, Firefox 129+, and Safari 17+ implement it. This tool checks for support and says so instead of falling back to a JavaScript implementation.

(h0…h63)=SHA-512(sk)a=clamp(h0…h31)A=a⋅B(h_0 \ldots h_{63}) = \mathrm{SHA\text{-}512}(\mathit{sk}) \qquad a = \mathrm{clamp}(h_0 \ldots h_{31}) \qquad A = a \cdot B

The public key is a compressed point

The 32 bytes of the public key are the point's y-coordinate as a little-endian integer, with the otherwise unused top bit of the last byte holding the low bit of x. Since x² = (y² − 1)/(d·y² + 1) mod p on this curve, y fixes x up to sign, and that one bit chooses between the two square roots. A verifier must reject an encoding whose y is not less than p = 2²⁵⁵ − 19, or for which no square root exists.

−x2+y2=1+d x2y2(mod2255−19),d=−121665121666-x^2 + y^2 = 1 + d\,x^2 y^2 \pmod{2^{255} - 19}, \qquad d = -\tfrac{121665}{121666}

Signing: a nonce derived, not drawn

ECDSA needs a fresh random nonce k for every signature, and a repeated or biased k leaks the private key (the ECC module covers the PlayStation 3 case). Ed25519 removes that failure mode by deriving the nonce r from the secret prefix and the message itself. The same key and message always give the same r, and therefore the same signature: "Sign again" shows this byte for byte. A different message gives an unrelated r.

The signature is R ‖ S: the encoded point R = r·B, then the scalar S = r + k·a mod ℓ, where k binds the signature to R, the public key, and the message. ℓ is the order of the base point, a 253-bit prime.

The actual WebCrypto call
const signature = await crypto.subtle.sign(
  { name: "Ed25519" },   // no hash option: SHA-512 is built into Ed25519
  privateKey,
  new TextEncoder().encode(message)
);
// signature is always 64 bytes: R (32) followed by S (32), both little-endian

Unlike ECDSA's sign(), there's no hash parameter and no prehashing: Ed25519 hashes the whole message twice internally (for r and for k), so the message isn't reduced to a digest before signing.

r=SHA-512(h32…h63 ∥ M) mod ℓR=r⋅Br = \mathrm{SHA\text{-}512}(h_{32} \ldots h_{63} \,\|\, M) \bmod \ell \qquad R = r \cdot B
k=SHA-512(R ∥ A ∥ M) mod ℓS=(r+k⋅a) mod ℓk = \mathrm{SHA\text{-}512}(R \,\|\, A \,\|\, M) \bmod \ell \qquad S = (r + k \cdot a) \bmod \ell

Verifying, and why S must be less than ℓ

The verifier recomputes k from R, A, and the message and checks one equation on the curve. RFC 8032 specifies the cofactored form below, multiplying both sides by 8, and also allows the stricter form without the 8s; implementations differ on which they use, which only matters for specially crafted edge cases.

Because ℓ·B is the identity point, S and S + ℓ satisfy the equation equally well. Without an extra rule, anyone could turn one valid signature into a second, different-looking valid one. RFC 8032 §5.1.7 closes that by requiring S < ℓ, and "Replace S with S + ℓ" shows it: the equation still holds, but the browser rejects the signature.

The actual WebCrypto calls
const publicKey = await crypto.subtle.importKey(
  "raw", publicKeyBytes, { name: "Ed25519" }, false, ["verify"]
);
const valid = await crypto.subtle.verify(
  { name: "Ed25519" },
  publicKey,
  signatureBytes,
  new TextEncoder().encode(message)
);   // false for a bad or non-canonical signature, no exception

Like ECDSA, verify() resolves to false rather than throwing. Only a structurally invalid public key makes importKey() throw.

[8][S]B=?[8]R+[8][k]A,0≤S<ℓ[8][S]B \stackrel{?}{=} [8]R + [8][k]A, \qquad 0 \le S < \ell

Now try it

Back to Ed25519 sign & verify →

Sign with real Ed25519 in your browser, check the RFC 8032 test vector, see that signing twice gives the same signature, and watch verification reject a tampered message or a malleable S.