Files
shopdb-flask/docs/PLUGIN-SIGNING.md
cproudlock dd30ca0c3f ADR-013 Phase 2: verify every plugins.* import via a meta_path guard
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.
2026-07-18 21:47:32 -04:00

5.7 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 .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.