rust.
rust5 min read

Ed25519 Signing in Rust with ed25519-dalek: Keygen, Verify, Persist

A practical bootstrap for ed25519 signing in Rust using ed25519-dalek v2: generate keypairs, sign and verify payloads, and persist keys to disk safely.

Ed25519 Signing in Rust with ed25519-dalek

Ed25519 is the boring-good default for asymmetric signatures in 2026. Fast verification, small keys (32 bytes), deterministic signatures, no nonce footguns. If you're building a daemon, a remote-execution agent, or an API client that needs to prove it owns a key, ed25519-dalek is the crate Rust developers reach for first.

This bootstrap walks through the four operations that 90% of services need: generating a keypair, signing a payload, verifying a signature, and persisting the key to disk so the process can restart without rotating identities. It targets ed25519-dalek v2, which restructured the API and types from v1 in ways that still trip people up two years later.

Why ed25519 over alternatives

When you reach for a signature scheme, the realistic candidates are RSA, ECDSA over secp256k1 or P-256, and Ed25519. Ed25519 wins on three axes that matter for production services:

  • Verification speed. Ed25519 verification on modern hardware runs around 50\u00b5s per signature, several times faster than RSA-2048 verify and competitive with secp256k1. If your service verifies thousands of signatures per second (think auth middleware on every request), this compounds.
  • Key size. A public key is 32 bytes, a signature 64 bytes. Compare to RSA-2048 at 256+ bytes per signature. Smaller keys mean smaller tokens, smaller wire payloads, less cache pressure.
  • No parameter choices. RSA forces you to pick key size, padding, hash. ECDSA forces you to pick a curve and worry about deterministic nonces. Ed25519 has one mode and uses SHA-512 internally \u2014 there is nothing to misconfigure.

The tradeoff: Ed25519 is not available in every legacy environment, and some HSM/KMS vendors only added support recently. For a Rust service running on Linux or macOS in 2026, those concerns are gone.

Setup

Add the crate. Use the latest 2.x release.

[dependencies]
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
rand = "0.8"

The rand_core feature pulls in the trait wiring needed for SigningKey::generate. Without it you'll hit a confusing trait-bound error from the compiler when calling generate.

Generating a keypair

In v1 you had a Keypair struct. In v2 that's gone \u2014 there's just SigningKey (private + public bundled) and VerifyingKey (public only). SigningKey::verifying_key() derives the public half on demand.

use ed25519_dalek::{SigningKey, VerifyingKey};
use rand::rngs::OsRng;

fn generate_keys() -> (SigningKey, VerifyingKey) {
    let mut csprng = OsRng;
    let signing_key = SigningKey::generate(&mut csprng);
    let verifying_key = signing_key.verifying_key();
    (signing_key, verifying_key)
}

OsRng reads from the operating system's CSPRNG (/dev/urandom on Linux, getrandom syscall on modern kernels, SecRandomCopyBytes on macOS). Do not substitute a seeded RNG here for "reproducible tests" \u2014 a leaked seed leaks every key you ever derive from it. If you want test determinism, fix it to a hard-coded byte array via SigningKey::from_bytes and treat that key as throwaway.

Signing and verifying

ed25519-dalek re-exports the Signer and Verifier traits from the signature crate. Bring them into scope or the method calls won't resolve.

use ed25519_dalek::{Signer, Verifier, Signature};

fn sign_and_verify(signing_key: &SigningKey, msg: &[u8]) -> bool {
    let signature: Signature = signing_key.sign(msg);
    let verifying_key = signing_key.verifying_key();
    verifying_key.verify(msg, &signature).is_ok()
}

A signature is 64 bytes (Signature::BYTE_SIZE). Serialize it with signature.to_bytes() for transport and reconstruct via Signature::from_bytes(&array). Use this when sending signatures over JSON (base64-encode first), gRPC (raw bytes), or a custom WebSocket protocol.

Verification returns Result<(), SignatureError>. Match on the error variant if you want to distinguish "invalid signature" from "malformed bytes" in your logs, though for an attacker-facing surface you should return a single opaque error to avoid leaking which check failed.

Persisting keys to disk

A real service needs to survive restarts. The naive approach \u2014 write signing_key.to_bytes() to a file \u2014 is correct but loses everything around safe handling. Three things to get right:

Encode the bytes as raw, not text. to_bytes() returns [u8; 32]. Write it directly. Avoid hex or base64 unless you have a specific operator-experience reason \u2014 extra layers mean extra bugs in parsing.

Set restrictive file permissions. On Unix, 0o600 (owner read/write, nothing for group or world). This is the same posture OpenSSH enforces on ~/.ssh/id_ed25519; ssh will refuse to load a key with looser permissions, and your service should follow the same rule.

Distinguish bootstrap from steady state. First run creates the key. Subsequent runs load it. Crashing because the file is missing is wrong; silently overwriting an existing key is worse.

use std::fs;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;

pub fn load_or_create_key(path: &Path) -> io::Result<SigningKey> {
    if path.exists() {
        let bytes = fs::read(path)?;
        let array: [u8; 32] = bytes.as_slice().try_into()
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "key file wrong size"))?;
        return Ok(SigningKey::from_bytes(&array));
    }

    let mut csprng = rand::rngs::OsRng;
    let signing_key = SigningKey::generate(&mut csprng);
    fs::write(path, signing_key.to_bytes())?;
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
    Ok(signing_key)
}

This pattern shows up in any agent or daemon that needs a stable identity: an SSH-style client key, an ed25519 identity for a WireGuard peer, a service account for an internal RPC. On the first call the function generates and persists; on every subsequent call it loads. The verifying key is derived from the signing key in memory, so you only need to persist 32 bytes total.

For multi-host deployments, the public half (signing_key.verifying_key().to_bytes()) is what you distribute to verifying parties. Treat it as you would an SSH authorized_keys entry: publish freely, pin tightly. The private half never leaves the host that generated it.

Pitfalls worth flagging

  • Don't confuse SigningKey::from_bytes with from_keypair_bytes. The former takes the 32-byte secret seed. The latter (when interoperating with v1-format files) takes the 64-byte secret-plus-public concatenation. Mixing them up produces silent garbage signatures that still verify against the matching wrong key.
  • Verifier::verify is constant-time, == on Signature is not. Always go through verify. Never write received_sig == expected_sig \u2014 it leaks timing information.
  • The serde feature is opt-in. If you want Serialize/Deserialize for keys and signatures (for example, embedding inside a JSON envelope), enable features = ["serde"] on the crate. Otherwise you'll be hand-rolling to_bytes/from_bytes plumbing through your serialization layer.

What to build next

Once signing works end-to-end, the obvious extensions are challenge-response auth (server emits a nonce, client signs and returns), signed message envelopes (sign a canonicalized JSON payload, ship signature alongside), and key rotation (keep a list of trusted verifying keys, age the old one out). Each is a 30-minute addition on top of the four primitives above; the hard part \u2014 generating, signing, verifying, persisting \u2014 is what this bootstrap covered.

References: