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

@@ -553,6 +553,283 @@ def apply_profile(profile: str):
"restart so new blueprints/routes register.", fg='yellow'))
def _shelf_keys(pubkeys):
"""Trusted keys for shelf/artifact verification: explicit --pubkey first,
else the site's PLUGIN_TRUSTED_KEYS."""
from .signing import load_trusted_keys
paths = list(pubkeys) or current_app.config.get('PLUGIN_TRUSTED_KEYS', [])
return load_trusted_keys(paths), paths
@plugin_cli.command('stamp-bundled')
@click.option('--key', 'key_path', required=True, type=click.Path(exists=True),
help='Private signing key PEM')
@click.option('--publisher', default='', help='Publisher id in the provenance')
@click.argument('name', required=False)
@with_appcontext
def stamp_bundled(key_path, publisher, name):
"""Write PROVENANCE.json/.sig into in-tree plugin dirs (image build step).
Stamps one plugin (NAME) or every discovered plugin, so verify-at-load
applies to bundled plugins identically to adopted ones. Run at image build
with the site/build key, then set PLUGIN_REQUIRE_SIGNED.
"""
from .signing import (load_private_key, build_provenance,
serialize_provenance, sign,
PROVENANCE_NAME, PROVENANCE_SIG)
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
private_key = load_private_key(Path(key_path).read_bytes())
names = [name] if name else pm.loader.discover_plugins()
plugins_dir = Path(pm.loader.plugins_dir)
for plugin_name in names:
plugin_dir = plugins_dir / plugin_name
manifest = pm.loader.load_manifest(plugin_name)
provenance = build_provenance(
plugin_dir, manifest['name'], manifest['version'], publisher)
provenance_bytes = serialize_provenance(provenance)
(plugin_dir / PROVENANCE_NAME).write_bytes(provenance_bytes)
(plugin_dir / PROVENANCE_SIG).write_bytes(
sign(private_key, provenance_bytes))
click.echo(click.style(f" stamped {plugin_name}", fg='green'))
@plugin_cli.command('shelf-build')
@click.option('--dir', 'shelf_dir', required=True,
type=click.Path(exists=True, file_okay=False),
help='Shelf directory holding the artifacts')
@click.option('--key', 'key_path', required=True,
type=click.Path(exists=True), help='Private signing key PEM')
@click.option('--serial', type=int, required=True,
help='Index serial (must exceed the current published serial)')
@click.option('--publisher', default='')
def shelf_build(shelf_dir, key_path, serial, publisher):
"""Build + sign the shelf index from the artifacts in DIR.
Carries forward the existing revoked list and refuses a serial that does not
advance past the published one (a lower serial would let a site's
anti-rollback reject the new index).
"""
import json as _json
from .signing import load_private_key
from .shelf import build_index, INDEX_NAME
shelf = Path(shelf_dir)
revoked = []
existing = shelf / INDEX_NAME
if existing.exists():
try:
prev = _json.loads(existing.read_text())
revoked = prev.get('revoked', [])
if serial <= int(prev.get('serial', -1)):
click.echo(click.style(
f"serial {serial} must exceed current "
f"{prev.get('serial')}", fg='red'))
raise SystemExit(1)
except (ValueError, TypeError):
pass
private_key = load_private_key(Path(key_path).read_bytes())
index = build_index(shelf, private_key, serial,
publisher=publisher, revoked=revoked)
total = sum(len(v) for v in index['plugins'].values())
click.echo(click.style(
f"Shelf index signed: serial {serial}, {total} artifact(s), "
f"{len(revoked)} revoked.", fg='green'))
@plugin_cli.command('shelf-list')
@click.option('--pubkey', 'pubkeys', multiple=True, type=click.Path(exists=True))
@with_appcontext
def shelf_list(pubkeys):
"""Browse the configured shelf (verifies index signature + serial)."""
from shopdb import __contract_version__
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from .shelf import load_index, read_last_serial, write_last_serial, is_revoked
shelf_dir = current_app.config.get('PLUGIN_SHELF_DIR')
if not shelf_dir:
click.echo(click.style("PLUGIN_SHELF_DIR not configured", fg='red'))
raise SystemExit(1)
keys, _ = _shelf_keys(pubkeys)
index, errors = load_index(shelf_dir, keys)
for err in errors:
click.echo(click.style(f" {err}", fg='red'))
if index is None:
raise SystemExit(1)
last = read_last_serial(current_app.instance_path)
if index['serial'] < last:
click.echo(click.style(
f"REFUSING shelf: index serial {index['serial']} < last seen "
f"{last} (rollback?)", fg='red'))
raise SystemExit(1)
if errors:
raise SystemExit(1)
write_last_serial(current_app.instance_path, index['serial'])
click.echo("")
click.echo(click.style(
f"Shelf (serial {index['serial']}):", fg='cyan', bold=True))
for name in sorted(index.get('plugins', {})):
for entry in index['plugins'][name]:
compat = 'ok'
spec = entry.get('core_version', '')
if spec and Version(__contract_version__) not in SpecifierSet(spec):
compat = f'needs core {spec}'
flags = []
if is_revoked(index, name, entry['version']):
flags.append('REVOKED')
if compat != 'ok':
flags.append(compat)
tag = (' [' + ', '.join(flags) + ']') if flags else ''
click.echo(f" {name:20} {entry['version']:10} "
f"({entry.get('tier', 'optional')}){tag}")
@plugin_cli.command('adopt')
@click.argument('spec')
@click.option('--pubkey', 'pubkeys', multiple=True, type=click.Path(exists=True))
@click.option('--force-downgrade', is_flag=True,
help='Allow adopting a version lower than the installed one')
@with_appcontext
def adopt_plugin(spec, pubkeys, force_downgrade):
"""Pull a plugin from the shelf, verify it, and install + enable it.
SPEC is a plugin name or name==version. Verifies the shelf index and the
artifact signature + hashes, unpacks atomically only after verification,
then installs + enables the plugin and its dependency closure.
Usage: flask plugin adopt printers (or printers==1.2.0)
"""
from packaging.version import Version
from ..exceptions import PluginError
from .shelf import (load_index, read_last_serial, write_last_serial,
resolve_version, unpack_verified)
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
name, _, version = spec.partition('==')
name = name.strip()
version = version.strip() or None
shelf_dir = current_app.config.get('PLUGIN_SHELF_DIR')
if not shelf_dir:
click.echo(click.style("PLUGIN_SHELF_DIR not configured", fg='red'))
raise SystemExit(1)
keys, _ = _shelf_keys(pubkeys)
index, errors = load_index(shelf_dir, keys)
for err in errors:
click.echo(click.style(f" {err}", fg='red'))
if index is None or errors:
raise SystemExit(1)
last = read_last_serial(current_app.instance_path)
if index['serial'] < last:
click.echo(click.style(
f"REFUSING shelf: serial {index['serial']} < last seen {last} "
f"(rollback?)", fg='red'))
raise SystemExit(1)
try:
resolved_version, artifact_name = resolve_version(index, name, version)
except (KeyError, ValueError) as e:
click.echo(click.style(f"{e}", fg='red'))
raise SystemExit(1)
state = pm.registry.get(name)
if state and not force_downgrade:
try:
if Version(resolved_version) < Version(state.version):
click.echo(click.style(
f"Refusing downgrade {state.version} -> {resolved_version} "
f"(use --force-downgrade)", fg='red'))
raise SystemExit(1)
except Exception:
pass
artifact_path = Path(shelf_dir) / artifact_name
plugins_dir = Path(pm.loader.plugins_dir)
click.echo(f"Verifying + unpacking {name} {resolved_version} ...")
try:
unpack_verified(artifact_path, keys, plugins_dir, name)
except ValueError as e:
click.echo(click.style(f" verification failed: {e}", fg='red'))
raise SystemExit(1)
# Clear any cached manifest so the freshly unpacked one is read.
pm.loader._manifests.pop(name, None)
try:
result = pm.apply_profile([name])
except PluginError as e:
click.echo(click.style(f" {e}", fg='red'))
click.echo(click.style(
" (adopt the missing dependency plugins first)", fg='yellow'))
raise SystemExit(1)
write_last_serial(current_app.instance_path, index['serial'])
click.echo("")
click.echo(click.style(
f"Adopted {name} {resolved_version}.", fg='green'))
if result['installed']:
click.echo(f" installed: {', '.join(result['installed'])}")
if result['enabled']:
click.echo(f" enabled: {', '.join(result['enabled'])}")
click.echo(click.style(
"Run 'flask plugin upgrade-all' then restart so routes register.",
fg='yellow'))
@plugin_cli.command('audit')
@click.option('--pubkey', 'pubkeys', multiple=True, type=click.Path(exists=True))
@with_appcontext
def audit_plugins(pubkeys):
"""Warn about installed plugins whose version is revoked on the shelf."""
from .shelf import load_index, is_revoked
pm = current_app.extensions.get('plugin_manager')
if not pm:
click.echo(click.style("Plugin manager not initialized", fg='red'))
raise SystemExit(1)
shelf_dir = current_app.config.get('PLUGIN_SHELF_DIR')
if not shelf_dir:
click.echo(click.style("PLUGIN_SHELF_DIR not configured", fg='red'))
raise SystemExit(1)
keys, _ = _shelf_keys(pubkeys)
index, errors = load_index(shelf_dir, keys)
for err in errors:
click.echo(click.style(f" {err}", fg='red'))
if index is None:
raise SystemExit(1)
flagged = []
for name, state in pm.registry.get_all().items():
if is_revoked(index, name, state.version):
flagged.append(f"{name} {state.version}")
click.echo("")
if flagged:
click.echo(click.style(
"REVOKED plugins installed:", fg='red', bold=True))
for item in flagged:
click.echo(click.style(f" {item}", fg='red'))
raise SystemExit(1)
click.echo(click.style("No installed plugin is revoked.", fg='green'))
@plugin_cli.command('migrate')
@click.argument('name')
@click.option('--revision', default='head', help='Target revision')