Cryptographic manifest signing for access exceptions
This guide shows how to sign the exception manifest that suppresses RBAC drift, so the pipeline suppresses only on exceptions that were approved through your change process and have not been altered since — verified with Ed25519 at the moment a delta is matched.
An unsigned whitelist is an attractive target. Anyone who can write to the file or table the drift engine reads can add a line that silences the alert for a privilege they just granted themselves. Scoring, routing, and expiry all trust that manifest; if its integrity is not enforced, they are enforcing nothing. The fix is to make the manifest a signed artifact: approvals produce a detached Ed25519 signature over a canonical serialization, and the engine refuses to honor any exception whose manifest does not verify against a trusted public key. This is the exception-side complement to the broader evidence-signing work in evidence integrity and signing — here the signed object is the live suppression list, not an after-the-fact audit record. It sits directly on top of exception routing and whitelisting.
When to use this — and when not to
Sign the exception manifest when:
- The manifest can be edited by more people, or more automation, than are authorized to approve exceptions — the moment write access and approval authority diverge, you need cryptographic binding.
- Suppression decisions must be defensible to an auditor who asks “prove this exception was approved and not tampered with after the fact.”
- You already run an approval workflow that can hold a signing key, so signing is a natural terminal step rather than a bolt-on.
Do not bother, or defer, when:
- The manifest lives in a repository where every merge is already signed and access-controlled, and the drift engine reads only from that verified commit — you may already have integrity from the VCS. Signing still adds match-time verification, but the urgency is lower.
- You have no secure place to hold a private key. A signing key on the same host as the drift engine, readable by the same service, buys almost nothing — solve custody first.
- You need confidentiality of the exceptions, not just integrity. Signing proves authorship and detects tampering; it does not encrypt. Pair it with transport/at-rest encryption if the contents are sensitive.
Step-by-step implementation
Step 1 — Canonicalize the manifest and sign it
A signature is only meaningful over exact bytes. Serialize the manifest deterministically — sorted keys, no insignificant whitespace, UTF-8 — so the same logical content always produces the same bytes to sign and verify. The manifest carries its own version and expires_at inside the signed payload, so those fields cannot be edited without breaking the signature.
import json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def canonical_bytes(manifest: dict) -> bytes:
"""Deterministic serialization: identical content -> identical bytes."""
return json.dumps(manifest, sort_keys=True, separators=(",", ":"),
ensure_ascii=False).encode("utf-8")
def sign_manifest(manifest: dict, private_key: Ed25519PrivateKey) -> bytes:
# Ed25519 signs the message directly — no separate hash or algorithm arg.
return private_key.sign(canonical_bytes(manifest))
Verify that signing then verifying round-trips, and that any edit invalidates the signature:
key = Ed25519PrivateKey.generate()
manifest = {"version": 7, "expires_at": "2026-07-19T00:00:00Z",
"exceptions": [{"principal": "ci_migrator", "privilege": "INSERT",
"object_scope": "orders.line_items"}]}
sig = sign_manifest(manifest, key)
key.public_key().verify(sig, canonical_bytes(manifest)) # no exception -> valid
tampered = {**manifest, "version": 8} # any change...
# key.public_key().verify(sig, canonical_bytes(tampered)) # ...raises InvalidSignature
print(len(sig)) # -> 64
Step 2 — Verify before any exception may suppress
The drift engine loads the manifest bytes, the detached signature, and a trusted public key it obtained out-of-band (not from the manifest itself — a self-described key proves nothing). Verification happens at match time and gates suppression: on any failure the engine treats the whole manifest as absent and lets the delta score normally.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from datetime import datetime, timezone
def load_trusted_manifest(raw: bytes, sig: bytes,
public_key: Ed25519PublicKey,
min_version: int,
now: datetime | None = None) -> dict | None:
now = now or datetime.now(timezone.utc)
try:
public_key.verify(sig, raw) # integrity + authenticity
except InvalidSignature:
return None # tampered or wrong key
manifest = json.loads(raw)
if manifest["version"] < min_version:
return None # replay of an older manifest
if datetime.fromisoformat(manifest["expires_at"]) <= now:
return None # stale manifest, refuse it
return manifest
Confirm the three rejection paths return None (no suppression) rather than raising into the hot path:
pub = key.public_key()
raw = canonical_bytes(manifest)
assert load_trusted_manifest(raw, sig, pub, min_version=7) is not None
assert load_trusted_manifest(raw, sig, pub, min_version=8) is None # replay
assert load_trusted_manifest(raw + b" ", sig, pub, min_version=7) is None # tamper
Step 3 — Bind key custody and defend against replay
The private key must live where the drift engine cannot reach it — an HSM, a KMS signing operation, or an offline approver workstation — so a compromise of the detection host cannot forge exceptions. Distribute only the 32-byte raw public key to verifiers, and defend against a valid-but-old manifest being replayed by tracking a monotonic version high-water mark.
from cryptography.hazmat.primitives import serialization
def export_public_key(private_key: Ed25519PrivateKey) -> bytes:
return private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw) # 32 bytes, distribute widely
class VersionGuard:
"""Reject any manifest at or below the highest version already accepted."""
def __init__(self, seen: int = 0):
self._seen = seen
def accept(self, version: int) -> bool:
if version <= self._seen:
return False # replay / rollback attempt
self._seen = version
return True
Expected behavior of the guard across a rotation from version 7 to 8 and a replay of 7:
>>> g = VersionGuard()
>>> g.accept(7), g.accept(8), g.accept(7)
(True, True, False)
Worked example: catching a forged suppression on PostgreSQL 15
Scenario: PostgreSQL 15. An attacker with write access to the manifest store adds an exception whitelisting ALL PRIVILEGES on cust.cards for a role they control, then re-serializes the file. They cannot sign it — the private key is held by the approval service in a KMS. The drift engine loads the modified bytes against the unchanged detached signature.
signed = {"version": 12, "expires_at": "2026-07-19T00:00:00Z",
"exceptions": [{"principal": "ci_migrator", "privilege": "INSERT",
"object_scope": "orders.line_items"}]}
sig = sign_manifest(signed, key) # produced by the KMS in prod
forged = {**signed, "exceptions": signed["exceptions"] + [
{"principal": "attacker", "privilege": "ALL PRIVILEGES",
"object_scope": "cust.cards"}]}
result = load_trusted_manifest(canonical_bytes(forged), sig,
key.public_key(), min_version=12)
print(result)
Expected output:
None
Because the forged manifest’s bytes no longer match the signature, load_trusted_manifest returns None. The engine proceeds as if no exceptions exist, so the attacker’s ALL PRIVILEGES grant on cust.cards is scored at full severity and pages — exactly the escalation the whitelist was supposed to be trusted to suppress, now un-suppressible without a real approval. The legitimate signed manifest continues to verify and suppress its one genuine INSERT exception.
Gotchas and engine-specific notes
Canonicalization is load-bearing. If you sign pretty-printed JSON and verify re-serialized JSON, verification fails on formatting alone — or worse, someone “fixes” it by relaxing the check. Fix the serialization instead: sort_keys=True, separators=(",", ":"), explicit UTF-8, on both sides. Sign the exact bytes you store.
Never take the public key from the manifest. A manifest that names its own verifying key can be re-signed by any key an attacker chooses. The trusted public key is configuration delivered out-of-band; the manifest is only data.
Ed25519 signs the message, not a hash. Unlike RSA-PSS or ECDSA workflows, the cryptography Ed25519 API takes the raw message to sign() and verify() and does the hashing internally — do not pre-hash, and do not pass a hash-algorithm argument. See the PyCA cryptography Ed25519 docs.
This overlaps with, but is not, evidence signing. The detached-signature and key-custody mechanics mirror signing compliance manifests with Ed25519; reuse that key-management guidance rather than re-deriving it. The distinction is timing and purpose: evidence signing seals a record after the fact, while exception signing gates a live control decision before suppression happens.
Engine independence. The manifest gates suppression regardless of backend — the same signed list governs PostgreSQL grants read from information_schema.role_table_grants and MySQL 8 grants read from mysql.role_edges. Sign the logical exception (canonical principal, privilege, scope) so one manifest covers both engines; the exception routing and whitelisting layer resolves each to its engine-specific grant.
Compliance note
Signing the exception manifest gives auditors cryptographic assurance that every suppressed finding was authorized: a valid Ed25519 signature over a versioned manifest ties each suppression to an approval that controlled the private key. This supports SOC 2 CC7.2 (detection of unauthorized changes) and NIST SP 800-53 SI-7 (software, firmware, and information integrity) by making tampering detectable rather than silent, and AC-3 (access enforcement) by ensuring only approved exceptions relax the control. The retained artifact — manifest bytes, signature, verifying key id, and version high-water mark — lets a reviewer independently re-verify any historical suppression decision offline.
Frequently asked questions
Why sign the whole manifest instead of each exception row?
Signing the whole versioned manifest prevents an attacker from deleting or reordering approved rows, not just editing one. A monotonic version on the signed payload also lets you detect replay of an entire older manifest, which per-row signatures alone miss.
Is Ed25519 the right choice over RSA or ECDSA here? For this use case yes: keys and signatures are small (32 and 64 bytes), verification is fast enough to run on every match, and the API resists the misuse that plagues ECDSA nonces. RSA offers no advantage for signing a short manifest.
How does this differ from signing audit evidence? Evidence signing seals an immutable record after an event for later proof. Exception signing gates a live decision — whether to suppress drift right now — so verification must happen at match time on every run, and expiry and replay defense matter more than long-term archival.
What happens during key rotation? Publish the new public key to verifiers before the approval service starts signing with the new private key, and accept both keys during the overlap window. Because verification is keyed and the manifest carries a version, a manifest signed by the retired key still verifies until it expires and its version is superseded.
Related
- Exception Routing and Whitelisting — the whitelist layer whose manifest this signs
- Signing Compliance Manifests with Ed25519 — the shared key-custody and signing mechanics for evidence
- Evidence Integrity and Signing — the broader integrity model these signatures fit into