Skip to content
ecdsa.com

Sign a message · Rust

How to sign a message with ECDSA in Rust

To sign a message with ECDSA in Rust, load the key into p256's SigningKey and call sign — RustCrypto hashes the message with SHA-256 and derives the nonce deterministically per RFC 6979. That makes Rust the easy case for tests: the same key and message always produce the same signature, here byte-identical to the example signature of this site's DER ⇄ raw converter.

Tested with p256 (RustCrypto) 0.14.0 (Rust 1.99.1, ecdsa 0.17.0) and OpenSSL 3.5.7 (openssl dgst cross-check).

Cargo.toml — p256 with ECDSA and PEM support
[dependencies]
p256 = { version = "=0.14.0", features = ["ecdsa", "pem"] }
src/main.rs — deterministic signature, low-S, DER and raw output
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
use p256::pkcs8::DecodePrivateKey;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// "BEGIN PRIVATE KEY" (PKCS#8); SigningKey::from_sec1_pem reads "BEGIN EC PRIVATE KEY"
let signing_key = SigningKey::from_pkcs8_pem(&std::fs::read_to_string("key.pem")?)?;
let message = std::fs::read("message.txt")?;
// RFC 6979: the nonce is derived from the key and the message — no RNG, same output every run
let signature: Signature = signing_key.sign(&message);
let signature = signature.normalize_s(); // low-S: the form Bitcoin-style verifiers insist on
std::fs::write("sig.der", signature.to_der().as_bytes())?; // ASN.1 DER
std::fs::write("sig.raw", signature.to_bytes())?; // r‖s, 64 bytes
let hex: String = signature.to_der().as_bytes().iter().map(|b| format!("{b:02x}")).collect();
println!("DER: {hex}");
Ok(())
}

How it works

  1. SigningKey::from_pkcs8_pem reads BEGIN PRIVATE KEY; legacy BEGIN EC PRIVATE KEY files load with SigningKey::from_sec1_pem instead.
  2. sign from the Signer trait hashes the message and signs; the nonce comes from RFC 6979 (an HMAC-DRBG over the key and the digest), so no random number generator is involved.
  3. normalize_s() replaces s with n − s when s is in the upper half of the group order. Both forms are valid ECDSA; low-S is the form strict verifiers require, and it never hurts lenient ones.
  4. to_der() produces ASN.1 DER for OpenSSL-style verifiers (the run checks it with openssl dgst -verify), to_bytes() the 64-byte r‖s for JWS and WebCrypto.

Gotchas

  • Deterministic is not the same as interoperable byte-for-byte: libraries with random nonces (OpenSSL, the JDK, .NET) produce different but equally valid signatures. Compare signatures across stacks by verifying them, not by diffing bytes.
  • p256 does not normalize s by itself. Without normalize_s(), about half the signatures are high-S — accepted by p256, rejected by k256 (secp256k1) verifiers and by Bitcoin consensus rules.
  • sign takes the message, not a digest. To sign a precomputed hash use PrehashSigner::sign_prehash from the hazmat module — passing a digest to sign hashes it twice.
  • SigningKey zeroizes its secret on drop, but the PEM String you read it from is ordinary memory. Keep key files out of logs, and drop or wrap the text in zeroize::Zeroizing in long-running services.

Related recipes