from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKeyfrom 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 from cryptography.exceptions import InvalidSignaturefrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKeyfrom 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")) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKeyfrom cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # 32-byte raw keys, as libsodium, Solana or a JWK "x" carry themraw_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
load_pem_private_keyreadsBEGIN PRIVATE KEYand returns whatever key type the PEM holds; theisinstancecheck turns a wrong key file into a clear error instead of a confusing one later.sign(message)has no algorithm parameter — SHA-512 is part of Ed25519 — and returns 64 bytes.verify(signature, message)returnsNoneon success and raisesInvalidSignatureotherwise; the helper converts that intoTrueorFalse.public_bytes(Encoding.Raw, PublicFormat.Raw)gives the 32-byte public key other ecosystems exchange, andEd25519PublicKey.from_public_bytesreads it back.
Gotchas
- Copying the ECDSA call shape fails:
private_key.sign(message, hashes.SHA256())raisesTypeError: 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_byteswants only the first 32 and raisesValueError: An Ed25519 private key is 32 bytes longfor the full 64. - The argument order is
verify(signature, data)— signature first. Swapped arguments do not raise aTypeError, they raiseInvalidSignature, which looks exactly like a forged signature. - PyNaCl produces the same signature for the same seed, but
SigningKey.signreturns the signature followed by the message (77 bytes here). Passsigned.signature, the first 64 bytes, to cryptography.