Files
punktfunk-plugin-index/README.md
T
enricobuehlerandClaude Fable 5 efb37d1826 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>
2026-07-20 20:25:16 +02:00

356 lines
15 KiB
Markdown

# 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.