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:

  1. Store countersignature only (the default) — installs what the store signed.
  2. Plus publishers I added — keys the owner added by pairing, optionally limited to one namespace. This is what makes plum-dev push work on your own box.
  3. 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
  1. 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.