Skip to content
ecdsa.com

Ed25519 sign & verify · Python

How to sign and verify Ed25519 signatures in Python

The cryptography package (pyca) signs and verifies Ed25519 signatures with dedicated key classes: Ed25519PrivateKey.sign takes only the message, and Ed25519PublicKey.verify raises InvalidSignature instead of returning False. Ed25519 (RFC 8032) is deterministic, so the RFC 8032 test key below always produces the same 64-byte signature.

Tested with cryptography 50.0.2 (Python 3.14.8, OpenSSL 3.5.7).

sign.py — sign message.txt with a PKCS#8 Ed25519 key
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key
private_key = load_pem_private_key(open("key.pem", "rb").read(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise TypeError(f"expected an Ed25519 key, got {type(private_key).__name__}")
message = open("message.txt", "rb").read()
signature = private_key.sign(message) # no hash argument: Ed25519 hashes with SHA-512 itself
open("sig.bin", "wb").write(signature)
print("signature:", signature.hex()) # 64 bytes, the same on every run
verify.py — verify, turning InvalidSignature into a boolean
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import load_pem_public_key
public_key = load_pem_public_key(open("pub.pem", "rb").read())
assert isinstance(public_key, Ed25519PublicKey)
message = open("message.txt", "rb").read()
signature = open("sig.bin", "rb").read()
def is_valid(data: bytes) -> bool:
try:
public_key.verify(signature, data) # returns None or raises
return True
except InvalidSignature:
return False
print("valid:", is_valid(message))
print("tampered message valid:", is_valid(b"hello ed25520"))
the same key as 32 raw bytes, the form libsodium, Solana and JWK use
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
# 32-byte raw keys, as libsodium, Solana or a JWK "x" carry them
raw_public = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
print("raw public key:", raw_public.hex())
same_key = Ed25519PublicKey.from_public_bytes(raw_public)
same_key.verify(signature, message)
print("raw key verifies: True")

How it works

  1. load_pem_private_key reads BEGIN PRIVATE KEY and returns whatever key type the PEM holds; the isinstance check turns a wrong key file into a clear error instead of a confusing one later.
  2. sign(message) has no algorithm parameter — SHA-512 is part of Ed25519 — and returns 64 bytes.
  3. verify(signature, message) returns None on success and raises InvalidSignature otherwise; the helper converts that into True or False.
  4. public_bytes(Encoding.Raw, PublicFormat.Raw) gives the 32-byte public key other ecosystems exchange, and Ed25519PublicKey.from_public_bytes reads it back.

Gotchas

  • Copying the ECDSA call shape fails: private_key.sign(message, hashes.SHA256()) raises TypeError: Ed25519PrivateKey.sign() takes 1 positional arguments but 2 were given.
  • A libsodium secret key is 64 bytes (seed followed by public key). Ed25519PrivateKey.from_private_bytes wants only the first 32 and raises ValueError: An Ed25519 private key is 32 bytes long for the full 64.
  • The argument order is verify(signature, data) — signature first. Swapped arguments do not raise a TypeError, they raise InvalidSignature, which looks exactly like a forged signature.
  • PyNaCl produces the same signature for the same seed, but SigningKey.sign returns the signature followed by the message (77 bytes here). Pass signed.signature, the first 64 bytes, to cryptography.

Related recipes