commit efb37d1826bc90fe378e92671e926eb41477e0f1 Author: enricobuehler Date: Mon Jul 20 20:20:20 2026 +0200 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6d7f2f4 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitea/pull_request_template.md b/.gitea/pull_request_template.md new file mode 100644 index 0000000..647023e --- /dev/null +++ b/.gitea/pull_request_template.md @@ -0,0 +1,78 @@ + + +## What changed + +- **Package:** +- **Version:** `` -> `` (previous pinned version -> new pinned version) +- **Type:** + +## Diff reviewed + +**Tarball compared:** + +``` +(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 + + + +### Anything that gave you pause + + + +--- + + diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..1bd68de --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -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" diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml new file mode 100644 index 0000000..a541bed --- /dev/null +++ b/.gitea/workflows/validate.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc05047 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6001f6c --- /dev/null +++ b/README.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..1bab9fd --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tools/keygen.ts b/tools/keygen.ts new file mode 100644 index 0000000..d2ca8a0 --- /dev/null +++ b/tools/keygen.ts @@ -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:` 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."); diff --git a/tools/keys.ts b/tools/keys.ts new file mode 100644 index 0000000..5ec3898 --- /dev/null +++ b/tools/keys.ts @@ -0,0 +1,59 @@ +/** + * Shared ed25519 key helpers. + * + * The host pins public keys in the exact string form `ed25519:`, 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:` 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:` 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", + }); +} diff --git a/tools/sign.ts b/tools/sign.ts new file mode 100644 index 0000000..22fabf3 --- /dev/null +++ b/tools/sign.ts @@ -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 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 , 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."); diff --git a/tools/validate.ts b/tools/validate.ts new file mode 100644 index 0000000..b57a81a --- /dev/null +++ b/tools/validate.ts @@ -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 { + 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, + 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, + seenPkgs: Map, +): 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-": ${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(); + 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 { + 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( + "", + "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(); // pkg -> pinned version + +if (!Array.isArray(doc.plugins)) { + fail("$.plugins", "must be an array"); +} else { + const seenIds = new Map(); + const seenPkgs = new Map(); + 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).`, +); diff --git a/tools/verify.ts b/tools/verify.ts new file mode 100644 index 0000000..117c051 --- /dev/null +++ b/tools/verify.ts @@ -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: (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})`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0fb31af --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/v1/index.json b/v1/index.json new file mode 100644 index 0000000..0b76a5a --- /dev/null +++ b/v1/index.json @@ -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": [] +}