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:
136
shopdb/plugins/signing.py
Normal file
136
shopdb/plugins/signing.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Ed25519 signing + provenance for plugin artifacts (ADR-013 Phase 1).
|
||||
|
||||
A plugin's provenance is a per-file SHA-256 map plus metadata (name, version,
|
||||
publisher, created). The detached signature covers the EXACT serialized
|
||||
provenance bytes, so verifying a plugin is: re-hash its files, re-serialize the
|
||||
provenance the same way, and check the signature. Any changed byte in any file
|
||||
changes a hash, which changes the serialized provenance, which fails the
|
||||
signature. The signature proves the artifact is exactly what a curator reviewed
|
||||
and signed - nothing about what the code does.
|
||||
|
||||
Uses the cryptography package (already a dependency for MySQL 8 auth).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
PROVENANCE_NAME = 'PROVENANCE.json'
|
||||
PROVENANCE_SIG = 'PROVENANCE.sig'
|
||||
|
||||
# Never packed, hashed, or counted as an unexpected artifact member.
|
||||
_EXCLUDE_DIRS = {'__pycache__', '.pytest_cache', '.mypy_cache'}
|
||||
_EXCLUDE_SUFFIXES = {'.pyc', '.pyo'}
|
||||
_CHUNK = 65536
|
||||
|
||||
|
||||
def generate_keypair():
|
||||
"""Return (private_pem, public_pem) as PEM bytes for a new ed25519 key."""
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
private_pem = private_key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return private_pem, public_pem
|
||||
|
||||
|
||||
def load_private_key(pem_bytes: bytes):
|
||||
return serialization.load_pem_private_key(pem_bytes, password=None)
|
||||
|
||||
|
||||
def load_public_key(pem_bytes: bytes):
|
||||
return serialization.load_pem_public_key(pem_bytes)
|
||||
|
||||
|
||||
def load_trusted_keys(pem_paths):
|
||||
"""Load public keys from a list of PEM file paths. Missing/unreadable paths
|
||||
are skipped (they simply cannot vouch for a signature)."""
|
||||
keys = []
|
||||
for path in pem_paths or []:
|
||||
try:
|
||||
keys.append(load_public_key(Path(path).read_bytes()))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return keys
|
||||
|
||||
|
||||
def _packable(plugin_dir: Path):
|
||||
"""Yield the plugin's real files (sorted, posix relpaths excluded from noise)."""
|
||||
for path in sorted(plugin_dir.rglob('*')):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(plugin_dir)
|
||||
if any(part in _EXCLUDE_DIRS for part in rel.parts):
|
||||
continue
|
||||
if path.suffix in _EXCLUDE_SUFFIXES:
|
||||
continue
|
||||
if path.name in (PROVENANCE_NAME, PROVENANCE_SIG):
|
||||
continue
|
||||
yield rel.as_posix(), path
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, 'rb') as handle:
|
||||
for chunk in iter(lambda: handle.read(_CHUNK), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_file_map(plugin_dir) -> dict:
|
||||
"""{posix relpath: sha256hex} for every packable file, sorted."""
|
||||
plugin_dir = Path(plugin_dir)
|
||||
return {relpath: _sha256_file(path) for relpath, path in _packable(plugin_dir)}
|
||||
|
||||
|
||||
def build_provenance(plugin_dir, name: str, version: str,
|
||||
publisher: str = '', created: str = None) -> dict:
|
||||
"""Provenance dict. `created` defaults to now (UTC, second precision)."""
|
||||
if created is None:
|
||||
created = datetime.now(timezone.utc).replace(
|
||||
microsecond=0, tzinfo=None).isoformat()
|
||||
return {
|
||||
'name': name,
|
||||
'version': version,
|
||||
'publisher': publisher or '',
|
||||
'created': created,
|
||||
'files': build_file_map(plugin_dir),
|
||||
}
|
||||
|
||||
|
||||
def serialize_provenance(provenance: dict) -> bytes:
|
||||
"""Canonical bytes that get signed AND stored, so sign and verify agree.
|
||||
|
||||
sort_keys + compact separators make this deterministic; the same dict always
|
||||
serializes to the same bytes regardless of insertion order.
|
||||
"""
|
||||
return json.dumps(
|
||||
provenance, sort_keys=True, separators=(',', ':')).encode('utf-8')
|
||||
|
||||
|
||||
def sign(private_key, data: bytes) -> bytes:
|
||||
return private_key.sign(data)
|
||||
|
||||
|
||||
def verify(public_keys, data: bytes, signature: bytes) -> bool:
|
||||
"""True if the signature validates against ANY trusted public key.
|
||||
|
||||
Multiple keys support overlap during key rotation.
|
||||
"""
|
||||
for key in public_keys:
|
||||
try:
|
||||
key.verify(signature, data)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
continue
|
||||
return False
|
||||
Reference in New Issue
Block a user