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.
Key Management Is the Hard Part
Signing code is a dozen lines. Key management is the part that decides whether the signature means anything, and it is where compliance signing schemes usually fail.
The private key must never be in source control, in an environment variable dumped by a crash reporter, or on a developer laptop. A cloud KMS or an HSM keeps it unextractable and gives you an audit log of every signing operation for free — which is itself useful evidence when the question is who issued a certificate and when.
The public key must be genuinely public and durably located: a well-known URL on the issuing organisation’s own domain, served over TLS, ideally alongside a human-readable statement of what it signs. A key distributed by email or embedded only in the documents it signs cannot establish anything.
Rotation needs planning before it is needed. Certificates signed with a retired key must remain verifiable, which means publishing keys with validity periods and keeping retired ones available rather than deleting them. A verifier then selects the key by the certificate’s issuance date rather than assuming the current one.
Signature Formats and What Recipients Can Check
The signature format decides who can verify without special tooling, which for a document exchanged between agencies matters more than cryptographic elegance.
A detached signature — a separate file alongside the document — is the simplest to produce and verify with standard tools, and the easiest to lose. An embedded PDF signature is verifiable in any PDF reader, which is a considerable practical advantage when the recipient is a planning office, and it constrains the document format. A JWS or COSE envelope around a structured certificate is ideal for machine-to-machine exchange and opaque to a human recipient.
Most schemes end up producing two: an embedded signature for the human-facing PDF, and a detached signature over the canonical JSON for systems. Both sign the same content digest, so they cannot disagree, and each serves an audience that would otherwise be unable to verify anything.
def sign_digest(kms_client, key_id: str, digest_hex: str) -> dict:
"""Sign the content digest, not the document — one signature covers every format."""
sig = kms_client.sign(key_id=key_id, digest=bytes.fromhex(digest_hex),
algorithm="ECDSA_SHA_256")
return {"alg": "ES256", "key_id": key_id, "digest": digest_hex,
"signature": base64.b64encode(sig).decode()}
What a Signature Does Not Prove
A signature establishes that the issuer produced this content and that it has not changed since. It says nothing about whether the content is correct.
This is worth stating explicitly in any scheme’s documentation, because signed documents attract more confidence than their contents warrant. A certificate signed with an impeccable key, issued from a pipeline with a mis-encoded setback rule, is a cryptographically perfect statement of a wrong answer.
The correctness assurance comes from elsewhere — the regression suite, the reconciliation against hand-computed cases, the review process on rule changes — and the signature simply prevents that assurance from being undermined in transit. Presenting the two together, and being clear about which does which, keeps expectations where they belong.
Related
Part of: Compliance certificate automation
- Generating tamper-evident compliance certificates — the digest this signs.
- Compliance testing and regression suites — where correctness assurance actually comes from.
- Versioning rule references in audit trails — what the signed claim names.
- Audit-ready report generation — the document a signature is usually embedded in.
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.