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:
enricobuehler
2026-07-20 20:25:16 +02:00
co-authored by Claude Fable 5
commit efb37d1826
14 changed files with 1735 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
<!--
This is the human half of "verified on every release".
CI can only prove the pinned hash matches what the registry serves. It cannot
tell you what that code DOES. Everything below is the part only a person can
do. A merged PR here makes code installable on other people's machines under
unom's badge -- treat it accordingly.
-->
## What changed
- **Package:**
- **Version:** `` -> `` (previous pinned version -> new pinned version)
- **Type:** <!-- new plugin / version bump / metadata only / security advisory -->
## Diff reviewed
**Tarball compared:** <!-- paste the command you ran, e.g.
npm pack @punktfunk/plugin-foo@0.2.0 --registry https://git.unom.io/api/packages/unom/npm/
npm pack @punktfunk/plugin-foo@0.3.0 --registry https://git.unom.io/api/packages/unom/npm/
then extract both and: diff -ru foo-0.2.0/ foo-0.3.0/
For a NEW plugin there is no previous version -- read the whole thing instead and say so. -->
```
(summary of what actually changed in the published artifact)
```
## Review checklist
Tick each box only after you have personally checked it **against the published
tarball**, not against the git repo. The tarball is what users execute; the repo
is only evidence about it.
- [ ] **Diffed against the previously pinned version.** I compared the new
tarball to the last version pinned in this index and read every change.
(New plugin: I read the entire published tarball.)
- [ ] **No unexpected network endpoints.** Every host the code contacts is
accounted for by the plugin's stated purpose. I grepped for URLs, IPs,
`fetch`/`http`/`net`/`dns`/websocket use, and found no telemetry,
analytics, or beaconing that is not disclosed.
- [ ] **No filesystem access beyond the stated purpose.** Reads and writes stay
within what the plugin is for. No access to credentials, SSH keys, browser
profiles, host config, or unrelated user data.
- [ ] **No obfuscated or minified code where source is expected.** Everything is
readable. Nothing is base64/hex blobs, `eval`, `new Function`, dynamic
`require` of computed strings, or a bundled build I cannot trace to source.
Vendored/bundled dependencies are declared and justified below.
- [ ] **No install lifecycle scripts.** package.json has no `preinstall`,
`install`, `postinstall`, `prepare`, or `prepublish` that executes code on
the user's machine at install time.
- [ ] **Dependencies reviewed and justified.** I reviewed every added or bumped
dependency: each is necessary, from a plausible source, and not
typosquatting a well-known name. New transitive weight is proportionate.
- [ ] **Pinned integrity matches the registry.** I fetched `dist.integrity` from
the registry myself and compared it to the value in this PR -- I did not
only trust CI.
- [ ] **Version is an exact semver.** No range, no `latest`, no `v` prefix, no
wildcard. One immutable version.
- [ ] **Metadata is accurate.** Title, description, icon, author, homepage,
license, `platforms`, and `minHost` all match reality.
- [ ] **`reviewedAt` is today's date** and reflects when *this* review happened.
### Dependencies added or changed
<!-- list each, with why it is needed. "none" is a fine answer. -->
### Anything that gave you pause
<!-- Note anything odd you decided was acceptable, and why. If nothing, say so
explicitly -- "nothing" is a real signal, a blank field is not. -->
---
<!--
Reviewer: if any box cannot be honestly ticked, do not merge. An unmerged PR
costs one release cycle. A bad merge ships code to every host that trusts this
index, signed by unom's key.
-->
+118
View File
@@ -0,0 +1,118 @@
# Validate -> sign -> self-verify -> commit the signature back to main.
#
# The index is served straight out of this repository over Gitea's anonymous
# raw endpoint, so "publishing" IS committing v1/index.json.sig to main. There
# is no separate static host and no deploy step.
#
# 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
#
# This workflow holds INDEX_SIGNING_KEY, the private half of the key the host
# pins. It therefore only ever runs on `main` (post-merge) or by explicit
# dispatch -- never on a pull request. PR validation lives in validate.yml,
# which has no access to the key. That split is the point: a fork PR can change
# index.json all it likes, but nothing it does gets signed until a human merges.
name: publish
run-name: ${{ gitea.actor }} sign plugin index
on:
push:
branches: [main]
# LOOP GUARD (primary -- this is the one relied on). This job's own output
# is v1/index.json.sig, and it commits that file back to main, which would
# retrigger the job. `paths-ignore` means "run unless EVERY changed path
# matches", so the bot's signature-only commit is skipped, while a push
# touching index.json (alone, or together with the signature) still runs.
#
# Do not add a `paths:` include list alongside this -- `paths` and
# `paths-ignore` are mutually exclusive for a single event.
paths-ignore:
- "v1/index.json.sig"
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
ref: main
# The token must be able to push to main. Gitea injects GITEA_TOKEN
# automatically, which is sufficient for an unprotected branch. If
# main is protected, set INDEX_BOT_TOKEN to a PAT permitted to push
# through the protection; it takes precedence when present.
token: ${{ secrets.INDEX_BOT_TOKEN || secrets.GITEA_TOKEN }}
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Full validation INCLUDING the live registry cross-check. If a pinned
# hash no longer matches upstream, we must not sign it.
- name: Validate index
run: bun run validate
- name: Sign index
env:
INDEX_SIGNING_KEY: ${{ secrets.INDEX_SIGNING_KEY }}
run: |
set -euo pipefail
if [ -z "${INDEX_SIGNING_KEY:-}" ]; then
echo "::error::INDEX_SIGNING_KEY secret is not set; refusing to publish an unsigned index"
exit 1
fi
# The key reaches the tool via env only -- never a file in the
# workspace, never an argument (which would show up in process lists).
bun run sign
# Self-check: re-verify what we just produced, with the same code path an
# auditor would use. Catches a key/host mismatch BEFORE it ships, rather
# than as every host in the field silently rejecting the catalog.
- name: Verify signature
run: bun run verify -- --pub "${{ vars.INDEX_PUBLIC_KEY || 'ed25519:n/KgBQSSDZqvyGHa8PkHWHZtV1zRXLk0BdQUi4/BD/w=' }}"
- name: Commit signature to main
run: |
set -euo pipefail
# ed25519 is deterministic: the same key over the same bytes yields a
# byte-identical signature. So a push that did not change index.json
# (a README edit, a tooling change) re-signs to exactly what is
# already committed and there is nothing to commit -- no churn, no
# empty commits, and no loop even before the guards take effect.
if [ -z "$(git status --porcelain -- v1/index.json.sig)" ]; then
echo "signature unchanged -- nothing to commit"
exit 0
fi
git config user.name "unom-bot"
git config user.email "bot@unom.io"
git add v1/index.json.sig
# LOOP GUARD (secondary, belt-and-braces). Gitea's runner honours
# skip-ci markers in the commit subject -- SKIP_WORKFLOW_STRINGS in
# app.ini, which defaults to [skip ci],[ci skip],[no ci],
# [skip actions],[actions skip]. The paths-ignore filter above is the
# mechanism actually relied on, because it holds even where an
# operator has narrowed SKIP_WORKFLOW_STRINGS. This marker is a
# second line of defence and a signal to humans reading the log.
git commit -m "chore(index): sign v1/index.json [skip ci]" \
-m "Signature over ${GITEA_SHA:-$GITHUB_SHA}. Generated by the publish workflow; do not edit by hand."
git push origin HEAD:main
- name: Summary
run: |
{
echo "### Plugin index signed and published"
echo
echo "- plugins: $(bun -e 'console.log(JSON.parse(require("fs").readFileSync("v1/index.json","utf8")).plugins.length)')"
echo "- advisories: $(bun -e 'console.log(JSON.parse(require("fs").readFileSync("v1/index.json","utf8")).security.length)')"
echo
echo "Served from:"
echo '```'
echo "https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json"
echo "https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json.sig"
echo '```'
} >> "$GITEA_STEP_SUMMARY"
+43
View File
@@ -0,0 +1,43 @@
# PR gate: validate only. NO signing, NO push, NO secrets.
#
# Pull requests can come from forks, and a fork PR must never be able to reach
# INDEX_SIGNING_KEY or a token that can write to main -- a malicious PR could
# otherwise exfiltrate the key by editing a tool or a workflow step. This job
# runs the validator and nothing else, so the worst a hostile PR can do is fail
# CI.
name: validate
run-name: validate plugin index
on:
pull_request:
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Includes the live registry cross-check: the pinned version must exist
# upstream and its dist.integrity must match the pinned hash exactly.
# This is the check that fails a PR pinning a hash nobody verified.
- name: Validate index
run: bun run validate
- name: Reviewer reminder
if: success()
run: |
{
echo "### Automated checks passed"
echo
echo "The index is well-formed and every pinned hash matches its registry."
echo
echo "**This does not mean the plugin is safe.** The machine can only confirm"
echo "that the pinned bytes are the bytes upstream serves -- not what those"
echo "bytes do. Work the review checklist in the PR description before merging."
} >> "$GITEA_STEP_SUMMARY"