Signing and trust
Last updated 2026-09-21
Nothing unsigned runs on a Plum Box. But the signature that matters is yours, not ours: you sign your bundle on your machine, the store adds a countersignature saying what it checked, and the box's owner decides which of those they require. If Plum disappears, boxes keep verifying and you keep shipping.
Your two keys
plum-dev keygen # once, ever
This writes two Ed25519 keys into ~/.config/plum-dev/:
- the signing key (
publisher.key, mode 0600) — signs every release; - the recovery key (
publisher.key.recovery+recovery.pub) — signs nothing day to day, and exists so that losing the signing key does not orphan your app. Move it off this machine.
A key's id (kid) is pub- followed by the first 16 hex characters of the SHA-256 of the raw
public key. Public keys are written ed25519:<base64url>. Register both public keys in the
console under Signing keys; the store refuses uploads signed by an unregistered key.
What a signed .plu contains
A bundle is a zip. Signing adds a META/ directory:
| Entry | Contents |
|---|---|
META/MANIFEST.sha256 |
one <sha256> <path> line per non-META file, sorted |
META/publisher.pub |
your signing public key |
META/publisher.sig |
Ed25519 signature over "plum-publisher-v1\n" + <MANIFEST bytes> |
META/recovery.pub |
your recovery public key (unless --no-recovery) |
META/rotation.json |
present only in a release that changes keys |
Verification is: the MANIFEST covers exactly the files in the zip, every hash matches, and the
signature over the MANIFEST is valid. plum-dev package produces this deterministically (sorted
entries, fixed timestamps), so the same source gives the same bytes and the same sha256.
plum-dev package . # -> dev.jan.notes-0.2.0.plu … bytes sha256 …
plum-dev inspect dev.jan.notes-0.2.0.plu
The store's countersignature
The store signs a small sidecar over the bundle's hash, never the bundle itself, so the file you
built is the file that ships. The signed payload is five newline-separated fields —
app_id, version, sha256, size, kind — under the domain prefix plum-bundle-v1.
Two kinds:
checks— the automatic checks passed. Applied at upload, so beta versions have it.reviewed— a person reviewed it. Applied when a public version is approved.
The countersignature travels in the catalog as
{"alg": "ed25519", "kid", "sig", "kind", "payload": "plum-bundle-v1"} and on the download
response as X-Plum-Countersign, X-Plum-Countersign-Kid and X-Plum-Countersign-Kind. Boxes
carry the store's public keys compiled in; there is no key-publication endpoint to trust.
What the box's owner decides
The owner picks a trust level for their box:
- Store countersignature only (the default) — installs what the store signed.
- Plus publishers I added — keys the owner added by pairing, optionally limited to one
namespace. This is what makes
plum-dev pushwork on your own box. - Any signed publisher — with a warning, trust-on-first-use for a key it has not seen.
Unsigned is refused at every level. The box records which key signed each installed app, and updates must be signed by the same key — a different key is refused unless the new version carries a valid rotation record.
Testing on your own box
There is no developer-mode toggle and no unsigned sideload path. Instead you pair:
plum-dev pair https://pb-1234.plumbox.me --namespace dev.jan.
# the box shows a 6-digit code in Settings › Developer; type it in
plum-dev push . --logs
The code appears only in the box's own settings (Developer), it expires in five minutes, and
confirming it does three things: your key joins that box's trusted publishers, scoped to your
namespace prefix; the box raises its trust level to 2 if it was lower; and the CLI receives a
token with the apps:install and apps:dev scopes. You cannot attach yourself to a box whose
owner does not read you the code.
An app installed this way carries your key and no countersignature, so the box leaves it out of automatic updates. The owner can drop your key again from the same settings page.
Rotation: changing your signing key
Boxes enforce key continuity, so a new key needs a rotation record — a small signed JSON
object shipped as META/rotation.json in the release that introduces the new key. It names
app_id, old_pub, new_pub, issued_at, signer and the signature, and is signed under the
domain prefix plum-rotation-v1.
Three signers are accepted, in the order you should want them:
# 1. you still have the old key
plum-dev rotate --app-id dev.jan.notes --old ./old-publisher.key -o rotation.json
# 2. the old key is gone; the recovery key signs instead
plum-dev rotate --app-id dev.jan.notes --recovery ./publisher.key.recovery \
--old-pub ed25519:<the old public key> -o rotation.json
plum-dev package . --rotation rotation.json
- Store-assisted, when both keys are gone. Request it on the app page in the console (from a console login — a CLI token is refused). The store mails you, waits 7 days, then signs a rotation record with its own key; you can cancel during the wait. Boxes at levels 1 and 2 accept it. Register the new key under Signing keys first.
Escrow: an encrypted backup of a key
Optional, and the store cannot read it:
plum-dev escrow seal -o escrow.txt # asks for a passphrase
plum-dev escrow seal --recovery-key -o rec.txt # the recovery key instead
plum-dev escrow open escrow.txt -o publisher.key
seal encrypts the key file with scrypt (N=2^15) and AES-256-GCM under a passphrase of at least
8 characters, producing v1.scrypt.<salt>.<nonce>.<ciphertext>. Paste that blob into the console
and the store keeps it as an opaque string. Lose the passphrase and the backup is lost with it.
Storing and reading an escrow blob needs a console login, not a CLI token.
When an install is refused
| Code | HTTP | Meaning |
|---|---|---|
signature_invalid |
422 | META/ is incomplete, a hash does not match, or the signature does not verify |
publisher_untrusted |
403 | this box does not trust that key at this trust level |
countersign_required |
403 | trust level 1: the store has not vouched for this bundle |
publisher_changed |
409 | a different key than the installed version, with no valid rotation record |
app_id_mismatch, server_bin_arch, sha256_mismatch |
422 | the bundle is not what was asked for, is not arm64, or does not match its hash |
The store's own refusals at upload time are listed in Publishing.
The honest cost
Lose the signing key, the recovery key and the escrow passphrase, and the only way back is the 7-day store-assisted rotation. Keep the recovery key somewhere the laptop is not.