Skip to content

ed25519

xero edited this page May 30, 2026 · 1 revision
logo

Ed25519: Edwards-Curve Digital Signatures

Classical digital signatures via Ed25519 (RFC 8032), the Edwards-curve EdDSA instance over edwards25519, with both pure (RFC 8032 §5.1.6, signature generation) and prehash (RFC 8032 §5.1.7, signature verification, Ed25519ph) modes and strict verification per FIPS 186-5 (Digital Signature Standard) §7.6.4, Verification.

Table of Contents


Overview

Ed25519 is the EdDSA instance defined by RFC 8032 (Edwards-Curve Digital Signature Algorithm) §5.1, Ed25519, over the twisted Edwards curve edwards25519 birationally equivalent to Curve25519. Signatures are 64 bytes, verifying keys are 32 bytes, and a secret key is the 32-byte seed the signer holds. This module covers the two RFC 8032 §5.1 modes:

  • Pure Ed25519 (RFC 8032 §5.1.6, signature generation) signs the message bytes directly with no context binding.
  • Ed25519ph (RFC 8032 §5.1.7, signature verification) signs a SHA-512 prehash of the message, with an optional context bound through the dom2(F=1, C) prefix.

The module does NOT implement Ed25519ctx (RFC 8032 §5.1, the pure-with-context variant); applications that need context-bound signing over arbitrary-length messages should use the prehash mode through signPrehashed and verifyPrehashed, or Ed25519PreHashSuite at the envelope layer.

Verification is strict per FIPS 186-5 §7.6.4: signature malleability vectors that the permissive cofactor-eight equation would accept get rejected. Specifically, s outside [0, L), non-canonical R or pk encodings, and small-order public keys all return false. The implementation rejects the same malleated records ACVP's testPassed=false EDDSA-sigVer corpus uses to exercise the strict gate.

The test corpus pairs RFC 8032 §7, Test Vectors for Ed25519, with the NIST ACVP EDDSA records: keyGen vectors prove the §5.1.5, Key Generation, derivation; sigGen vectors prove signing byte-equivalence to a third-party implementation; sigVer including the testPassed=false records prove that the strict checks fire on every spec-defined failure mode. Verifier coverage lives in vector_audit.md.


Init

import { init }       from 'leviathan-crypto'
import { ed25519Wasm } from 'leviathan-crypto/ed25519/embedded'

await init({ ed25519: ed25519Wasm })

The leviathan-crypto/ed25519/embedded subpath exports the WASM blob under three names that all resolve to the same string: ed25519Wasm (reads most naturally in this context), curve25519Wasm (the canonical name shared with the leviathan-crypto/x25519/embedded subpath), and x25519Wasm (the alias preferred under the matching subpath). Pick whichever matches the surrounding code; tree-shaking is unaffected.

Ed25519 and X25519 share a single WASM binary, curve25519.wasm, which hosts the GF(2^255-19) field arithmetic, the edwards25519 substrate, the Montgomery ladder, the scalar arithmetic mod L, point compression and decompression, plus an embedded SHA-512 for the Ed25519 hash chain. The top-level init() accepts ed25519 and x25519 as aliases that resolve to the underlying curve25519 module; calling either alias enables both classes, and passing both aliases with the same source is de-duped at the init layer. Passing different sources for the two aliases throws.

For tree-shakeable imports the leviathan-crypto/ed25519 subpath exports its own init function:

import { ed25519Init } from 'leviathan-crypto/ed25519'
import { ed25519Wasm } from 'leviathan-crypto/ed25519/embedded'

await ed25519Init(ed25519Wasm)

ed25519Init(source) and x25519Init(source) both load the same WASM module under the curve25519 slot. Calling x25519Init also enables the Ed25519 class and vice versa. Ed25519 ships scalar (no WebAssembly SIMD); init({ ed25519: ... }) works on every WASM-capable runtime regardless of SIMD support.


Ed25519 API

Construction is parameter-less. Every public method runs against the singleton curve25519 instance, stages inputs at fixed offsets above the WASM mutable region, calls the underlying export, copies outputs to fresh Uint8Arrays, then wipes the staged buffers.

Constructor

new Ed25519()

Throws if init({ ed25519: ... }) (or init({ x25519: ... }), or the subpath init) has not been called. Cheap, runs a layout assertion and returns.

