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

Five-stage manifest signing and verification A vertical flow: hash the artifacts into a manifest, canonicalize the JSON, produce a detached Ed25519 signature, verify the signature and every digest on the restore side, then emit a POSIX exit code. Hash artifacts into manifest Canonicalize JSON Ed25519 sign (detached) Verify signature + digests Emit exit code

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 cryptography library 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 .sig on 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.

python
#!/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.

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

python
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

  1. Generate a key pair. python3 manifest_sign.py keygen ./keys/backup_ed25519 writes a 0600 private key and a public key. Do this once; keep the private key off the runners.
  2. Sign a backup. python3 manifest_sign.py sign /backups/2026-07-18 ./keys/backup_ed25519.key hashes every file under the root and writes manifest.json plus manifest.json.sig alongside them.
  3. Distribute the public key. Copy backup_ed25519.pub to each drill runner; the private key never leaves the signer.
  4. 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.
  5. Read the exit code. echo $? returns 0 only when the signature and every digest verify; 1 on any tampering or missing artifact; 2 on a usage error.

Verification and Expected Output

A clean verification prints the signature confirmation, one line per artifact, and exits 0:

text
$ 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:

text
$ 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.

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.