ADR-013 Phase 2: enforcement + signed shelf + adopt

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.
This commit is contained in:
cproudlock
2026-07-18 20:44:54 -04:00
parent 86f5f1be68
commit 5b19f3b554
12 changed files with 1048 additions and 8 deletions

View File

@@ -58,18 +58,21 @@ def test_registry_migrates_equipment_to_machines(tmp_path):
# --- Manager enable/disable dependency guards + permission seeding -----------
def _write_plugin(plugins_dir, name, deps=None, permissions=None):
def _write_plugin(plugins_dir, name, deps=None, permissions=None, tier=None):
"""Write a minimal valid synthetic plugin (manifest.json + plugin.py)."""
pdir = plugins_dir / name
pdir.mkdir()
(pdir / 'manifest.json').write_text(json.dumps({
manifest = {
'name': name,
'version': '1.0.0',
'description': f'synthetic {name}',
'dependencies': deps or [],
'core_version': '>=0.1.0,<1.0.0',
'api_prefix': f'/api/{name}',
}))
}
if tier:
manifest['tier'] = tier
(pdir / 'manifest.json').write_text(json.dumps(manifest))
(pdir / '__init__.py').write_text('')
(pdir / 'plugin.py').write_text(
'from shopdb.plugins.base import BasePlugin, PluginMeta\n\n'
@@ -286,6 +289,28 @@ def test_validate_bundled_plugin_passes(app):
assert 'valid' in result.output
def test_core_tier_plugin_cannot_be_disabled_or_uninstalled(app, db, tmp_path):
"""A tier=core plugin refuses disable and uninstall (ADR-013)."""
plugins_dir = tmp_path / 'plugins'
plugins_dir.mkdir()
_write_plugin(plugins_dir, 'kernel', tier='core')
registry = PluginRegistry(tmp_path / 'plugins.json')
registry.register('kernel', '1.0.0', enabled=True)
pm = PluginManager()
pm._app = app
pm._db = db
pm.registry = registry
pm.loader = PluginLoader(plugins_dir, registry)
pm.migration_manager = None
assert pm.disable_plugin('kernel') is False
assert pm.registry.is_enabled('kernel')
assert pm.uninstall_plugin('kernel') is False
assert pm.registry.is_installed('kernel')
# --- Seed idempotency -------------------------------------------------------
def test_seed_settings_is_idempotent(app, db):

180
tests/test_plugin_shelf.py Normal file
View File

@@ -0,0 +1,180 @@
"""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')

View File

@@ -0,0 +1,167 @@
"""Verify-at-load / verify-at-migrate enforcement tests (ADR-013 Phase 2).
The security model rests on: with PLUGIN_REQUIRE_SIGNED set, a plugin only
loads or migrates when its tree matches a trusted signature; unsigned, tampered,
or wrong-key plugins are refused; and the dev-trust exemption only relaxes this
under DEBUG/TESTING.
"""
import json
import pytest
from shopdb.exceptions import PluginError
from shopdb.plugins import signing
from shopdb.plugins.loader import PluginLoader
from shopdb.plugins.migrations import PluginMigrationManager
from shopdb.plugins.registry import PluginRegistry
from shopdb.plugins.verification import PluginVerifier
def _write_loadable_plugin(plugins_dir, name='demo'):
"""A minimal plugin whose plugin.py actually imports + instantiates."""
pdir = plugins_dir / name
pdir.mkdir(parents=True, exist_ok=True)
(pdir / 'manifest.json').write_text(json.dumps({
'name': name, 'version': '1.0.0', 'description': 'demo',
'dependencies': [], 'core_version': '>=0.1.0,<1.0.0',
'api_prefix': f'/api/{name}',
}))
(pdir / '__init__.py').write_text('')
(pdir / 'plugin.py').write_text(
'from shopdb.plugins.base import BasePlugin, PluginMeta\n\n'
'class ThePlugin(BasePlugin):\n'
' @property\n'
' def meta(self):\n'
f' return PluginMeta(name={name!r}, version="1.0.0",\n'
' description="demo")\n'
' def get_blueprint(self):\n'
' return None\n'
' def get_models(self):\n'
' return []\n'
)
return pdir
def _stamp(pdir, private_key, name='demo', version='1.0.0'):
provenance = signing.build_provenance(pdir, name, version, 'publisher')
provenance_bytes = signing.serialize_provenance(provenance)
(pdir / signing.PROVENANCE_NAME).write_bytes(provenance_bytes)
(pdir / signing.PROVENANCE_SIG).write_bytes(
signing.sign(private_key, provenance_bytes))
@pytest.fixture
def keypair(tmp_path):
private_pem, public_pem = signing.generate_keypair()
pub_path = tmp_path / 'curator.pub'
pub_path.write_bytes(public_pem)
return signing.load_private_key(private_pem), str(pub_path)
# --- PluginVerifier policy --------------------------------------------------
def test_enforcement_off_allows_unsigned(tmp_path):
_write_loadable_plugin(tmp_path / 'plugins')
verifier = PluginVerifier(tmp_path / 'plugins', require_signed=False)
ok, _ = verifier.check('demo')
assert ok
def test_require_signed_without_keys_refuses_all(tmp_path):
_write_loadable_plugin(tmp_path / 'plugins')
verifier = PluginVerifier(tmp_path / 'plugins', require_signed=True,
trusted_key_paths=[])
ok, reason = verifier.check('demo')
assert not ok
assert 'no trusted keys' in reason
def test_signed_plugin_passes(tmp_path, keypair):
private_key, pub_path = keypair
pdir = _write_loadable_plugin(tmp_path / 'plugins')
_stamp(pdir, private_key)
verifier = PluginVerifier(tmp_path / 'plugins', require_signed=True,
trusted_key_paths=[pub_path])
ok, reason = verifier.check('demo')
assert ok, reason
def test_tampered_plugin_refused(tmp_path, keypair):
private_key, pub_path = keypair
pdir = _write_loadable_plugin(tmp_path / 'plugins')
_stamp(pdir, private_key)
(pdir / 'plugin.py').write_text('# tampered after signing\n')
verifier = PluginVerifier(tmp_path / 'plugins', require_signed=True,
trusted_key_paths=[pub_path])
ok, reason = verifier.check('demo')
assert not ok
assert 'hash mismatch' in reason
def test_wrong_key_refused(tmp_path, keypair):
private_key, _ = keypair
other_pub = tmp_path / 'attacker.pub'
other_pub.write_bytes(signing.generate_keypair()[1])
pdir = _write_loadable_plugin(tmp_path / 'plugins')
_stamp(pdir, private_key)
verifier = PluginVerifier(tmp_path / 'plugins', require_signed=True,
trusted_key_paths=[str(other_pub)])
ok, reason = verifier.check('demo')
assert not ok
assert 'signature does not match' in reason
def test_dev_trust_dir_only_relaxes_in_dev(tmp_path, keypair):
_, pub_path = keypair
_write_loadable_plugin(tmp_path / 'plugins') # unsigned
plugins_dir = tmp_path / 'plugins'
dev = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[pub_path],
dev_trust_dirs=[str(plugins_dir)], is_dev=True)
ok, reason = dev.check('demo')
assert ok and reason == 'dev-trusted'
prod = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[pub_path],
dev_trust_dirs=[str(plugins_dir)], is_dev=False)
ok2, _ = prod.check('demo')
assert not ok2
# --- verify-at-load and verify-at-migrate integration -----------------------
def test_loader_refuses_unsigned_when_enforced(tmp_path, app, db):
plugins_dir = tmp_path / 'plugins'
_write_loadable_plugin(plugins_dir)
registry = PluginRegistry(tmp_path / 'plugins.json')
loader = PluginLoader(plugins_dir, registry)
loader.verifier = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[])
# TESTING app is strict -> the refusal surfaces as a raise.
with pytest.raises(PluginError):
loader.load_plugin('demo', app, db)
def test_loader_loads_signed_when_enforced(tmp_path, app, db, keypair):
private_key, pub_path = keypair
plugins_dir = tmp_path / 'plugins'
pdir = _write_loadable_plugin(plugins_dir)
_stamp(pdir, private_key)
registry = PluginRegistry(tmp_path / 'plugins.json')
loader = PluginLoader(plugins_dir, registry)
loader.verifier = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[pub_path])
plugin = loader.load_plugin('demo', app, db)
assert plugin is not None
assert plugin.meta.name == 'demo'
def test_migrate_refused_when_unverified(tmp_path):
plugins_dir = tmp_path / 'plugins'
_write_loadable_plugin(plugins_dir)
manager = PluginMigrationManager(plugins_dir, 'sqlite://')
manager.verifier = PluginVerifier(plugins_dir, require_signed=True,
trusted_key_paths=[])
assert manager.run_plugin_migrations('demo') is False