keygen()

Generate a new key pair using a fresh 32-byte seed from crypto.getRandomValues. Equivalent to calling keygenDerand with a random seed; the local seed buffer is wiped on return.

const ed = new Ed25519()
const { publicKey, secretKey } = ed.keygen()
// publicKey: 32-byte encoded A (RFC 8032 §5.1.2, encoding)
// secretKey: 32-byte seed (RFC 8032 §5.1.5, key generation)
ed.dispose()

keygenDerand(seed)

Deterministic key generation, RFC 8032 §5.1.5, key generation. Use this when you must derive a key from a known seed (testing, ACVP records, key escrow, deterministic deployments). Throws RangeError if seed.length !== 32.

const seed = new Uint8Array(32)
crypto.getRandomValues(seed)

const ed = new Ed25519()
const { publicKey, secretKey } = ed.keygenDerand(seed)
ed.dispose()
seed.fill(0)

secretKey is a fresh copy of seed; the RFC 8032 §5.1.5 "sk = seed" identity holds. Treat the seed with the same care as the secret key.

sign(sk, pk, M)

Pure Ed25519 sign, RFC 8032 §5.1.6, signature generation. Returns a 64-byte signature R || s.

const ed = new Ed25519()
const { publicKey, secretKey } = ed.keygen()
const sig = ed.sign(secretKey, publicKey, message)
const ok  = ed.verify(publicKey, message, sig)   // true
ed.dispose()

The caller passes pk alongside sk; the WASM re-derives pk from sk internally and compares it against the caller-supplied value. A mismatch traps via unreachable and the TypeScript wrapper rethrows as SigningError('sig-malformed-input'). See Fault-Injection Defense for the rationale.

Pure Ed25519 has no native context parameter. Applications that need context-bound signing should use signPrehashed (or Ed25519PreHashSuite at the envelope layer).

Important

Pure-mode signing has a per-call message ceiling of approximately 248 KB; messages above the ceiling throw RangeError. Pure Ed25519 signatures are non-streamable by design. For larger payloads use the prehash mode (signPrehashed directly, or Ed25519PreHashSuite wrapped in SignStream / VerifyStream); the digest is computed at the TypeScript layer and only the 64-byte SHA-512 output crosses the WASM boundary.

signPrehashed(sk, pk, digest, ctx)

Ed25519ph sign, RFC 8032 §5.1.7, signature verification (the spec defines pure and prehash together; the prehash variant adds the dom2 prefix). Returns a 64-byte signature.

import { SHA512 } from 'leviathan-crypto'

const digest = new SHA512().hash(message)   // 64 bytes
const sig    = ed.signPrehashed(secretKey, publicKey, digest, new Uint8Array(0))

The caller supplies the 64-byte SHA-512 digest; the library does not compute it. Wrong-length digest throws RangeError. ctx is required and must be a Uint8Array; pass an empty Uint8Array(0) explicitly when no context binding is needed. Per RFC 8032 §5.1, Ed25519, |C| is encoded in a single octet, so 255 is the spec ceiling; longer ctx values throw RangeError. The dom2(F=1, ctx) prefix binds ctx into both SHA-512 inputs that produce r and k.

verify(pk, M, sig)

Strict pure Ed25519 verify, RFC 8032 §5.1.7, signature verification, with FIPS 186-5 §7.6.4, Verification, strict gates. Returns true on success, false on every signature failure mode. Throws only on caller-side contract violations (non-Uint8Array inputs, wrong-length pk or sig).

const ok = ed.verify(publicKey, message, sig)   // boolean

The cryptographic checks live inside the WASM and run in this order: pk decompresses canonically, R decompresses canonically, s lies in [0, L), [8]A is not the identity, and the signature equation [s]B = R + [k]A holds. The first four guard against signature malleability and small-order forgery; the last is the §5.1.7 strict verification equation. A failure in any returns false.

verifyPrehashed(pk, digest, ctx, sig)

Strict Ed25519ph verify, RFC 8032 §5.1.7. Same rejection conditions as verify, plus the dom2(F=1, ctx) prefix on the SHA-512 input that produces k. Returns boolean for every signature outcome.

const ok = ed.verifyPrehashed(publicKey, digest, ctx, sig)

