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