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.

Where the keys live, and for how longPrivate keys in a KMS or HSM, public keys at a durable well-known URL, and rotation planned so retired keys remain available for verification.Private key: KMS or HSM, never extractableand its signing operations are themselves an audit logPublic key: a well-known URL on your own domaina key distributed by email establishes nothingValidity periods published with each keyverifiers select by issuance dateRetired keys kept, never deletedold certificates must stay verifiable
A signature is only as meaningful as the key management behind it.

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.

Signature formats by who can verify themDetached signatures, embedded PDF signatures and JWS envelopes compared by tooling required and audience served.Verified withServesDetached signatureStandard command-line toolsTechnical recipients; easy to loseEmbedded PDFsignatureAny PDF readerPlanning offices — the practical winJWS or COSE envelopeA library, in codeMachine-to-machine exchange
Most schemes produce two — one for humans, one for systems — both over the same content digest, so they cannot disagree.

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.

What the signature is being asked to proveA signature establishes authorship and integrity; correctness comes from the regression suite and the review process, not from cryptography.Is the question whether thedocument was altered, orwhether the answer isright?right?The regression suite answers itsignatures say nothing about correctnessaltered?The signature answers itauthorship and integrity, cryptographicallyPresent both, and be clear which does which
A perfectly signed certificate from a mis-encoded rule is a flawless statement of a wrong answer.

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.

Part of: Compliance certificate automation

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.