Made Open

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

KeySignsPublic key shipped as
release-signingThe release manifest at releases.made-open.org/manifest.jsonRELEASE_SIGNING_PUBLIC_KEY env var, embedded at hub build time
plugin-registryThe plugin registry index at plugins.made-open.org/index.jsonPLUGIN_REGISTRY_PUBLIC_KEY env var, embedded at hub build time
Per-publisher plugin keysIndividual 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:

  1. Move .priv.hex to 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.
  2. Commit nothing from keys/. The directory ships a .gitignore that blocks everything, but double-check: git status keys/ should show nothing.
  3. 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's apps/hub/src/keys/release-signing-public.ts and plugin-publisher-keys.ts read 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:

  1. Removes any existing signature field from the top level.
  2. Re-serializes with JSON.stringify (plain-object insertion order).
  3. Signs those bytes with Ed25519.
  4. Re-attaches the signature as "ed25519:<128-hex>" and writes dist/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_URL pointed 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:

  1. Generate a new key (generate-signing-key.mjs release-signing-2027).
  2. 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.
  3. Wait for adoption. The weekly check cadence means new builds take 1-2 weeks to land even when users are paying attention.
  4. Once adoption is acceptable, start signing manifests with the new key.
  5. 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:

  1. Stop publishing immediately. Do not push any manifest until the response is complete.
  2. Rotate the key now, not later. Follow the rotation procedure above but compress the timeline.
  3. Ship a hub build that bakes in the new key and refuses the old one. A simple if (oldKey) throw in getReleaseSigningPublicKey is sufficient.
  4. Invalidate the CDN cache for any existing signed manifest so it can no longer be served even if an attacker kept a copy.
  5. 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.
  6. 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 ReleaseManifestSchema locally (pipe through the shared package's test fixtures).
  • Image digests are pinned (@sha256:...), not mutable tags.
  • migrationChecksum matches a local computation over the migration files that will ship with the release.
  • minUpgradeFrom matches the oldest version you intend to support upgrading from.
  • sign-json-manifest.mjs ran cleanly.
  • verify-json-manifest.mjs returned 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.