DEV Community

Royal Simpson Pinto
Royal Simpson Pinto

Posted on

Signing and Verifying Data with Ed25519 in Python

I got into Ed25519 while building answerproof, a small tool that signs the receipts my RAG pipeline emits so a reader can later prove the answer came from a specific set of retrieved chunks and was not tampered with afterward. The moment I needed "prove this bytes blob is exactly what I produced," a signature scheme was the right tool, and Ed25519 turned out to be the friendliest one to reach for. In this post I want to teach the technique itself, using those RAG receipts as a running example.

Ed25519 is an elliptic-curve signature scheme. You hold a private (signing) key, you hand out a public (verifying) key, and anyone with the public key can check that a piece of data was signed by whoever holds the private key. It is fast, the keys and signatures are small (32-byte keys, 64-byte signatures), and the API is hard to misuse. That last point matters more than it sounds.

Generating a key pair

I use the cryptography library because it ships good Ed25519 primitives. Install it first:

pip install cryptography
Enter fullscreen mode Exit fullscreen mode

Generating a key pair is one call:

from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey,
    Ed25519PublicKey,
)

private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
Enter fullscreen mode Exit fullscreen mode

The private key is your secret. The public key is what you publish so others can verify your signatures.

Signing data (detached signatures)

Ed25519 signatures are detached by design: signing produces a 64-byte signature that lives separately from the message. You keep the message as it is and attach the signature alongside it. This fits my receipts perfectly, because the receipt JSON stays human-readable and the signature rides in a separate field.

import json

receipt = {
    "query": "What is the refund window?",
    "chunk_ids": ["doc12#p3", "doc12#p4"],
    "answer_hash": "sha256:9f2a...",
}

# Serialize deterministically so the exact same bytes are what you sign and verify.
message = json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8")

signature = private_key.sign(message)  # 64 raw bytes
Enter fullscreen mode Exit fullscreen mode

The single most important habit here: sign bytes, not objects. Whatever you sign, you must be able to reproduce those exact bytes at verification time. That is why I serialize with sort_keys=True and fixed separators. If the byte layout changes, verification fails, and it should.

Verifying a signature

Verification uses the public key. The cryptography library does not return True or False. Instead it raises InvalidSignature on failure and returns None on success. Treat the exception as the failure path.

from cryptography.exceptions import InvalidSignature

def verify(public_key: Ed25519PublicKey, signature: bytes, message: bytes) -> bool:
    try:
        public_key.verify(signature, message)
        return True
    except InvalidSignature:
        return False

print(verify(public_key, signature, message))  # True
Enter fullscreen mode Exit fullscreen mode

Change a single byte of message or signature and it flips to False. That is the whole guarantee: the signature is valid only for the exact bytes it was made over, made by the exact key it claims.

Sharing keys: base64url encoding

Raw keys are bytes, and bytes do not travel well through JSON, HTTP headers, or config files. I encode them as base64url, which is URL and filename safe and avoids the + and / characters that plain base64 uses.

To export the public key so a verifier can import it later:

import base64
from cryptography.hazmat.primitives import serialization

def b64url_encode(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")

def b64url_decode(text: str) -> bytes:
    padding = "=" * (-len(text) % 4)
    return base64.urlsafe_b64decode(text + padding)

# Public key to a shareable string
public_raw = public_key.public_bytes(
    encoding=serialization.Encoding.Raw,
    format=serialization.PublicFormat.Raw,
)
public_b64 = b64url_encode(public_raw)
Enter fullscreen mode Exit fullscreen mode

I strip the = padding on the way out and re-add it on the way in, which keeps the string clean in URLs. Reconstructing the public key on the verifier side:

public_key_again = Ed25519PublicKey.from_public_bytes(b64url_decode(public_b64))
Enter fullscreen mode Exit fullscreen mode

The signature travels the same way. In my receipts it lands as a field:

receipt_signed = {
    **receipt,
    "sig": b64url_encode(signature),
    "pubkey": public_b64,
}
Enter fullscreen mode Exit fullscreen mode

A verifier reads the receipt, re-serializes the signed fields into the same canonical bytes, decodes sig and pubkey, and calls verify. If it passes, the receipt is authentic and unmodified.

The pynacl alternative

If you prefer PyNaCl, the same flow is a few lines. It returns a combined SignedMessage, but it also supports detached signatures:

from nacl.signing import SigningKey, VerifyKey
from nacl.exceptions import BadSignatureError

signing_key = SigningKey.generate()
verify_key = signing_key.verify_key

signature = signing_key.sign(message).signature  # detached 64 bytes

try:
    verify_key.verify(message, signature)
    ok = True
except BadSignatureError:
    ok = False
Enter fullscreen mode Exit fullscreen mode

Both libraries implement the same standard, so a signature made by one verifies under the other as long as you feed identical bytes and use the raw key encoding.

The honest caveat: key management

Signing is the easy part. The hard part, the part no library solves for you, is protecting the private key. A signature only means something if the private key stayed secret. If it leaks, anyone can forge receipts that verify perfectly, and you have no way to tell forgeries from the real thing after the fact.

So do not hardcode signing keys in source, and do not commit them. Load them from a secret manager or an environment variable at runtime, keep them off disk where you can, and give yourself a rotation story: publish a key identifier alongside each signature so you can retire a compromised key and move to a new one without invalidating your whole history. In answerproof I treat the private key as the single most sensitive thing in the system, because it is. The cryptography is only as trustworthy as the secret behind it.

That is the full loop: generate, sign detached, verify, and exchange keys as base64url strings. Once you have it wired up, signing becomes a boring, reliable primitive you can drop into receipts, audit logs, webhook payloads, or anything else where "this is exactly what I produced" needs to be provable. If you want to see it grounded in a real RAG receipt pipeline, the code lives at github.com/AgentPostmortem/answerproof.

Top comments (0)