Signing compliance manifests with Ed25519

This page shows how to bind provenance to an RBAC privilege manifest by signing its canonical bytes with an Ed25519 key using Python’s cryptography library, then verifying the detached signature at ingest so a tampered or unauthored manifest is rejected before it is trusted.

When to use this — and when not to

Sign with Ed25519 when:

  • You ship a privilege manifest or evidence bundle across a trust boundary — CI to a controller, a signer service to storage, one team to an auditor — and the receiver must confirm who produced it, not just that it parses.
  • You already hash and chain your records for integrity and need the missing provenance layer, so a self-consistent forgery is still rejected.
  • You want a compact, fast, widely supported signature scheme with small keys (32 bytes) and deterministic signing.

Do not reach for it when:

  • You need multiple parties to co-sign with threshold rules, or key rotation with a certificate hierarchy — that is a PKI/X.509 problem, not a bare Ed25519 one.
  • You only need integrity within a single trust boundary that already guarantees provenance — a hash chain alone may suffice.
  • You cannot protect the private key. A signature is only as trustworthy as the key’s isolation; without a real secret store, signing is theater.
Sign on one side with the private key, verify at ingest with the public key The signer canonicalizes the manifest and signs its bytes with an Ed25519 private key, emitting a 64-byte detached signature stored next to the manifest. At ingest the verifier recomputes the canonical bytes and checks the detached signature against the trusted public key, accepting on a valid signature and rejecting on an invalid one. Only the public key crosses the boundary. signer boundary verifier boundary · ingest canonical manifest sorted-key JSON bytes Ed25519 sign private key · sk.sign(bytes) detached signature 64 bytes · stored beside manifest re-canonicalize recompute the bytes Ed25519 verify public key · pk.verify(sig, bytes) accept · or reject InvalidSignature → reject public key only crosses the boundary
Figure 1 — The private key stays on the signer side; only the public key and the detached signature travel. Verification at ingest recomputes the canonical bytes, so a manifest edited in transit fails before it is trusted.

Step-by-step implementation

Step 1 — Generate and persist the key pair

Generate an Ed25519 key pair, then serialize the private key encrypted (PKCS8 + PEM, passphrase-protected) and the public key openly (SubjectPublicKeyInfo PEM). The private PEM belongs in a secret store; the public PEM is distributed to every verifier.

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

def generate_keypair(passphrase: bytes) -> tuple[bytes, bytes]:
    sk = Ed25519PrivateKey.generate()
    private_pem = sk.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.BestAvailableEncryption(passphrase),
    )
    public_pem = sk.public_key().public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    return private_pem, public_pem

Verify the serialized forms are what you expect before storing them:

priv, pub = generate_keypair(b"correct horse battery staple")
assert priv.startswith(b"-----BEGIN ENCRYPTED PRIVATE KEY-----")
assert pub.startswith(b"-----BEGIN PUBLIC KEY-----")

Step 2 — Canonicalize the manifest and produce a detached signature

Sign the exact canonical bytes of the manifest — never the in-memory object, whose serialization can drift. Ed25519’s sign takes the message bytes directly and returns a 64-byte detached signature; store it beside the manifest rather than embedding it, so the signed bytes stay byte-for-byte reproducible.

import json
from pydantic import BaseModel

class PrivilegeManifest(BaseModel):
    run_id: str
    environment: str
    grants: list[dict]        # normalized (principal, privilege, object_scope) rows

def canonical_bytes(manifest: PrivilegeManifest) -> bytes:
    payload = manifest.model_dump(mode="json")
    return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")

def sign_manifest(manifest: PrivilegeManifest, private_pem: bytes,
                  passphrase: bytes) -> bytes:
    sk = serialization.load_pem_private_key(private_pem, password=passphrase)
    return sk.sign(canonical_bytes(manifest))     # 64-byte detached signature

Confirm the signature is the expected length:

manifest = PrivilegeManifest(run_id="2026-07-18-nightly", environment="prod",
    grants=[{"principal": "app_svc", "privilege": "SELECT", "object_scope": "cust.cards"}])
sig = sign_manifest(manifest, priv, b"correct horse battery staple")
assert len(sig) == 64

Step 3 — Verify at ingest

The receiver loads the trusted public key, recomputes the canonical bytes from the manifest it received, and calls verify. Ed25519 verify returns None on success and raises InvalidSignature on any mismatch — a wrong key, a tampered manifest, or a corrupted signature all land in the same branch, which you convert into a clean accept/reject.

from cryptography.exceptions import InvalidSignature

