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.
127 lines
4.8 KiB
Markdown
127 lines
4.8 KiB
Markdown
# 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-<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 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.
|