A re-review showed the previous "single import choke point" claim was wrong: `plugins` is a normal importable package, so core request handlers that do `from plugins.<name>.models import ...` never passed through the loader and ran unverified - an attacker who dropped a file into plugins/<name>/ got arbitrary in-process code execution on an ordinary HTTP request (and a planted .pyc ran from cache). Gating load_plugin_class covered only plugin.py, one path of many. Fix: importguard.py installs a sys.meta_path finder (under enforcement) that intercepts EVERY plugins.<name>.* import, verifies the plugin's signed provenance once, then verifies each module file against it and execs the exact bytes it hashed - read once, compiled, exec'd, never a .pyc, never a re-opened file. This closes the submodule bypass and the planted-bytecode read, and the read-once exec closes the verify-vs-exec TOCTOU on the import path. The import system, not one method, is the real choke point. - init_app installs the guard when PLUGIN_REQUIRE_SIGNED, clears it otherwise. - load_plugin_class now verifies plugin.py from a single read and execs that buffer (finding #3 on that file); its submodule imports flow through the guard. - docs: stamp-bundled must cover every plugin dir present (a disabled plugin's module can be imported by core); recommend a read-only plugins/ owned by the deploy user as defense in depth (closes the residual migrate-time race an attacker with concurrent write could otherwise attempt). Earlier review's fixes #3 (migrate code paths) and #4 (shelf content binding) were confirmed sound and are unchanged. 7 import-guard tests (submodule verify, tamper, unsigned refused, planted .pyc ignored, real import through the guard, install/uninstall). 1061 pass, naming green.
142 lines
5.7 KiB
Markdown
142 lines
5.7 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 or migrates when its tree matches a trusted signature.
|
|
Under enforcement a `sys.meta_path` guard verifies EVERY `plugins.<name>.*`
|
|
import (not just `plugin.py`) - including the `from plugins.<name>.models import
|
|
...` that core request handlers do - against the plugin's signed provenance, and
|
|
executes the exact bytes it hashed (never a `.pyc`). 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.
|
|
|
|
Because every `plugins.*` import is verified, `stamp-bundled` must cover EVERY
|
|
plugin directory present (its no-argument form does), not only the enabled ones
|
|
- core code can import a disabled plugin's module, and an unstamped one would be
|
|
refused.
|
|
|
|
Defense in depth - set filesystem permissions so the app's runtime user CANNOT
|
|
write the `plugins/` directory (owned by the deploy user). Import-time
|
|
verification closes the "attacker drops a file, a request imports it" path; a
|
|
strict read-only `plugins/` also closes the narrow verify-vs-migrate race where
|
|
an attacker with concurrent write to `plugins/` swaps a migration script between
|
|
the check and alembic re-reading 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.
|