From 74745f1f20f22920f0262db37d1ba95c3407bcc5 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Tue, 5 May 2026 01:02:58 +0900 Subject: [PATCH] ci: sign plugins-index.json in-repo, drop worker signing dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair with the worker-side simplification on the librefang PR — the worker is now a pure transport (no key material, no signing) and this repo's CI takes over signature production. scripts/sign-plugins-index.mjs reads REGISTRY_PRIVATE_KEY from a GitHub Actions secret, signs plugins-index.json with Ed25519, and writes plugins-index.json.sig alongside it. Aborts loudly when the secret is missing so a misconfigured CI can't silently ship an unsigned payload. The workflow now runs build → sign → commit (.json + .sig) → push → poke worker /refresh. The worker fetches the committed .json + .sig verbatim and stores both — the daemon then verifies against the embedded pubkey it ships with. Closes PR review CRITICAL #1: the worker is no longer a sign-anything oracle reachable via REGISTRY_REFRESH_TOKEN. Trust root is now this repo's branch protection + Actions secret scope, not a token any CI job that can talk to stats.librefang.ai can use to mint signatures. Note: the keypair was rotated as part of this change (PR not yet merged so no daemon TOFU pins exist). New pubkey: ClGa0Ucap8NdrKAy1rw9Tt6A9I8eg4zJ53+xIuKMuq0= The plugins-index.json.sig committed here is signed with the matching new private key, in lockstep with the daemon EMBEDDED_REGISTRY_PUBKEY constant and all three worker [vars] entries. --- .github/workflows/refresh-cache.yml | 7 ++- plugins-index.json.sig | 1 + scripts/sign-plugins-index.mjs | 72 +++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 plugins-index.json.sig create mode 100644 scripts/sign-plugins-index.mjs diff --git a/.github/workflows/refresh-cache.yml b/.github/workflows/refresh-cache.yml index 65c2944..8a88822 100644 --- a/.github/workflows/refresh-cache.yml +++ b/.github/workflows/refresh-cache.yml @@ -45,11 +45,16 @@ jobs: node scripts/build-plugins-index.mjs node scripts/build-registry-index.mjs + - name: Sign plugins-index.json + env: + REGISTRY_PRIVATE_KEY: ${{ secrets.REGISTRY_PRIVATE_KEY }} + run: node scripts/sign-plugins-index.mjs + - name: Commit regenerated indexes if changed run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add plugins-index.json registry-index.json + git add plugins-index.json plugins-index.json.sig registry-index.json if git diff --cached --quiet; then echo "indexes already up-to-date" else diff --git a/plugins-index.json.sig b/plugins-index.json.sig new file mode 100644 index 0000000..aa60f11 --- /dev/null +++ b/plugins-index.json.sig @@ -0,0 +1 @@ +efERqfz7oGh0AoBrnVKocOUjOUR5h3a8vkIcvqd+HP+k3Rr99diJsExKwXAogfz4TpTWPn0rqLjVgQpP8MxXBw== diff --git a/scripts/sign-plugins-index.mjs b/scripts/sign-plugins-index.mjs new file mode 100644 index 0000000..5f3136d --- /dev/null +++ b/scripts/sign-plugins-index.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Sign plugins-index.json with the registry's Ed25519 private key and +// commit the .sig alongside the JSON. Pair with build-plugins-index.mjs +// (which produces the bytes) — this script's only job is to attach a +// detached signature. +// +// Run by .github/workflows/refresh-cache.yml. Reads the PKCS#8-base64 +// private key from the REGISTRY_PRIVATE_KEY env var (set as a GitHub +// Actions repo secret); aborts loudly if the secret is missing or +// malformed so a misconfigured CI run can't silently ship an unsigned +// index. +// +// PR #4600 review CRITICAL #1: signing now happens here, not in the +// Cloudflare worker. The worker never holds the private key — it just +// transports the bytes + signature this script produces. That ties the +// trust root to the registry repo's branch protection + Actions secret +// scope, instead of the worker being a sign-anything oracle reachable +// via REGISTRY_REFRESH_TOKEN. + +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +const repoRoot = path.resolve(new URL('.', import.meta.url).pathname, '..') +const indexPath = path.join(repoRoot, 'plugins-index.json') +const sigPath = path.join(repoRoot, 'plugins-index.json.sig') + +const pkcs8B64 = (process.env.REGISTRY_PRIVATE_KEY || '').trim() +if (!pkcs8B64) { + console.error( + 'REGISTRY_PRIVATE_KEY is not set — refusing to ship unsigned index. ' + + 'Add the PKCS#8-base64 Ed25519 private key as a GitHub Actions secret ' + + 'on this repo (gh secret set REGISTRY_PRIVATE_KEY).', + ) + process.exit(1) +} +if (!fs.existsSync(indexPath)) { + console.error( + `plugins-index.json missing at ${indexPath} — run scripts/build-plugins-index.mjs first.`, + ) + process.exit(1) +} + +const pkcs8Der = Buffer.from(pkcs8B64.replace(/\s+/g, ''), 'base64') +let key +try { + key = crypto.createPrivateKey({ key: pkcs8Der, format: 'der', type: 'pkcs8' }) +} catch (e) { + console.error(`REGISTRY_PRIVATE_KEY is not a valid PKCS#8-DER Ed25519 key: ${e.message}`) + process.exit(1) +} +if (key.asymmetricKeyType !== 'ed25519') { + console.error( + `REGISTRY_PRIVATE_KEY is a ${key.asymmetricKeyType} key — must be ed25519`, + ) + process.exit(1) +} + +const indexBytes = fs.readFileSync(indexPath) +const sigBytes = crypto.sign(null, indexBytes, key) +const sigB64 = sigBytes.toString('base64') + +const prev = fs.existsSync(sigPath) ? fs.readFileSync(sigPath, 'utf8').trim() : null +if (prev === sigB64) { + console.log(`plugins-index.json.sig unchanged (${sigBytes.length} bytes)`) + process.exit(0) +} +fs.writeFileSync(sigPath, sigB64 + '\n') +console.log( + `plugins-index.json.sig written: ${sigBytes.length} bytes signature ` + + `over ${indexBytes.length} bytes of index`, +)