audit-labs/evidence-seal

Tamper-evident seals and chain of custody for audit evidence.

clone: git clone https://gitbay.org/audit-labs/evidence-seal.git

v0.1.0: evidence_seal/signing.py · raw

  1"""Optional ed25519 signing of manifests.
  2
  3Signing adds attribution — *who* sealed the evidence — on top of the integrity
  4the hashes already provide. It requires the ``cryptography`` package (install
  5``evidence-seal[sign]``); the core seal/verify path never imports this module.
  6
  7The signature covers the manifest's canonical bytes (the same bytes its id is
  8derived from), so a valid signature vouches for every file hash and all
  9metadata at once.
 10"""
 11
 12from __future__ import annotations
 13
 14from datetime import datetime, timezone
 15from pathlib import Path
 16
 17from .manifest import canonical_bytes
 18
 19
 20def _require_crypto():
 21    try:
 22        from cryptography.hazmat.primitives.asymmetric import ed25519
 23    except ImportError as exc:  # pragma: no cover - exercised via a clear message
 24        raise RuntimeError(
 25            "signing requires the 'cryptography' package — install evidence-seal[sign]"
 26        ) from exc
 27    return ed25519
 28
 29
 30def generate_keypair(private_path: str | Path, public_path: str | Path) -> None:
 31    """Write a new ed25519 keypair as PEM files (private key is unencrypted)."""
 32    ed25519 = _require_crypto()
 33    from cryptography.hazmat.primitives import serialization
 34
 35    private = ed25519.Ed25519PrivateKey.generate()
 36    Path(private_path).write_bytes(
 37        private.private_bytes(
 38            encoding=serialization.Encoding.PEM,
 39            format=serialization.PrivateFormat.PKCS8,
 40            encryption_algorithm=serialization.NoEncryption(),
 41        )
 42    )
 43    Path(public_path).write_bytes(
 44        private.public_key().public_bytes(
 45            encoding=serialization.Encoding.PEM,
 46            format=serialization.PublicFormat.SubjectPublicKeyInfo,
 47        )
 48    )
 49
 50
 51def _public_hex(public_key) -> str:
 52    from cryptography.hazmat.primitives import serialization
 53
 54    raw = public_key.public_bytes(
 55        encoding=serialization.Encoding.Raw,
 56        format=serialization.PublicFormat.Raw,
 57    )
 58    return raw.hex()
 59
 60
 61def sign_manifest(manifest: dict, private_key_path: str | Path) -> dict:
 62    """Return a copy of *manifest* with a ``signature`` block attached."""
 63    _require_crypto()
 64    from cryptography.hazmat.primitives import serialization
 65
 66    private = serialization.load_pem_private_key(
 67        Path(private_key_path).read_bytes(), password=None
 68    )
 69    signature = private.sign(canonical_bytes(manifest))
 70    signed = dict(manifest)
 71    signed["signature"] = {
 72        "algorithm": "ed25519",
 73        "public_key": _public_hex(private.public_key()),
 74        "value": signature.hex(),
 75        "signed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
 76    }
 77    return signed
 78
 79
 80def verify_signature(manifest: dict, expected_public_key: str | None = None) -> tuple[bool, str]:
 81    """Verify a manifest's signature.
 82
 83    Returns ``(ok, message)``. If *expected_public_key* (hex) is given, the
 84    signer's key must also match it — otherwise a valid signature by *any* key
 85    would pass, which proves integrity but not identity.
 86    """
 87    ed25519 = _require_crypto()
 88
 89    block = manifest.get("signature")
 90    if not block:
 91        return False, "manifest is not signed"
 92    if block.get("algorithm") != "ed25519":
 93        return False, f"unsupported signature algorithm: {block.get('algorithm')}"
 94
 95    signer_key = block.get("public_key", "")
 96    if expected_public_key and signer_key != expected_public_key:
 97        return False, "signer key does not match the expected public key"
 98
 99    try:
100        public = ed25519.Ed25519PublicKey.from_public_bytes(bytes.fromhex(signer_key))
101        public.verify(bytes.fromhex(block["value"]), canonical_bytes(manifest))
102    except Exception as exc:
103        return False, f"signature is invalid: {exc}"
104    return True, f"valid ed25519 signature by {signer_key[:16]}"
105
106
107def load_public_hex(public_key_path: str | Path) -> str:
108    """Load a PEM public key and return its raw hex form (for --pubkey checks)."""
109    _require_crypto()
110    from cryptography.hazmat.primitives import serialization
111
112    public = serialization.load_pem_public_key(Path(public_key_path).read_bytes())
113    return _public_hex(public)