#!/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.");