feat: signed plugin index with validation and publish pipeline
The catalog the Punktfunk plugin store fetches. Served straight out of this repository over Gitea's anonymous raw endpoint: https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig Hosts verify the ed25519 signature against a compiled-in public key and only then parse. Verified that the raw endpoint serves blobs byte-for-byte, which the signature depends on; .gitattributes pins LF so a Windows checkout cannot break it from the other direction. Entries pin one exact version plus that version's registry tarball integrity hash -- no ranges, no "latest". A plugin author publishing a new version changes nothing for users; the new version becomes installable only when a reviewer works the checklist and lands a new pinned entry here. That data shape is what makes "verified on every release" enforceable rather than a promise. Seeded with the two first-party plugins, both integrity hashes confirmed against the live registry: - @punktfunk/plugin-rom-manager 0.3.1 (linux, windows) - @punktfunk/plugin-playnite 0.1.1 (windows) Tooling (bun + TypeScript, node builtins only): - validate: every field rule the host enforces, plus a live registry cross-check that the pinned version exists and its dist.integrity matches the pin. Strict on unknown keys, since the host silently drops entries that fail validation -- a `min_host` typo would otherwise ship as a missing version floor with no error anywhere. - sign / verify / keygen: ed25519 over the exact bytes of index.json. keygen never prints the private key; verify defaults to the host-pinned public key so an index can be audited with no arguments. CI splits by trust: pull requests run validate only and hold no secrets, so a fork PR can never reach the signing key or a token that can write to main. Publishing from main validates, signs, self-verifies, then commits the signature back. The loop guard is a paths-ignore filter on v1/index.json.sig, with a [skip ci] marker as a second line of defence; ed25519 determinism means an unchanged index re-signs to identical bytes and commits nothing at all. CI signs after the merge, so there is a brief window where index.json is newer than its signature. It fails closed -- hosts reject the document and keep their last good cached catalog -- and is documented as such in the README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
# Punktfunk plugin index
|
||||
|
||||
The signed catalog of plugins that the Punktfunk plugin store shows you.
|
||||
|
||||
A Punktfunk host fetches two files, served directly from this repository over
|
||||
Gitea's anonymous raw endpoint:
|
||||
|
||||
```
|
||||
https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json
|
||||
https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig
|
||||
```
|
||||
|
||||
The first is the catalog; the second is an ed25519 signature over the exact
|
||||
bytes of the first. The host pins the index URL and derives the signature URL
|
||||
by appending `.sig`.
|
||||
|
||||
The host verifies the signature against a public key compiled into the host
|
||||
binary, and only then parses the catalog. An index that fails the signature
|
||||
check is discarded whole — the host keeps whatever catalog it already had
|
||||
rather than trusting an unverified one.
|
||||
|
||||
This repository is the source of truth for that catalog. Everything in it
|
||||
exists to make one sentence true: **a plugin version becomes installable from
|
||||
the official store only after a human has reviewed that exact version.**
|
||||
|
||||
---
|
||||
|
||||
## The trust model
|
||||
|
||||
### The pin is the gate
|
||||
|
||||
A catalog entry does not point at a package. It points at **one exact version
|
||||
and that version's tarball integrity hash**:
|
||||
|
||||
```json
|
||||
"pkg": "@punktfunk/plugin-rom-manager",
|
||||
"version": "0.3.0",
|
||||
"integrity": "sha512-b+CLy5+N/xnqVErqufbIs90gMcXmRvbH4fok9XE9nySGnFvWMBNQvZpVLjc6VDy8hKpsD5Zh1EGle4hn3B5HJQ=="
|
||||
```
|
||||
|
||||
There are no version ranges. There is no `latest`. A plugin author publishing
|
||||
a new version to the registry changes nothing for users — the store still
|
||||
offers the pinned version, and the host still installs exactly the bytes whose
|
||||
hash is written here. The new version becomes installable only when someone
|
||||
opens a pull request against this repository, a reviewer works the checklist,
|
||||
and the change is merged and re-signed.
|
||||
|
||||
That is what "verified" means on the store badge. Not "we trust this author" —
|
||||
*we read this build*.
|
||||
|
||||
The `integrity` hash makes the pin enforceable rather than advisory. Even if the
|
||||
registry were compromised and served different bytes for version 0.3.0, the hash
|
||||
would not match and the host would refuse the install.
|
||||
|
||||
### What the signature does and does not do
|
||||
|
||||
The signature proves the catalog came from unom and was not altered in transit
|
||||
or at rest on whatever is serving it. That matters especially here, where the
|
||||
serving infrastructure is a git host: anyone who could push to this repository,
|
||||
or tamper with what Gitea serves, still could not produce a catalog any host
|
||||
would accept without the signing key. It says nothing about the plugins
|
||||
themselves — that is the review's job. Two independent gates:
|
||||
|
||||
| Gate | Protects against | Enforced by |
|
||||
| --- | --- | --- |
|
||||
| ed25519 signature | a tampered or spoofed catalog | the host, before parsing |
|
||||
| pinned version + integrity | a tampered or unreviewed *plugin* | the host, at install |
|
||||
| human review | malicious or careless *code* | the pull request checklist |
|
||||
|
||||
### The badge is unom's alone
|
||||
|
||||
The "verified" badge means unom reviewed that specific version. It is a claim
|
||||
about work we did, so it **never transfers**. An operator who adds a third-party
|
||||
source gets that source's plugins listed without the badge, no matter what that
|
||||
source's own index says about itself. A third-party index cannot mark its
|
||||
entries as unom-verified, because the badge is not a field in this format — it
|
||||
is a property of *which source the entry came from*, decided host-side.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
v1/index.json the catalog (the only file that matters)
|
||||
v1/index.json.sig written by CI, committed, served as-is
|
||||
tools/validate.ts shape rules + live registry cross-check
|
||||
tools/sign.ts sign index.json with the private key
|
||||
tools/verify.ts verify a signature against a public key
|
||||
tools/keygen.ts generate a new signing keypair
|
||||
tools/keys.ts shared ed25519 key encoding helpers
|
||||
.gitea/workflows/validate.yml PR gate: validate only, no secrets
|
||||
.gitea/workflows/publish.yml main: validate, sign, verify, commit .sig
|
||||
.gitea/pull_request_template.md the review checklist
|
||||
```
|
||||
|
||||
Requires [Bun](https://bun.sh). No dependencies beyond Node builtins.
|
||||
|
||||
```sh
|
||||
bun run validate # check the catalog (hits the registry)
|
||||
bun run validate -- --offline # skip the network, shape rules only
|
||||
bun run validate -- --fix # rewrite in canonical formatting
|
||||
bun run verify # check the signature against the host-pinned key
|
||||
```
|
||||
|
||||
`validate` is the thing that fails a bad pull request. It checks every field
|
||||
rule the host enforces, and then checks each entry against its live registry:
|
||||
the pinned version must exist, and its `dist.integrity` must equal the pinned
|
||||
`integrity`, byte for byte.
|
||||
|
||||
It is deliberately strict about unknown keys. The host ignores fields it does
|
||||
not recognise, so a slip like `min_host` instead of `minHost` would parse
|
||||
"successfully" and silently lose its meaning — the entry would ship without a
|
||||
host-version floor and land on machines too old to run it. The validator treats
|
||||
that as an error and suggests the right spelling.
|
||||
|
||||
---
|
||||
|
||||
## The format
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "unom official",
|
||||
"generated": "2026-07-20T18:00:00Z",
|
||||
"plugins": [
|
||||
{
|
||||
"id": "rom-manager", // ^[a-z][a-z0-9-]*$, <=64, unique in this index
|
||||
"pkg": "@punktfunk/plugin-rom-manager", // MUST be scoped: @scope/name
|
||||
"registry": "https://git.unom.io/api/packages/unom/npm/", // https, trailing slash
|
||||
"title": "ROM Manager", // 1-64 chars
|
||||
"description": "...", // <=280 chars
|
||||
"icon": "gamepad-2", // lucide icon name, [a-z0-9-]{1,48}
|
||||
"author": "unom", // <=64 chars
|
||||
"homepage": "https://...", // https only
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"version": "0.3.0", // EXACT semver, never a range
|
||||
"integrity": "sha512-...", // the registry's dist.integrity, verbatim
|
||||
"verification": { "reviewedAt": "2026-07-20" },
|
||||
"minHost": "0.15.0", // optional, exact semver
|
||||
"platforms": ["linux", "windows"] // optional subset of linux|windows|macos
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"pkg": "@scope/plugin-name",
|
||||
"versions": "<0.3.2", // a Rust semver::VersionReq
|
||||
"reason": "Short description of the problem.",
|
||||
"url": "https://..." // optional advisory link
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Keys are **camelCase** (`minHost`, `reviewedAt`). Omitting `platforms`, or
|
||||
giving an empty array, means all platforms.
|
||||
|
||||
### `security` is about what is already installed
|
||||
|
||||
The `security` list is checked against plugins a host **already has installed**,
|
||||
not against the catalog. Removing an entry from `plugins` stops new installs,
|
||||
but does nothing for the people who installed it last month. A revocation is how
|
||||
you reach those machines.
|
||||
|
||||
Each `versions` string must parse as a Rust `semver::VersionReq` — for example
|
||||
`<0.3.2`, or `>=1.0.0, <1.2.0`. The validator implements that grammar and
|
||||
rejects anything the host could not parse. It also refuses a bare `*`, which
|
||||
would revoke every version a package ever had, and refuses a revocation that
|
||||
matches a version this same index still pins.
|
||||
|
||||
---
|
||||
|
||||
## Submitting a plugin
|
||||
|
||||
1. **Publish to a registry with a scoped package name.** Scoped is mandatory:
|
||||
`@yourscope/plugin-name`. The scope is what maps an entry to its registry, so
|
||||
an unscoped name has nowhere to resolve from. Unscoped names are rejected by
|
||||
the validator.
|
||||
|
||||
2. **Get the integrity hash from the registry.** Not from a local build — from
|
||||
whatever the registry actually serves:
|
||||
|
||||
```sh
|
||||
curl -sS "https://git.unom.io/api/packages/unom/npm/@yourscope%2fplugin-name" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)['versions']['1.0.0']['dist']['integrity'])"
|
||||
```
|
||||
|
||||
3. **Add your entry to `v1/index.json`** with that exact version and hash, and
|
||||
`verification.reviewedAt` set to the review date.
|
||||
|
||||
4. **Run `bun run validate`** and open a pull request. The template is the
|
||||
review checklist; fill it in honestly, including the "anything that gave you
|
||||
pause" field.
|
||||
|
||||
For a version bump, change `version`, `integrity`, and `reviewedAt`. Nothing
|
||||
else usually needs to move.
|
||||
|
||||
### How review works
|
||||
|
||||
CI proves the pinned bytes are the bytes the registry serves. It cannot tell you
|
||||
what those bytes do. A reviewer therefore diffs the new tarball against the
|
||||
previously pinned one and reads the changes, looking for undisclosed network
|
||||
endpoints, filesystem access beyond the plugin's purpose, obfuscated code,
|
||||
install lifecycle scripts, and unjustified dependencies. The full list is in
|
||||
`.gitea/pull_request_template.md`.
|
||||
|
||||
Review is against the **published tarball**, not the git repository. The tarball
|
||||
is what users execute; the repo is only evidence about it, and the two can
|
||||
differ.
|
||||
|
||||
When the pull request merges to `main`, CI validates, signs, self-verifies, and
|
||||
commits `v1/index.json.sig` back to `main`. Hosts pick the new catalog up on
|
||||
their next fetch — see [the signing window](#the-signing-window-and-why-it-fails-closed)
|
||||
for what happens to a host that fetches in the minute before the signature
|
||||
lands.
|
||||
|
||||
---
|
||||
|
||||
## Signing and key rotation
|
||||
|
||||
The signature is over the **exact bytes** of `v1/index.json` — no
|
||||
canonicalisation. Any whitespace change invalidates it, which is why the
|
||||
validator enforces one canonical formatting.
|
||||
|
||||
CI signs automatically from the `INDEX_SIGNING_KEY` secret (a PKCS#8 PEM). The
|
||||
private key exists in exactly one place — that secret — and reaches the signer
|
||||
through an environment variable, never a file in the workspace or a command-line
|
||||
argument. Pull requests run a separate workflow with no access to it, so a fork
|
||||
PR cannot get anything signed.
|
||||
|
||||
The public key currently pinned in the host:
|
||||
|
||||
```
|
||||
ed25519:n/KgBQSSDZqvyGHa8PkHWHZtV1zRXLk0BdQUi4/BD/w=
|
||||
```
|
||||
|
||||
`bun run verify` defaults to this key, so anyone can audit a published index
|
||||
with no arguments and no setup:
|
||||
|
||||
```sh
|
||||
bun run verify
|
||||
```
|
||||
|
||||
The base64 payload is the **raw 32-byte** ed25519 public key — not DER, not PEM.
|
||||
That is the form the host pins.
|
||||
|
||||
### Rotation
|
||||
|
||||
The host pins **two key slots**, which makes rotation a rollout rather than a
|
||||
flag day:
|
||||
|
||||
1. `bun run keygen` — writes a new PKCS#8 PEM (mode 0600, never printed to
|
||||
stdout) and prints the new public key.
|
||||
2. Put the new public key in the host's **second** slot, alongside the current
|
||||
one. Ship a host release that trusts both.
|
||||
3. Replace `INDEX_SIGNING_KEY` with the new private key. New publishes are now
|
||||
signed with the new key, and both old and new hosts accept them.
|
||||
4. Once enough hosts have the two-slot release, drop the old key from the host
|
||||
and delete its private half.
|
||||
|
||||
The overlap is what avoids bricking the store for anyone who has not updated. A
|
||||
host that only knows the old key keeps working until step 4, and a host on the
|
||||
new release accepts both. Never skip straight to signing with a key no shipped
|
||||
host pins — every host in the field would reject the catalog at once.
|
||||
|
||||
If a key is *compromised* rather than merely aged out, you cannot wait for the
|
||||
overlap: ship a host release that trusts only the new key, and treat every index
|
||||
signed by the old one as suspect.
|
||||
|
||||
---
|
||||
|
||||
## Third-party sources
|
||||
|
||||
An operator who wants plugins that are not in this index adds their own source:
|
||||
a URL to another `index.json` in this same format, plus the `ed25519:` public
|
||||
key that signs it. The host applies the identical machinery — signature check
|
||||
first, then pinned version and integrity on install.
|
||||
|
||||
What such a source does **not** get is the verified badge. Its plugins are shown
|
||||
as coming from that source, attributed to it, and unbadged, because unom did not
|
||||
review them. The badge tracks who did the review, not who serves the file.
|
||||
|
||||
This is the intended escape hatch. You are not limited to what unom has had time
|
||||
to review; you just have to decide for yourself whether to trust the source you
|
||||
are adding, and the host is honest with you about which is which.
|
||||
|
||||
---
|
||||
|
||||
## How the index is served
|
||||
|
||||
Straight out of this repository. Gitea serves raw files anonymously over HTTPS,
|
||||
so the committed files *are* the distribution:
|
||||
|
||||
```
|
||||
https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json
|
||||
https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig
|
||||
```
|
||||
|
||||
There is no separate static host and no deploy step. `v1/index.json.sig` is
|
||||
committed to `main` rather than ignored, because it is the published artifact —
|
||||
and keeping it in git history means every signature this index has ever carried
|
||||
is independently auditable, alongside the exact catalog bytes it covered.
|
||||
|
||||
Only CI writes it. If you sign locally to experiment, move the result to
|
||||
`scratch.sig` (gitignored) so a throwaway signature can never be mistaken for a
|
||||
real one. Until the first publish run has happened, `v1/index.json.sig` does not
|
||||
exist in the repository at all — that is expected, not a missing file.
|
||||
|
||||
Two details this arrangement depends on, both verified:
|
||||
|
||||
- Gitea serves raw files **anonymously**, so a host needs no credentials.
|
||||
- The raw endpoint serves the blob **byte for byte**, with no rewriting. The
|
||||
signature covers exact bytes, so this is load-bearing; `.gitattributes` pins
|
||||
LF line endings to keep a Windows checkout from breaking it from the other
|
||||
direction.
|
||||
|
||||
### The signing window, and why it fails closed
|
||||
|
||||
CI signs **after** the merge. The sequence on a normal release is:
|
||||
|
||||
1. A pull request updating `v1/index.json` is merged to `main`.
|
||||
2. The publish workflow starts: validate, sign, self-verify.
|
||||
3. It commits `v1/index.json.sig` back to `main`.
|
||||
|
||||
Between steps 1 and 3 — roughly a minute — the raw URLs serve a **new
|
||||
`index.json` against the previous `.sig`**. The signature does not cover those
|
||||
bytes, so it does not verify.
|
||||
|
||||
That window fails closed, by design. A host fetching mid-window rejects the
|
||||
document, keeps serving its last known-good cached catalog, and marks it stale.
|
||||
Nobody sees a partially-trusted or unverified entry; they see yesterday's
|
||||
catalog for another minute. The cost is a brief unavailability of *new* entries,
|
||||
not a weakening of the trust model — which is the correct direction for the
|
||||
trade to fall.
|
||||
|
||||
Two consequences worth internalising:
|
||||
|
||||
- **Do not hand-edit `v1/index.json` on `main`.** Every direct push opens the
|
||||
window again until CI catches up. Go through a pull request.
|
||||
- **A verification failure right after a merge is expected**, not an incident.
|
||||
Check whether the publish workflow has finished before investigating.
|
||||
|
||||
If the window ever becomes a real problem, the fix is to sign in the merge
|
||||
pipeline rather than after it, or to publish both files atomically to a static
|
||||
host. Neither is needed at this scale.
|
||||
|
||||
### Moving to a dedicated static host later
|
||||
|
||||
If this repo ever becomes the wrong place to serve from — traffic, availability,
|
||||
wanting atomic two-file publishes — the migration is small and contained. The
|
||||
host pins the index URL as a single constant; pointing it at a new origin is a
|
||||
one-constant change plus a host release.
|
||||
|
||||
Nothing about the format, the signing, the key slots, or the review process
|
||||
changes, because none of them depend on where the bytes are served. The
|
||||
signature is over the document, not over its location.
|
||||
Reference in New Issue
Block a user