def verify_manifest(manifest: PrivilegeManifest, signature: bytes,
                    public_pem: bytes) -> bool:
    pk: Ed25519PublicKey = serialization.load_pem_public_key(public_pem)
    try:
        pk.verify(signature, canonical_bytes(manifest))   # returns None on success
        return True
    except InvalidSignature:
        return False

Verify both the honest path and a tampered manifest:

print(verify_manifest(manifest, sig, pub))          # True — authentic

manifest.grants[0]["privilege"] = "ALL PRIVILEGES"  # falsify after signing
print(verify_manifest(manifest, sig, pub))          # False — rejected at ingest

Expected output:

True
False

Worked example: signing a nightly manifest in CI

Scenario: a CI job on PostgreSQL 15 extracts effective grants each night and emits a PrivilegeManifest, which a downstream reconciliation controller consumes to decide what to apply. Without a signature, a compromised CI runner could inject an extra GRANT ALL PRIVILEGES row and the controller would apply it as gospel. The extraction is read-only:

SELECT grantee, privilege_type,
       table_schema || '.' || table_name AS object_scope
FROM information_schema.role_table_grants
WHERE grantee NOT IN ('postgres', 'PUBLIC')
ORDER BY grantee, table_schema, table_name;

The CI job signs the canonical manifest with a private key held only in the CI secret store, and writes manifest.json plus manifest.sig. The controller ships with the public PEM baked in and calls verify_manifest before acting. If an attacker edits manifest.json to add the rogue grant, the recomputed canonical bytes no longer match the signature and verify_manifest returns False, so the controller refuses the whole bundle rather than applying a single unauthored row. The same key discipline governs signed access exceptions in cryptographic manifest signing for access exceptions, where a signed waiver is what lets an approved deviation bypass the alarm without leaving a hole.

Gotchas and engine-specific notes

Sign bytes, not objects. The single most common failure is signing one serialization and verifying another — a re-ordered key, a differently formatted number, a trailing newline. Route both sign and verify through the identical canonical_bytes helper, and treat the signed bytes as opaque; never re-serialize between signing and storing.

Ed25519 has no hash parameter. Unlike RSA or ECDSA in this library, Ed25519PrivateKey.sign(data) and public_key().verify(signature, data) take only the message and signature — Ed25519 hashes internally. Do not pass a padding or hash-algorithm argument; there is no place for one, and pre-hashing the message yourself changes what you signed.

Detached beats embedded. Storing the signature inside the manifest means you must strip it before verifying, reintroducing the canonicalization drift you were avoiding. Keep the signature in a sibling file or column so the signed bytes are always reproducible verbatim.

Key handling is the real threat model. Ed25519 is not the weak link — key custody is. Load the private key only inside the signer’s trust boundary, never log or serialize it unencrypted, and rotate by publishing a new public key with an overlap window. For engine parity, note that manifests from PostgreSQL (information_schema.role_table_grants, pg_auth_members) and MySQL 8 (mysql.role_edges, resolved after SET ROLE) must be canonicalized to the same schema before signing, or a cross-engine verifier will reject a legitimately identical state.

Compliance note

Signing evidence with Ed25519 provides the non-repudiation and provenance that PCI-DSS Requirement 10.5 and NIST SP 800-53 AU-10 look for: an auditor can confirm the manifest was produced by the holder of a specific key and not altered afterward, using only the public key and the detached signature. Combined with a verifiable hash chain over the record sequence, the signature completes the SOC 2 CC7.x audit-integrity story described in evidence integrity and signing. The artifact you retain per run is the canonical manifest, its detached signature, and the published public key — a self-contained, offline-verifiable bundle that an assessor can validate without any access to your systems.

Frequently asked questions

Why Ed25519 rather than RSA or ECDSA? Ed25519 has small keys (32 bytes), fast deterministic signing, no per-signature randomness to get wrong, and no hash-or-padding parameters to misconfigure. For signing a compact manifest it is simpler and harder to misuse than RSA, while offering strong, modern security.

Should the signature be detached or embedded in the manifest? Detached. Embedding forces you to strip the signature field before verifying, which reopens the canonicalization drift that causes false rejections. A sibling .sig file keeps the signed bytes byte-for-byte reproducible.

Do I need to hash the manifest before signing it? No. Ed25519 hashes the message internally, so you pass the canonical bytes straight to sign and verify. Pre-hashing yourself changes what you actually signed and will fail verification unless the verifier repeats the exact same pre-hash.

How do I rotate the signing key without breaking old evidence? Keep verifying historical manifests against the public key that signed them, and start signing new manifests with the new key during an overlap window. Record which key id signed each manifest so a verifier always selects the matching public key.

Up: Evidence Integrity and Signing