This guide is part of the Audit Trail & Evidence Automation section within the broader Core Architecture & Compliance Mapping framework, and it solves one narrow problem: how do you sign a compliance artifact today so an auditor, opening it three years from now, can prove it has not been altered and that your organization — not someone else — produced it?
The wrong answer is to store a SHA-256 hash next to the file and call it “tamper-evident.” A hash proves integrity only if the hash itself is protected; anyone who can edit the artifact can recompute the hash. A digital signature closes that gap: it binds integrity to a private key that never leaves a secrets store, so an auditor with only your published public key can verify both that the bytes are unchanged and that the holder of the signing key attested to them. This guide builds a detached Ed25519 signing and verification path that serializes the artifact canonically first, so verification is reproducible byte-for-byte years after the fact.
When to Use This Approach
Reach for detached signatures when all three conditions hold:
- The artifact must survive as legal evidence, not just a database row. An expenditure attestation, a subrecipient risk assessment, or a closeout certification eventually reconciles against 2 CFR §200.303 internal-control requirements. When a federal auditor asks “who certified this figure and has it changed,” a signature answers non-repudiably; an unsigned JSON blob does not.
- You need integrity that outlives the application that wrote it. NIST SP 800-53 control AU-9 requires audit information to be protected from unauthorized modification, and SC-13 requires that protection use validated cryptography. A detached signature is portable: it travels with the artifact into cold storage and verifies without the original database, ORM, or service being alive.
- Verifiers are external and untrusting. Auditors, pass-through grantors, and courts do not have access to your internal systems and should not have to trust them. Publishing a public key lets them verify independently, which is the entire point of non-repudiation.
Key custody hardware, the append-only ledger that records when each artifact was signed, and long-term storage retention windows are explicitly out of scope here. Immutable event ordering belongs to Building an Append-Only Audit Ledger in Postgres; retention scheduling belongs to Automating 2 CFR 200 Record Retention Schedules; the artifact’s field structure belongs to Designing a Compliance Artifact Metadata Schema. This stage takes a Python dictionary in and emits a signature envelope out.
Step-by-Step Implementation
The reference implementation targets Python 3.10+ and uses the cryptography library, which wraps the audited OpenSSL and libsodium primitives. Do not roll your own Ed25519 with pynacl bindings you hand-assemble, and do not reach for the low-level hashlib HMAC — a keyed hash proves you share a secret, not that a specific private-key holder signed.
pip install "cryptography==42.0.8"
Step 1: Choose Ed25519 and set the signing contract
Ed25519 is the default choice over RSA for new compliance signing for three concrete reasons. Its keys are tiny — a 32-byte public key versus a 256-byte-plus RSA-2048 modulus — so publishing and storing them for every verifier is cheap. It is deterministic: signing the same bytes with the same key always yields the same 64-byte signature, with no reliance on a per-signature random nonce that a weak RNG can leak (the flaw that broke ECDSA implementations). And it sidesteps the padding-scheme footguns (PKCS#1 v1.5 versus PSS) that make RSA easy to misconfigure. Reserve RSA only for interoperating with a verifier that cannot accept EdDSA.
Configure a standard-library audit logger and fix the input/output contract: a mapping goes in, a signature envelope dictionary comes out.
import json
import base64
import logging
from datetime import datetime, timezone
from typing import Any, Mapping
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
AUDIT_LOGGER = logging.getLogger("grant_compliance.signing")
AUDIT_LOGGER.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
AUDIT_LOGGER.addHandler(_handler)
ALGORITHM = "Ed25519"
class SignatureError(RuntimeError):
"""Raised when signing or verification cannot complete safely."""
SignatureError is a named type so callers can distinguish a cryptographic failure from an ordinary ValueError; never let a signing routine fail silently or return None in place of a signature.
Step 2: Serialize the artifact canonically before signing
A signature covers bytes, not a Python object. If you sign json.dumps(artifact) with default settings, re-serializing the same dictionary in a different key order — or on a different Python build — produces different bytes and the signature fails to verify even though nothing changed. Canonicalize first: sort keys, use fixed separators with no incidental whitespace, disable ASCII escaping, and encode as UTF-8. Verification later must call the identical function.
def canonicalize(artifact: Mapping[str, Any]) -> bytes:
"""Serialize a compliance artifact to reproducible canonical JSON bytes."""
try:
return json.dumps(
artifact,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
except (TypeError, ValueError) as exc:
AUDIT_LOGGER.error("Artifact is not canonically serializable: %s", exc)
raise SignatureError("artifact not serializable") from exc
sort_keys=True removes ordering as a variable, separators=(",", ":") strips the spaces Python inserts by default, and ensure_ascii=False keeps a grantee name like “Fundación” as real UTF-8 rather than \uXXXX escapes. Nested dictionaries are sorted recursively, so the whole tree is deterministic.
Step 3: Load the private key from a secrets store and sign
The private key is loaded at runtime from a secrets manager (AWS Secrets Manager, HashiCorp Vault, or a cloud KMS/HSM) and passed in as bytes — it must never be hardcoded, committed, or logged. NIST SP 800-53 SC-13 expects the key material behind validated cryptography to be handled this way. Signing produces a raw 64-byte signature, which is detached: it is returned separately from the artifact rather than wrapped around it.
def sign_artifact(artifact: Mapping[str, Any], private_key_pem: bytes) -> bytes:
"""Return a detached Ed25519 signature over the canonical artifact bytes."""
from cryptography.hazmat.primitives.serialization import load_pem_private_key
try:
key = load_pem_private_key(private_key_pem, password=None)
except (ValueError, TypeError) as exc:
AUDIT_LOGGER.error("Private key failed to load: %s", exc)
raise SignatureError("invalid private key material") from exc
if not isinstance(key, Ed25519PrivateKey):
raise SignatureError("loaded key is not an Ed25519 private key")
payload = canonicalize(artifact)
signature = key.sign(payload) # Ed25519 signs the message directly, no pre-hash
AUDIT_LOGGER.info("Signed %d-byte payload | sig len=%d", len(payload), len(signature))
return signature
Note that Ed25519PrivateKey.sign takes the message directly — there is no separate hashing step to configure, because EdDSA hashes internally. Fetch private_key_pem inside the request scope and let it fall out of memory immediately; do not cache it in a module global.
Step 4: Package the detached signature with key-id and algorithm
A bare 64-byte signature is useless to a future auditor who does not know which key or which algorithm produced it. Wrap it in a small envelope that names the signing key-id, the algorithm, and the signing timestamp, with the signature base64-encoded for safe storage in JSON or a text column. Store this envelope alongside the artifact — same row, same object prefix, same archive — but never inside the artifact bytes you signed.
def build_signature_envelope(
signature: bytes, key_id: str, artifact_id: str
) -> dict[str, str]:
"""Wrap a detached signature with the metadata a verifier needs years later."""
if not key_id:
raise SignatureError("key_id is required to resolve the public key later")
envelope = {
"artifact_id": artifact_id,
"algorithm": ALGORITHM,
"key_id": key_id,
"signature_b64": base64.b64encode(signature).decode("ascii"),
"signed_at": datetime.now(timezone.utc).isoformat(),
}
AUDIT_LOGGER.info("Envelope built | artifact=%s | key_id=%s", artifact_id, key_id)
return envelope
The key_id is the join key a verifier uses to look up the matching public key in your published directory, so it must be stable and unique per key pair — a fingerprint of the public key works well.
Step 5: Rotate keys with overlapping validity
Keys must rotate on a schedule (annually is a common baseline), but rotating naively invalidates every historical signature. The fix is overlapping validity: each public key carries a not_before/not_after window, new artifacts are signed with the current key, and old public keys stay published so their historical signatures still verify. A verifier picks the public key by the envelope’s key_id, not by “the newest key.”
def resolve_public_key(
key_id: str, key_directory: Mapping[str, dict[str, Any]]
) -> Ed25519PublicKey:
"""Resolve the published public key for a signature's key_id."""
from cryptography.hazmat.primitives.serialization import load_pem_public_key
record = key_directory.get(key_id)
if record is None:
raise SignatureError(f"no published key for key_id={key_id}")
key = load_pem_public_key(record["public_key_pem"].encode("utf-8"))
if not isinstance(key, Ed25519PublicKey):
raise SignatureError(f"key_id={key_id} is not Ed25519")
return key
Because verification keys are selected by key_id, retiring a key means marking it “no longer used for new signatures” — never deleting it. A deleted public key silently strands every artifact it ever signed, which is exactly the record loss 2 CFR §200.303 internal controls are meant to prevent.
Step 6: Verify at audit time
Verification re-runs the identical canonicalization, resolves the public key from the envelope’s key_id, and checks the signature. Ed25519PublicKey.verify raises InvalidSignature on any mismatch — return a clean boolean and log the outcome, but never swallow the exception into a bare except.
def verify_artifact(
artifact: Mapping[str, Any],
envelope: Mapping[str, str],
key_directory: Mapping[str, dict[str, Any]],
) -> bool:
"""Verify a detached signature against the re-serialized artifact."""
if envelope.get("algorithm") != ALGORITHM:
raise SignatureError(f"unexpected algorithm {envelope.get('algorithm')!r}")
public_key = resolve_public_key(envelope["key_id"], key_directory)
payload = canonicalize(artifact)
signature = base64.b64decode(envelope["signature_b64"])
try:
public_key.verify(signature, payload)
except InvalidSignature:
AUDIT_LOGGER.warning(
"INVALID signature | artifact=%s | key_id=%s",
envelope.get("artifact_id"), envelope.get("key_id"),
)
return False
AUDIT_LOGGER.info("VALID signature | artifact=%s", envelope.get("artifact_id"))
return True
If either the artifact bytes changed by a single character or the wrong public key is resolved, verify raises InvalidSignature and the function returns False. That is the non-repudiation guarantee an auditor relies on, satisfying the modification-protection intent of NIST SP 800-53 AU-9.
Verification
Confirm the signing path behaves correctly with four checks:
- An untampered round trip verifies. Sign an artifact, then verify the unchanged artifact against its envelope and assert the result is
True. This proves the canonicalization used on both sides is identical. - A single-byte edit fails verification. Change one field value in the artifact after signing and assert
verify_artifactreturnsFalse— never raises unhandled. This is the tamper-evidence property. - Key reordering does not break the signature. Rebuild the same artifact dictionary with its keys inserted in a different order and assert it still verifies, proving
sort_keys=Trueremoved ordering as a variable. - A wrong key resolves to invalid, not a crash. Point a valid signature at a different
key_id’s public key and assert it returnsFalse.
A valid verification emits one INFO audit line; a tampered or wrong-key artifact emits a WARNING. Ship those logs to the write-once tier maintained by Building an Append-Only Audit Ledger in Postgres so the verification history is itself audit-protected.
artifact = {"artifact_id": "closeout-2026-014", "amount": "482000.00", "fy": 2026}
priv = load_signing_key_pem_from_vault() # bytes, never inline
sig = sign_artifact(artifact, priv)
env = build_signature_envelope(sig, key_id="ed25519-2026", artifact_id="closeout-2026-014")
directory = {"ed25519-2026": {"public_key_pem": published_pub_pem}}
assert verify_artifact(artifact, env, directory) is True
tampered = {**artifact, "amount": "999999.00"}
assert verify_artifact(tampered, env, directory) is False
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
InvalidSignature on an untouched artifact |
Verification serialized the artifact differently than signing (key order, whitespace, or ASCII escaping) | Call the exact same canonicalize on both sides; never re-json.dumps with default settings during verify. |
SignatureError: invalid private key material |
Key loaded from the wrong secret, PEM truncated, or password-protected key passed with password=None |
Fetch the correct secret from the vault; supply the passphrase to load_pem_private_key if the key is encrypted. |
SignatureError: no published key for key_id |
The signing key was deleted or never published to the verifier directory | Keep retired public keys published with an expiry window; resolve by key_id, never delete. |
| Signature verifies but non-repudiation is disputed | Same key shared across environments, so “who signed” is ambiguous | Use a distinct key pair per environment and signer, and record the key_id in the envelope and ledger. |
TypeError: Object of type Decimal is not JSON serializable |
Artifact holds Decimal, datetime, or bytes that json.dumps cannot encode |
Coerce to canonical strings before signing (e.g. money as a fixed-precision string) so bytes are stable. |
Related
- Parent section: Audit Trail & Evidence Automation
- Where signature-verification events are recorded immutably: Building an Append-Only Audit Ledger in Postgres
- How long signed artifacts and keys must be retained: Automating 2 CFR 200 Record Retention Schedules
- The field structure of the artifact you are signing: Designing a Compliance Artifact Metadata Schema