An adversarial security review of the Phase 2 trust model found four real bypasses (two remote-triggerable to in-process code execution). Root cause for three: the set of bytes verification covered was smaller than the set that determined execution. Fixes: 1. Bytecode-cache blind spot (CRITICAL). verify_dir excluded __pycache__/.pyc, so a planted cache ran while escaping the hash map. verify_dir now flags any bytecode as an unexpected file; the loader strips bytecode before verify and imports under sys.dont_write_bytecode, so only verified source executes. 2. Unauthenticated verify-at-load bypass (CRITICAL). load_plugin_class imported plugin.py with no gate, reachable via discover_available / an anonymous GET /api/plugins. The verify+strip gate moved INTO load_plugin_class - the single import choke point every path flows through - so an unsigned/tampered plugin is never imported. discover_available skips a refused plugin instead of 500. 3. Ungated migration entrypoints (HIGH). downgrade_plugin and get_current_head (ScriptDirectory imports version modules) ran plugin code with no check. All alembic-invoking methods now pass through _verify_ok (strip + verify) first and run under no-bytecode. 4. Revocation/content bypass (HIGH). The signed index bound a filename, not content; adopt did not bind the delivered bytes to the resolved version, so revoked bytes could be served under a live filename. The index now records a per-artifact SHA-256; adopt verifies the on-disk digest and requires the artifact's own signed manifest version to equal the resolved version. Enforcement stays default-off; strip/no-bytecode run only under enforcement, so the unsigned path is unchanged. 6 regression tests (planted bytecode, the discover import path, downgrade gate, version-swap). 1054 pass, naming green.
214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
"""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 hashlib
|
|
import json
|
|
import shutil
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from packaging.version import Version, InvalidVersion
|
|
|
|
from . import signing, packaging
|
|
|
|
|
|
def _sha256_file(path) -> str:
|
|
digest = hashlib.sha256()
|
|
with open(path, 'rb') as handle:
|
|
for chunk in iter(lambda: handle.read(65536), b''):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
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,
|
|
# Bind the entry to the artifact CONTENT, not just a filename, so an
|
|
# attacker who can rewrite the shelf folder cannot serve different
|
|
# (e.g. revoked) bytes under a live filename (finding #4).
|
|
'sha256': _sha256_file(artifact),
|
|
})
|
|
|
|
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 for name[==version] from the index.
|
|
|
|
With no version, picks the highest non-revoked version. Returns
|
|
(version, artifact_filename, sha256_or_None) 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'], entry.get('sha256')
|
|
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'], best.get('sha256')
|
|
|
|
|
|
def unpack_verified(artifact_path, public_keys, plugins_dir, name: str,
|
|
expected_version: str = None, expected_sha256: str = None):
|
|
"""Verify an artifact, then atomically place it at plugins_dir/name.
|
|
|
|
Binds the delivered bytes to what the signed index resolved: the file digest
|
|
must match expected_sha256 (when given) and the artifact's OWN signed
|
|
manifest must carry name (and expected_version, when given). This stops a
|
|
revoked/other version being served under a live filename (finding #4).
|
|
Verifies signature + every file hash in staging BEFORE anything can import;
|
|
only a fully verified tree is moved into place. Raises ValueError on any
|
|
failure.
|
|
"""
|
|
plugins_dir = Path(plugins_dir)
|
|
|
|
if expected_sha256 is not None:
|
|
actual_sha = _sha256_file(artifact_path)
|
|
if actual_sha != expected_sha256:
|
|
raise ValueError(
|
|
'artifact bytes do not match the signed index digest')
|
|
|
|
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}'")
|
|
if expected_version is not None and manifest.get('version') != expected_version:
|
|
raise ValueError(
|
|
f"artifact version '{manifest.get('version')}' != resolved "
|
|
f"'{expected_version}' (bytes served under wrong filename?)")
|
|
|
|
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
|