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:
64
shopdb/plugins/verification.py
Normal file
64
shopdb/plugins/verification.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Plugin signature enforcement policy (ADR-013 Phase 2).
|
||||
|
||||
One PluginVerifier is built per app from config and shared by the loader
|
||||
(verify-at-load) and the migration manager (verify-at-migrate). A plugin only
|
||||
loads or migrates when it verifies against a trusted key - so signing stops
|
||||
being advisory and becomes enforced everywhere plugin code executes.
|
||||
|
||||
Default is OFF (PLUGIN_REQUIRE_SIGNED unset), so existing deploys are
|
||||
unchanged. A site opts into hardening by stamping its plugins
|
||||
(`flask plugin stamp-bundled`), pinning keys (PLUGIN_TRUSTED_KEYS), and setting
|
||||
PLUGIN_REQUIRE_SIGNED. Fail-closed: an unverifiable plugin does NOT run.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from . import signing, packaging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginVerifier:
|
||||
"""Decides whether a plugin may load/migrate under the site's trust policy."""
|
||||
|
||||
def __init__(self, plugins_dir, require_signed=False, trusted_key_paths=None,
|
||||
dev_trust_dirs=None, is_dev=False):
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.require_signed = bool(require_signed)
|
||||
self.is_dev = bool(is_dev)
|
||||
# dev trust dirs only mean anything in dev/test (ADR-013); prod ignores.
|
||||
self.dev_trust_dirs = [Path(p).resolve() for p in (dev_trust_dirs or [])]
|
||||
self._keys = signing.load_trusted_keys(trusted_key_paths or [])
|
||||
|
||||
def _dev_exempt(self, plugin_dir: Path) -> bool:
|
||||
# unsigned dev/external-repo plugins pass, but ONLY under DEBUG/TESTING
|
||||
if not self.is_dev or not self.dev_trust_dirs:
|
||||
return False
|
||||
resolved = Path(plugin_dir).resolve()
|
||||
for trusted in self.dev_trust_dirs:
|
||||
try:
|
||||
resolved.relative_to(trusted)
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
def check(self, plugin_name: str):
|
||||
"""(ok, reason). ok True = plugin may load/migrate."""
|
||||
if not self.require_signed:
|
||||
return True, 'enforcement off'
|
||||
|
||||
plugin_dir = self.plugins_dir / plugin_name
|
||||
if self._dev_exempt(plugin_dir):
|
||||
return True, 'dev-trusted'
|
||||
|
||||
if not self._keys:
|
||||
# require-signed with nothing to verify against = refuse everything.
|
||||
return False, ('PLUGIN_REQUIRE_SIGNED is set but no trusted keys '
|
||||
'are configured')
|
||||
|
||||
_, errors = packaging.verify_dir(plugin_dir, self._keys)
|
||||
if errors:
|
||||
return False, '; '.join(errors)
|
||||
return True, 'verified'
|
||||
Reference in New Issue
Block a user