Public: anyone can read this; the crew can change it.

One admin command re-seals every sealed value under the newest key, and says how many it moved #11504

Parked opened this
Closed without merging

torque/forge:torque/11473-one-admin-command-re-seals-every-sealed → main 8 commits fork 3 threads · 3 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
9 files changed +658 −9
docs/ORIGINS.md +2 −6
@@ -1058,12 +1058,8 @@ carries token material, a `state`, or a `code`.
10581058 0600 — the forge refuses either file wider than that) and restart: a fresh
10591059 `secret.key` is minted, new values seal under it, and `open` uses whichever
10601060 key a value names. Values from before the format change (`v1:<iv>:<ct>`)
1061 name no key and try the current key, then the old one. Nothing re-seals old
1062 values yet, so `secret.key.old` must stay until every token has been
1063 rewritten (reconnecting an origin does it); delete it and those values come
1064 back `bad_key`, as losing `secret.key` did before. A snapshot carries only
1065 `secret.key`, and the HMAC keys derived from it (passports, visitor links)
1066 change with it.
1061 name no key and try the current key, then the old one. To finish a
1062 rotation, see docs/features/rotate-secret-key.md.
outdated

Move this rotation how-to into docs/features/rotate-secret-key.md and leave a two-line pointer here.

10671063 - **DNS rebinding is not closed.** The SSRF policy checks address _literals_;
10681064 a hostname that resolves into a private range still gets through, because
10691065 pinning the resolved address needs a connect hook Bun's `fetch` does not
docs/RECOVERY.md +2
@@ -117,6 +117,8 @@ which line it stops at.
117117 - Sealed values, unless the passphrase was set: they stay sealed and are set
118118 again by hand (the restore lists them by name).
119119 - App variables, which are sealed the same way.
120 - `secret.key.old`. Finish a rotation before a snapshot:
121 docs/features/rotate-secret-key.md.
120122 - The crew's clones: working copies that agents make again from the
121123 repositories.
122124 - Anything outside the data directory: the code, the machine, the bun.
docs/features/rotate-secret-key.md added +63
@@ -0,0 +1,63 @@
1 # Rotate secret.key
2
3 `scripts/rotate-secret-key.ts` (#11473) finishes a rotation of the data
4 directory's `secret.key` in one deliberate act: it re-seals every sealed value
5 under the newest key and says, per table, how many it moved. It is an offline
6 admin command; there is no page and no terminal screen.
7
8 ## How
9
10 1. **Stop the forge.** A running forge holds the key it read at boot. The
11 script does not detect a running forge; this warning is the guard.
12 2. **Dry run.** `FORGE_DATA=<data> bun scripts/rotate-secret-key.ts` changes
13 nothing. It says whether it would mint a key and counts per table what would
14 move, what is already current, and what is unopenable. Never a value.
15 3. **Apply.** `FORGE_DATA=<data> bun scripts/rotate-secret-key.ts --apply`.
16 - With no `secret.key.old` kept, it mints a new key first: the new one is
17 written as `secret.key.next` (0600), the current key is hard-linked to
18 `secret.key.old`, and `secret.key.next` is renamed over `secret.key`.
19 `secret.key` never stops existing.
20 - With `secret.key.old` already kept, a rotation is under way: it mints
21 nothing and only moves what is left. An existing `secret.key.old` is never
22 overwritten.
23 - Then, in one `IMMEDIATE` transaction, it re-seals every sealed column
24 under the current key: origin tokens, OAuth app and flow secrets, the OIDC
25 signing key, prompt and subscription tokens, forge secrets, app
26 variables, and secret feature settings.
27 4. **Run it again.** A second `--apply` moves 0. It then says
28 `nothing needs secret.key.old any more`.
29 5. **Restart the forge**, then delete `secret.key.old`. Once it is gone, a
30 later run rotates again.
31
32 ## When it fails
33
34 - **The transaction rolls back.** Nothing moves in the database. The key files
35 moved first, so every value is still sealed under the key now kept as
36 `secret.key.old`, which `open` still reads. The forge keeps working; run the
37 script again to finish.
38 - **Unopenable values.** A value that opens under neither key is left exactly
39 as it was and counted as unopenable. Keep `secret.key.old` until each one is
40 set again by hand. Delete it early and those values come back `bad_key`.
41 - **Feature settings.** `app_feature_setting.value` holds plain values too, so
42 only values shaped like a seal (`v1:` or `v2:`) are read. A value that does
43 not parse as a seal is treated as plain text and skipped. A well-formed `v2:`
44 seal that fails to open is counted as unopenable, like any other sealed
45 value.
46
47 ## What changes with the key
48
49 The HMAC keys derived from `secret.key` change with it:
50
51 - passports and visitor links already handed out stop verifying;
52 - app identities;
53 - the signing secret each mirror's webhook was registered with. **Register
54 each mirror's webhook again** after a rotation.
55
56 A snapshot carries only `secret.key`, never `secret.key.old`: finish a
57 rotation (step 4 moves 0) before taking one (docs/RECOVERY.md).
58
59 ## Test
60
61 `test/rotate-secret-key.test.ts`: the dry run writes nothing, `--apply` moves
62 each table and a second run moves 0, a rollback leaves everything opening, and
63 the `SEALED` list covers every `*_enc` column in the schema.
plugins/claude-code/.claude-plugin/plugin.json +1 −1
@@ -1,7 +1,7 @@
11 {
22 "name": "forge",
33 "version": "0.2.0",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked).",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked, waves, held, forks).",

Unrelated to the issue: drop from this pull and land separately.

55 "mcpServers": {
66 "forge": {
77 "type": "http",
scripts/rotate-secret-key.ts added +299
@@ -0,0 +1,299 @@
1 /**
2 * `bun scripts/rotate-secret-key.ts [--apply]`: finish a rotation of the
3 * data directory's `secret.key` in one deliberate act.
4 *
5 * Reads `FORGE_DATA` (as the forge does, `src/kernel/preflight.ts`
6 * `dataDirOf`). Without `--apply` it changes nothing and says what it would
7 * do. With `--apply`:
8 *
9 * 1. When no `secret.key.old` is kept, it mints a new key: the new one is
10 * written beside as `secret.key.next` (0600), the current key is
11 * hard-linked to `secret.key.old`, and `secret.key.next` is renamed over
12 * `secret.key`. `secret.key` never stops existing, and an existing
13 * `secret.key.old` is never overwritten: it may be the only key that
14 * still opens something. When one is kept, a rotation is already under
15 * way and no key is minted; the run only finishes it.
16 * 2. In ONE transaction, it opens every sealed value (`src/kernel/secret.ts`
17 * `open`, which reads the current and the old key) and seals again under
18 * the current key each one that names another key or none (v1). A value
19 * already under the current key is left alone, so a second run moves
20 * nothing. A value that opens under neither key is left as it is and
21 * counted.
22 *
23 * The files move before the database does, so a transaction that rolls back
24 * leaves every value sealed under the key now kept as `secret.key.old`, which
25 * `open` still reads: the forge keeps working, and running again finishes.
26 *
27 * Prints counts per table, never a value. Stop the forge first: a running
28 * forge holds the key it read at boot (see docs/RECOVERY.md).
29 */
30
31 import { Database } from "bun:sqlite";
32 import { randomBytes } from "node:crypto";
33 import {
34 chmodSync,
35 closeSync,
36 existsSync,
37 fsyncSync,
38 linkSync,
39 openSync,
40 renameSync,
41 rmSync,
42 writeSync,
43 } from "node:fs";
44 import { join } from "node:path";
45 import { applyPragmas } from "../src/kernel/db.ts";
46 import { dataDirOf } from "../src/kernel/preflight.ts";
47 import {
48 forgetKey,
49 loadKeys,
50 open,
51 previousKeyPath,
52 seal,
53 sealedBy,
54 secretKeyPath,
55 } from "../src/kernel/secret.ts";
56
57 const KEY_BYTES = 32; // AES-256, as src/kernel/secret.ts
58
59 type Row = Record<string, string | null>;
60
61 /** One table's sealed columns and the AAD its writer binds each value to. */
62 export interface SealedColumns {
63 readonly table: string;
64 readonly columns: readonly string[];
65 /** The row's columns the AAD is made from. */
66 readonly by: readonly string[];
67 readonly aad: (row: Row) => string;
68 /** The column holds plain values too: only ones shaped like a seal are read. */
69 readonly mixed?: true;
70 }
71
72 const s = (row: Row, k: string): string => row[k] ?? "";
73
74 /**
75 * Every sealed column the forge writes, with the AAD its `seal` call uses
76 * (the file named beside each). `test/rotate-secret-key.test.ts` holds this
77 * list to every `*_enc` column in the schema.
78 */
79 export const SEALED: readonly SealedColumns[] = [
80 // src/core/origins.ts: bound to the origin's id.
81 { table: "origin", columns: ["token_enc", "refresh_token_enc"], by: ["id"], aad: (r) => s(r, "id") },
82 // src/origins/oauth.ts: an app's client secret, bound to its id.
83 { table: "origin_app", columns: ["client_secret_enc"], by: ["id"], aad: (r) => s(r, "id") },
84 // src/origins/oauth.ts: a flow in progress, bound to the flow's id.
85 {
86 table: "oauth_flow",
87 columns: ["client_secret_enc", "verifier_enc", "device_code_enc", "target_enc"],
88 by: ["id"],
89 aad: (r) => s(r, "id"),
90 },
91 // src/core/oidc.ts: the signing key, bound to its kid.
92 { table: "oidc_key", columns: ["private_jwk"], by: ["kid"], aad: (r) => s(r, "kid") },
93 // src/core/prompts.ts
94 { table: "prompt_source", columns: ["token_enc"], by: ["id"], aad: (r) => `prompt-source:${s(r, "id")}` },
95 // src/core/secret_store.ts
96 {
97 table: "secret",
98 columns: ["value_enc"],
99 by: ["scope", "name"],
100 aad: (r) => `secret:${s(r, "scope")}:${s(r, "name")}`,
101 },
102 // src/core/subscriptions.ts
103 { table: "subscription", columns: ["token_enc"], by: ["id"], aad: (r) => `subscription:${s(r, "id")}` },
104 // src/core/app_env.ts
105 { table: "app_env", columns: ["value_enc"], by: ["app_id", "name"], aad: (r) => `${s(r, "app_id")}:${s(r, "name")}` },
106 // src/apps/feature_env.ts: only settings declared secret are sealed.
107 {
108 table: "app_feature_setting",
109 columns: ["value"],
110 by: ["app_id", "key"],
111 aad: (r) => `feature-setting:${s(r, "app_id")}:${s(r, "key")}`,
112 mixed: true,
113 },
114 ];
115
116 const looksSealed = (v: string): boolean => /^v[12]:/.test(v);
117
118 export interface TableCount {
119 readonly table: string;
120 /** Sealed under another key (or none named) and sealed again under the current one. */
121 readonly moved: number;
122 /** Already under the current key. */
123 readonly current: number;
124 /** Opens under neither key: left exactly as it was. */
125 readonly unopenable: number;
126 }
127
128 export interface RotateReport {
129 readonly applied: boolean;
130 /** A new key was (or, in a dry run, would be) minted. */
131 readonly minted: boolean;
132 /** The id of the key everything ends under; `null` in a dry run that would mint. */
133 readonly keyId: string | null;
134 readonly tables: readonly TableCount[];
135 }
136
137 export interface RotateOptions {
138 readonly apply: boolean;
139 /** Test seam: runs inside the transaction after every update, before commit. */
140 readonly beforeCommit?: () => void;
141 }
142
143 export type RotateRefusal = "no_database" | "no_key" | "key_next_present";
144
145 export class Refused extends Error {
146 constructor(readonly why: RotateRefusal, message: string) {
147 super(message);
148 }
149 }
150
151 /** Write a fresh key beside `secret.key`, then swap it in, keeping the old one. */
152 function mintBeside(dataDir: string): void {
153 const current = secretKeyPath(dataDir);
154 const next = `${current}.next`;
155 // `wx`: a leftover `.next` is an earlier run that stopped mid-swap; refuse
156 // rather than guess which key it holds.
157 const fd = openSync(next, "wx", 0o600);
158 try {
159 writeSync(fd, randomBytes(KEY_BYTES));
160 fsyncSync(fd);
161 } finally {
162 closeSync(fd);
163 }
164 chmodSync(next, 0o600); // umask may have widened the mode at creation
165 try {
166 // A hard link: `secret.key` is never absent, and an existing `.old` fails
167 // the link (EEXIST) instead of being overwritten.
168 linkSync(current, previousKeyPath(dataDir));
169 } catch (error) {
170 rmSync(next, { force: true });
171 throw error;
172 }
173 renameSync(next, current);
174 forgetKey(dataDir);
175 }
176
177 function tableColumns(db: Database, table: string): Set<string> {
178 const rows = db.query<{ name: string }, []>(`PRAGMA table_info(${JSON.stringify(table)})`).all();
179 return new Set(rows.map((r) => r.name));
180 }
181
182 export function rotateSecretKey(dataDir: string, options: RotateOptions): RotateReport {
183 const dbPath = join(dataDir, "forge.db");
184 if (!existsSync(dbPath)) throw new Refused("no_database", `${dbPath} does not exist: point FORGE_DATA at the forge's data directory`);
185 if (!existsSync(secretKeyPath(dataDir)))
186 throw new Refused("no_key", `${secretKeyPath(dataDir)} does not exist: nothing is sealed under a key this directory holds`);
187 if (existsSync(`${secretKeyPath(dataDir)}.next`))
188 throw new Refused(
189 "key_next_present",
190 `${secretKeyPath(dataDir)}.next exists: an earlier run stopped while swapping keys; see docs/RECOVERY.md`,
191 );
192
193 forgetKey(dataDir);
194 const minted = !existsSync(previousKeyPath(dataDir));
195 if (options.apply && minted) mintBeside(dataDir);
196 forgetKey(dataDir);
197 const ring = loadKeys(dataDir);
198 // In a dry run that would mint, the current key is about to become the old
199 // one: every value that opens would move.
200 const target = options.apply || !minted ? ring.current.id : null;
201
202 const db = new Database(dbPath, { readwrite: true, strict: true });
203 try {
204 applyPragmas(db);
205 const tables: TableCount[] = [];
206 const work = (): void => {
207 for (const spec of SEALED) {
208 const have = tableColumns(db, spec.table);
209 const columns = spec.columns.filter((c) => have.has(c));
210 if (columns.length === 0 || !spec.by.every((c) => have.has(c))) continue;
211 let moved = 0;
212 let current = 0;
213 let unopenable = 0;
214 const select = [...spec.by, ...columns].map((c) => `"${c}"`).join(", ");
215 const rows = db.query<Row & { rid: number }, []>(`SELECT rowid AS rid, ${select} FROM "${spec.table}"`).all();
216 for (const row of rows) {
217 for (const column of columns) {
218 const value = row[column];
219 if (typeof value !== "string" || value === "") continue;
220 if (spec.mixed && !looksSealed(value)) continue;
221 const aad = spec.aad(row);
222 const opened = open(dataDir, value, aad);
223 if (!opened.ok) {
224 // A plain setting that merely starts like a seal is not one.
225 if (spec.mixed && opened.error === "bad_format") continue;
226 unopenable++;
227 continue;
228 }
229 if (target !== null && sealedBy(value) === target) {
230 current++;
231 continue;
232 }
233 moved++;
234 if (options.apply)
235 db.query(`UPDATE "${spec.table}" SET "${column}" = ?1 WHERE rowid = ?2`).run(
236 seal(dataDir, opened.value, aad),
237 row.rid,
238 );
239 }
240 }
241 tables.push({ table: spec.table, moved, current, unopenable });
242 }
243 options.beforeCommit?.();
244 };
245 if (options.apply) db.transaction(work).immediate();
246 else work();
247 return { applied: options.apply, minted, keyId: target, tables };
248 } finally {
249 db.close();
250 forgetKey(dataDir);
251 }
252 }
253
254 /** The lines printed for a report: counts and key ids, never a value. */
255 export function describeReport(report: RotateReport): string[] {
256 const verb = report.applied ? "moved" : "would move";
257 const lines: string[] = [];
258 if (report.minted)
259 lines.push(
260 report.applied
261 ? `minted a new secret.key (${report.keyId}); the previous one is kept as secret.key.old`
262 : "would mint a new secret.key and keep the current one as secret.key.old",
263 );
264 else lines.push(`secret.key.old is kept already: finishing that rotation under secret.key (${report.keyId}), no key minted`);
265 const width = Math.max(5, ...report.tables.map((t) => t.table.length));
266 lines.push(`${"table".padEnd(width)} ${verb.padStart(10)} current unopenable`);
267 for (const t of report.tables)
268 lines.push(
269 `${t.table.padEnd(width)} ${String(t.moved).padStart(10)} ${String(t.current).padStart(7)} ${String(t.unopenable).padStart(10)}`,
270 );
271 const moved = report.tables.reduce((n, t) => n + t.moved, 0);
272 const stuck = report.tables.reduce((n, t) => n + t.unopenable, 0);
273 lines.push(`${verb}: ${moved}; unopenable: ${stuck}`);
274 if (!report.applied) lines.push("dry run: nothing written; run again with --apply");
275 else if (moved === 0 && stuck === 0 && !report.minted)
276 lines.push("nothing needs secret.key.old any more: delete it once the forge has restarted, then a later run rotates again");
277 else lines.push("restart the forge so it reads the new key");
278 if (stuck > 0) lines.push("unopenable values were left as they were: keep secret.key.old until they are set again by hand");
279 return lines;
280 }
281
282 const USAGE = "usage: FORGE_DATA=<data-dir> bun scripts/rotate-secret-key.ts [--apply]";
283
284 if (import.meta.main) {
285 const args = process.argv.slice(2);
286 if (args.some((a) => a !== "--apply")) {
287 console.error(USAGE);
288 process.exit(2);
289 }
290 try {
291 const report = rotateSecretKey(dataDirOf(process.env), { apply: args.includes("--apply") });
292 for (const line of describeReport(report)) console.log(line);
293 } catch (error) {
294 // Our refusals and SQLite's errors name paths and tables, never a value.
295 console.error(`rotate-secret-key: ${error instanceof Error ? error.message : String(error)}`);
296 if (!(error instanceof Refused)) console.error("the database was not changed; run again to finish");
297 process.exit(1);
298 }
299 }
src/kernel/secret.ts +11
@@ -166,6 +166,17 @@ export function seal(dataDir: string, plain: string, aad: string): string {
166166 return `${VERSION}:${keyId(key)}:${iv.toString("base64url")}:${body.toString("base64url")}`;
167167 }
168168
169 /**
170 * The id of the key a sealed value names, without opening it: the keyid of a
171 * v2 value, `null` for a v1 value (which names none) or anything else.
172 */
173 export function sealedBy(sealed: string): string | null {
174 const parts = sealed.split(":");
175 return parts.length === 4 && parts[0] === VERSION && KEY_ID.test(parts[1]!)
176 ? parts[1]!
177 : null;
178 }
179
169180 export type OpenError = "bad_format" | "bad_key";
170181
171182 /**
src/looks/backoff.ts +4 −1
@@ -8,6 +8,8 @@
88 * and fire the retries itself.
99 */
1010
11 import { during } from "../perf/jobs.ts";
12
1113 /** Time, and a way to run something later: the real ones unless a test says otherwise. */
1214 export interface BackoffClock {
1315 now(): number;
@@ -18,7 +20,8 @@ export interface BackoffClock {
1820 export const realClock: BackoffClock = {
1921 now: () => Date.now(),
2022 after(ms, fn) {
21 const timer = setTimeout(fn, ms);
23 // A retry is loop work: it runs inside during so a stall names it (#11175).
24 const timer = setTimeout(() => during("looks-retry", fn), ms);
2225 timer.unref?.();
2326 return () => clearTimeout(timer);
2427 },
test/plugin-claude-oauth.test.ts +1 −1
@@ -17,7 +17,7 @@ test("the description names every verb the command handles", async () => {
1717 const md = await Bun.file(`${dir}/commands/forge.md`).text();
1818 const list = md.match(/^description: Talk to your forge: (.*)$/m)![1] as string;
1919 const verbs = list.split(",").map((v) => v.trim());
20 expect(verbs.length).toBe(12);
20 expect(verbs.length).toBe(15);

Unrelated 12 to 15 fix: drop from this pull with plugin.json.

2121 for (const v of verbs) expect(plugin.description).toContain(v);
2222 });
2323
test/rotate-secret-key.test.ts added +275
@@ -0,0 +1,275 @@
1 /**
2 * `scripts/rotate-secret-key.ts`: rows sealed under key A by the forge's own
3 * writers are sealed again under a new key B, and open with A deleted. A dry
4 * run writes nothing, a second run moves nothing, and a transaction that
5 * fails leaves every row exactly as it was and still opening.
6 */
7
8 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9 import type { Database } from "bun:sqlite";
10 import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { describeReport, rotateSecretKey, SEALED, type RotateReport } from "../scripts/rotate-secret-key.ts";
14 import { openDb } from "../src/kernel/db.ts";
15 import { migrations } from "../src/core/schema.ts";
16 import { forgetKey, keyId, loadKey, open, previousKeyPath, sealedBy, secretKeyPath } from "../src/kernel/secret.ts";
17 import { createOrigin } from "../src/core/origins.ts";
18 import { setSecret } from "../src/core/secret_store.ts";
19 import { addSubscription, subscriptionAad } from "../src/core/subscriptions.ts";
20 import { resolveAppEnv, setAppEnv } from "../src/core/app_env.ts";
21 import { addPromptSource, openPromptToken } from "../src/core/prompts.ts";
22 import { signingKey } from "../src/core/oidc.ts";
23 import { setFeatureSettings } from "../src/core/features.ts";
24 import { keptSettings, sealSetting } from "../src/apps/feature_env.ts";
25 import type { FeatureSpec } from "../src/apps/feature.ts";
26
27 const PLAIN = {
28 token: "ghp_origin-token-plain",
29 refresh: "ghr_refresh-plain",
30 secret: "forge-secret-plain",
31 sub: "sk-ant-api03-subscription-plain",
32 env: "app-env-plain",
33 prompt: "IGQ-prompt-token-plain",
34 setting: "feature-setting-plain",
35 };
36
37 const SPEC = { settings: [{ key: "API_KEY", type: "secret" }, { key: "COLOR", type: "string" }] } as unknown as FeatureSpec;
38
39 interface Ids {
40 origin: string;
41 sub: string;
42 prompt: string;
43 kid: string;
44 }
45
46 let dir = "";
47 const dbPath = (): string => join(dir, "forge.db");
48 const db = (): Database => openDb(dbPath(), migrations);
49
50 /** Seal one value in every table through the forge's own writers. */
51 async function seed(): Promise<Ids> {
52 const d = db();
53 // The rows' owners (user, app, repo) are not what is under test.
54 d.exec("PRAGMA foreign_keys = OFF");
55 try {
56 const origin = createOrigin(d, dir, {
57 userId: "u1",
58 kind: "github",
59 apiUrl: "https://api.github.com",
60 webUrl: "https://github.com",
61 login: "alice",
62 token: PLAIN.token,
63 authKind: "oauth",
64 refreshToken: PLAIN.refresh,
65 });
66 if (!origin.ok) throw new Error(origin.error);
67 expect(setSecret(d, dir, "forge", "", "DEPLOY_KEY", PLAIN.secret).ok).toBe(true);
68 const sub = addSubscription(d, dir, { name: "main", token: PLAIN.sub });
69 if (!sub.ok) throw new Error(sub.error);
70 expect(setAppEnv(d, dir, "app1", "DB_PASSWORD", PLAIN.env).ok).toBe(true);
71 const prompt = addPromptSource(d, dir, { repoId: "r1", kind: "instagram", token: PLAIN.prompt, atUtc: "08:00" });
72 if (!prompt.ok) throw new Error(prompt.error);
73 const key = await signingKey(d, dir);
74 setFeatureSettings(
75 d,
76 "app1",
77 new Map([
78 ["API_KEY", sealSetting(dir, "app1", "API_KEY", PLAIN.setting)],
79 ["COLOR", "v1:not-a-seal"],
80 ]),
81 "u1",
82 );
83 return { origin: origin.value.id, sub: sub.value.id, prompt: prompt.value.id, kid: key.kid };
84 } finally {
85 d.close();
86 }
87 }
88
89 /** Every value, opened through the readers the forge itself uses. */
90 function readAll(ids: Ids): Record<string, unknown> {
91 forgetKey(dir);
92 const d = db();
93 try {
94 const o = d
95 .query<{ token_enc: string; refresh_token_enc: string }, [string]>(
96 "SELECT token_enc, refresh_token_enc FROM origin WHERE id = ?1",
97 )
98 .get(ids.origin)!;
99 const sec = d.query<{ value_enc: string }, []>("SELECT value_enc FROM secret").get()!;
100 const sub = d.query<{ token_enc: string }, [string]>("SELECT token_enc FROM subscription WHERE id = ?1").get(ids.sub)!;
101 const jwk = d.query<{ private_jwk: string }, [string]>("SELECT private_jwk FROM oidc_key WHERE kid = ?1").get(ids.kid)!;
102 const val = (r: { ok: boolean; value?: string }): string | undefined => (r.ok ? r.value : undefined);
103 const kept = keptSettings(d, dir, "app1", SPEC);
104 return {
105 token: val(open(dir, o.token_enc, ids.origin)),
106 refresh: val(open(dir, o.refresh_token_enc, ids.origin)),
107 secret: val(open(dir, sec.value_enc, "secret:forge:DEPLOY_KEY")),
108 sub: val(open(dir, sub.token_enc, subscriptionAad(ids.sub))),
109 env: resolveAppEnv(d, dir, "app1", "production").DB_PASSWORD,
110 prompt: val(openPromptToken(d, dir, ids.prompt)),
111 setting: kept.get("API_KEY"),
112 jwk: open(dir, jwk.private_jwk, ids.kid).ok,
113 color: kept.get("COLOR"),
114 };
115 } finally {
116 d.close();
117 }
118 }
119
120 /** Every sealed value in the database, by table and column, as stored. */
121 function stored(): string[] {
122 const d = db();
123 try {
124 const out: string[] = [];
125 for (const spec of SEALED)
126 for (const c of spec.columns)
127 for (const r of d.query<Record<string, string>, []>(`SELECT "${c}" AS v FROM "${spec.table}"`).all())
128 if (r.v) out.push(`${spec.table}.${c}=${r.v}`);
129 return out.sort();
130 } finally {
131 d.close();
132 }
133 }
134
135 const total = (r: RotateReport, k: "moved" | "current" | "unopenable"): number =>
136 r.tables.reduce((n, t) => n + t[k], 0);
137 const count = (r: RotateReport, table: string) => r.tables.find((t) => t.table === table);
138
139 describe("rotate-secret-key", () => {
140 beforeEach(() => {
141 dir = mkdtempSync(join(tmpdir(), "forge-rotate-"));
142 });
143 afterEach(() => {
144 forgetKey(dir);
145 rmSync(dir, { recursive: true, force: true });
146 });
147
148 test("every *_enc column in the schema is one the rotation reseals", () => {
149 const d = openDb(":memory:", migrations);
150 const tables = d.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'").all();
151 const enc: string[] = [];
152 for (const t of tables)
153 for (const c of d.query<{ name: string }, []>(`PRAGMA table_info("${t.name}")`).all())
154 if (c.name.endsWith("_enc")) enc.push(`${t.name}.${c.name}`);
155 d.close();
156 const covered = new Set(SEALED.flatMap((s) => s.columns.map((c) => `${s.table}.${c}`)));
157 expect(enc.length).toBeGreaterThan(0);
158 expect(enc.filter((c) => !covered.has(c))).toEqual([]);
159 });
160
161 test("rows sealed under A open under B with A deleted, and a second run moves nothing", async () => {
162 const a = loadKey(dir);
163 const idA = keyId(a);
164 const ids = await seed();
165 const before = readAll(ids);
166 expect(before).toEqual({ ...PLAIN, jwk: true, color: "v1:not-a-seal" });
167
168 const report = rotateSecretKey(dir, { apply: true });
169 expect(report.minted).toBe(true);
170 // Hand count: origin 2 (token, refresh), one each for secret,
171 // subscription, app_env, prompt_source, oidc_key and the one secret
172 // feature setting: 8. The plain setting is not a seal and is not counted.
173 expect(total(report, "moved")).toBe(8);
174 expect(count(report, "origin")?.moved).toBe(2);
175 expect(count(report, "app_feature_setting")?.moved).toBe(1);
176 expect(total(report, "unopenable")).toBe(0);
177
178 const b = readFileSync(secretKeyPath(dir));
179 expect(b.equals(a)).toBe(false);
180 expect(readFileSync(previousKeyPath(dir)).equals(a)).toBe(true);
181 expect(statSync(secretKeyPath(dir)).mode & 0o777).toBe(0o600);
182 expect(report.keyId).toBe(keyId(b));
183 for (const v of stored().filter((v) => !v.endsWith("not-a-seal")))
184 expect(sealedBy(v.slice(v.indexOf("=") + 1))).toBe(keyId(b));
185
186 // A is gone: everything still opens, under B alone.
187 rmSync(previousKeyPath(dir));
188 expect(readAll(ids)).toEqual(before);
189 expect(stored().some((v) => v.includes(`:${idA}:`))).toBe(false);
190 });
191
192 test("a second run straight after the first moves nothing and keeps secret.key.old", async () => {
193 await seed();
194 rotateSecretKey(dir, { apply: true });
195 const old = readFileSync(previousKeyPath(dir));
196 const rows = stored();
197 const again = rotateSecretKey(dir, { apply: true });
198 expect(again.minted).toBe(false);
199 expect(total(again, "moved")).toBe(0);
200 expect(stored()).toEqual(rows);
201 expect(readFileSync(previousKeyPath(dir)).equals(old)).toBe(true);
202 expect(describeReport(again).join("\n")).toContain("nothing needs secret.key.old");
203 });
204
205 test("the dry run writes nothing and counts what --apply would move", async () => {
206 const a = loadKey(dir);
207 await seed();
208 const rows = stored();
209 const report = rotateSecretKey(dir, { apply: false });
210 expect(report.applied).toBe(false);
211 expect(report.minted).toBe(true);
212 expect(total(report, "moved")).toBe(8);
213 expect(stored()).toEqual(rows);
214 expect(readFileSync(secretKeyPath(dir)).equals(a)).toBe(true);
215 expect(existsSync(previousKeyPath(dir))).toBe(false);
216 expect(describeReport(report).join("\n")).toContain("would move: 8");
217 });
218
219 test("a failure before commit rolls back whole: every row as it was, and still opening", async () => {
220 const ids = await seed();
221 const before = readAll(ids);
222 const rows = stored();
223 expect(() =>
224 rotateSecretKey(dir, {
225 apply: true,
226 beforeCommit: () => {
227 throw new Error("disk full");
228 },
229 }),
230 ).toThrow("disk full");
231 expect(stored()).toEqual(rows);
232 // The key moved first, so the rows sit under secret.key.old and open.
233 expect(existsSync(previousKeyPath(dir))).toBe(true);
234 expect(readAll(ids)).toEqual(before);
235 // Running again finishes the rotation without minting a third key.
236 const done = rotateSecretKey(dir, { apply: true });
237 expect(done.minted).toBe(false);
238 expect(total(done, "moved")).toBe(8);
239 rmSync(previousKeyPath(dir));
240 expect(readAll(ids)).toEqual(before);
241 });
242
243 test("a v1 value from before key ids is moved too", async () => {
244 const ids = await seed();
245 // Rewrite the origin token as the old format would have: v1, no key id.
246 const { createCipheriv, randomBytes } = await import("node:crypto");
247 const key = loadKey(dir);
248 const iv = randomBytes(12);
249 const c = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
250 c.setAAD(Buffer.from(ids.origin, "utf8"));
251 const body = Buffer.concat([c.update(PLAIN.token, "utf8"), c.final(), c.getAuthTag()]);
252 const d = db();
253 d.query("UPDATE origin SET token_enc = ?1 WHERE id = ?2").run(
254 `v1:${iv.toString("base64url")}:${body.toString("base64url")}`,
255 ids.origin,
256 );
257 d.close();
258 expect(total(rotateSecretKey(dir, { apply: true }), "moved")).toBe(8);
259 rmSync(previousKeyPath(dir));
260 expect(readAll(ids).token).toBe(PLAIN.token);
261 });
262
263 test("the report never carries a plaintext", async () => {
264 await seed();
265 const text = describeReport(rotateSecretKey(dir, { apply: true })).join("\n");
266 for (const p of Object.values(PLAIN)) expect(text).not.toContain(p);
267 });
268
269 test("refuses a directory with no database or no key, writing nothing", () => {
270 expect(() => rotateSecretKey(dir, { apply: true })).toThrow("forge.db does not exist");
271 db().close();
272 expect(() => rotateSecretKey(dir, { apply: true })).toThrow("secret.key does not exist");
273 expect(existsSync(secretKeyPath(dir))).toBe(false);
274 });
275 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs