How to Generate and Verify Digital Signatures in Rust

Generate and verify digital signatures in Rust using the ring crate's Ed25519KeyPair to sign messages and verify their authenticity.

When a checksum isn't enough

You are shipping a configuration file to a fleet of edge devices. You need to guarantee that the file has not been tampered with during transit, and you need to prove it came from your build pipeline. A checksum catches accidental corruption. It does not stop a malicious actor from swapping your config with a backdoor. You need a digital signature.

How digital signatures actually work

Digital signatures rely on asymmetric cryptography. You hold a private key that stays offline. You publish a public key that anyone can use. When you sign a message, the private key produces a unique byte sequence tied to both the key and the exact bytes of the message. Change a single character in the message, and the verification fails. Hand the signature to someone else with your public key, and they can mathematically prove you signed it without ever seeing your private key.

Think of it like a notary stamp combined with a fingerprint. The stamp proves the document was processed by an authorized office. The fingerprint ensures the stamp belongs to a specific person. In code, the private key is the fingerprint. The signature is the stamped impression. The public key is the reference stamp used to check the impression.

Rust's ecosystem leans heavily on ring for this work. ring is a thin, audited wrapper around BoringSSL's cryptographic primitives. It prioritizes security over flexibility. You get a small set of well-tested algorithms instead of a sprawling API.

Minimal example

use ring::signature::{self, Ed25519KeyPair, KeyPair};

/// Signs a message and immediately verifies it using Ed25519.
fn main() {
    // Generate raw PKCS8 bytes using the system random number generator.
    let pkcs8_bytes = Ed25519KeyPair::generate_pkcs8().unwrap();

    // Parse the bytes into a usable key pair object.
    let key_pair = Ed25519KeyPair::from_pkcs8(&pkcs8_bytes).unwrap();

    // The exact bytes you want to protect.
    let message = b"Hello, world!";

    // Create the signature. This operation is constant-time to prevent side-channel attacks.
    let signature = key_pair.sign(message);

    // Extract the public key for verification.
    let public_key = key_pair.public_key();

    // Verify returns Ok(()) on success or Err on failure.
    let is_valid = public_key.verify(message, &signature).is_ok();
    println!("Signature valid: {}", is_valid);
}

Add ring = "0.17" to your Cargo.toml. Run it, and you will see Signature valid: true. The code generates a fresh key, signs a static byte slice, and verifies it in the same process. In production, you will split the signing and verification into separate binaries or services.

Keep your signing and verification logic in separate crates. Isolate the private key behind a narrow API.

What happens under the hood

Here is what happens when sign and verify execute. Ed25519KeyPair::generate_pkcs8() creates a 32-byte private seed, derives the full 64-byte private key, and computes the corresponding 32-byte public key. It wraps them in a PKCS8 structure, which is a standardized container format. The sign method hashes the message using SHA-512, mixes it with the private key, and produces a 64-byte signature. The verify method repeats the hashing, reconstructs the expected signature components using the public key, and checks for a mathematical match.

ring enforces constant-time execution for these operations. Constant time means the CPU takes the exact same amount of cycles regardless of the input data. This prevents timing attacks where an observer measures how long verification takes to guess parts of the key. The compiler cannot enforce constant time. The library authors do it by carefully choosing assembly routines and avoiding data-dependent branches.

Trust the library's timing guarantees. Do not roll your own crypto to optimize it.

Realistic workflow

Real applications rarely sign hardcoded strings. They sign files, API payloads, or database records. They also need to load keys from disk or a secret manager, and they need to handle verification failures gracefully.

use ring::signature::{self, Ed25519KeyPair, KeyPair};
use std::fs;

/// Loads a key from disk, signs a file, and verifies the result.
fn sign_and_verify_file() -> Result<(), Box<dyn std::error::Error>> {
    // Read the payload you want to sign.
    let payload = fs::read("data.bin")?;

    // In production, load this from a secure vault, not a local file.
    let pkcs8_bytes = fs::read("private_key.pk8")?;
    let key_pair = Ed25519KeyPair::from_pkcs8(&pkcs8_bytes)?;

    // Sign the entire file contents.
    let signature = key_pair.sign(&payload);

    // Serialize the signature to bytes for storage or transmission.
    let sig_bytes = signature.as_ref();

    // Load the corresponding public key.
    let public_key_bytes = fs::read("public_key.der")?;
    let public_key = signature::UnparsedPublicKey::new(&signature::ED25519, &public_key_bytes);

    // Verify the signature against the original payload.
    public_key.verify(&payload, sig_bytes)?;

    Ok(())
}

Notice the use of UnparsedPublicKey. ring does not give you a public key struct that you can freely clone or mutate. It gives you a verifier that holds a reference to the raw bytes. This design prevents accidental key swapping and keeps memory access predictable. The verify method returns a Result<(), ring::error::Unspecified>. The error type is deliberately vague. Exposing detailed failure reasons can leak information to attackers. A failed verification is always treated as a single opaque failure.

Treat verification failures as security boundaries. Log them, alert your team, and reject the payload.

Pitfalls and compiler friction

Crypto APIs look simple until they bite you. The most common trap is key format confusion. PKCS8 is for private keys. DER or PEM is for public keys. If you pass a public key to a private key constructor, ring returns an Unspecified error. If you try to verify a signature against a different message length than the one that was signed, verification fails silently with the same opaque error.

Another trap is assuming ring supports every algorithm. It supports Ed25519, ECDSA (P-256, P-384), and RSA (2048, 3072, 4096). It deliberately excludes weaker curves and deprecated padding schemes. If you need RSA-PSS or Ed448, you will hit a wall. The compiler will reject missing methods with E0599 (no method named sign found). If you try to pass a &str instead of &[u8], you get E0308 (mismatched types). ring works exclusively with byte slices. Convert strings to UTF-8 bytes before signing.

Never store private keys in environment variables without base64 encoding. Shell interpreters can truncate or mangle binary data. Base64 the PKCS8 bytes, store the string, and decode at runtime.

The Rust community treats ring as the default choice for new projects. Its design philosophy favors doing one thing correctly over providing every possible configuration. You will see ring::error::Unspecified everywhere in production code. Developers accept the vague error type because it eliminates side-channel leakage. Another convention is keeping cryptographic operations behind a thin trait boundary. This lets you swap ring for aws-lc-rs later without rewriting your application logic.

Define a Signer trait early. Hide the backend behind it.

Choosing your cryptographic backend

Use ring when you need a small, audited, constant-time cryptographic library that covers the most common modern algorithms. Use aws-lc-rs when you want a drop-in replacement for ring that uses Amazon's LibreCrypto backend and supports additional features like Ed448 or custom build configurations. Use the signature crate from the RustCrypto ecosystem when you need a pure-Rust implementation, algorithm flexibility, or integration with other RustCrypto traits. Use rustls when your goal is TLS handshake verification rather than raw message signing. Reach for ring or aws-lc-rs for production security-critical paths. Reach for signature when you are building a library that needs to abstract over multiple backends.

Pick the backend that matches your compliance requirements. Stick with it.

Where to go next