Files
shopdb-flask/docs/PLUGIN-SIGNING.md
cproudlock 5b19f3b554 ADR-013 Phase 2: enforcement + signed shelf + adopt
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.
2026-07-18 20:44:54 -04:00

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