"""Signed shelf index + adopt-mechanics tests (ADR-013 Phase 2). Covers: a signed index round-trips and a tampered/wrong-key index is rejected; serial anti-rollback state; revocation; version resolution; and unpack_verified placing only a fully verified tree (refusing a tampered artifact). """ import json import zipfile import pytest from shopdb.plugins import signing, packaging, shelf def _write_plugin(plugin_dir, name='demo', version='1.0.0', tier=None): plugin_dir.mkdir(parents=True, exist_ok=True) manifest = { 'name': name, 'version': version, 'description': 'demo', 'dependencies': [], 'core_version': '>=0.1.0,<1.0.0', 'api_prefix': f'/api/{name}', } if tier: manifest['tier'] = tier (plugin_dir / 'manifest.json').write_text(json.dumps(manifest)) (plugin_dir / 'plugin.py').write_text('# demo\n') return plugin_dir @pytest.fixture def keypair(tmp_path): private_pem, public_pem = signing.generate_keypair() return (signing.load_private_key(private_pem), signing.load_public_key(public_pem)) def _build_shelf(tmp_path, private_key, specs, serial=1, revoked=None): """specs: list of (name, version). Packs each into a shelf dir + index.""" shelf_dir = tmp_path / 'shelf' shelf_dir.mkdir(parents=True, exist_ok=True) for name, version in specs: src = tmp_path / 'src' / f'{name}-{version}' _write_plugin(src, name=name, version=version) packaging.pack(src, private_key, out_dir=shelf_dir) shelf.build_index(shelf_dir, private_key, serial, revoked=revoked) return shelf_dir # --- index sign/verify ------------------------------------------------------ def test_index_round_trip(tmp_path, keypair): priv, pub = keypair shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')]) index, errors = shelf.load_index(shelf_dir, [pub]) assert errors == [] assert index['serial'] == 1 assert 'demo' in index['plugins'] def test_tampered_index_rejected(tmp_path, keypair): priv, pub = keypair shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')]) # change the index bytes but keep the old signature path = shelf_dir / shelf.INDEX_NAME data = json.loads(path.read_text()) data['serial'] = 999 path.write_bytes(signing.serialize_provenance(data)) _, errors = shelf.load_index(shelf_dir, [pub]) assert any('signature does not match' in e for e in errors) def test_index_wrong_key_rejected(tmp_path, keypair): priv, _ = keypair shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')]) wrong_pub = signing.load_public_key(signing.generate_keypair()[1]) _, errors = shelf.load_index(shelf_dir, [wrong_pub]) assert any('signature does not match' in e for e in errors) def test_index_no_keys_unverifiable(tmp_path, keypair): priv, _ = keypair shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')]) _, errors = shelf.load_index(shelf_dir, []) assert any('unverifiable' in e for e in errors) # --- anti-rollback state ---------------------------------------------------- def test_serial_state_round_trip(tmp_path): assert shelf.read_last_serial(tmp_path) == -1 shelf.write_last_serial(tmp_path, 5) assert shelf.read_last_serial(tmp_path) == 5 # --- revocation + version resolution ---------------------------------------- def test_resolve_highest_non_revoked(tmp_path, keypair): priv, pub = keypair shelf_dir = _build_shelf( tmp_path, priv, [('demo', '1.0.0'), ('demo', '1.2.0')]) index, _ = shelf.load_index(shelf_dir, [pub]) version, artifact, sha = shelf.resolve_version(index, 'demo') assert version == '1.2.0' assert artifact == 'demo-1.2.0.shopdbplugin' assert sha and len(sha) == 64 def test_resolve_skips_revoked(tmp_path, keypair): priv, pub = keypair shelf_dir = _build_shelf( tmp_path, priv, [('demo', '1.0.0'), ('demo', '1.2.0')], revoked=[{'name': 'demo', 'version': '1.2.0'}]) index, _ = shelf.load_index(shelf_dir, [pub]) version, _, _ = shelf.resolve_version(index, 'demo') assert version == '1.0.0' def test_resolve_explicit_revoked_raises(tmp_path, keypair): priv, pub = keypair shelf_dir = _build_shelf( tmp_path, priv, [('demo', '1.0.0')], revoked=[{'name': 'demo', 'version': '1.0.0'}]) index, _ = shelf.load_index(shelf_dir, [pub]) with pytest.raises(ValueError): shelf.resolve_version(index, 'demo', '1.0.0') def test_resolve_unknown_raises(tmp_path, keypair): priv, pub = keypair shelf_dir = _build_shelf(tmp_path, priv, [('demo', '1.0.0')]) index, _ = shelf.load_index(shelf_dir, [pub]) with pytest.raises(KeyError): shelf.resolve_version(index, 'nope') # --- verified atomic unpack ------------------------------------------------- def test_unpack_verified_places_tree(tmp_path, keypair): priv, pub = keypair src = _write_plugin(tmp_path / 'src' / 'demo') artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist') plugins_dir = tmp_path / 'live' plugins_dir.mkdir() target = shelf.unpack_verified(artifact, [pub], plugins_dir, 'demo') assert target == plugins_dir / 'demo' assert (target / 'manifest.json').exists() assert (target / signing.PROVENANCE_NAME).exists() # staging cleaned up assert not (plugins_dir / shelf.STAGING_DIR / 'demo').exists() def test_unpack_verified_refuses_tampered(tmp_path, keypair): priv, pub = keypair src = _write_plugin(tmp_path / 'src' / 'demo') artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist') # tamper a member, keep provenance + sig with zipfile.ZipFile(artifact) as zin: members = {n: zin.read(n) for n in zin.namelist()} members['manifest.json'] += b'tampered' with zipfile.ZipFile(artifact, 'w', zipfile.ZIP_DEFLATED) as zout: for name, data in members.items(): zout.writestr(name, data) plugins_dir = tmp_path / 'live' plugins_dir.mkdir() with pytest.raises(ValueError): shelf.unpack_verified(artifact, [pub], plugins_dir, 'demo') # nothing placed assert not (plugins_dir / 'demo').exists() def test_unpack_verified_wrong_name_refused(tmp_path, keypair): priv, pub = keypair src = _write_plugin(tmp_path / 'src' / 'demo') artifact = packaging.pack(src, priv, out_dir=tmp_path / 'dist') plugins_dir = tmp_path / 'live' plugins_dir.mkdir() with pytest.raises(ValueError): shelf.unpack_verified(artifact, [pub], plugins_dir, 'somethingelse') def test_revoked_bytes_under_live_filename_refused(tmp_path, keypair): """Finding #4: an attacker swaps the genuinely-signed bytes of a revoked 1.0.0 into the file named for the live 2.0.0. Adopt must refuse because the artifact's own signed version (and content digest) do not match the resolved version.""" priv, pub = keypair shelf_dir = tmp_path / 'shelf' shelf_dir.mkdir() # pack both versions for version in ('1.0.0', '2.0.0'): src = _write_plugin(tmp_path / 'src' / version, version=version) packaging.pack(src, priv, out_dir=shelf_dir) # index revokes 1.0.0, records true digests for 2.0.0 shelf.build_index(shelf_dir, priv, 1, revoked=[{'name': 'demo', 'version': '1.0.0'}]) index, _ = shelf.load_index(shelf_dir, [pub]) resolved_version, artifact_name, expected_sha = shelf.resolve_version( index, 'demo') assert resolved_version == '2.0.0' # attacker overwrites the 2.0.0 file with the signed 1.0.0 bytes live = shelf_dir / artifact_name (shelf_dir / 'demo-1.0.0.shopdbplugin').replace(live) plugins_dir = tmp_path / 'live' plugins_dir.mkdir() with pytest.raises(ValueError): shelf.unpack_verified(live, [pub], plugins_dir, 'demo', expected_version=resolved_version, expected_sha256=expected_sha) assert not (plugins_dir / 'demo').exists()