"""Pack and verify signed plugin artifacts (.shopdbplugin) - ADR-013 Phase 1. A .shopdbplugin is a zip of a plugin directory plus PROVENANCE.json (a per-file SHA-256 map + metadata) and PROVENANCE.sig (a detached ed25519 signature over the exact provenance bytes). pack() builds and signs it; verify_artifact() and verify_dir() re-hash the contents and check the signature against trusted keys. """ import contextlib import hashlib import json import shutil import sys import zipfile from pathlib import Path from . import signing ARTIFACT_SUFFIX = '.shopdbplugin' def strip_bytecode(root) -> None: """Remove every __pycache__ dir and .pyc/.pyo under root. A signed plugin tree must contain only the source that was hashed. Stripping bytecode before verify + import guarantees the loader cannot execute a planted or stale cache in place of the verified source. """ root = Path(root) if not root.exists(): return for path in root.rglob('__pycache__'): if path.is_dir(): shutil.rmtree(path, ignore_errors=True) for suffix in ('*.pyc', '*.pyo'): for path in root.rglob(suffix): try: path.unlink() except OSError: pass @contextlib.contextmanager def no_bytecode(): """Import/exec without writing .pyc, so a just-stripped tree stays clean and a later verify does not trip over freshly written bytecode.""" previous = sys.dont_write_bytecode sys.dont_write_bytecode = True try: yield finally: sys.dont_write_bytecode = previous def pack(plugin_dir, private_key, publisher: str = '', out_dir=None, created: str = None) -> Path: """Build a signed artifact from a plugin directory. Returns its path. Caller is expected to have validated the directory first (the CLI does). """ plugin_dir = Path(plugin_dir) manifest = json.loads((plugin_dir / 'manifest.json').read_text()) name = manifest['name'] version = manifest['version'] provenance = signing.build_provenance( plugin_dir, name, version, publisher, created) provenance_bytes = signing.serialize_provenance(provenance) signature = signing.sign(private_key, provenance_bytes) out_dir = Path(out_dir) if out_dir else plugin_dir.parent out_dir.mkdir(parents=True, exist_ok=True) artifact = out_dir / f'{name}-{version}{ARTIFACT_SUFFIX}' with zipfile.ZipFile(artifact, 'w', zipfile.ZIP_DEFLATED) as archive: for relpath in provenance['files']: archive.write(plugin_dir / relpath, relpath) archive.writestr(signing.PROVENANCE_NAME, provenance_bytes) archive.writestr(signing.PROVENANCE_SIG, signature) return artifact def _verify_map(read_bytes, member_names, public_keys): """Shared core: given a way to read a member's bytes and the set of member names, verify signature + every file hash + no unexpected files. read_bytes(name) -> bytes. Returns (manifest_or_None, errors_list). """ errors = [] if signing.PROVENANCE_NAME not in member_names \ or signing.PROVENANCE_SIG not in member_names: return None, ['missing PROVENANCE.json or PROVENANCE.sig'] provenance_bytes = read_bytes(signing.PROVENANCE_NAME) signature = read_bytes(signing.PROVENANCE_SIG) if not public_keys: errors.append('no trusted keys supplied - signature unverifiable') elif not signing.verify(public_keys, provenance_bytes, signature): errors.append('signature does not match any trusted key') try: provenance = json.loads(provenance_bytes) except json.JSONDecodeError as exc: return None, errors + [f'PROVENANCE.json is not valid JSON: {exc}'] filemap = provenance.get('files', {}) for relpath, expected in filemap.items(): if relpath not in member_names: errors.append(f'declared file missing: {relpath}') continue actual = hashlib.sha256(read_bytes(relpath)).hexdigest() if actual != expected: errors.append(f'hash mismatch: {relpath}') allowed = set(filemap) | {signing.PROVENANCE_NAME, signing.PROVENANCE_SIG} for member in member_names: if member not in allowed: errors.append(f'unexpected file not in provenance: {member}') manifest = None if 'manifest.json' in member_names: try: manifest = json.loads(read_bytes('manifest.json')) except json.JSONDecodeError: errors.append('manifest.json is not valid JSON') return manifest, errors def verify_artifact(artifact_path, public_keys): """Verify a .shopdbplugin file. Returns (manifest_or_None, errors_list). errors empty = signature trusted AND every file hash intact AND no extra files. public_keys is a list of loaded ed25519 public keys (may be empty, which fails closed with an 'unverifiable' error). """ with zipfile.ZipFile(artifact_path) as archive: member_names = { name for name in archive.namelist() if not name.endswith('/')} return _verify_map(archive.read, member_names, public_keys) def verify_dir(plugin_dir, public_keys): """Verify an unpacked, adopted plugin directory that carries PROVENANCE files (used by verify-at-load/migrate in Phase 2). Same guarantees as verify_artifact. """ plugin_dir = Path(plugin_dir) member_names = set() for path in plugin_dir.rglob('*'): if path.is_file(): member_names.add(path.relative_to(plugin_dir).as_posix()) # Ignore only non-executable dev noise. __pycache__/.pyc are NOT ignored - # a planted bytecode cache must surface as an "unexpected file" so it cannot # execute while escaping the hash map (finding #1). member_names = { m for m in member_names if not any(part in signing._VERIFY_EXCLUDE_DIRS for part in Path(m).parts) } def read_bytes(relpath): return (plugin_dir / relpath).read_bytes() return _verify_map(read_bytes, member_names, public_keys)