|
11 | 11 | // This prints both values ready to paste, deriving the keyId with the SAME function the runtime uses, so |
12 | 12 | // the published key and the anchors that reference it cannot disagree. |
13 | 13 | // |
14 | | -// npm run ledger:anchor-keygen |
| 14 | +// npm run ledger:anchor-keygen # prints both halves, to copy by hand |
| 15 | +// npm run ledger:anchor-keygen -- --secret-file <path> # writes the private half, prints only the public |
15 | 16 | // |
16 | | -// PRINTS a private key to stdout. It is never written to disk, never committed, and the output is meant to |
17 | | -// go straight into your secret store (`wrangler secret put`, a compose env file, a vault entry). Run it on a |
18 | | -// machine you trust, and do not paste the private half into a shell history you keep. |
| 17 | +// PREFER `--secret-file` for real provisioning (#9940). The default mode prints a private key to stdout, |
| 18 | +// which then lives in terminal scrollback, in whatever captured the session, and in shell history if piped |
| 19 | +// -- one of ours had to be discarded for exactly that reason. With `--secret-file` the key goes straight to |
| 20 | +// disk at 0600 and is never displayed, so there is nothing for a human to copy and nothing to leak. Only the |
| 21 | +// PUBLIC half is printed, which is meant to be published. |
| 22 | +import { writeFileSync } from "node:fs"; |
| 23 | + |
19 | 24 | import { computeAnchorKeyId } from "../src/review/ledger-anchor"; |
20 | 25 |
|
21 | 26 | function toPem(base64: string, label: string): string { |
@@ -61,9 +66,56 @@ export async function generateAnchorKeypair(now: string = new Date().toISOString |
61 | 66 | }; |
62 | 67 | } |
63 | 68 |
|
| 69 | +/** |
| 70 | + * Write the private half straight to a file, 0600, and print NOTHING of it (#9940). |
| 71 | + * |
| 72 | + * The default mode prints both halves for a human to copy. That is fine for a first read of the runbook and |
| 73 | + * wrong for actually provisioning: the key lands in terminal scrollback, in shell history if piped, and in |
| 74 | + * whatever captured the session -- one of ours had to be discarded for exactly that reason. With |
| 75 | + * `--secret-file` the operator never sees the key, so there is nothing to leak by copying it, and the only |
| 76 | + * thing on stdout is the PUBLIC half, which is meant to be published. |
| 77 | + * |
| 78 | + * Bare value, no trailing newline, no comment header: the ORB reads this file verbatim, so anything else in |
| 79 | + * it becomes part of the key. |
| 80 | + */ |
| 81 | +function writePrivateKeyFile(path: string, privateKeyPem: string): void { |
| 82 | + // wx: refuse to clobber. Overwriting a live signing key silently would invalidate every anchor already |
| 83 | + // published under it, and the rotation rule (never remove a retired key, only close it) exists precisely |
| 84 | + // because those anchors must stay verifiable forever. |
| 85 | + writeFileSync(path, privateKeyPem.trim(), { encoding: "utf8", mode: 0o600, flag: "wx" }); |
| 86 | +} |
| 87 | + |
64 | 88 | async function main(): Promise<void> { |
| 89 | + const secretFileFlag = process.argv.indexOf("--secret-file"); |
| 90 | + const secretFile: string | undefined = secretFileFlag === -1 ? undefined : process.argv[secretFileFlag + 1]; |
| 91 | + if (secretFileFlag !== -1 && (secretFile === undefined || secretFile.startsWith("--"))) { |
| 92 | + console.error("--secret-file requires a path, e.g. --secret-file /run/secrets/ledger-anchor-private-key"); |
| 93 | + process.exit(2); |
| 94 | + } |
| 95 | + |
65 | 96 | const { keyId, publishedKeys, privateKeyPem } = await generateAnchorKeypair(); |
66 | 97 |
|
| 98 | + if (secretFile) { |
| 99 | + try { |
| 100 | + writePrivateKeyFile(secretFile, privateKeyPem); |
| 101 | + } catch (error) { |
| 102 | + const code = (error as { code?: string }).code; |
| 103 | + console.error( |
| 104 | + code === "EEXIST" |
| 105 | + ? `${secretFile} already exists. Refusing to overwrite a signing key: every anchor published under it must stay verifiable. Move it aside deliberately, then re-run.` |
| 106 | + : `Could not write ${secretFile}: ${error instanceof Error ? error.message : String(error)}`, |
| 107 | + ); |
| 108 | + process.exit(1); |
| 109 | + } |
| 110 | + console.log("# ── LOOPOVER_LEDGER_ANCHOR_KEYS (public, safe to publish and to commit) ──"); |
| 111 | + console.log(`LOOPOVER_LEDGER_ANCHOR_KEYS='${publishedKeys}'`); |
| 112 | + console.log(""); |
| 113 | + console.log(`# Private key written to ${secretFile} (0600). It was NOT printed -- nothing to copy, nothing to leak.`); |
| 114 | + console.log(`# keyId ${keyId}. Point LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY_FILE at that path, then recreate the container.`); |
| 115 | + return; |
| 116 | + } |
| 117 | + |
| 118 | + |
67 | 119 | console.log("# ── LOOPOVER_LEDGER_ANCHOR_KEYS (public, safe to publish and to commit) ──"); |
68 | 120 | console.log("# Serve this verbatim; it is what /v1/public/decision-ledger/anchor-key returns."); |
69 | 121 | console.log(`LOOPOVER_LEDGER_ANCHOR_KEYS='${publishedKeys}'`); |
|
0 commit comments