ADR-013 Phase 1: signed plugin artifacts (pack/validate/keygen)
Packaging + provenance for the plugin marketplace. No runtime behavior change yet - verification is available on demand; enforcing it at plugin load/migrate and pulling from a shelf are Phase 2. - signing.py: ed25519 key pairs + provenance. Provenance is a sorted per-file SHA-256 map plus metadata; the detached signature covers the exact serialized provenance bytes, so verifying is re-hash files, re-serialize, check signature. verify() accepts any of several trusted keys (rotation). Uses cryptography (already a dependency). - packaging.py: pack() builds a signed <name>-<version>.shopdbplugin (zip + PROVENANCE.json + PROVENANCE.sig). verify_artifact()/verify_dir() re-hash and check the signature, and flag a tampered file, an unexpected file, a wrong/absent key - all fail closed. - CLI: `flask plugin keygen` (publisher key pair), `flask plugin pack <name> --key` (validates then signs), and `flask plugin validate` extended to a signed artifact by path (--pubkey, else PLUGIN_TRUSTED_KEYS). - config PLUGIN_TRUSTED_KEYS: os.pathsep-separated public-key PEM paths, delivered with the site config, never read from the shelf. .env.example documents it. - docs/PLUGIN-SIGNING.md: curator flow (keygen offline, review, pack, publish, pin keys, rotate). The signature proves an artifact is exactly what a curator signed, not that the code is safe - human review before signing is the control. 11 tests: sign/verify round trip, wrong key, provenance excludes noise, serialize determinism, pack + verify, tamper -> hash mismatch, extra file, no-key fail-closed, verify_dir. 1028 pass, naming green.
This commit is contained in:
131
shopdb/plugins/packaging.py
Normal file
131
shopdb/plugins/packaging.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Pack and verify signed plugin artifacts (.shopdbplugin) - ADR-013 Phase 1.
|
||||
|
||||
A .shopdbplugin is a zip of a plugin directory plus PROVENANCE.json (a per-file
|
||||
SHA-256 map + metadata) and PROVENANCE.sig (a detached ed25519 signature over
|
||||
the exact provenance bytes). pack() builds and signs it; verify_artifact() and
|
||||
verify_dir() re-hash the contents and check the signature against trusted keys.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from . import signing
|
||||
|
||||
ARTIFACT_SUFFIX = '.shopdbplugin'
|
||||
|
||||
|
||||
def pack(plugin_dir, private_key, publisher: str = '',
|
||||
out_dir=None, created: str = None) -> Path:
|
||||
"""Build a signed artifact from a plugin directory. Returns its path.
|
||||
|
||||
Caller is expected to have validated the directory first (the CLI does).
|
||||
"""
|
||||
plugin_dir = Path(plugin_dir)
|
||||
manifest = json.loads((plugin_dir / 'manifest.json').read_text())
|
||||
name = manifest['name']
|
||||
version = manifest['version']
|
||||
|
||||
provenance = signing.build_provenance(
|
||||
plugin_dir, name, version, publisher, created)
|
||||
provenance_bytes = signing.serialize_provenance(provenance)
|
||||
signature = signing.sign(private_key, provenance_bytes)
|
||||
|
||||
out_dir = Path(out_dir) if out_dir else plugin_dir.parent
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact = out_dir / f'{name}-{version}{ARTIFACT_SUFFIX}'
|
||||
|
||||
with zipfile.ZipFile(artifact, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
for relpath in provenance['files']:
|
||||
archive.write(plugin_dir / relpath, relpath)
|
||||
archive.writestr(signing.PROVENANCE_NAME, provenance_bytes)
|
||||
archive.writestr(signing.PROVENANCE_SIG, signature)
|
||||
|
||||
return artifact
|
||||
|
||||
|
||||
def _verify_map(read_bytes, member_names, public_keys):
|
||||
"""Shared core: given a way to read a member's bytes and the set of member
|
||||
names, verify signature + every file hash + no unexpected files.
|
||||
|
||||
read_bytes(name) -> bytes. Returns (manifest_or_None, errors_list).
|
||||
"""
|
||||
errors = []
|
||||
if signing.PROVENANCE_NAME not in member_names \
|
||||
or signing.PROVENANCE_SIG not in member_names:
|
||||
return None, ['missing PROVENANCE.json or PROVENANCE.sig']
|
||||
|
||||
provenance_bytes = read_bytes(signing.PROVENANCE_NAME)
|
||||
signature = read_bytes(signing.PROVENANCE_SIG)
|
||||
|
||||
if not public_keys:
|
||||
errors.append('no trusted keys supplied - signature unverifiable')
|
||||
elif not signing.verify(public_keys, provenance_bytes, signature):
|
||||
errors.append('signature does not match any trusted key')
|
||||
|
||||
try:
|
||||
provenance = json.loads(provenance_bytes)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, errors + [f'PROVENANCE.json is not valid JSON: {exc}']
|
||||
|
||||
filemap = provenance.get('files', {})
|
||||
for relpath, expected in filemap.items():
|
||||
if relpath not in member_names:
|
||||
errors.append(f'declared file missing: {relpath}')
|
||||
continue
|
||||
actual = hashlib.sha256(read_bytes(relpath)).hexdigest()
|
||||
if actual != expected:
|
||||
errors.append(f'hash mismatch: {relpath}')
|
||||
|
||||
allowed = set(filemap) | {signing.PROVENANCE_NAME, signing.PROVENANCE_SIG}
|
||||
for member in member_names:
|
||||
if member not in allowed:
|
||||
errors.append(f'unexpected file not in provenance: {member}')
|
||||
|
||||
manifest = None
|
||||
if 'manifest.json' in member_names:
|
||||
try:
|
||||
manifest = json.loads(read_bytes('manifest.json'))
|
||||
except json.JSONDecodeError:
|
||||
errors.append('manifest.json is not valid JSON')
|
||||
|
||||
return manifest, errors
|
||||
|
||||
|
||||
def verify_artifact(artifact_path, public_keys):
|
||||
"""Verify a .shopdbplugin file. Returns (manifest_or_None, errors_list).
|
||||
|
||||
errors empty = signature trusted AND every file hash intact AND no extra
|
||||
files. public_keys is a list of loaded ed25519 public keys (may be empty,
|
||||
which fails closed with an 'unverifiable' error).
|
||||
"""
|
||||
with zipfile.ZipFile(artifact_path) as archive:
|
||||
member_names = {
|
||||
name for name in archive.namelist() if not name.endswith('/')}
|
||||
return _verify_map(archive.read, member_names, public_keys)
|
||||
|
||||
|
||||
def verify_dir(plugin_dir, public_keys):
|
||||
"""Verify an unpacked, adopted plugin directory that carries PROVENANCE
|
||||
files (used by verify-at-load/migrate in Phase 2). Same guarantees as
|
||||
verify_artifact.
|
||||
"""
|
||||
plugin_dir = Path(plugin_dir)
|
||||
|
||||
member_names = set()
|
||||
for path in plugin_dir.rglob('*'):
|
||||
if path.is_file():
|
||||
member_names.add(path.relative_to(plugin_dir).as_posix())
|
||||
# Drop the noise the packer also excludes, so an on-disk __pycache__ does
|
||||
# not read as an "unexpected file".
|
||||
member_names = {
|
||||
m for m in member_names
|
||||
if not any(part in signing._EXCLUDE_DIRS for part in Path(m).parts)
|
||||
and Path(m).suffix not in signing._EXCLUDE_SUFFIXES
|
||||
}
|
||||
|
||||
def read_bytes(relpath):
|
||||
return (plugin_dir / relpath).read_bytes()
|
||||
|
||||
return _verify_map(read_bytes, member_names, public_keys)
|
||||
Reference in New Issue
Block a user