Wrong-length digest throws RangeError. Oversize ctx (ctx.length > 255) returns false inside the WASM rather than throwing; verify maps every invalid input shape to "not verified" so a malformed signature never produces a runtime exception.

dispose()

Wipe all curve25519 WASM scratch memory. Idempotent. Safe to call multiple times. Every public method already wipes the WASM mutable region and the TS-side staging region on its own success and throw paths; dispose() is defence-in-depth at instance teardown.


Pure vs Prehash Modes

The two RFC 8032 §5.1 modes bind different inputs into the signing equation. Pure Ed25519 hashes prefix || M to derive the per-signature nonce r and hashes R || pk || M to derive the challenge scalar k. Ed25519ph wraps both hash inputs in the dom2(F=1, ctx) prefix and substitutes digest = SHA-512(M) for M. Signatures from the two modes are not interchangeable on the same key because the bytes hashed into k differ; a §5.1.6 signature does not verify under verifyPrehashed and vice versa.

Pick pure when:

  • The message bytes fit in a single Uint8Array at sign time.
  • The protocol does not require a context label.
  • Byte-for-byte interop with classical RFC 8032 §7, Test Vectors for Ed25519, signers is required (test corpus, third-party signers, FIDO U2F).

Pick prehash when:

  • The message is too large to buffer (a streaming signer computes SHA-512 incrementally and hands the digest to signPrehashed).
  • The protocol binds a context string into the signature for domain separation, key purpose, or version pinning.
  • A FIPS 140 boundary computes SHA-512 in a different module from Ed25519 and supplies the digest at the boundary.

Per RFC 8032 §5.1, Ed25519, ctx must be 255 bytes or fewer; pure mode rejects every non-empty ctx (no spec-defined home for it), prehash mode binds ctx via dom2.


Validation Behavior

Ed25519 distinguishes two failure classes: verification failures (binary, return false) versus caller-contract violations (throw TypeError or RangeError). The split follows FIPS 186-5 §7.6.4, Verification.

Condition sign / variants verify / variants
Input not a Uint8Array throw TypeError throw TypeError
seed.length !== 32 throw RangeError n/a
sk.length !== 32 throw RangeError n/a
pk.length !== 32 throw RangeError throw RangeError
sig.length !== 64 n/a throw RangeError
digest.length !== 64 (prehash) throw RangeError throw RangeError
ctx.length > 255 (prehash sign) trap (rethrown) return false
M.length exceeds per-call WASM scratch throw RangeError throw RangeError
Caller pk does not match pk derived from sk SigningError n/a
Off-curve / non-canonical pk encoding n/a return false
Off-curve / non-canonical R encoding n/a return false
s >= L (strict-S, ACVP "modify s" path) n/a return false
Small-order pk ([8]A == identity) n/a return false
Signature equation [s]B != R + [k]A n/a return false

Wrong-shape inputs are caller mistakes and throw so the bug surfaces immediately. Cryptographic failures map to false because they are indistinguishable from a wrong-key attempt and should not raise as exceptions. The asymmetry on ctx.length > 255 between sign and verify matches the underlying WASM posture: sign aborts unreachably so the TypeScript wrapper rethrows; verify returns 0 so the wrapper passes that through as boolean false.

The strict-verification checks are SUF-CMA-critical. RFC 8032 §5.1.7, signature verification, leaves room for the permissive cofactor-eight equation [8s]G = [8](R + [k]A), which would accept malleated s values and small-order R. FIPS 186-5 §7.6.4 locks down the strict form; this implementation follows FIPS 186-5 and rejects every spec-defined malleation.


Key & Signature Format

Verifying key pk, RFC 8032 §5.1.2, encoding:

pk = enc(A) = (sign_bit_of_x << 255) | y_le

32 bytes total. Bytes 0..30 plus the low 7 bits of byte 31 carry the y-coordinate in little-endian; bit 7 of byte 31 carries the sign of x. Non-canonical encodings (y >= p) are rejected on decompress.

Secret key sk, RFC 8032 §5.1.5, key generation:

sk = seed  (32 random bytes)

The RFC 8032 spec defines sk to be the seed itself, not the clamped scalar a. The signer expands h = SHA-512(seed), clamps a = clamp(h[0..32]) per RFC 7748 (Elliptic Curves for Security) §5, The X25519 and X448 Functions, derives prefix = h[32..64], and computes pk = encode([a]B). The library stores sk as the seed and re-derives a / prefix on every sign call. Treat the seed with the same care as the full key: compromise of the seed reveals everything.

