Files
enricobuehlerandClaude Fable 5 05e772fedd
publish / publish (push) Successful in 26s
chore(index): repin to the signing key held in this repo's secret
The previous key was pasted into unom/punktfunk's secret store rather than
this one. Gitea secrets don't cross repos and can't be read back, so that
key was unusable here and is abandoned; INDEX_SIGNING_KEY on THIS repo now
holds the private half of the key pinned below.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:05:39 +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:V7KKMg8sq2A2TW7D/GFWaM0ruAvigpld9r93JdWcQHw=
```
`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.