Skip to content

Commit a6bbd98

Browse files
committed
feat(ledger): provision the anchor key without ever displaying it
Refs #9940. The keygen printed the private key to stdout, so provisioning meant a human copying a signing key out of a terminal -- where it lands in scrollback, in shell history if piped, and in whatever captured the session. One of ours had to be discarded for exactly that reason. `--secret-file <path>` writes the private half directly at 0600 and prints only the public half, which is meant to be published. Nothing to copy, nothing to leak. Point LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY_FILE at the path -- the generic <NAME>_FILE mechanism every self-host secret already supports -- and recreate the container. Refuses to overwrite an existing file: replacing a live signing key strands every anchor published under it, which is the same reason a retired key is closed rather than removed. The printing mode stays for reading the shape before provisioning.
1 parent d0efca4 commit a6bbd98

2 files changed

Lines changed: 70 additions & 8 deletions

File tree

apps/loopover-ui/content/docs/self-hosting-configuration.mdx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -534,12 +534,22 @@ healthy idle instance.
534534
### 1. Generate the keypair
535535

536536
```bash
537-
npm run ledger:anchor-keygen
537+
npm run ledger:anchor-keygen -- --secret-file /run/secrets/ledger-anchor-private-key
538538
```
539539

540-
Prints both halves already in the encodings the runtime expects, with the key id derived from the public
541-
half so the two cannot drift apart. It writes nothing to disk — the private key exists only in that output,
542-
so run it on a machine you trust and paste straight into your secret store.
540+
Writes the private half straight to that path at `0600` and **never prints it** — so there is nothing to
541+
copy and nothing to leave in scrollback, shell history, or a captured session. Only the public half is
542+
printed, which is what you publish anyway. Then point `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY_FILE` at that
543+
path (the generic `<NAME>_FILE` mechanism every self-host secret supports) and recreate the container.
544+
545+
It refuses to overwrite an existing file: replacing a live signing key would strand every anchor already
546+
published under it, and the rotation rule below exists precisely so those stay verifiable.
547+
548+
Dropping `--secret-file` prints both halves instead, for reading the shape before you provision. Prefer the
549+
file mode for anything real — a private key on a terminal is a private key you now have to be careful with.
550+
551+
Either way the key id is derived from the public half with the same function the runtime uses, so the
552+
published key and the anchors referencing it cannot drift apart.
543553

544554
### 2. Provision both halves
545555

scripts/gen-ledger-anchor-keypair.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@
1111
// This prints both values ready to paste, deriving the keyId with the SAME function the runtime uses, so
1212
// the published key and the anchors that reference it cannot disagree.
1313
//
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
1516
//
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+
1924
import { computeAnchorKeyId } from "../src/review/ledger-anchor";
2025

2126
function toPem(base64: string, label: string): string {
@@ -61,9 +66,56 @@ export async function generateAnchorKeypair(now: string = new Date().toISOString
6166
};
6267
}
6368

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+
6488
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+
6596
const { keyId, publishedKeys, privateKeyPem } = await generateAnchorKeypair();
6697

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+
67119
console.log("# ── LOOPOVER_LEDGER_ANCHOR_KEYS (public, safe to publish and to commit) ──");
68120
console.log("# Serve this verbatim; it is what /v1/public/decision-ledger/anchor-key returns.");
69121
console.log(`LOOPOVER_LEDGER_ANCHOR_KEYS='${publishedKeys}'`);

0 commit comments

Comments
 (0)