Completes the marketplace security model. Verification stops being advisory: a plugin only loads or migrates when its tree matches a trusted signature, and plugins are pulled from a signed shelf with anti-rollback and revocation. Enforcement (default OFF - existing deploys unchanged): - verification.py PluginVerifier, shared by the loader (verify-at-load, before plugin.py is imported) and the migration manager (verify-at-migrate, before any DDL). Fail-closed: an unsigned/tampered/wrong-key plugin does not run. - Gated by PLUGIN_REQUIRE_SIGNED. PLUGIN_DEV_TRUST_DIRS exempts named dirs but only under DEBUG/TESTING; production ignores it. - flask plugin stamp-bundled writes provenance into in-tree plugins so verify-at-load applies to bundled plugins too (image build step). - tier:core manifest guard: uninstall/disable refuse a core-tier plugin. Shelf (shelf.py): - Signed shelf-index.json (+ .sig): monotonic serial (a site refuses an older index - anti-rollback), revoked list carried across builds, per-entry version/tier/core_version for browse. Index is a browse layer only; adopt reads security-bearing fields from the verified artifact. - flask plugin shelf-build / shelf-list / adopt / audit. adopt verifies index + artifact (signature + every file hash), unpacks to staging, re-verifies, then atomically moves into place and installs+enables the closure. Refuses a downgrade without --force-downgrade. Anti-rollback serial stored in instance/shelf-state.json. - config PLUGIN_SHELF_DIR; the app only reads the folder, never speaks a network. .env.example + docs/PLUGIN-SIGNING.md document the flow. 22 tests: verifier policy (off / no-keys / signed / tampered / wrong-key / dev-exempt), verify-at-load + verify-at-migrate integration, tier guard, index sign/verify + tamper/wrong-key, serial state, revocation, version resolution, verified atomic unpack + tamper refusal. Live-smoked keygen->pack->shelf-build ->list->adopt->audit + serial guard. 1050 pass, naming green.
4.8 KiB
Plugin signing and packaging (curator guide)
ADR-013 Phase 1. How a plugin becomes a signed, verifiable artifact and how a site trusts it. The signature proves an artifact is EXACTLY what a curator reviewed and signed - it does not prove the code is safe. Human review before signing is the actual safety control; the signature makes that review's verdict tamper-evident all the way to the point of execution.
Requires the cryptography package (already a dependency).
One-time: create the publisher key pair
flask plugin keygen --out ./keys --name curator
Writes keys/curator.key (PRIVATE) and keys/curator.pub (public).
- Keep the
.keyOFFLINE with the curator. It is the only thing that can sign a trusted artifact. Never put it on the plugin shelf or in the repo. - Distribute the
.pubwith each site's deployed config and pin it (below). - Rotation: generate a new pair, pin BOTH public keys on sites for an overlap
window (
verifyaccepts any trusted key), then retire the old one.
Per plugin: review, then pack
-
Review the plugin's source. This is the security gate - read what it does.
-
Validate and package in one step:
flask plugin pack printers --key ./keys/curator.key --publisher west-jeffersonpackrefuses to sign a directory that does not validate (manifest schema, name/dir match, core_version, dependencies on disk). On success it writesprinters-<version>.shopdbplugin- a zip of the plugin plus:PROVENANCE.json: name, version, publisher, created, and a sorted{file: sha256}map of every packaged file.PROVENANCE.sig: a detached ed25519 signature over the exactPROVENANCE.jsonbytes.
-
Publish the artifact to the shelf (a SharePoint-synced or copied folder). Transport is untrusted; the signature is what makes it safe.
Verify an artifact
flask plugin validate dist/printers-1.0.0.shopdbplugin --pubkey ./keys/curator.pub
Checks, fail-closed: signature against the trusted key(s), every file's hash,
no unexpected files, manifest schema, and that the plugin's core_version
admits this framework's contract version. Any changed byte in any file fails
the hash check; a signature from an untrusted key fails the signature check.
Pin trusted keys on a site
Set PLUGIN_TRUSTED_KEYS to one or more public-key PEM paths, separated by the
OS path separator (: on Linux, ; on Windows), in the site's environment:
PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub:/etc/shopdb/keys/curator-next.pub
Keys are read only from this deployed config, never from the shelf - a folder an
attacker could write must not also carry the keys that authenticate it. With no
keys set, validate on an artifact fails closed (unverifiable).
Enforce signatures (Phase 2)
By default nothing is enforced - plugins load unsigned, as before. To require signatures on a site:
-
Stamp the plugins the image ships with, so verify-at-load applies to them too (run at image build with the site/build key):
flask plugin stamp-bundled --key ./keys/curator.keyThis writes
PROVENANCE.json+PROVENANCE.siginto each in-tree plugin. -
Pin the public key(s) and turn enforcement on (site config):
PLUGIN_TRUSTED_KEYS=/etc/shopdb/keys/curator.pub PLUGIN_REQUIRE_SIGNED=true
Now a plugin only loads (verify-at-load) or migrates (verify-at-migrate) when
its tree matches a trusted signature. An unsigned, tampered, or wrong-key
plugin is refused - fail-closed. PLUGIN_DEV_TRUST_DIRS exempts named
directories, but ONLY under DEBUG/TESTING (the external-repo dev workflow);
production ignores it.
The shelf and adopt (Phase 2)
A shelf is a read-only folder of artifacts plus a signed index. The app reads
PLUGIN_SHELF_DIR; it never talks to SharePoint - a sync (or robocopy/USB)
populates that folder, and the signature makes the transport untrusted and
interchangeable.
Publish (curator, after packing artifacts into the shelf folder):
flask plugin shelf-build --dir /srv/shelf --key ./keys/curator.key --serial 3
The index carries a monotonic serial (a site refuses an index older than the
last it saw) and a revoked list (carried forward across builds). Bump
--serial on every publish.
On a site:
flask plugin shelf-list # browse (verifies index + serial)
flask plugin adopt printers # or printers==1.2.0
flask plugin audit # warn if an installed version is revoked
adopt verifies the shelf index and the artifact (signature + every file
hash), unpacks into a staging area, re-verifies, then atomically moves it into
place and installs + enables it with its dependency closure. It refuses a
downgrade unless --force-downgrade. Run flask plugin upgrade-all and restart
afterward so migrations apply and routes register.