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
v1.0.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)