Completes the marketplace security model. Verification stops being advisory: a plugin only loads or migrates when its tree matches a trusted signature, and plugins are pulled from a signed shelf with anti-rollback and revocation. Enforcement (default OFF - existing deploys unchanged): - verification.py PluginVerifier, shared by the loader (verify-at-load, before plugin.py is imported) and the migration manager (verify-at-migrate, before any DDL). Fail-closed: an unsigned/tampered/wrong-key plugin does not run. - Gated by PLUGIN_REQUIRE_SIGNED. PLUGIN_DEV_TRUST_DIRS exempts named dirs but only under DEBUG/TESTING; production ignores it. - flask plugin stamp-bundled writes provenance into in-tree plugins so verify-at-load applies to bundled plugins too (image build step). - tier:core manifest guard: uninstall/disable refuse a core-tier plugin. Shelf (shelf.py): - Signed shelf-index.json (+ .sig): monotonic serial (a site refuses an older index - anti-rollback), revoked list carried across builds, per-entry version/tier/core_version for browse. Index is a browse layer only; adopt reads security-bearing fields from the verified artifact. - flask plugin shelf-build / shelf-list / adopt / audit. adopt verifies index + artifact (signature + every file hash), unpacks to staging, re-verifies, then atomically moves into place and installs+enables the closure. Refuses a downgrade without --force-downgrade. Anti-rollback serial stored in instance/shelf-state.json. - config PLUGIN_SHELF_DIR; the app only reads the folder, never speaks a network. .env.example + docs/PLUGIN-SIGNING.md document the flow. 22 tests: verifier policy (off / no-keys / signed / tampered / wrong-key / dev-exempt), verify-at-load + verify-at-migrate integration, tier guard, index sign/verify + tamper/wrong-key, serial state, revocation, version resolution, verified atomic unpack + tamper refusal. Live-smoked keygen->pack->shelf-build ->list->adopt->audit + serial guard. 1050 pass, naming green.
181 lines
6.4 KiB
Python
181 lines
6.4 KiB
Python
"""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 = shelf.resolve_version(index, 'demo')
|
|
assert version == '1.2.0'
|
|
assert artifact == 'demo-1.2.0.shopdbplugin'
|
|
|
|
|
|
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')
|