Signature sig, RFC 8032 §5.1.6, signature generation:

sig = encode(R) || encode(s)

64 bytes total. Bytes 0..32 carry the compressed Edwards point R per §5.1.2; bytes 32..64 carry the scalar s as a little-endian 32-byte integer in [0, L).

The curve order L is

L = 2^252 + 27742317777372353535851937790883648493

RFC 8032 §5.1, Ed25519, parameters. The strict-S check rejects any s >= L.


Wipe Discipline

Every Ed25519 public method ends with a two-phase wipe:

  1. The WASM-side wipeBuffers export zeroes the mutable region from MUTABLE_START to BUFFER_END. That covers the SHA-512 state, the clamped scalar a, the signing prefix, the per-signature r and k, the Edwards points A and R, the pk-check buffer, and the ladder scratch.
  2. The TypeScript wrapper zeroes the I/O staging region above BUFFER_END (the seed slot, pk slot, sig slot, digest slot, ctx slot, and the variable-length message slot). The WASM does not own that region; the wrapper owns it and is responsible for the wipe.

Both phases run inside a try / finally so the wipe fires on the success path and on every throw path. Caller-supplied buffers (sk, pk, M, digest, sig, ctx) are NEVER mutated; the library copies them into the staging region, never aliases them.

dispose() re-runs both phases as defence-in-depth at instance teardown. Calling it multiple times is safe.


Fault-Injection Defense

Ed25519.sign and Ed25519.signPrehashed accept the public key alongside the secret key. The WASM ignores the caller-supplied pk during signing and re-derives pk from sk; it then compares the derived value against the caller-supplied buffer and aborts via unreachable if they differ.

This is defence against a narrow but documented attack class. RFC 8032 §5.1.6, signature generation, derives r as SHA-512(prefix || M) where prefix = h[32..64]. A fault-injection attacker who can flip bits in the sk-derived intermediates (the clamped scalar a, or the prefix bytes) can bias r in ways that leak the long-term scalar through standard ECC fault analysis. Forcing the signer to also know the encoded pk means the attacker must know both the seed and the derived public key, which removes any advantage from a sk-only fault.

The TypeScript wrapper catches the WebAssembly.RuntimeError that an unreachable trap raises and rethrows it as SigningError('sig-malformed-input', ...) so callers can branch on the failure. The cost is one extra Edwards scalar multiplication per sign, roughly 5ms at parameter-set sizes; verifies are unaffected (verify operates on public inputs).

Ed25519Suite and Ed25519PreHashSuite route through the unexported _signInternalPk / _signPrehashedInternalPk helpers on the Ed25519 class, which derive pk inside the same WASM call and skip the cross-check. At the suite call site the comparison would be between two outputs of the same potentially-faulted module on the same call, so the defence collapses to no defence; skipping it saves one basepoint scalar multiplication per sign on the hot path that every Sign and SignStream invocation traverses.

Direct-class callers who hold a stored, known-good pk should keep using Ed25519.sign(sk, pk, M) and Ed25519.signPrehashed(sk, pk, digest, ctx) to retain the fault-injection defence. See architecture.md §Where defense ends for the canonical posture.


Error Reference

