"""Signed plugin shelf: index build/verify, anti-rollback state, adopt mechanics (ADR-013 Phase 2). The shelf is a read-only folder of .shopdbplugin artifacts plus a signed shelf-index.json (+ .sig). The index carries a monotonic serial (a site refuses an index older than the last it saw) and a revoked list. Browse reads the index; ADOPT always re-verifies the artifact's own signed manifest, never trusting the index for anything security-bearing. """ import hashlib import json import shutil import zipfile from pathlib import Path from packaging.version import Version, InvalidVersion from . import signing, packaging def _sha256_file(path) -> str: digest = hashlib.sha256() with open(path, 'rb') as handle: for chunk in iter(lambda: handle.read(65536), b''): digest.update(chunk) return digest.hexdigest() INDEX_NAME = 'shelf-index.json' INDEX_SIG = 'shelf-index.sig' STATE_NAME = 'shelf-state.json' STAGING_DIR = '.staging' def _read_artifact_manifest(artifact_path) -> dict: with zipfile.ZipFile(artifact_path) as archive: return json.loads(archive.read('manifest.json')) def build_index(shelf_dir, private_key, serial: int, publisher: str = '', revoked=None) -> dict: """Scan the shelf for artifacts, build and SIGN the index. Caller supplies a serial that must exceed any previously published one.""" shelf_dir = Path(shelf_dir) plugins = {} for artifact in sorted(shelf_dir.glob(f'*{packaging.ARTIFACT_SUFFIX}')): manifest = _read_artifact_manifest(artifact) plugins.setdefault(manifest['name'], []).append({ 'version': manifest['version'], 'tier': manifest.get('tier', 'optional'), 'core_version': manifest.get('core_version', ''), 'artifact': artifact.name, # Bind the entry to the artifact CONTENT, not just a filename, so an # attacker who can rewrite the shelf folder cannot serve different # (e.g. revoked) bytes under a live filename (finding #4). 'sha256': _sha256_file(artifact), }) index = { 'serial': int(serial), 'publisher': publisher or '', 'plugins': plugins, 'revoked': list(revoked or []), } index_bytes = signing.serialize_provenance(index) # canonical, sorted bytes (shelf_dir / INDEX_NAME).write_bytes(index_bytes) (shelf_dir / INDEX_SIG).write_bytes(signing.sign(private_key, index_bytes)) return index def load_index(shelf_dir, public_keys): """(index_or_None, errors). Verifies the index signature. Serial-vs-state is the caller's check (needs the instance path).""" shelf_dir = Path(shelf_dir) index_path = shelf_dir / INDEX_NAME sig_path = shelf_dir / INDEX_SIG if not index_path.exists() or not sig_path.exists(): return None, ['shelf has no signed index (shelf-index.json/.sig)'] index_bytes = index_path.read_bytes() signature = sig_path.read_bytes() errors = [] if not public_keys: errors.append('no trusted keys - shelf index unverifiable') elif not signing.verify(public_keys, index_bytes, signature): errors.append('shelf index signature does not match any trusted key') try: index = json.loads(index_bytes) except json.JSONDecodeError as exc: return None, errors + [f'shelf index is not valid JSON: {exc}'] return index, errors def read_last_serial(instance_path) -> int: state = Path(instance_path) / STATE_NAME if state.exists(): try: return int(json.loads(state.read_text()).get('last_serial', -1)) except (json.JSONDecodeError, ValueError, TypeError): return -1 return -1 def write_last_serial(instance_path, serial: int) -> None: state = Path(instance_path) / STATE_NAME state.parent.mkdir(parents=True, exist_ok=True) state.write_text(json.dumps({'last_serial': int(serial)}, indent=2)) def is_revoked(index: dict, name: str, version: str) -> bool: for entry in index.get('revoked', []): if entry.get('name') == name and entry.get('version') == version: return True return False def _safe_version(value): try: return Version(value) except InvalidVersion: return None def resolve_version(index: dict, name: str, version: str = None): """Pick the artifact for name[==version] from the index. With no version, picks the highest non-revoked version. Returns (version, artifact_filename, sha256_or_None) or raises KeyError/ValueError. """ candidates = index.get('plugins', {}).get(name) if not candidates: raise KeyError(f'plugin {name} not on the shelf') if version is not None: for entry in candidates: if entry['version'] == version: if is_revoked(index, name, version): raise ValueError(f'{name} {version} is revoked') return version, entry['artifact'], entry.get('sha256') raise KeyError(f'{name} {version} not on the shelf') live = [e for e in candidates if not is_revoked(index, name, e['version'])] if not live: raise ValueError(f'every published version of {name} is revoked') best = max(live, key=lambda e: (_safe_version(e['version']) or Version('0'),)) return best['version'], best['artifact'], best.get('sha256') def unpack_verified(artifact_path, public_keys, plugins_dir, name: str, expected_version: str = None, expected_sha256: str = None): """Verify an artifact, then atomically place it at plugins_dir/name. Binds the delivered bytes to what the signed index resolved: the file digest must match expected_sha256 (when given) and the artifact's OWN signed manifest must carry name (and expected_version, when given). This stops a revoked/other version being served under a live filename (finding #4). Verifies signature + every file hash in staging BEFORE anything can import; only a fully verified tree is moved into place. Raises ValueError on any failure. """ plugins_dir = Path(plugins_dir) if expected_sha256 is not None: actual_sha = _sha256_file(artifact_path) if actual_sha != expected_sha256: raise ValueError( 'artifact bytes do not match the signed index digest') manifest, errors = packaging.verify_artifact(artifact_path, public_keys) if errors: raise ValueError('; '.join(errors)) if manifest.get('name') != name: raise ValueError( f"artifact manifest name '{manifest.get('name')}' != '{name}'") if expected_version is not None and manifest.get('version') != expected_version: raise ValueError( f"artifact version '{manifest.get('version')}' != resolved " f"'{expected_version}' (bytes served under wrong filename?)") staging = plugins_dir / STAGING_DIR staging.mkdir(parents=True, exist_ok=True) staged = staging / name if staged.exists(): shutil.rmtree(staged) with zipfile.ZipFile(artifact_path) as archive: archive.extractall(staged) # Re-verify the unpacked tree before it goes live (defense against an # extract that did not land what we verified). _, staged_errors = packaging.verify_dir(staged, public_keys) if staged_errors: shutil.rmtree(staged, ignore_errors=True) raise ValueError('staged tree failed re-verification: ' + '; '.join(staged_errors)) target = plugins_dir / name backup = staging / f'{name}.backup' if target.exists(): if backup.exists(): shutil.rmtree(backup) target.rename(backup) try: staged.rename(target) except OSError: # cross-device or rename race: fall back to copy, keep backup on failure shutil.copytree(staged, target) shutil.rmtree(staged, ignore_errors=True) if backup.exists(): shutil.rmtree(backup, ignore_errors=True) return target