An adversarial security review of the Phase 2 trust model found four real
bypasses (two remote-triggerable to in-process code execution). Root cause for
three: the set of bytes verification covered was smaller than the set that
determined execution. Fixes:
1. Bytecode-cache blind spot (CRITICAL). verify_dir excluded __pycache__/.pyc,
so a planted cache ran while escaping the hash map. verify_dir now flags any
bytecode as an unexpected file; the loader strips bytecode before verify and
imports under sys.dont_write_bytecode, so only verified source executes.
2. Unauthenticated verify-at-load bypass (CRITICAL). load_plugin_class imported
plugin.py with no gate, reachable via discover_available / an anonymous GET
/api/plugins. The verify+strip gate moved INTO load_plugin_class - the single
import choke point every path flows through - so an unsigned/tampered plugin
is never imported. discover_available skips a refused plugin instead of 500.
3. Ungated migration entrypoints (HIGH). downgrade_plugin and get_current_head
(ScriptDirectory imports version modules) ran plugin code with no check. All
alembic-invoking methods now pass through _verify_ok (strip + verify) first
and run under no-bytecode.
4. Revocation/content bypass (HIGH). The signed index bound a filename, not
content; adopt did not bind the delivered bytes to the resolved version, so
revoked bytes could be served under a live filename. The index now records a
per-artifact SHA-256; adopt verifies the on-disk digest and requires the
artifact's own signed manifest version to equal the resolved version.
Enforcement stays default-off; strip/no-bytecode run only under enforcement, so
the unsigned path is unchanged. 6 regression tests (planted bytecode, the
discover import path, downgrade gate, version-swap). 1054 pass, naming green.
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.