Error Cause
Error: leviathan-crypto: call init({ ed25519: ... }) before using Ed25519 Class constructor invoked before init.
TypeError: leviathan-crypto: ed25519 seed must be a Uint8Array keygenDerand passed a non-Uint8Array seed.
RangeError: leviathan-crypto: ed25519 seed must be 32 bytes (got N) keygenDerand passed a wrong-length seed.
TypeError: leviathan-crypto: ed25519 secret key must be a Uint8Array sign or signPrehashed passed a non-Uint8Array sk.
RangeError: leviathan-crypto: ed25519 secret key must be 32 bytes (got N) sign or signPrehashed passed a wrong-length sk.
TypeError: leviathan-crypto: ed25519 public key must be a Uint8Array Any method passed a non-Uint8Array pk.
RangeError: leviathan-crypto: ed25519 public key must be 32 bytes (got N) Any method passed a wrong-length pk.
TypeError: leviathan-crypto: ed25519 message must be a Uint8Array sign or verify passed a non-Uint8Array message.
RangeError: leviathan-crypto: ed25519 pure-mode message length N exceeds the per-call ... Message larger than the WASM input scratch (~248 KB cap). Use Ed25519PreHashSuite + SignStream for larger payloads.
TypeError: leviathan-crypto: ed25519 signature must be a Uint8Array verify or verifyPrehashed passed a non-Uint8Array sig.
RangeError: leviathan-crypto: ed25519 signature must be 64 bytes (got N) verify or verifyPrehashed passed a wrong-length sig.
TypeError: leviathan-crypto: ed25519 prehash digest must be a Uint8Array signPrehashed or verifyPrehashed passed a non-Uint8Array.
RangeError: leviathan-crypto: ed25519 prehash digest must be 64 bytes (got N) Prehash digest is not exactly 64 bytes.
TypeError: leviathan-crypto: ed25519 context must be a Uint8Array signPrehashed or verifyPrehashed passed a non-Uint8Array.
RangeError: leviathan-crypto: ed25519 context must be <= 255 bytes (got N) ctx longer than the RFC 8032 §5.1 ceiling.
SigningError('sig-malformed-input', 'leviathan-crypto: ed25519 sign aborted ...') Caller-supplied pk does not match pk derived from sk.

verify and verifyPrehashed return false on every signature failure (wrong sig, off-curve pk or R, non-canonical encodings, s >= L, small-order pk). They throw only on the contract violations above.


SignatureSuites

The Ed25519 suites wrap the primitive into the SignatureSuite interface for use with Sign, SignStream, and VerifyStream. Two suite consts ship:

  • Ed25519Suite (format byte 0x01), pure Ed25519, satisfies SignatureSuite only.
  • Ed25519PreHashSuite (format byte 0x11), Ed25519ph with fixed SHA-512 prehash, satisfies StreamableSignatureSuite and plugs into SignStream / VerifyStream.

Pure mode does NOT bind user context. Pure Ed25519 has no spec-defined home for ctx, so Ed25519Suite.sign(sk, msg, ctx) throws SigningError('sig-ctx-unsupported') on any non-empty ctx and routes callers to Ed25519PreHashSuite for context-bound signatures.

Prehash mode binds the suite-level ctxDomain plus the caller's user_ctx through buildEffectiveCtx before passing the result into the WASM's dom2(F=1, effective_ctx) prefix. Each method instantiates a fresh Ed25519 instance inside a try / finally { dispose() } block, so WASM key material is wiped on every path. The Ed25519PreHashSuite.wasmModules array lists ['curve25519', 'sha2']: curve25519 hosts the signing substrate, sha2 drives the running SHA-512 in the streaming prehash path.

See signaturesuite.md for the full wire format, format-byte allocation, and worked examples through Sign, SignStream, and VerifyStream.


Suite integration

