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.
154 lines
5.7 KiB
Python
154 lines
5.7 KiB
Python
"""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)
|