Release Signing Runbook
This document describes the operational process for generating, using, and rotating the Ed25519 keys that sign Made Open's release manifest and plugin registry index. It is intended for the person or team that actually cuts releases.
Everything the hub verifies at runtime is specified in the update management design spec. This runbook tells you how to produce what that spec assumes exists.
Why signing matters
The hub's update system trusts exactly one thing: the Ed25519 signature on the release manifest (and, separately, on the plugin registry index). Every other piece of trust — image digests, plugin permissions, migration checksums, trusted publisher fingerprints — is information carried inside the signed body. Compromise the signing key and an attacker can:
- Ship a malicious
ghcr.io/made-open/hub@sha256:…digest to every instance that has update checks enabled. - Downgrade users to a vulnerable earlier release.
- Silently trust a third-party plugin publisher and load arbitrary code into the plugin sandbox.
Treat the private key the way you'd treat the root CA for a private PKI. It does not live in CI. It does not live on a developer laptop that also reads email.
Keys in play
| Key | Signs | Public key shipped as |
|---|---|---|
release-signing | The release manifest at releases.made-open.org/manifest.json | RELEASE_SIGNING_PUBLIC_KEY env var, embedded at hub build time |
plugin-registry | The plugin registry index at plugins.made-open.org/index.json | PLUGIN_REGISTRY_PUBLIC_KEY env var, embedded at hub build time |
| Per-publisher plugin keys | Individual plugin tarball entries (signed over {version, sha256, manifest}) | Stored in trusted_publishers table by fingerprint after the user runs made-open plugin trust <fingerprint> |
The first two are project keys that ship to every instance. The per-publisher keys are the user's problem — third parties generate their own and the user opts in per-fingerprint.
This runbook only covers the project keys.
1. One-time key generation
Do this on an air-gapped or otherwise hardened workstation. Any laptop that hasn't been used for signing before needs to be wiped-and-reinstalled; do not reuse a general-purpose dev machine.
git clone https://github.com/drdropout/made-open.git
cd made-open
pnpm install --frozen-lockfile
# Release manifest key
node scripts/generate-signing-key.mjs release-signing-2026
# Plugin registry key
node scripts/generate-signing-key.mjs plugin-registry-2026
Each invocation writes two files under keys/ (gitignored):
<label>.priv.hex— 64-hex private key, mode 0600<label>.pub.hex— 64-hex public key
The label lets you distinguish key generations when you rotate. Pick a naming convention you can live with; <purpose>-<year> is fine for most projects.
The script prints the public key hex to stdout. Record it in your password manager (or a secure team share) — you will need it at build time.
After generation:
- Move
.priv.hexto offline storage immediately. Copy it to two independent hardware tokens (YubiKeys, encrypted USB sticks, whatever you use for TLS root material). Delete the on-disk copy. Verify you can read it back from both backups before declaring the key production-ready. - Commit nothing from
keys/. The directory ships a.gitignorethat blocks everything, but double-check:git status keys/should show nothing. - Publish the public key hex where the hub build can pick it up. For GitHub Actions this means a repository secret named
RELEASE_SIGNING_PUBLIC_KEY/PLUGIN_REGISTRY_PUBLIC_KEY. The hub'sapps/hub/src/keys/release-signing-public.tsandplugin-publisher-keys.tsread these at startup.
2. Signing a release manifest
Given a draft manifest at dist/manifest.json (see the update-management design spec for the required shape), sign it:
# From a workstation that has the private key accessible.
node scripts/sign-json-manifest.mjs \
dist/manifest.json \
/secure/offline/release-signing-2026.priv.hex \
dist/manifest.signed.json
The script:
- Removes any existing
signaturefield from the top level. - Re-serializes with
JSON.stringify(plain-object insertion order). - Signs those bytes with Ed25519.
- Re-attaches the signature as
"ed25519:<128-hex>"and writesdist/manifest.signed.json.
Do not post-process the output. Do not run jq against it, do not pretty-print it differently, do not delete trailing newlines. Any of those will reorder or alter bytes and the hub will reject the result.
Always verify locally before publishing:
node scripts/verify-json-manifest.mjs \
dist/manifest.signed.json \
keys/release-signing-2026.pub.hex
Exit 0 means the signature round-trips correctly. Anything else is a bug and must be investigated before the manifest goes to the CDN.
3. Signing a plugin registry index
Same script, different inputs:
node scripts/sign-json-manifest.mjs \
dist/plugin-index.json \
/secure/offline/plugin-registry-2026.priv.hex \
dist/plugin-index.signed.json
node scripts/verify-json-manifest.mjs \
dist/plugin-index.signed.json \
keys/plugin-registry-2026.pub.hex
The RemoteRegistryIndexSchema in packages/shared/src/types/plugin-registry.ts defines the shape that the hub expects. The signature field is required at the top level and must be verified by the PluginRegistryClient before any plugin is installed.
Individual plugin entries inside the index carry their own publisher signatures (over {version, sha256, manifest}) — those are generated by the plugin authors using their own keys, not by this runbook.
4. Publishing
Once you have a verified *.signed.json:
# Example using any static host. Replace with your actual publishing target.
aws s3 cp dist/manifest.signed.json \
s3://made-open-releases/manifest.json \
--content-type application/json \
--cache-control "public, max-age=300"
Key publish-time rules:
- Atomic swap only. Write to a temporary object, then rename or switch a pointer. Half-written JSON on the CDN is the worst possible state because it may still parse.
- Short cache TTLs. Hours, not days. Users who enable update checks will poll weekly by default, but short TTLs give you a fast rollback path if you ship a bad manifest.
- Log the current signature hex alongside each publish. Both for audit and so users with
UPDATE_MANIFEST_URLpointed at a mirror can cross-check.
5. Key rotation (planned)
Rotate the project signing keys at least once a year, or immediately on any suspicion of compromise.
The mechanics are ordinary:
- Generate a new key (
generate-signing-key.mjs release-signing-2027). - Roll out a hub build that embeds the new public key. Users must install this build before you cut the next manifest — otherwise their verification will fail and their instances will stop seeing updates.
- Wait for adoption. The weekly check cadence means new builds take 1-2 weeks to land even when users are paying attention.
- Once adoption is acceptable, start signing manifests with the new key.
- After a further grace period, destroy the old private key.
Made Open does not currently support multi-key manifests (e.g., detached signatures from both old and new keys) — when you cut over, you commit to a hard cutover. If that changes, this runbook needs an update.
6. Compromise response
If the private key leaks or you have credible reason to suspect it has:
- Stop publishing immediately. Do not push any manifest until the response is complete.
- Rotate the key now, not later. Follow the rotation procedure above but compress the timeline.
- Ship a hub build that bakes in the new key and refuses the old one. A simple
if (oldKey) throwingetReleaseSigningPublicKeyis sufficient. - Invalidate the CDN cache for any existing signed manifest so it can no longer be served even if an attacker kept a copy.
- Tell users. Every self-hosted instance is operated by a different person; you cannot patch them in place. A GitHub release note and a post-install message at the next hub boot are the best you can do. Be explicit about why rotation is required and what a compromised manifest could have shipped.
- Post-mortem. Document how the key was exposed, what control failed, and what you'll change before generating the next key.
7. Checklist before cutting a release
Run through this before each signed publish:
- Draft manifest parses against the
ReleaseManifestSchemalocally (pipe through the shared package's test fixtures). - Image digests are pinned (
@sha256:...), not mutable tags. -
migrationChecksummatches a local computation over the migration files that will ship with the release. -
minUpgradeFrommatches the oldest version you intend to support upgrading from. -
sign-json-manifest.mjsran cleanly. -
verify-json-manifest.mjsreturned exit 0 against the signed output. - The signed manifest was written to the publish target via an atomic rename.
- CDN cache TTL is no longer than a few hours.
- The new signature hex is recorded in the release audit log.
8. Related reading
- Update management design spec
ManifestClient.ts— how the hub fetches and verifiesVerifier.ts— the Ed25519 verification primitivePluginRegistryClient.ts— plugin-side equivalentsign-json-manifest.mjs— the signing script referenced heregenerate-signing-key.mjs— the key-generation scriptverify-json-manifest.mjs— the local verification script