Digitally Signing Compliance Certificates in Python
Digital signatures add authorship and non-repudiation to a compliance certificate: they prove that your issuing authority, and no impostor, produced the document, and that its content has not changed since. This guide uses the cryptography library to sign a certificate’s content hash with an Ed25519 key pair, shows an RSA alternative for environments that mandate it, explains how to distribute the public key, and demonstrates verifying a signature downstream. It builds directly on the fingerprint produced when you first make a certificate tamper-evident.
Prerequisites
Step-by-step
Step 1: Generate and persist an Ed25519 key pair
Ed25519 is fast, produces compact 64-byte signatures, and needs no parameter tuning, which makes it an excellent default for signing certificate hashes. Generate the key once and guard the private half like any other secret.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
def generate_keys():
private_key = Ed25519PrivateKey.generate()
# Serialize for storage in a KMS/secret store, not the repo.
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
pub_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return priv_pem, pub_pem
Step 2: Sign the certificate’s content hash
Sign the same SHA-256 digest that makes the certificate tamper-evident, produced exactly as in generating tamper-evident compliance certificates. Signing the hash rather than the whole payload keeps the operation cheap and consistent.
import hashlib, json
def content_hash_bytes(body: dict) -> bytes:
canonical = json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(canonical).digest() # raw 32 bytes to sign
def sign_certificate(body: dict, priv_pem: bytes) -> dict:
private_key = serialization.load_pem_private_key(priv_pem, password=None)
signature = private_key.sign(content_hash_bytes(body)) # Ed25519: no hash arg needed
return {"body": body, "signature": signature.hex(), "alg": "ed25519"}
Step 3: Distribute the public key
Verifiers need your public key through a channel they already trust, so a signature actually proves authorship. Publish it at a stable HTTPS endpoint or bundle it with your agency’s key registry, and tag it with a key identifier so certificates can name which key signed them.
def key_id(pub_pem: bytes) -> str:
# A short, stable fingerprint verifiers can pin and reference.
return hashlib.sha256(pub_pem).hexdigest()[:16]
Step 4: Verify a signature downstream
Verification loads the public key, recomputes the hash from the certificate body, and checks the signature. An invalid signature raises InvalidSignature, which you should treat as a hard failure.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
def verify_certificate(cert: dict, pub_pem: bytes) -> bool:
public_key = serialization.load_pem_public_key(pub_pem)
if not isinstance(public_key, Ed25519PublicKey):
raise TypeError("Expected an Ed25519 public key")
try:
public_key.verify(bytes.fromhex(cert["signature"]),
content_hash_bytes(cert["body"]))
return True
except InvalidSignature:
return False
Step 5 (optional): RSA for mandated environments
Some agencies require RSA. The cryptography library supports it with PSS padding; the trade-off is much larger keys and signatures. Use this only when policy demands it.
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
def sign_rsa(body: dict, rsa_priv: rsa.RSAPrivateKey) -> bytes:
return rsa_priv.sign(
content_hash_bytes(body),
padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH),
hashes.Prehashed(hashes.SHA256()), # we already hashed the canonical body
)
Verification
Prove the round trip end to end: generate keys, sign a body, then verify. Flipping any field in the body must cause verification to fail, which demonstrates that the signature covers the content.
priv, pub = generate_keys()
body = {"schema": "gcc-cert/1.0", "parcel_id": "OR-33012",
"status": "COMPLIANT", "rule_version": "[email protected]+ab12cd3",
"crs": "EPSG:2913"} # Oregon State Plane North (intl ft)
cert = sign_certificate(body, priv)
print("valid:", verify_certificate(cert, pub)) # -> valid: True
cert["body"]["status"] = "NON_COMPLIANT" # tamper
print("after edit:", verify_certificate(cert, pub)) # -> after edit: False
Common Pitfalls
- Committing the private key. A leaked signing key lets anyone forge certificates. Keep it in a KMS or secrets manager and rotate it if exposure is even suspected.
- Signing the wrong bytes. The verifier must hash the body exactly as the signer did. Reuse a single canonicalization function on both sides, or signatures will fail on genuine certificates.
- Ignoring the CRS in the signed body. A signature over a body that omits its coordinate reference system authenticates an ambiguous claim. Sign the same body that already embeds the CRS and rule version.
Frequently Asked Questions
Should I choose Ed25519 or RSA for signing certificates?
Ed25519 is the better default for new systems: keys and signatures are small, signing and verification are fast, and there are no padding parameters to misconfigure. Choose RSA only when an external policy, legacy verifier, or certificate authority requires it, and if you do, use PSS padding rather than the older PKCS1v15 scheme.
How do verifiers trust that a public key really belongs to my agency?
The public key must reach verifiers over a channel they already trust, such as an HTTPS endpoint under your official domain, a signed key registry, or a certificate issued by a recognized authority. Distributing the key inside the same certificate it verifies proves nothing, so the trust anchor always lives outside the document.
What happens when a signing key is compromised or rotated?
Stop signing with the old key immediately and publish the new public key. Because each certificate names the key that signed it, verifiers can still validate older certificates against the retired key while treating anything signed after the compromise window as suspect. Pair rotation with the revocation tracking described in the certificate module so superseded documents are marked accordingly.
Do I still need the content hash if I am signing?
Yes. The signature is computed over the hash, so the hash remains the integrity core described in compliance certificate automation. Hashing gives you tamper-evidence, and the signature layers authorship and non-repudiation on top of it; neither replaces the other.