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:
185
shopdb/plugins/shelf.py
Normal file
185
shopdb/plugins/shelf.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Signed plugin shelf: index build/verify, anti-rollback state, adopt mechanics
|
||||
(ADR-013 Phase 2).
|
||||
|
||||
The shelf is a read-only folder of .shopdbplugin artifacts plus a signed
|
||||
shelf-index.json (+ .sig). The index carries a monotonic serial (a site refuses
|
||||
an index older than the last it saw) and a revoked list. Browse reads the index;
|
||||
ADOPT always re-verifies the artifact's own signed manifest, never trusting the
|
||||
index for anything security-bearing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.version import Version, InvalidVersion
|
||||
|
||||
from . import signing, packaging
|
||||
|
||||
INDEX_NAME = 'shelf-index.json'
|
||||
INDEX_SIG = 'shelf-index.sig'
|
||||
STATE_NAME = 'shelf-state.json'
|
||||
STAGING_DIR = '.staging'
|
||||
|
||||
|
||||
def _read_artifact_manifest(artifact_path) -> dict:
|
||||
with zipfile.ZipFile(artifact_path) as archive:
|
||||
return json.loads(archive.read('manifest.json'))
|
||||
|
||||
|
||||
def build_index(shelf_dir, private_key, serial: int,
|
||||
publisher: str = '', revoked=None) -> dict:
|
||||
"""Scan the shelf for artifacts, build and SIGN the index. Caller supplies a
|
||||
serial that must exceed any previously published one."""
|
||||
shelf_dir = Path(shelf_dir)
|
||||
plugins = {}
|
||||
for artifact in sorted(shelf_dir.glob(f'*{packaging.ARTIFACT_SUFFIX}')):
|
||||
manifest = _read_artifact_manifest(artifact)
|
||||
plugins.setdefault(manifest['name'], []).append({
|
||||
'version': manifest['version'],
|
||||
'tier': manifest.get('tier', 'optional'),
|
||||
'core_version': manifest.get('core_version', ''),
|
||||
'artifact': artifact.name,
|
||||
})
|
||||
|
||||
index = {
|
||||
'serial': int(serial),
|
||||
'publisher': publisher or '',
|
||||
'plugins': plugins,
|
||||
'revoked': list(revoked or []),
|
||||
}
|
||||
index_bytes = signing.serialize_provenance(index) # canonical, sorted bytes
|
||||
(shelf_dir / INDEX_NAME).write_bytes(index_bytes)
|
||||
(shelf_dir / INDEX_SIG).write_bytes(signing.sign(private_key, index_bytes))
|
||||
return index
|
||||
|
||||
|
||||
def load_index(shelf_dir, public_keys):
|
||||
"""(index_or_None, errors). Verifies the index signature. Serial-vs-state is
|
||||
the caller's check (needs the instance path)."""
|
||||
shelf_dir = Path(shelf_dir)
|
||||
index_path = shelf_dir / INDEX_NAME
|
||||
sig_path = shelf_dir / INDEX_SIG
|
||||
if not index_path.exists() or not sig_path.exists():
|
||||
return None, ['shelf has no signed index (shelf-index.json/.sig)']
|
||||
|
||||
index_bytes = index_path.read_bytes()
|
||||
signature = sig_path.read_bytes()
|
||||
|
||||
errors = []
|
||||
if not public_keys:
|
||||
errors.append('no trusted keys - shelf index unverifiable')
|
||||
elif not signing.verify(public_keys, index_bytes, signature):
|
||||
errors.append('shelf index signature does not match any trusted key')
|
||||
|
||||
try:
|
||||
index = json.loads(index_bytes)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, errors + [f'shelf index is not valid JSON: {exc}']
|
||||
return index, errors
|
||||
|
||||
|
||||
def read_last_serial(instance_path) -> int:
|
||||
state = Path(instance_path) / STATE_NAME
|
||||
if state.exists():
|
||||
try:
|
||||
return int(json.loads(state.read_text()).get('last_serial', -1))
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return -1
|
||||
return -1
|
||||
|
||||
|
||||
def write_last_serial(instance_path, serial: int) -> None:
|
||||
state = Path(instance_path) / STATE_NAME
|
||||
state.parent.mkdir(parents=True, exist_ok=True)
|
||||
state.write_text(json.dumps({'last_serial': int(serial)}, indent=2))
|
||||
|
||||
|
||||
def is_revoked(index: dict, name: str, version: str) -> bool:
|
||||
for entry in index.get('revoked', []):
|
||||
if entry.get('name') == name and entry.get('version') == version:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_version(value):
|
||||
try:
|
||||
return Version(value)
|
||||
except InvalidVersion:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_version(index: dict, name: str, version: str = None):
|
||||
"""Pick the artifact filename for name[==version] from the index.
|
||||
|
||||
With no version, picks the highest non-revoked version. Returns
|
||||
(version, artifact_filename) or raises KeyError/ValueError.
|
||||
"""
|
||||
candidates = index.get('plugins', {}).get(name)
|
||||
if not candidates:
|
||||
raise KeyError(f'plugin {name} not on the shelf')
|
||||
|
||||
if version is not None:
|
||||
for entry in candidates:
|
||||
if entry['version'] == version:
|
||||
if is_revoked(index, name, version):
|
||||
raise ValueError(f'{name} {version} is revoked')
|
||||
return version, entry['artifact']
|
||||
raise KeyError(f'{name} {version} not on the shelf')
|
||||
|
||||
live = [e for e in candidates if not is_revoked(index, name, e['version'])]
|
||||
if not live:
|
||||
raise ValueError(f'every published version of {name} is revoked')
|
||||
best = max(live, key=lambda e: (_safe_version(e['version']) or Version('0'),))
|
||||
return best['version'], best['artifact']
|
||||
|
||||
|
||||
def unpack_verified(artifact_path, public_keys, plugins_dir, name: str):
|
||||
"""Verify an artifact, then atomically place it at plugins_dir/name.
|
||||
|
||||
Verifies signature + every file hash in a staging area BEFORE it can be
|
||||
imported; only a fully verified tree is moved into place. Returns the final
|
||||
plugin directory. Raises ValueError with all problems on any failure.
|
||||
"""
|
||||
plugins_dir = Path(plugins_dir)
|
||||
|
||||
manifest, errors = packaging.verify_artifact(artifact_path, public_keys)
|
||||
if errors:
|
||||
raise ValueError('; '.join(errors))
|
||||
if manifest.get('name') != name:
|
||||
raise ValueError(
|
||||
f"artifact manifest name '{manifest.get('name')}' != '{name}'")
|
||||
|
||||
staging = plugins_dir / STAGING_DIR
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
staged = staging / name
|
||||
if staged.exists():
|
||||
shutil.rmtree(staged)
|
||||
|
||||
with zipfile.ZipFile(artifact_path) as archive:
|
||||
archive.extractall(staged)
|
||||
|
||||
# Re-verify the unpacked tree before it goes live (defense against an
|
||||
# extract that did not land what we verified).
|
||||
_, staged_errors = packaging.verify_dir(staged, public_keys)
|
||||
if staged_errors:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise ValueError('staged tree failed re-verification: '
|
||||
+ '; '.join(staged_errors))
|
||||
|
||||
target = plugins_dir / name
|
||||
backup = staging / f'{name}.backup'
|
||||
if target.exists():
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
target.rename(backup)
|
||||
try:
|
||||
staged.rename(target)
|
||||
except OSError:
|
||||
# cross-device or rename race: fall back to copy, keep backup on failure
|
||||
shutil.copytree(staged, target)
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
return target
|
||||
Reference in New Issue
Block a user