The integration tier exercises the v3 sign layer against the real Ed25519 primitive. It asserts:

  • Sign.sign / Sign.verify round-trip for both Ed25519Suite and Ed25519PreHashSuite.
  • SignStream + VerifyStream round-trip via the prehash suite, proving the SHA-512 running-hash wiring in sign/hasher.ts lines up with the suite's signPrehashed / verifyPrehashed path.
  • RFC 8032 §7.1 cross-check: the pure suite's envelope sig bytes for §7.1 records match the RFC reference signature verbatim (the envelope's 2-byte preamble prefixes them without changing the sig).
  • ACVP §7.3 spot-check: ACVP sigGen records with empty context flow through the suite layer cleanly.
  • Cross-mode tamper: flipping suite_byte 0x010x11 yields SigningError('sig-suite-mismatch') from Sign.verify (the verifier checks the wire byte against the suite's formatEnum).

Stream equivalence

Ed25519PreHashSuite (0x11) is deterministic per RFC 8032 §5.1.7, so Sign.sign and SignStream produce byte-identical signatures over the same (sk, msg, ctx). The stream-equivalence test asserts that byte-equality across every chunk shape.


Cross-References

Document Description
architecture Repository structure, build and CI, WASM modules, public API, test suite, and security posture
init.md init() API and module-loader contract
signing.md Sign, SignStream, VerifyStream, envelope wire format, SigningError
signaturesuite.md SignatureSuite interface plus the Ed25519Suite and Ed25519PreHashSuite consts
asm_curve25519.md Low-level WASM module reference
ed25519_audit.md Ed25519 audit checklist
x25519.md Companion key-agreement primitive sharing the same WASM module
exports.md Full export catalog

Leviathan-Crypto Wiki

Leviathan logo

Getting Started

Authenticated Encryption

Digital Signatures

Ciphers

  • Serpent-256 TypeScript | WASM
    • Serpent, SerpentCtr, SerpentCbc, SerpentGenerator
  • ChaCha20 TypeScript | WASM
    • ChaCha20, Poly1305, ChaCha20Poly1305, XChaCha20Poly1305, ChaCha20Generator
  • AES TypeScript | WASM
    • AES, AESCbc, AESCtr, AESGCM, AESGCMSIV, AESGenerator

Signature Primitives

  • ML-DSA TypeScript | WASM
    • pure (FIPS 204): MlDsa44, MlDsa65, MlDsa87
    • pure-mode suites: MlDsa44Suite, MlDsa65Suite, MlDsa87Suite
    • prehash suites: MlDsa44PreHashSuite, MlDsa65PreHashSuite, MlDsa87PreHashSuite
  • SLH-DSA TypeScript | WASM
    • pure (FIPS 205): SlhDsa128f, SlhDsa192f, SlhDsa256f
    • pure-mode suites: SlhDsa128fSuite, SlhDsa192fSuite, SlhDsa256fSuite
    • prehash suites: SlhDsa128fPreHashSuite, SlhDsa192fPreHashSuite, SlhDsa256fPreHashSuite
  • Ed25519 TypeScript | WASM
    • Ed25519 (pure + Ed25519ph), Ed25519Suite, Ed25519PreHashSuite
  • ECDSA-P256 TypeScript | WASM
    • EcdsaP256 (hedged + RFC 6979), EcdsaP256Suite
    • DER codec: ecdsaSignatureToDer, ecdsaSignatureFromDer, encodeEcPrivateKey, decodeEcPrivateKey, pointDecompress
  • Hybrid composites PQ-only | Classical+PQ
    • PQ-only: MlDsa44SlhDsa128fSuite, MlDsa65SlhDsa192fSuite, MlDsa87SlhDsa256fSuite
    • Classical+PQ: MlDsa44Ed25519Suite, MlDsa65Ed25519Suite, MlDsa44EcdsaP256Suite, MlDsa65EcdsaP256Suite

Key Agreement

Post-Quantum

  • ML-KEM TypeScript | WASM
    • MlKem512, MlKem768, MlKem1024
  • Ratchet (SPQR)
    • KDFChain, ratchetInit, kemRatchetEncap, kemRatchetDecap, RatchetKeypair, SkippedKeyStore

Hashing

  • Hashing overview
  • SHA-2 TypeScript | WASM
    • SHA256, SHA384, SHA512, SHA224, SHA512_224, SHA512_256
    • HMAC_SHA256, HMAC_SHA384, HMAC_SHA512, HKDF_SHA256, HKDF_SHA512
  • SHA-3 TypeScript | WASM
    • SHA3_224, SHA3_256, SHA3_384, SHA3_512, SHAKE128, SHAKE256
  • BLAKE3 TypeScript | WASM
    • BLAKE3, BLAKE3Stream, BLAKE3KeyedHash, BLAKE3KeyedHashStream
    • BLAKE3DeriveKey, BLAKE3DeriveKeyStream, BLAKE3OutputReader, BLAKE3Hash
  • KMAC
    • CSHAKE128, CSHAKE256, KMAC128, KMAC256, KMACXOF128, KMACXOF256

Transparency Log

  • Merkle
    • MerkleVerifier, MerkleLog
    • SignedLog, Sha256Tree, Blake3Tree, MemoryStorage

Utilities

  • Fortuna CSPRNG
    • Fortuna, SerpentGenerator, ChaCha20Generator, AESGenerator, SHA256Hash, SHA3_256Hash, BLAKE3Hash
  • Utils TypeScript | WASM
    • constantTimeEqual, randomBytes, wipe, encoding helpers
  • TypeScript interfaces
    • Hash, KeyedHash, Blockcipher, Streamcipher, AEAD, Generator, HashFn

Project

Reference

Clone this wiki locally