Signing Backup Manifests with Python cryptography
This page builds the concrete module behind the parent guide on cryptographic manifest signing: a single runnable tool that hashes a set of backup artifacts into a canonical manifest, seals that manifest with a detached Ed25519 signature, and — on the restore side — refuses to proceed unless both the signature and every per-artifact digest verify. It sits one layer above the checksum validation pipeline, because a digest list is only trustworthy once the manifest carrying it is proven authentic, and every sign and verify event it emits is destined for immutable audit trails so the chain of custody survives review. Verification failures are surfaced with distinct exit codes so the orchestrator can route a forged manifest and a bit-rotted artifact through the same error categorization framework as separate events.
Architecture and Execution Model
Figure. The signer hashes and canonicalizes the manifest then seals it; the verifier re-checks the detached signature and every digest before returning a POSIX exit code the orchestrator gates on.
The signing and verifying halves live in one module but run in different trust zones. Signing runs once on a host that holds the private key; verifying runs on every drill runner with only the public key, and returns 0 (verified), 1 (bad signature or a mismatched digest), or 2 (usage error).
Prerequisites
-
Python 3.8+.
-
The
cryptographylibrary for Ed25519 key handling and signing:bash pip install cryptography -
A generated key pair. Generate once, store the private key on the signing host (offline or HSM-backed in production), and distribute the public key to every drill runner:
bash python3 manifest_sign.py keygen ./keys/backup_ed25519 -
Read access to the backup artifacts on the signer, and to both the manifest and its
.sigon each verifier.
Production Implementation
The module has one canonicalization function shared by both sides, a manifest builder that streams each artifact through SHA-256, and three subcommands: keygen, sign, and verify. Canonicalization is the load-bearing detail — signer and verifier must serialize the manifest to byte-identical output or every verification fails spuriously.
#!/usr/bin/env python3
"""Sign and verify backup manifests with detached Ed25519 signatures.
Subcommands:
keygen <keybase> write <keybase>.key (private) + <keybase>.pub
sign <root> <keybase.key> hash artifacts under <root>, sign the manifest
verify <manifest> <sig> <pub> verify signature, then every artifact digest
Exit codes:
0 manifest signature valid AND every artifact digest matches
1 bad signature, or a digest/size mismatch, or a missing artifact
2 usage / configuration error
"""
import hashlib
import json
import sys
import time
from pathlib import Path
from typing import Dict, Tuple
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
READ_CHUNK = 1024 * 1024 # 1 MiB streaming window keeps the heap flat
def canonicalize(manifest: dict) -> bytes:
"""Deterministic signing bytes: sorted keys, no incidental whitespace, UTF-8."""
return json.dumps(
manifest,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
def hash_file(path: Path) -> Tuple[str, int]:
"""Stream a file through SHA-256; return (hexdigest, byte size)."""
h = hashlib.sha256()
size = 0
with path.open("rb") as fh:
for block in iter(lambda: fh.read(READ_CHUNK), b""):
h.update(block)
size += len(block)
return h.hexdigest(), size
def build_manifest(root: Path, key_id: str) -> dict:
"""Hash every regular file under root into a manifest keyed by relative path."""
artifacts: Dict[str, dict] = {}
for path in sorted(p for p in root.rglob("*") if p.is_file()):
digest, size = hash_file(path)
artifacts[str(path.relative_to(root))] = {"sha256": digest, "size": size}
return {
"schema": "bv-manifest/1",
"backup_epoch": int(time.time()),
"key_id": key_id,
"artifacts": artifacts,
}
The keygen subcommand writes the private key in encrypted-capable PKCS8 PEM and the public key in SubjectPublicKeyInfo PEM. In production the private key belongs on an HSM or air-gapped host; here it is written to disk with restrictive permissions so the module is runnable end to end.
def cmd_keygen(keybase: str) -> int:
private_key = Ed25519PrivateKey.generate()
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,
)
key_path = Path(f"{keybase}.key")
key_path.write_bytes(priv_pem)
key_path.chmod(0o600)
Path(f"{keybase}.pub").write_bytes(pub_pem)
print(f"wrote {keybase}.key (private, 0600) and {keybase}.pub (public)")
return 0
def cmd_sign(root: str, key_file: str) -> int:
root_path = Path(root)
if not root_path.is_dir():
print(f"artifact root {root!r} is not a directory", file=sys.stderr)
return 2
priv = serialization.load_pem_private_key(Path(key_file).read_bytes(), password=None)
if not isinstance(priv, Ed25519PrivateKey):
print("key file is not an Ed25519 private key", file=sys.stderr)
return 2
manifest = build_manifest(root_path, key_id=Path(key_file).stem)
signature = priv.sign(canonicalize(manifest))
manifest_path = root_path / "manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
(root_path / "manifest.json.sig").write_bytes(signature)
print(f"signed {len(manifest['artifacts'])} artifacts -> {manifest_path}")
return 0
Note that cmd_sign writes a human-readable pretty-printed manifest.json for operators, but the signature is computed over canonicalize(manifest), not over the pretty bytes. The verifier reloads the JSON and re-canonicalizes it, so the on-disk formatting is free to be readable without affecting the signature.
def cmd_verify(manifest_file: str, sig_file: str, pub_file: str) -> int:
try:
manifest = json.loads(Path(manifest_file).read_text(encoding="utf-8"))
signature = Path(sig_file).read_bytes()
pub = serialization.load_pem_public_key(Path(pub_file).read_bytes())
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"config error: {exc}", file=sys.stderr)
return 2
if not isinstance(pub, Ed25519PublicKey):
print("public key file is not an Ed25519 key", file=sys.stderr)
return 2
# 1. Signature gate: reject untrusted manifests before reading any digest.
try:
pub.verify(signature, canonicalize(manifest))
except InvalidSignature:
print("SIGNATURE INVALID -> manifest untrusted, refusing restore",
file=sys.stderr)
return 1
print(f"signature valid (key_id={manifest.get('key_id')})")
# 2. Digest gate: every artifact must still reproduce its trusted digest.
root = Path(manifest_file).resolve().parent
bad = 0
for rel, spec in manifest["artifacts"].items():
artifact = root / rel
if not artifact.is_file():
print(f"MISSING {rel}", file=sys.stderr)
bad += 1
continue
digest, size = hash_file(artifact)
if digest != spec["sha256"] or size != spec["size"]:
print(f"DIGEST MISMATCH {rel}", file=sys.stderr)
bad += 1
else:
print(f"OK {rel}")
return 1 if bad else 0
def main(argv: list) -> int:
if len(argv) >= 2 and argv[1] == "keygen" and len(argv) == 3:
return cmd_keygen(argv[2])
if len(argv) >= 2 and argv[1] == "sign" and len(argv) == 4:
return cmd_sign(argv[2], argv[3])
if len(argv) >= 2 and argv[1] == "verify" and len(argv) == 5:
return cmd_verify(argv[2], argv[3], argv[4])
print("usage: manifest_sign.py keygen <keybase> | "
"sign <root> <key.key> | verify <manifest> <sig> <pub>",
file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))
The verifier’s two-gate structure is deliberate. The signature gate runs first and returns before a single digest is read, because the digest list is untrusted input until the signature proves it authentic. Only then does the digest gate re-hash each artifact against the now-trusted manifest, exactly the check the checksum validation pipeline performs — the signing layer’s job is to guarantee the digests it checks against were not themselves rewritten.
Step-by-Step Execution Walkthrough
- Generate a key pair.
python3 manifest_sign.py keygen ./keys/backup_ed25519writes a0600private key and a public key. Do this once; keep the private key off the runners. - Sign a backup.
python3 manifest_sign.py sign /backups/2026-07-18 ./keys/backup_ed25519.keyhashes every file under the root and writesmanifest.jsonplusmanifest.json.sigalongside them. - Distribute the public key. Copy
backup_ed25519.pubto each drill runner; the private key never leaves the signer. - Verify before restore.
python3 manifest_sign.py verify /backups/2026-07-18/manifest.json /backups/2026-07-18/manifest.json.sig ./keys/backup_ed25519.pub. - Read the exit code.
echo $?returns0only when the signature and every digest verify;1on any tampering or missing artifact;2on a usage error.
Verification and Expected Output
A clean verification prints the signature confirmation, one line per artifact, and exits 0:
$ python3 manifest_sign.py verify backup/manifest.json backup/manifest.json.sig keys/backup_ed25519.pub
signature valid (key_id=backup_ed25519)
OK base.tar.zst
OK wal/000000010000002B.gz
$ echo $?
0
If an attacker edits an artifact or a digest after signing, the signature gate fails before any digest is even checked, and the runner refuses the restore:
$ python3 manifest_sign.py verify backup/manifest.json backup/manifest.json.sig keys/backup_ed25519.pub
SIGNATURE INVALID -> manifest untrusted, refusing restore
$ echo $?
1
Success means both gates passed: a 0 exit certifies that the manifest was produced by the key holder and that every stored artifact still reproduces the digest that key sealed.
Failure Modes and Troubleshooting
| Symptom | Cause | Remediation |
|---|---|---|
SIGNATURE INVALID on an untouched manifest |
Signer and verifier canonicalized differently | Ensure both run the identical canonicalize function; never sign the pretty-printed bytes |
Exit 2, public key file is not an Ed25519 key |
Wrong or truncated public key PEM distributed to the runner | Redistribute the .pub written by keygen; confirm it is SubjectPublicKeyInfo PEM |
DIGEST MISMATCH after a valid signature |
Artifact bytes changed since signing (bit rot or edit) | Quarantine the artifact; the signature proves the manifest is authentic, so the stored file is at fault |
MISSING for an artifact present in the manifest |
Artifact deleted or an incomplete transfer to the runner | Re-sync the backup set; a missing artifact fails the drill exactly like a mismatch |
Exit 2, key file is not an Ed25519 private key |
RSA or wrong-format key passed to sign |
Regenerate with keygen, or adapt the loader for your RSA-PSS regime |
Signature verifies but key_id is unexpected |
Manifest signed by a rotated or unknown key | Check the key_id against your published key registry and revocation list before trusting it |
Integration Notes
Because verify returns clean POSIX codes, it drops into any scheduler as a gate. In an Airflow DAG, a BashOperator runs verify as the first task in the restore branch; a non-zero exit fails the task and short-circuits sandbox provisioning so no environment is stood up around an untrusted manifest. Under Celery, dispatch verify from a task that raises on non-zero, giving event-driven drills a low-latency trust check the moment a backup lands. Route the distinction between a SIGNATURE INVALID result and a DIGEST MISMATCH into your error categorization framework: the first signals possible tampering of the ledger and pages on-call, while the second signals storage-level corruption of a single artifact — different incidents that must not collapse into one alert. Handle key rotation by signing new manifests under a new key_id while keeping every retired .pub published, so historical manifests keep verifying against the key that sealed them.
Frequently Asked Questions
Why verify the signature before checking the artifact digests?
The digest list is only trustworthy if the manifest carrying it is authentic. If the verifier checked digests first, it would be comparing artifacts against numbers an attacker could have rewritten. Verifying the Ed25519 signature first proves the whole digest list came from the key holder, and only then is it safe to re-hash each artifact against those trusted numbers.
Why is the signature detached instead of embedded in the manifest?
A detached signature lives in a separate .sig file, so the bytes that were signed are exactly the manifest bytes on disk with nothing to strip out first. Embedding the signature inside the manifest would mean the signed content includes a placeholder for its own signature, which forces a fragile remove-then-canonicalize step on every verify. Detachment keeps the signed input unambiguous.
Can the manifest.json stay human-readable if it is signed?
Yes. The signer writes a pretty-printed manifest.json for operators but signs canonicalize(manifest), not the pretty bytes. The verifier reloads the JSON and re-canonicalizes it before checking the signature, so on-disk indentation and key order in the readable file never affect the verdict.
How do I rotate the signing key without breaking old backups?
Generate a new key pair, start signing new manifests under its key_id, and keep every retired public key published to the runners. Because each manifest names the key that signed it, a verifier selects the matching .pub by key_id, so manifests sealed under the previous key keep verifying indefinitely.
Related
- Cryptographic manifest signing — the parent guide covering key custody, rotation, and countersignatures in depth.
- Checksum validation pipelines — the digest-comparison layer this module makes trustworthy by sealing its manifest.
- Immutable audit trails — where each sign and verify event is preserved for forensic review.
- Error categorization frameworks — routing a bad signature and a mismatched digest as distinct incidents.
This module is one component of the broader cryptographic manifest signing guide. For authoritative API behavior, consult the cryptography Ed25519 documentation and the Python hashlib documentation.