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:
cproudlock
2026-07-18 20:21:57 -04:00
parent d178726687
commit 86f5f1be68
7 changed files with 687 additions and 27 deletions

View File

@@ -0,0 +1,153 @@
"""Signed plugin artifact packaging tests (ADR-013 Phase 1).
Covers the provenance + ed25519 signing that the marketplace trust model rests
on: a valid signature over an intact file set verifies, and every tampering
path (flipped file byte, wrong key, missing/extra file, no key) fails closed.
"""
import json
import zipfile
import pytest
from shopdb.plugins import signing, packaging
def _write_plugin(plugin_dir, name='demo', version='1.0.0', deps=None):
plugin_dir.mkdir(parents=True, exist_ok=True)
(plugin_dir / 'manifest.json').write_text(json.dumps({
'name': name, 'version': version, 'description': 'demo',
'dependencies': deps or [], 'core_version': '>=0.1.0,<1.0.0',
'api_prefix': f'/api/{name}',
}))
(plugin_dir / 'plugin.py').write_text('# demo plugin\n')
(plugin_dir / 'models').mkdir()
(plugin_dir / 'models' / '__init__.py').write_text('')
# noise that must NOT be packed or hashed
(plugin_dir / '__pycache__').mkdir()
(plugin_dir / '__pycache__' / 'x.pyc').write_bytes(b'\x00\x01')
return plugin_dir
# --- signing primitives -----------------------------------------------------
def test_sign_verify_round_trip():
private_pem, public_pem = signing.generate_keypair()
priv = signing.load_private_key(private_pem)
pub = signing.load_public_key(public_pem)
data = b'provenance bytes'
sig = signing.sign(priv, data)
assert signing.verify([pub], data, sig) is True
assert signing.verify([pub], b'other bytes', sig) is False
def test_wrong_key_does_not_verify():
priv = signing.load_private_key(signing.generate_keypair()[0])
other_pub = signing.load_public_key(signing.generate_keypair()[1])
sig = signing.sign(priv, b'data')
assert signing.verify([other_pub], b'data', sig) is False
def test_provenance_excludes_noise(tmp_path):
plugin_dir = _write_plugin(tmp_path / 'demo')
filemap = signing.build_file_map(plugin_dir)
assert 'manifest.json' in filemap
assert 'models/__init__.py' in filemap
assert not any('__pycache__' in k or k.endswith('.pyc') for k in filemap)
assert signing.PROVENANCE_NAME not in filemap
def test_serialize_provenance_is_order_independent():
a = {'name': 'x', 'version': '1', 'files': {'b': '2', 'a': '1'}}
b = {'files': {'a': '1', 'b': '2'}, 'version': '1', 'name': 'x'}
assert signing.serialize_provenance(a) == signing.serialize_provenance(b)
# --- pack + verify ----------------------------------------------------------
@pytest.fixture
def keypair():
private_pem, public_pem = signing.generate_keypair()
return (signing.load_private_key(private_pem),
signing.load_public_key(public_pem))
def test_pack_then_verify_ok(tmp_path, keypair):
priv, pub = keypair
plugin_dir = _write_plugin(tmp_path / 'demo')
artifact = packaging.pack(plugin_dir, priv, publisher='site-a',
out_dir=tmp_path / 'dist')
assert artifact.name == 'demo-1.0.0.shopdbplugin'
manifest, errors = packaging.verify_artifact(artifact, [pub])
assert errors == []
assert manifest['name'] == 'demo'
def test_verify_fails_with_no_keys(tmp_path, keypair):
priv, _ = keypair
artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv,
out_dir=tmp_path / 'dist')
_, errors = packaging.verify_artifact(artifact, [])
assert any('unverifiable' in e for e in errors)
def test_verify_fails_with_wrong_key(tmp_path, keypair):
priv, _ = keypair
wrong_pub = signing.load_public_key(signing.generate_keypair()[1])
artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv,
out_dir=tmp_path / 'dist')
_, errors = packaging.verify_artifact(artifact, [wrong_pub])
assert any('signature does not match' in e for e in errors)
def test_verify_detects_tampered_file(tmp_path, keypair):
priv, pub = keypair
artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv,
out_dir=tmp_path / 'dist')
# Rewrite the zip with one member's bytes changed, keeping provenance + sig.
with zipfile.ZipFile(artifact) as zin:
members = {n: zin.read(n) for n in zin.namelist()}
members['manifest.json'] += b'tampered'
with zipfile.ZipFile(artifact, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in members.items():
zout.writestr(name, data)
_, errors = packaging.verify_artifact(artifact, [pub])
assert any('hash mismatch' in e for e in errors)
def test_verify_detects_extra_file(tmp_path, keypair):
priv, pub = keypair
artifact = packaging.pack(_write_plugin(tmp_path / 'demo'), priv,
out_dir=tmp_path / 'dist')
with zipfile.ZipFile(artifact, 'a', zipfile.ZIP_DEFLATED) as z:
z.writestr('sneaky.py', 'print("surprise")')
_, errors = packaging.verify_artifact(artifact, [pub])
assert any('unexpected file' in e for e in errors)
def test_verify_dir_round_trip(tmp_path, keypair):
"""An unpacked directory carrying PROVENANCE files verifies (Phase 2 uses
this for verify-at-load)."""
priv, pub = keypair
plugin_dir = _write_plugin(tmp_path / 'demo')
provenance = signing.build_provenance(plugin_dir, 'demo', '1.0.0', 'site-a')
prov_bytes = signing.serialize_provenance(provenance)
(plugin_dir / signing.PROVENANCE_NAME).write_bytes(prov_bytes)
(plugin_dir / signing.PROVENANCE_SIG).write_bytes(
signing.sign(priv, prov_bytes))
_, errors = packaging.verify_dir(plugin_dir, [pub])
assert errors == []
# Tamper a file on disk -> hash mismatch.
(plugin_dir / 'plugin.py').write_text('# changed\n')
_, errors = packaging.verify_dir(plugin_dir, [pub])
assert any('hash mismatch' in e for e in errors)