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

@@ -60,6 +60,16 @@ class Config:
# API key for the unattended PowerShell collector scripts
COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '')
# Trusted plugin publisher public keys (ADR-013). os.pathsep-separated PEM
# file paths, delivered out-of-band with the deployed config - NEVER read
# from the plugin shelf. Used to verify signed plugin artifacts. Empty on a
# site that does not adopt marketplace plugins.
PLUGIN_TRUSTED_KEYS = [
path.strip()
for path in os.environ.get('PLUGIN_TRUSTED_KEYS', '').split(os.pathsep)
if path.strip()
]
ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true'
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')