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:
@@ -280,38 +280,28 @@ def new_plugin(name: str, description: str, overwrite: bool):
|
||||
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
|
||||
|
||||
|
||||
@plugin_cli.command('validate')
|
||||
@click.argument('name')
|
||||
@with_appcontext
|
||||
def validate_plugin(name: str):
|
||||
"""Validate a plugin directory against the manifest schema + contract.
|
||||
def _dep_name(dep: str) -> str:
|
||||
"""Bare plugin name from a dependency spec, dropping any PEP440 range."""
|
||||
for sep in ('>', '<', '=', '!', '~', ' '):
|
||||
dep = dep.split(sep)[0]
|
||||
return dep.strip()
|
||||
|
||||
Pre-publish gate (directory mode). Checks: manifest loads and its name
|
||||
matches the directory, required/typed fields per the manifest schema, the
|
||||
core_version range admits this framework's contract version, and every
|
||||
declared hard dependency exists on disk. Exits non-zero on any failure.
|
||||
|
||||
Usage: flask plugin validate printers
|
||||
"""
|
||||
def _validate_directory(pm, name: str) -> list:
|
||||
"""Run the directory-mode checks, echoing each. Returns a failures list."""
|
||||
from shopdb import __contract_version__
|
||||
from ..exceptions import PluginError
|
||||
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
failures = []
|
||||
|
||||
# 1. Manifest loads (raises on missing/unparseable/name-mismatch).
|
||||
try:
|
||||
manifest = pm.loader.load_manifest(name)
|
||||
except PluginError as e:
|
||||
click.echo(click.style(f" manifest: {e}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
click.echo(click.style(" manifest loads + name matches directory", fg='green'))
|
||||
return [str(e)]
|
||||
click.echo(click.style(
|
||||
" manifest loads + name matches directory", fg='green'))
|
||||
|
||||
# 2. Schema.
|
||||
schema_errors = _check_against_schema(manifest, _load_manifest_schema())
|
||||
if schema_errors:
|
||||
for err in schema_errors:
|
||||
@@ -320,7 +310,6 @@ def validate_plugin(name: str):
|
||||
else:
|
||||
click.echo(click.style(" schema OK", fg='green'))
|
||||
|
||||
# 3. Contract version range admits this framework.
|
||||
try:
|
||||
pm.loader.check_contract_version(name, __contract_version__)
|
||||
click.echo(click.style(
|
||||
@@ -329,24 +318,184 @@ def validate_plugin(name: str):
|
||||
failures.append(str(e))
|
||||
click.echo(click.style(f" core_version: {e}", fg='red'))
|
||||
|
||||
# 4. Declared hard dependencies exist on disk (name-only; ranges stripped
|
||||
# later when the loader gains range semantics).
|
||||
available = set(pm.loader.discover_plugins())
|
||||
dep_failures = []
|
||||
for dep in manifest.get('dependencies', []):
|
||||
depname = dep.split('>')[0].split('<')[0].split('=')[0].split('!')[0].split('~')[0].strip()
|
||||
depname = _dep_name(dep)
|
||||
if depname not in available:
|
||||
dep_failures.append(depname)
|
||||
failures.append(f"dependency '{depname}' not found on disk")
|
||||
click.echo(click.style(
|
||||
f" dependency '{depname}' not found on disk", fg='red'))
|
||||
if not failures and manifest.get('dependencies'):
|
||||
if manifest.get('dependencies') and not dep_failures:
|
||||
click.echo(click.style(" dependencies present on disk", fg='green'))
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
def _validate_artifact(pubkey_paths, artifact_path: str) -> list:
|
||||
"""Verify a signed .shopdbplugin: signature + hashes + manifest schema +
|
||||
contract. Returns a failures list."""
|
||||
from shopdb import __contract_version__
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version
|
||||
from .packaging import verify_artifact
|
||||
from .signing import load_trusted_keys
|
||||
|
||||
keys = load_trusted_keys(pubkey_paths)
|
||||
if not keys:
|
||||
click.echo(click.style(
|
||||
" no trusted keys (pass --pubkey or set PLUGIN_TRUSTED_KEYS)",
|
||||
fg='red'))
|
||||
|
||||
manifest, errors = verify_artifact(artifact_path, keys)
|
||||
failures = list(errors)
|
||||
for err in errors:
|
||||
click.echo(click.style(f" {err}", fg='red'))
|
||||
if not errors:
|
||||
click.echo(click.style(
|
||||
" signature trusted + every file hash intact", fg='green'))
|
||||
|
||||
if manifest is not None:
|
||||
schema_errors = _check_against_schema(
|
||||
manifest, _load_manifest_schema())
|
||||
for err in schema_errors:
|
||||
failures.append(f"schema: {err}")
|
||||
click.echo(click.style(f" schema: {err}", fg='red'))
|
||||
if not schema_errors:
|
||||
click.echo(click.style(" schema OK", fg='green'))
|
||||
|
||||
spec = manifest.get('core_version', '')
|
||||
if spec and Version(__contract_version__) not in SpecifierSet(spec):
|
||||
failures.append(
|
||||
f"core_version {spec} excludes contract {__contract_version__}")
|
||||
click.echo(click.style(
|
||||
f" core_version {spec} excludes contract "
|
||||
f"{__contract_version__}", fg='red'))
|
||||
elif spec:
|
||||
click.echo(click.style(
|
||||
f" core_version admits contract {__contract_version__}",
|
||||
fg='green'))
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
@plugin_cli.command('validate')
|
||||
@click.argument('target')
|
||||
@click.option('--pubkey', 'pubkeys', multiple=True,
|
||||
type=click.Path(exists=True),
|
||||
help='Trusted public key PEM (artifact mode). Repeatable.')
|
||||
@with_appcontext
|
||||
def validate_plugin(target: str, pubkeys):
|
||||
"""Validate a plugin - directory (by name) or a signed artifact (by path).
|
||||
|
||||
Directory mode (`flask plugin validate printers`): manifest loads + name
|
||||
match, manifest schema, core_version admits this contract, dependencies
|
||||
exist on disk.
|
||||
|
||||
Artifact mode (`flask plugin validate dist/printers-1.0.0.shopdbplugin`):
|
||||
signature against trusted keys (--pubkey, else PLUGIN_TRUSTED_KEYS), every
|
||||
file hash, no unexpected files, manifest schema + core_version. Exits
|
||||
non-zero on any failure.
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
is_artifact = target.endswith('.shopdbplugin') and Path(target).is_file()
|
||||
if is_artifact:
|
||||
keys = list(pubkeys) or current_app.config.get(
|
||||
'PLUGIN_TRUSTED_KEYS', [])
|
||||
failures = _validate_artifact(keys, target)
|
||||
label = Path(target).name
|
||||
else:
|
||||
failures = _validate_directory(pm, target)
|
||||
label = target
|
||||
|
||||
click.echo("")
|
||||
if failures:
|
||||
click.echo(click.style(
|
||||
f"{name}: INVALID ({len(failures)} problem(s))", fg='red'))
|
||||
f"{label}: INVALID ({len(failures)} problem(s))", fg='red'))
|
||||
raise SystemExit(1)
|
||||
click.echo(click.style(f"{name}: valid", fg='green'))
|
||||
click.echo(click.style(f"{label}: valid", fg='green'))
|
||||
|
||||
|
||||
@plugin_cli.command('keygen')
|
||||
@click.option('--out', 'out_dir', default='.', type=click.Path(),
|
||||
help='Directory to write the key pair into')
|
||||
@click.option('--name', 'key_name', default='curator',
|
||||
help='Base filename for the pair')
|
||||
def keygen(out_dir: str, key_name: str):
|
||||
"""Generate an ed25519 publisher key pair for signing plugin artifacts.
|
||||
|
||||
Writes <name>.key (PRIVATE - keep offline with the curator) and <name>.pub
|
||||
(public - pin on each site via PLUGIN_TRUSTED_KEYS). Not app-bound.
|
||||
"""
|
||||
from .signing import generate_keypair
|
||||
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
private_pem, public_pem = generate_keypair()
|
||||
|
||||
private_path = out / f'{key_name}.key'
|
||||
public_path = out / f'{key_name}.pub'
|
||||
private_path.write_bytes(private_pem)
|
||||
public_path.write_bytes(public_pem)
|
||||
try:
|
||||
private_path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
click.echo(click.style(f"Private key: {private_path}", fg='green'))
|
||||
click.echo(click.style(f"Public key: {public_path}", fg='green'))
|
||||
click.echo("")
|
||||
click.echo(click.style(
|
||||
"Keep the .key OFFLINE. Never place it on the plugin shelf. Distribute "
|
||||
"the .pub with each site's config and list it in PLUGIN_TRUSTED_KEYS.",
|
||||
fg='yellow'))
|
||||
|
||||
|
||||
@plugin_cli.command('pack')
|
||||
@click.argument('name')
|
||||
@click.option('--key', 'key_path', required=True,
|
||||
type=click.Path(exists=True), help='Private signing key PEM')
|
||||
@click.option('--publisher', default='',
|
||||
help='Publisher id recorded in the provenance')
|
||||
@click.option('--out', 'out_dir', default=None, type=click.Path(),
|
||||
help='Output directory (default: the plugins/ directory)')
|
||||
@with_appcontext
|
||||
def pack_plugin(name: str, key_path: str, publisher: str, out_dir):
|
||||
"""Validate a plugin directory, then emit a signed artifact.
|
||||
|
||||
Produces <name>-<version>.shopdbplugin. Fails without packing if the
|
||||
directory does not validate.
|
||||
|
||||
Usage: flask plugin pack printers --key curator.key --publisher west-jefferson
|
||||
"""
|
||||
from .signing import load_private_key
|
||||
from .packaging import pack
|
||||
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f"Validating {name} ...")
|
||||
failures = _validate_directory(pm, name)
|
||||
if failures:
|
||||
click.echo("")
|
||||
click.echo(click.style(
|
||||
f"Refusing to pack {name}: {len(failures)} validation problem(s)",
|
||||
fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
plugin_dir = Path(pm.loader.plugins_dir) / name
|
||||
private_key = load_private_key(Path(key_path).read_bytes())
|
||||
artifact = pack(plugin_dir, private_key,
|
||||
publisher=publisher, out_dir=out_dir)
|
||||
click.echo("")
|
||||
click.echo(click.style(f"Packed + signed: {artifact}", fg='green'))
|
||||
|
||||
|
||||
@plugin_cli.command('apply-profile')
|
||||
|
||||
Reference in New Issue
Block a user