# 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 `.key` OFFLINE 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 `.pub` with each site's deployed config and pin it (below). - Rotation: generate a new pair, pin BOTH public keys on sites for an overlap window (`verify` accepts any trusted key), then retire the old one. ## Per plugin: review, then pack 1. Review the plugin's source. This is the security gate - read what it does. 2. Validate and package in one step: ``` flask plugin pack printers --key ./keys/curator.key --publisher west-jefferson ``` `pack` refuses to sign a directory that does not validate (manifest schema, name/dir match, core_version, dependencies on disk). On success it writes `printers-.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 exact `PROVENANCE.json` bytes. 3. 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: 1. 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.key ``` This writes `PROVENANCE.json` + `PROVENANCE.sig` into each in-tree plugin. 2. 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.