"""Ed25519 signing + provenance for plugin artifacts (ADR-013 Phase 1). A plugin's provenance is a per-file SHA-256 map plus metadata (name, version, publisher, created). The detached signature covers the EXACT serialized provenance bytes, so verifying a plugin is: re-hash its files, re-serialize the provenance the same way, and check the signature. Any changed byte in any file changes a hash, which changes the serialized provenance, which fails the signature. The signature proves the artifact is exactly what a curator reviewed and signed - nothing about what the code does. Uses the cryptography package (already a dependency for MySQL 8 auth). """ import hashlib import json from datetime import datetime, timezone from pathlib import Path from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey PROVENANCE_NAME = 'PROVENANCE.json' PROVENANCE_SIG = 'PROVENANCE.sig' # Never packed, hashed, or counted as an unexpected artifact member. _EXCLUDE_DIRS = {'__pycache__', '.pytest_cache', '.mypy_cache'} _EXCLUDE_SUFFIXES = {'.pyc', '.pyo'} _CHUNK = 65536 def generate_keypair(): """Return (private_pem, public_pem) as PEM bytes for a new ed25519 key.""" private_key = Ed25519PrivateKey.generate() private_pem = private_key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), ) public_pem = private_key.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, ) return private_pem, public_pem def load_private_key(pem_bytes: bytes): return serialization.load_pem_private_key(pem_bytes, password=None) def load_public_key(pem_bytes: bytes): return serialization.load_pem_public_key(pem_bytes) def load_trusted_keys(pem_paths): """Load public keys from a list of PEM file paths. Missing/unreadable paths are skipped (they simply cannot vouch for a signature).""" keys = [] for path in pem_paths or []: try: keys.append(load_public_key(Path(path).read_bytes())) except (OSError, ValueError): continue return keys def _packable(plugin_dir: Path): """Yield the plugin's real files (sorted, posix relpaths excluded from noise).""" for path in sorted(plugin_dir.rglob('*')): if not path.is_file(): continue rel = path.relative_to(plugin_dir) if any(part in _EXCLUDE_DIRS for part in rel.parts): continue if path.suffix in _EXCLUDE_SUFFIXES: continue if path.name in (PROVENANCE_NAME, PROVENANCE_SIG): continue yield rel.as_posix(), path def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with open(path, 'rb') as handle: for chunk in iter(lambda: handle.read(_CHUNK), b''): digest.update(chunk) return digest.hexdigest() def build_file_map(plugin_dir) -> dict: """{posix relpath: sha256hex} for every packable file, sorted.""" plugin_dir = Path(plugin_dir) return {relpath: _sha256_file(path) for relpath, path in _packable(plugin_dir)} def build_provenance(plugin_dir, name: str, version: str, publisher: str = '', created: str = None) -> dict: """Provenance dict. `created` defaults to now (UTC, second precision).""" if created is None: created = datetime.now(timezone.utc).replace( microsecond=0, tzinfo=None).isoformat() return { 'name': name, 'version': version, 'publisher': publisher or '', 'created': created, 'files': build_file_map(plugin_dir), } def serialize_provenance(provenance: dict) -> bytes: """Canonical bytes that get signed AND stored, so sign and verify agree. sort_keys + compact separators make this deterministic; the same dict always serializes to the same bytes regardless of insertion order. """ return json.dumps( provenance, sort_keys=True, separators=(',', ':')).encode('utf-8') def sign(private_key, data: bytes) -> bytes: return private_key.sign(data) def verify(public_keys, data: bytes, signature: bytes) -> bool: """True if the signature validates against ANY trusted public key. Multiple keys support overlap during key rotation. """ for key in public_keys: try: key.verify(signature, data) return True except InvalidSignature: continue return False