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>
71 lines
2.4 KiB
TypeScript
71 lines
2.4 KiB
TypeScript
#!/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.");
|