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
+11
View File
@@ -0,0 +1,11 @@
# The signature covers the EXACT BYTES of v1/index.json, and Gitea's raw
# endpoint serves the blob verbatim. Any line-ending rewrite -- a Windows
# checkout with core.autocrlf, an editor that "helpfully" normalises -- would
# change those bytes and invalidate a signature that is otherwise perfectly
# good, producing a failure that looks like tampering and is not.
#
# Force LF everywhere, and pin the two published files explicitly.
* text=auto eol=lf
v1/index.json text eol=lf
v1/index.json.sig text eol=lf
+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"
+19
View File
@@ -0,0 +1,19 @@
# Private signing keys. NEVER commit one of these.
# The real key lives only in the INDEX_SIGNING_KEY CI secret.
*.pem
*.key
index-signing-key*
# NOTE: v1/index.json.sig IS committed -- it is the published artifact. The
# index is served from this repo's raw URLs, so the committed signature is what
# hosts fetch, and keeping it in git history makes every past signature
# independently auditable. Only CI (with the real key) should ever write it.
#
# If you sign locally to experiment, write the result somewhere clearly
# disposable so it cannot be mistaken for the real signature and committed:
# bun run sign -- --key throwaway.pem && mv v1/index.json.sig scratch.sig
*.scratch.sig
scratch.sig
node_modules/
.DS_Store
+355
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@punktfunk/plugin-index",
"version": "1.0.0",
"private": true,
"description": "Signed plugin catalog served to Punktfunk hosts",
"type": "module",
"scripts": {
"validate": "bun tools/validate.ts",
"sign": "bun tools/sign.ts",
"verify": "bun tools/verify.ts",
"keygen": "bun tools/keygen.ts"
},
"engines": {
"bun": ">=1.0.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bun
/**
* Generate an ed25519 signing keypair for the index.
*
* Prints the PUBLIC key in the exact form the host pins (`ed25519:<base64>` of
* the raw 32 bytes) and writes the PRIVATE key as a PKCS#8 PEM.
*
* The private key is NEVER printed to stdout -- it only ever reaches a file
* with 0600 permissions, so it cannot leak into a terminal scrollback, a CI
* log, or a shell history via a pipe.
*
* Usage:
* bun tools/keygen.ts [--out path/to/key.pem] [--force]
*
* Then: store the PEM contents in the INDEX_SIGNING_KEY CI secret, add the
* public key to a host key slot, and destroy every other copy of the PEM.
*/
import { generateKeyPairSync } from "node:crypto";
import { existsSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { encodePublicKey } from "./keys.ts";
function die(message: string): never {
console.error(`error: ${message}`);
process.exit(1);
}
const args = process.argv.slice(2);
let out = "index-signing-key.pem";
let force = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === "--out") {
const next = args[++i];
if (!next) die("--out needs a path");
out = next;
} else if (arg.startsWith("--out=")) {
out = arg.slice("--out=".length);
} else if (arg === "--force") {
force = true;
} else {
die(`unknown argument ${arg}`);
}
}
const outPath = resolve(out);
if (existsSync(outPath) && !force) {
die(
`${outPath} already exists. Refusing to overwrite a private key -- ` +
"move it aside first, or pass --force if you are certain it is disposable.",
);
}
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const pem = privateKey.export({ type: "pkcs8", format: "pem" }) as string;
// mode 0600 on create; also set explicitly in case of a pre-existing file.
writeFileSync(outPath, pem, { mode: 0o600 });
console.log(`private key -> ${outPath} (mode 0600, NOT printed, do not commit)`);
console.log("");
console.log("public key (pin this in the host):");
console.log("");
console.log(` ${encodePublicKey(publicKey)}`);
console.log("");
console.log("Next steps:");
console.log(" 1. Put the PEM contents in the INDEX_SIGNING_KEY repository secret.");
console.log(" 2. Add the public key above to a host key slot (the host pins two,");
console.log(" so you can roll to a new key before retiring the old one).");
console.log(" 3. Destroy every copy of the PEM outside the secret store.");
+59
View File
@@ -0,0 +1,59 @@
/**
* Shared ed25519 key helpers.
*
* The host pins public keys in the exact string form `ed25519:<base64>`, where
* the base64 payload is the RAW 32-byte ed25519 public key -- NOT a DER/SPKI
* blob and NOT a PEM. node:crypto only exports SPKI, so we slice the raw key
* out of it (and splice it back in on the way in).
*
* An ed25519 SPKI DER is always exactly 44 bytes:
* 30 2a 30 05 06 03 2b 65 70 03 21 00 || <32-byte raw key>
* so the prefix is a fixed 12-byte constant.
*/
import { createPublicKey, type KeyObject } from "node:crypto";
/** Fixed 12-byte DER prefix of an ed25519 SubjectPublicKeyInfo. */
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
export const PUBKEY_PREFIX = "ed25519:";
/**
* The public key currently compiled into the Punktfunk host (slot 1).
* `verify` defaults to this so a signed index can be checked with no arguments.
*/
export const DEFAULT_PUBLIC_KEY =
"ed25519:n/KgBQSSDZqvyGHa8PkHWHZtV1zRXLk0BdQUi4/BD/w=";
/** Encode a node KeyObject public key as the host's `ed25519:<base64>` form. */
export function encodePublicKey(key: KeyObject): string {
const spki = key.export({ type: "spki", format: "der" });
if (spki.length !== 44 || !spki.subarray(0, 12).equals(SPKI_PREFIX)) {
throw new Error(
`not an ed25519 public key (unexpected SPKI: ${spki.length} bytes)`,
);
}
return PUBKEY_PREFIX + spki.subarray(12).toString("base64");
}
/** Parse the host's `ed25519:<base64>` form into a usable KeyObject. */
export function decodePublicKey(text: string): KeyObject {
const trimmed = text.trim();
if (!trimmed.startsWith(PUBKEY_PREFIX)) {
throw new Error(`public key must start with "${PUBKEY_PREFIX}"`);
}
const b64 = trimmed.slice(PUBKEY_PREFIX.length);
if (!/^[A-Za-z0-9+/]{43}=$/.test(b64)) {
throw new Error(
"public key payload must be base64 of exactly 32 raw bytes",
);
}
const raw = Buffer.from(b64, "base64");
if (raw.length !== 32) {
throw new Error(`public key must be 32 raw bytes, got ${raw.length}`);
}
return createPublicKey({
key: Buffer.concat([SPKI_PREFIX, raw]),
format: "der",
type: "spki",
});
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bun
/**
* Sign v1/index.json with an ed25519 private key.
*
* The signature is over the EXACT BYTES of index.json -- no canonicalisation,
* no re-serialisation. Whatever is committed is what gets signed and what the
* host hashes, so any whitespace change invalidates the signature (validate.ts
* enforces canonical formatting to keep that from happening by accident).
*
* Output is base64 of the raw 64-byte signature, written to v1/index.json.sig.
*
* Key input, in precedence order:
* --key <path> PKCS#8 PEM file
* $INDEX_SIGNING_KEY_FILE PKCS#8 PEM file
* $INDEX_SIGNING_KEY PKCS#8 PEM contents (this is what CI uses)
*
* Usage:
* bun tools/sign.ts [--key path/to/key.pem] [path/to/index.json]
*/
import { createPrivateKey, createPublicKey, sign } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { encodePublicKey } from "./keys.ts";
function die(message: string): never {
console.error(`error: ${message}`);
process.exit(1);
}
const args = process.argv.slice(2);
let keyPath: string | undefined;
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === "--key") {
keyPath = args[++i];
if (!keyPath) die("--key needs a path");
} else if (arg.startsWith("--key=")) {
keyPath = arg.slice("--key=".length);
} else if (arg.startsWith("--")) {
die(`unknown flag ${arg}`);
} else {
positional.push(arg);
}
}
keyPath ??= process.env.INDEX_SIGNING_KEY_FILE;
let pem: string;
let source: string;
if (keyPath) {
source = keyPath;
try {
pem = readFileSync(keyPath, "utf8");
} catch (err) {
die(`cannot read key file ${keyPath}: ${(err as Error).message}`);
}
} else if (process.env.INDEX_SIGNING_KEY) {
source = "$INDEX_SIGNING_KEY";
pem = process.env.INDEX_SIGNING_KEY;
} else {
die(
"no signing key. Pass --key <pkcs8.pem>, or set INDEX_SIGNING_KEY_FILE (path) " +
"or INDEX_SIGNING_KEY (PEM contents).",
);
}
// Tolerate secrets stores that mangle newlines into literal \n.
if (!pem.includes("\n") && pem.includes("\\n")) pem = pem.replace(/\\n/g, "\n");
let key;
try {
key = createPrivateKey({ key: pem, format: "pem" });
} catch (err) {
die(`${source} is not a readable PKCS#8 PEM private key: ${(err as Error).message}`);
}
if (key.asymmetricKeyType !== "ed25519") {
die(`${source} is a ${key.asymmetricKeyType ?? "unknown"} key; an ed25519 key is required`);
}
const file = resolve(positional[0] ?? "v1/index.json");
const sigFile = `${file}.sig`;
let data: Buffer;
try {
data = readFileSync(file);
} catch (err) {
die(`cannot read ${file}: ${(err as Error).message}`);
}
// ed25519 is a pure signature scheme: the digest argument MUST be null.
const signature = sign(null, data, key);
if (signature.length !== 64) {
die(`expected a 64-byte ed25519 signature, got ${signature.length}`);
}
writeFileSync(sigFile, `${signature.toString("base64")}\n`);
// Report the public key so the operator can eyeball WHICH key just signed --
// the most likely deploy mistake is signing with a key the host does not pin.
const publicKey = encodePublicKey(createPublicKey(key));
console.log(`signed ${file} (${data.length} bytes)`);
console.log(`wrote ${sigFile}`);
console.log(`key ${publicKey}`);
console.log("\nThe host must pin that public key in one of its two key slots.");
+691
View File
@@ -0,0 +1,691 @@
#!/usr/bin/env bun
/**
* Validate v1/index.json.
*
* Two layers:
* 1. Shape/rule validation of every field, matching what the host parser
* accepts. The host SILENTLY DROPS an entry that fails validation, so a
* typo here would ship as "the plugin just isn't in the store" with no
* error anywhere. This validator is the only place that failure is loud.
* 2. Live registry cross-check: the pinned version must exist upstream and
* its dist.integrity must equal the pinned integrity. This is the check
* that makes the pin meaningful -- an entry whose hash does not match the
* registry is either stale or an attack.
*
* Usage:
* bun tools/validate.ts [--offline] [--fix] [path/to/index.json]
*
* --offline skip layer 2 (no network). CI must NOT use this.
* --fix rewrite the file in canonical formatting instead of erroring.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
// ---------------------------------------------------------------------------
// error collection
// ---------------------------------------------------------------------------
interface Problem {
path: string;
message: string;
}
const problems: Problem[] = [];
function fail(path: string, message: string): void {
problems.push({ path, message });
}
// ---------------------------------------------------------------------------
// primitive checks
// ---------------------------------------------------------------------------
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/**
* Reject any key we do not know about. Deliberately strict: the host ignores
* unknown keys, so a snake_case slip like `min_host` would parse "fine" and
* silently lose its meaning. Catching it here is the whole point.
*/
function checkKeys(
path: string,
obj: Record<string, unknown>,
required: readonly string[],
optional: readonly string[] = [],
): void {
const known = new Set([...required, ...optional]);
for (const key of Object.keys(obj)) {
if (!known.has(key)) {
const hint = [...known].find(
(k) => k.toLowerCase().replace(/[_-]/g, "") ===
key.toLowerCase().replace(/[_-]/g, ""),
);
fail(
`${path}.${key}`,
`unknown key${hint ? ` -- did you mean "${hint}"? (keys are camelCase)` : ""}`,
);
}
}
for (const key of required) {
if (!(key in obj)) fail(`${path}.${key}`, "missing required key");
}
}
function requireString(
path: string,
value: unknown,
opts: { min?: number; max?: number } = {},
): string | undefined {
if (typeof value !== "string") {
fail(path, `must be a string, got ${value === undefined ? "nothing" : typeof value}`);
return undefined;
}
const { min = 1, max } = opts;
if (value.length < min) {
fail(path, `must be at least ${min} character${min === 1 ? "" : "s"}`);
return undefined;
}
if (max !== undefined && value.length > max) {
fail(path, `must be at most ${max} characters (got ${value.length})`);
return undefined;
}
return value;
}
function requireHttpsUrl(path: string, value: unknown): string | undefined {
const s = requireString(path, value, { max: 2048 });
if (s === undefined) return undefined;
let url: URL;
try {
url = new URL(s);
} catch {
fail(path, `not a valid URL: ${JSON.stringify(s)}`);
return undefined;
}
if (url.protocol !== "https:") {
fail(path, `must be https, got ${url.protocol.replace(":", "")}`);
return undefined;
}
return s;
}
// ---------------------------------------------------------------------------
// semver
// ---------------------------------------------------------------------------
/** Official semver.org recommended regex, anchored. Exact versions only. */
const EXACT_SEMVER =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
function requireExactVersion(path: string, value: unknown): string | undefined {
const s = requireString(path, value, { max: 256 });
if (s === undefined) return undefined;
if (!EXACT_SEMVER.test(s)) {
const looksLikeRange = /[\^~*<>=|\s]|\.x$/i.test(s);
fail(
path,
looksLikeRange
? `must be an EXACT semver version, not a range: ${JSON.stringify(s)}`
: `not a valid semver version: ${JSON.stringify(s)}`,
);
return undefined;
}
return s;
}
const PRE_IDENT = /^(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)$/;
const BUILD_IDENT = /^[0-9a-zA-Z-]+$/;
/**
* Check that a string parses as a Rust `semver::VersionReq`.
*
* Mirrors the grammar of the `semver` crate: comma-separated comparators, each
* an optional operator (= > >= < <= ~ ^) followed by a possibly-partial version
* (major[.minor[.patch]]) with optional pre-release and build metadata, or a
* wildcard (* / x / X). Numeric parts reject leading zeros and must fit u64,
* exactly as the crate does.
*
* Where this differs from the crate it is STRICTER, never looser -- so anything
* accepted here is guaranteed to parse host-side.
*/
function parseVersionReq(text: string): string | null {
if (text.trim() === "") return "must not be empty";
const parts = text.split(",");
for (const rawPart of parts) {
const part = rawPart.trim();
if (part === "") return "empty comparator (stray or trailing comma)";
const m = /^(=|>=|<=|>|<|~|\^)?\s*(.*)$/.exec(part);
if (!m) return `cannot parse comparator ${JSON.stringify(part)}`;
const [, , versionText = ""] = m;
if (versionText === "") {
return `comparator ${JSON.stringify(part)} has an operator but no version`;
}
// Split off build metadata, then pre-release.
let rest = versionText;
let build: string | undefined;
const plus = rest.indexOf("+");
if (plus !== -1) {
build = rest.slice(plus + 1);
rest = rest.slice(0, plus);
}
let pre: string | undefined;
const dash = rest.indexOf("-");
if (dash !== -1) {
pre = rest.slice(dash + 1);
rest = rest.slice(0, dash);
}
const nums = rest.split(".");
if (nums.length > 3) {
return `too many version segments in ${JSON.stringify(versionText)}`;
}
let sawWildcard = false;
for (const seg of nums) {
if (seg === "*" || seg === "x" || seg === "X") {
sawWildcard = true;
continue;
}
if (sawWildcard) {
return `${JSON.stringify(versionText)}: a numeric segment cannot follow a wildcard`;
}
if (seg === "") return `empty version segment in ${JSON.stringify(versionText)}`;
if (!/^\d+$/.test(seg)) {
return `version segment ${JSON.stringify(seg)} is not a number`;
}
if (seg.length > 1 && seg.startsWith("0")) {
return `version segment ${JSON.stringify(seg)} has a leading zero`;
}
if (BigInt(seg) > 18446744073709551615n) {
return `version segment ${JSON.stringify(seg)} does not fit in u64`;
}
}
if (sawWildcard && (pre !== undefined || build !== undefined)) {
return `${JSON.stringify(versionText)}: wildcards cannot carry pre-release or build metadata`;
}
if (pre !== undefined) {
if (pre === "") return `empty pre-release in ${JSON.stringify(versionText)}`;
for (const id of pre.split(".")) {
if (!PRE_IDENT.test(id)) {
return `invalid pre-release identifier ${JSON.stringify(id)}`;
}
}
}
if (build !== undefined) {
if (build === "") return `empty build metadata in ${JSON.stringify(versionText)}`;
for (const id of build.split(".")) {
if (!BUILD_IDENT.test(id)) {
return `invalid build identifier ${JSON.stringify(id)}`;
}
}
}
}
return null;
}
// ---------------------------------------------------------------------------
// entry-level validation
// ---------------------------------------------------------------------------
const ID_RE = /^[a-z][a-z0-9-]*$/;
const ICON_RE = /^[a-z0-9-]{1,48}$/;
/** npm scoped name. Scoped is mandatory: the scope is what maps to a registry. */
const PKG_RE = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
const INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]+={0,2}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const RFC3339_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
const PLATFORMS = ["linux", "windows", "macos"] as const;
const PLUGIN_REQUIRED = [
"id",
"pkg",
"registry",
"title",
"description",
"icon",
"author",
"homepage",
"license",
"version",
"integrity",
"verification",
] as const;
const PLUGIN_OPTIONAL = ["minHost", "platforms"] as const;
interface CheckTarget {
path: string;
pkg: string;
registry: string;
version: string;
integrity: string;
}
/** Returns a registry cross-check job if the entry had enough valid fields. */
function validatePlugin(
path: string,
entry: unknown,
seenIds: Map<string, string>,
seenPkgs: Map<string, string>,
): CheckTarget | undefined {
if (!isPlainObject(entry)) {
fail(path, "must be an object");
return undefined;
}
checkKeys(path, entry, PLUGIN_REQUIRED, PLUGIN_OPTIONAL);
// id
const id = requireString(`${path}.id`, entry.id, { max: 64 });
if (id !== undefined) {
if (!ID_RE.test(id)) {
fail(
`${path}.id`,
`must match ^[a-z][a-z0-9-]*$ (lowercase, starts with a letter): ${JSON.stringify(id)}`,
);
} else {
const prior = seenIds.get(id);
if (prior) fail(`${path}.id`, `duplicate id ${JSON.stringify(id)}, already used by ${prior}`);
else seenIds.set(id, path);
}
}
// pkg
const pkg = requireString(`${path}.pkg`, entry.pkg, { max: 214 });
if (pkg !== undefined && !PKG_RE.test(pkg)) {
fail(
`${path}.pkg`,
pkg.startsWith("@")
? `not a valid npm package name: ${JSON.stringify(pkg)}`
: `must be a SCOPED package name (@scope/name), got ${JSON.stringify(pkg)}`,
);
} else if (pkg !== undefined) {
const prior = seenPkgs.get(pkg);
if (prior) fail(`${path}.pkg`, `duplicate package ${JSON.stringify(pkg)}, already listed at ${prior}`);
else seenPkgs.set(pkg, path);
}
// registry
const registry = requireHttpsUrl(`${path}.registry`, entry.registry);
if (registry !== undefined && !registry.endsWith("/")) {
fail(
`${path}.registry`,
"must end with a trailing slash so the package name can be appended safely",
);
}
// plain text fields
requireString(`${path}.title`, entry.title, { max: 64 });
requireString(`${path}.description`, entry.description, { max: 280 });
requireString(`${path}.author`, entry.author, { max: 64 });
requireString(`${path}.license`, entry.license, { max: 64 });
const icon = requireString(`${path}.icon`, entry.icon, { max: 48 });
if (icon !== undefined && !ICON_RE.test(icon)) {
fail(
`${path}.icon`,
`must be a lucide icon name matching ^[a-z0-9-]{1,48}$ (kebab-case, e.g. "gamepad-2"): ${JSON.stringify(icon)}`,
);
}
requireHttpsUrl(`${path}.homepage`, entry.homepage);
const version = requireExactVersion(`${path}.version`, entry.version);
// integrity
const integrity = requireString(`${path}.integrity`, entry.integrity, { max: 200 });
if (integrity !== undefined) {
if (!INTEGRITY_RE.test(integrity)) {
fail(
`${path}.integrity`,
`must be a Subresource Integrity string of the form "sha512-<base64>": ${JSON.stringify(integrity)}`,
);
} else {
const raw = Buffer.from(integrity.slice("sha512-".length), "base64");
if (raw.length !== 64) {
fail(
`${path}.integrity`,
`sha512 digest must decode to 64 bytes, got ${raw.length}`,
);
}
}
}
// verification
if (!isPlainObject(entry.verification)) {
if ("verification" in entry) fail(`${path}.verification`, "must be an object");
} else {
checkKeys(`${path}.verification`, entry.verification, ["reviewedAt"]);
const reviewedAt = requireString(
`${path}.verification.reviewedAt`,
entry.verification.reviewedAt,
{ max: 10 },
);
if (reviewedAt !== undefined) {
if (!DATE_RE.test(reviewedAt)) {
fail(`${path}.verification.reviewedAt`, `must be YYYY-MM-DD, got ${JSON.stringify(reviewedAt)}`);
} else {
const parsed = new Date(`${reviewedAt}T00:00:00Z`);
if (Number.isNaN(parsed.getTime()) || !parsed.toISOString().startsWith(reviewedAt)) {
fail(`${path}.verification.reviewedAt`, `not a real calendar date: ${reviewedAt}`);
} else if (parsed.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
fail(`${path}.verification.reviewedAt`, `is in the future: ${reviewedAt}`);
}
}
}
}
// minHost (optional)
if (entry.minHost !== undefined) requireExactVersion(`${path}.minHost`, entry.minHost);
// platforms (optional)
if (entry.platforms !== undefined) {
if (!Array.isArray(entry.platforms)) {
fail(`${path}.platforms`, "must be an array");
} else {
const seen = new Set<string>();
entry.platforms.forEach((p, i) => {
if (typeof p !== "string" || !(PLATFORMS as readonly string[]).includes(p)) {
fail(
`${path}.platforms[${i}]`,
`must be one of ${PLATFORMS.join(" | ")}, got ${JSON.stringify(p)}`,
);
return;
}
if (seen.has(p)) fail(`${path}.platforms[${i}]`, `duplicate platform ${JSON.stringify(p)}`);
seen.add(p);
});
}
}
if (pkg && registry && version && integrity && INTEGRITY_RE.test(integrity)) {
return { path, pkg, registry, version, integrity };
}
return undefined;
}
function validateSecurity(path: string, entry: unknown): { pkg?: string; req?: string } {
if (!isPlainObject(entry)) {
fail(path, "must be an object");
return {};
}
checkKeys(path, entry, ["pkg", "versions", "reason"], ["url"]);
const pkg = requireString(`${path}.pkg`, entry.pkg, { max: 214 });
if (pkg !== undefined && !PKG_RE.test(pkg)) {
fail(`${path}.pkg`, `must be a scoped npm package name, got ${JSON.stringify(pkg)}`);
}
const versions = requireString(`${path}.versions`, entry.versions, { max: 256 });
if (versions !== undefined) {
const err = parseVersionReq(versions);
if (err) {
fail(`${path}.versions`, `not a valid semver requirement (${err}): ${JSON.stringify(versions)}`);
} else if (versions.trim() === "*") {
// Guard rail: `*` revokes every version ever published. If that is really
// intended, say so explicitly with a bounded range.
fail(
`${path}.versions`,
'refusing a bare "*" revocation -- it would revoke every version of the package; use an explicit bound',
);
}
}
requireString(`${path}.reason`, entry.reason, { max: 280 });
if (entry.url !== undefined) requireHttpsUrl(`${path}.url`, entry.url);
return { pkg, req: versions };
}
// ---------------------------------------------------------------------------
// live registry cross-check
// ---------------------------------------------------------------------------
/**
* npm packument URL for a scoped package. The scope separator is percent-
* encoded but the leading `@` is left literal -- this is the form Gitea's npm
* API is known to answer.
*/
function packumentUrl(registry: string, pkg: string): string {
return registry + pkg.replace("/", "%2f");
}
async function crossCheck(target: CheckTarget): Promise<void> {
const url = packumentUrl(target.registry, target.pkg);
let body: unknown;
try {
const res = await fetch(url, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) {
fail(target.path, `registry lookup failed: HTTP ${res.status} ${res.statusText} for ${url}`);
return;
}
body = await res.json();
} catch (err) {
fail(target.path, `registry lookup failed for ${url}: ${(err as Error).message}`);
return;
}
if (!isPlainObject(body) || !isPlainObject(body.versions)) {
fail(target.path, `registry returned no "versions" map for ${target.pkg}`);
return;
}
const meta = body.versions[target.version];
if (meta === undefined) {
const available = Object.keys(body.versions).slice(-8).join(", ");
fail(
target.path,
`version ${target.version} of ${target.pkg} does not exist in the registry (latest seen: ${available || "none"})`,
);
return;
}
if (!isPlainObject(meta) || !isPlainObject(meta.dist)) {
fail(target.path, `registry entry for ${target.pkg}@${target.version} has no "dist" block`);
return;
}
const upstream = meta.dist.integrity;
if (typeof upstream !== "string" || upstream === "") {
fail(
target.path,
`registry entry for ${target.pkg}@${target.version} has no dist.integrity -- cannot pin this package`,
);
return;
}
if (upstream !== target.integrity) {
fail(
target.path,
`INTEGRITY MISMATCH for ${target.pkg}@${target.version}\n` +
` pinned here: ${target.integrity}\n` +
` registry: ${upstream}\n` +
" The pinned hash must be copied verbatim from the registry. If the registry\n" +
" changed for a version that was already published, stop and investigate.",
);
return;
}
console.log(` ok ${target.pkg}@${target.version} integrity matches registry`);
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
const offline = args.includes("--offline");
const fixFormat = args.includes("--fix");
const positional = args.filter((a) => !a.startsWith("--"));
const file = resolve(positional[0] ?? "v1/index.json");
let text: string;
try {
text = readFileSync(file, "utf8");
} catch (err) {
console.error(`error: cannot read ${file}: ${(err as Error).message}`);
process.exit(1);
}
let doc: unknown;
try {
doc = JSON.parse(text);
} catch (err) {
console.error(`${file}: not valid JSON: ${(err as Error).message}`);
process.exit(1);
}
// Canonical formatting. The signature covers the exact bytes of this file, so
// keeping it byte-stable makes review diffs minimal and re-signing predictable.
const canonical = `${JSON.stringify(doc, null, 2)}\n`;
if (text !== canonical) {
if (fixFormat) {
writeFileSync(file, canonical);
console.log(`fixed formatting of ${file}`);
text = canonical;
} else {
fail(
"<file>",
"not in canonical formatting (2-space indent, trailing newline). Run: bun run validate -- --fix",
);
}
}
if (!isPlainObject(doc)) {
console.error(`${file}: top level must be a JSON object`);
process.exit(1);
}
checkKeys("$", doc, ["schema", "name", "generated", "plugins", "security"]);
if (doc.schema !== 1) {
fail("$.schema", `must be exactly 1 (number), got ${JSON.stringify(doc.schema)}`);
}
requireString("$.name", doc.name, { max: 64 });
const generated = requireString("$.generated", doc.generated, { max: 40 });
if (generated !== undefined) {
if (!RFC3339_UTC_RE.test(generated)) {
fail("$.generated", `must be an RFC 3339 UTC timestamp like 2026-07-20T18:00:00Z, got ${JSON.stringify(generated)}`);
} else if (Number.isNaN(new Date(generated).getTime())) {
fail("$.generated", `not a real timestamp: ${generated}`);
}
}
const targets: CheckTarget[] = [];
const pinned = new Map<string, string>(); // pkg -> pinned version
if (!Array.isArray(doc.plugins)) {
fail("$.plugins", "must be an array");
} else {
const seenIds = new Map<string, string>();
const seenPkgs = new Map<string, string>();
doc.plugins.forEach((entry, i) => {
const target = validatePlugin(`$.plugins[${i}]`, entry, seenIds, seenPkgs);
if (target) {
targets.push(target);
pinned.set(target.pkg, target.version);
}
});
}
if (!Array.isArray(doc.security)) {
fail("$.security", "must be an array");
} else {
doc.security.forEach((entry, i) => {
const path = `$.security[${i}]`;
const { pkg, req } = validateSecurity(path, entry);
// Self-consistency: never ship a catalog that pins a version it also revokes.
if (pkg && req && pinned.has(pkg)) {
const version = pinned.get(pkg)!;
if (satisfiesSimple(version, req)) {
fail(
path,
`revokes ${pkg} ${req}, which matches the version pinned in $.plugins (${version}) -- bump the pin or narrow the revocation`,
);
}
}
});
}
/**
* Minimal "does this exact version satisfy this requirement" check, used only
* for the self-consistency guard above. Handles the comparator forms we allow;
* on anything it cannot decide it returns false (no false alarms).
*/
function satisfiesSimple(version: string, req: string): boolean {
const v = parseExact(version);
if (!v) return false;
return req.split(",").every((rawPart) => {
const part = rawPart.trim();
const m = /^(=|>=|<=|>|<|~|\^)?\s*(.*)$/.exec(part);
if (!m) return false;
const op = m[1] ?? "^";
const bare = (m[2] ?? "").split("+")[0]!;
if (/[*xX]/.test(bare)) return true;
const parts = bare.split("-")[0]!.split(".").map((n) => Number(n));
const [major = 0, minor, patch] = parts;
const lower: [number, number, number] = [major, minor ?? 0, patch ?? 0];
const cmp = compare(v, lower);
switch (op) {
case "=":
return cmp === 0;
case ">":
return cmp > 0;
case ">=":
return cmp >= 0;
case "<":
return cmp < 0;
case "<=":
return cmp <= 0;
case "~": {
if (cmp < 0) return false;
if (minor === undefined) return v[0] === major;
return v[0] === major && v[1] === minor;
}
case "^": {
if (cmp < 0) return false;
if (major > 0) return v[0] === major;
if (minor === undefined || minor > 0) return v[0] === 0 && v[1] === (minor ?? 0);
return v[0] === 0 && v[1] === 0 && v[2] === (patch ?? 0);
}
default:
return false;
}
});
}
function parseExact(version: string): [number, number, number] | null {
const m = EXACT_SEMVER.exec(version);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
function compare(a: [number, number, number], b: [number, number, number]): number {
for (let i = 0; i < 3; i++) {
if (a[i]! !== b[i]!) return a[i]! < b[i]! ? -1 : 1;
}
return 0;
}
// Layer 2: live registry.
if (problems.length === 0 && !offline) {
console.log(`checking ${targets.length} pinned package(s) against their registries...`);
await Promise.all(targets.map(crossCheck));
} else if (offline) {
console.log("skipping live registry cross-check (--offline)");
}
if (problems.length > 0) {
console.error(`\n${file}: ${problems.length} problem(s):\n`);
for (const p of problems) console.error(` ${p.path}\n ${p.message}\n`);
process.exit(1);
}
const pluginCount = Array.isArray(doc.plugins) ? doc.plugins.length : 0;
const securityCount = Array.isArray(doc.security) ? doc.security.length : 0;
console.log(
`\nOK: ${file} is valid -- ${pluginCount} plugin(s), ${securityCount} security advisory(ies).`,
);
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bun
/**
* Verify v1/index.json.sig against v1/index.json.
*
* This is exactly what the host does before it will parse a single byte of the
* catalog, so a green run here means a host that pins the same key will accept
* the index. Used as a post-sign self-check in CI, and by anyone auditing.
*
* Public key, in precedence order:
* --pub ed25519:<base64> (or a path to a file containing that string)
* $INDEX_PUBLIC_KEY
* the key currently pinned in the host (see tools/keys.ts)
*
* Usage:
* bun tools/verify.ts [--pub ed25519:...] [path/to/index.json]
*/
import { verify } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { DEFAULT_PUBLIC_KEY, PUBKEY_PREFIX, decodePublicKey } from "./keys.ts";
function die(message: string): never {
console.error(`error: ${message}`);
process.exit(1);
}
const args = process.argv.slice(2);
let pubArg: string | undefined;
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === "--pub") {
pubArg = args[++i];
if (!pubArg) die("--pub needs a value");
} else if (arg.startsWith("--pub=")) {
pubArg = arg.slice("--pub=".length);
} else if (arg.startsWith("--")) {
die(`unknown flag ${arg}`);
} else {
positional.push(arg);
}
}
let source = "host-pinned default";
let pubText = DEFAULT_PUBLIC_KEY;
if (pubArg) {
// Accept either the literal ed25519:... string or a file containing it.
if (!pubArg.startsWith(PUBKEY_PREFIX) && existsSync(pubArg)) {
source = pubArg;
pubText = readFileSync(pubArg, "utf8").trim();
} else {
source = "--pub";
pubText = pubArg;
}
} else if (process.env.INDEX_PUBLIC_KEY) {
source = "$INDEX_PUBLIC_KEY";
pubText = process.env.INDEX_PUBLIC_KEY;
}
let publicKey;
try {
publicKey = decodePublicKey(pubText);
} catch (err) {
die(`bad public key from ${source}: ${(err as Error).message}`);
}
const file = resolve(positional[0] ?? "v1/index.json");
const sigFile = `${file}.sig`;
let data: Buffer;
try {
data = readFileSync(file);
} catch (err) {
die(`cannot read ${file}: ${(err as Error).message}`);
}
let sigText: string;
try {
sigText = readFileSync(sigFile, "utf8");
} catch (err) {
die(`cannot read ${sigFile}: ${(err as Error).message}`);
}
// Whitespace-tolerant on read, matching the host.
const b64 = sigText.replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) {
die(`${sigFile} is not valid base64`);
}
const signature = Buffer.from(b64, "base64");
if (signature.length !== 64) {
die(`${sigFile} must decode to a 64-byte ed25519 signature, got ${signature.length} bytes`);
}
if (!verify(null, data, publicKey, signature)) {
console.error(`FAILED: signature in ${sigFile} does not match ${file}`);
console.error(` key used: ${pubText.trim()} (from ${source})`);
console.error(
"\n Either the index was modified after signing, or it was signed with a\n" +
" different key than the one being checked. Re-run `bun run sign`, and\n" +
" confirm the signing key matches a slot the host pins.",
);
process.exit(1);
}
console.log(`OK: ${sigFile} is a valid signature over ${file} (${data.length} bytes)`);
console.log(` key: ${pubText.trim()} (from ${source})`);
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"types": ["bun-types"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["tools/**/*.ts"]
}
+49
View File
@@ -0,0 +1,49 @@
{
"schema": 1,
"name": "unom official",
"generated": "2026-07-20T18:00:00Z",
"plugins": [
{
"id": "rom-manager",
"pkg": "@punktfunk/plugin-rom-manager",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "ROM Manager",
"description": "Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider. Includes a console-hosted web UI for mapping and cleanup.",
"icon": "gamepad-2",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
"license": "MIT OR Apache-2.0",
"version": "0.3.1",
"integrity": "sha512-SGqMriqQPOQobXMYiT20w0rcTMNdYfZc8No3fPu57njMN+2eTVBVlQjyn1t3KoRFxfoRYGwuumN4X3aNmS0Tpw==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"linux",
"windows"
]
},
{
"id": "playnite",
"pkg": "@punktfunk/plugin-playnite",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "Playnite",
"description": "Bridges your Playnite library on Windows into the host game library, keeping installed games, metadata, and launch commands in sync as a library provider.",
"icon": "library",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
"license": "MIT OR Apache-2.0",
"version": "0.1.1",
"integrity": "sha512-H40QEcUN7g0wHTy5CFCOTA4+j/FypbVzWSZ0OlW5Ej5+FnnT1fMUDVOx8KEH34mavTy70fd1zx3zDucu9hNQuQ==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"windows"
]
}
],
"security": []
}