Cryptographically Signing Grant Compliance Artifacts

Produce detached Ed25519 signatures over grant compliance artifacts with canonical JSON serialization, vault-held keys, overlapping rotation, and audit-time verification aligned to NIST SP 800-53 AU-9, SC-13, and 2 CFR §200.303.

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.

Detached Ed25519 signing and verification path for a compliance artifact On the signing path, a compliance artifact dictionary is serialized to canonical JSON with sorted keys and fixed separators, then signed with an Ed25519 private key loaded from a secrets vault, producing a detached signature envelope that carries the key-id and algorithm and is stored alongside the artifact. On the verification path used at audit time, the published Ed25519 public key, the re-serialized artifact, and the stored signature enter a verify step that resolves to either valid or invalid; a mismatch in either bytes or key produces invalid. Sign once, verify forever Canonical bytes make the signature reproducible; the private key never leaves the vault. Artifact dict / mapping Canonical JSON sorted keys · UTF-8 Ed25519 sign private key Detached signature key-id · algorithm Secrets vault / HSM never in code Public key published for verifiers verify at audit Valid Invalid vault key signature match mismatch

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.

bash
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.

python
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.

python
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.

python
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.

python
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.”

python
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.

python
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:

  1. 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.
  2. A single-byte edit fails verification. Change one field value in the artifact after signing and assert verify_artifact returns False — never raises unhandled. This is the tamper-evidence property.
  3. 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=True removed ordering as a variable.
  4. A wrong key resolves to invalid, not a crash. Point a valid signature at a different key_id’s public key and assert it returns False.

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.

python
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.