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 #11524

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

  • Merged. scout/11473-one-admin-command-re-seals-every-sealed was merged into main as abf00b0.

Review

Sign in to review this change.
6 files changed +497 −14
docs/ORIGINS.md +7 −5
@@ -1058,11 +1058,13 @@ 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)
1061 name no key and try the current key, then the old one.
1062 `bun scripts/rotate-secret-key.ts --apply` (forge stopped) re-seals every
1063 sealed column under the newest key in one transaction and says how many it
1064 moved per table — see docs/RECOVERY.md, "Rotating the secret key". Until it
1065 has run, `secret.key.old` must stay; delete it before then and the values it
1066 sealed come back `bad_key`, as losing `secret.key` did before. A snapshot
1067 carries only `secret.key`, and the HMAC keys derived from it (passports, visitor links)
10661068 change with it.
10671069 - **DNS rebinding is not closed.** The SSRF policy checks address _literals_;
10681070 a hostname that resolves into a private range still gets through, because
docs/RECOVERY.md +2
@@ -112,6 +112,8 @@ which line it stops at.
112112 - **key** — restoring the secret key, opened with the passphrase. Likely the
113113 passphrase is missing or wrong. Check `FORGE_SNAPSHOT_PASSPHRASE`.
114114
115 Replacing a leaked `secret.key`: docs/features/rotate-secret-key.md.
116
115117 ## What a snapshot does NOT hold
116118
117119 - Sealed values, unless the passphrase was set: they stay sealed and are set
docs/features/rotate-secret-key.md added +30
@@ -0,0 +1,30 @@
1 # Rotating the secret key
2
3 `secret.key` in the data directory seals every stored credential. To replace
4 it — it leaked, or a person who held it left — stop the forge, then:
5
6 FORGE_DATA=/data bun scripts/rotate-secret-key.ts # dry run: counts only
7 FORGE_DATA=/data bun scripts/rotate-secret-key.ts --apply # writes
8
9 `--apply` mints a new key, re-seals every sealed column (origin and OAuth
10 tokens, account tokens, prompt-source tokens, secrets, app variables, secret
11 feature settings, the OIDC signing key) under it in one transaction, then
12 keeps the old key as `secret.key.old` and puts the new one in place. It prints
13 counts per table and key ids, never a value. A write that fails rolls the
14 whole re-seal back and leaves the old key where it was.
15
16 While `secret.key.old` is there a rotation is under way: a run mints nothing
17 and only moves what is still under an older key onto the current one, so
18 running it again moves nothing — and the by-hand rotation (move `secret.key`
19 to `secret.key.old`, restart) is finished by the same command. Start the
20 forge, check that origins and secrets work, then delete `secret.key.old`; the
21 next run starts a new rotation.
22
23 - Passports and visitor links are signed with keys derived from `secret.key`,
24 so they end with it.
25 - A value counted "unopenable" opens under no key the directory holds and is
26 left as it is. The report then warns to keep `secret.key.old`, and `--apply`
27 exits 3, so a script driving the rotation does not go on to delete it.
28 - A new sealed column must be added to `SEALED_COLUMNS` in
29 `scripts/rotate-secret-key.ts`; test/rotate-secret-key.test.ts fails on any
30 `*_enc` column in the schema that is missing from it.
scripts/rotate-secret-key.ts added +238
@@ -0,0 +1,238 @@
1 /**
2 * `bun scripts/rotate-secret-key.ts [--apply]`: finish a key rotation in one
3 * deliberate step (#11473). Reads `$FORGE_DATA` (stop the forge first).
4 *
5 * With no `secret.key.old` beside the key, a run mints a fresh key, re-seals
6 * every sealed column under it in one transaction, then keeps the old key as
7 * `secret.key.old` and puts the new one in place as `secret.key`. With a
8 * `secret.key.old` already there, a rotation is under way: the run mints
9 * nothing and only moves what is still sealed under an older key onto the
10 * current one. So a second run moves nothing, and the old key is never
11 * overwritten while a value may still need it; delete `secret.key.old` once
12 * you are satisfied, and the next run starts a new rotation.
13 *
14 * Dry run by default: it opens every value, counts, and writes nothing.
15 * `--apply` writes. A value that opens under no key held is left as it is,
16 * counted and warned about, and an apply that left one exits 3; the
17 * transaction rolls back whole if any write fails.
18 * Prints counts per table, never a plaintext and never a key.
19 */
20
21 import { Database } from "bun:sqlite";
22 import { randomBytes } from "node:crypto";
23 import { chmodSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24 import { join } from "node:path";
25 import {
26 forgetKey,
27 keyId,
28 type NamedKey,
29 openWith,
30 previousKeyPath,
31 sealedKeyId,
32 sealWith,
33 secretKeyPath,
34 } from "../src/kernel/secret.ts";
35
36 /** Where a crashed apply left the key it was moving to. */
37 export const nextKeyPath = (dataDir: string): string => join(dataDir, "secret.key.next");
38
39 type Row = Record<string, string | number | null>;
40
41 /** One sealed column: its table, and the additional data each row's value is bound to. */
42 export interface SealedColumn {
43 readonly table: string;
44 readonly column: string;
45 readonly aad: (row: Row) => string;
46 }
47
48 const s = (row: Row, key: string): string => String(row[key] ?? "");
49
50 /**
51 * Every column the forge seals, with the AAD its writer binds it to — kept
52 * beside the writers' own `aad` helpers: src/core/origins.ts,
53 * src/origins/oauth.ts, src/core/prompts.ts, src/core/subscriptions.ts,
54 * src/core/secret_store.ts, src/core/app_env.ts, src/core/oidc.ts and
55 * src/apps/feature_env.ts. A new sealed column belongs here too.
56 */
57 export const SEALED_COLUMNS: readonly SealedColumn[] = [
58 { table: "origin", column: "token_enc", aad: (r) => s(r, "id") },
59 { table: "origin", column: "refresh_token_enc", aad: (r) => s(r, "id") },
60 { table: "origin_app", column: "client_secret_enc", aad: (r) => s(r, "id") },
61 { table: "oauth_flow", column: "client_secret_enc", aad: (r) => s(r, "id") },
62 { table: "oauth_flow", column: "verifier_enc", aad: (r) => s(r, "id") },
63 { table: "oauth_flow", column: "device_code_enc", aad: (r) => s(r, "id") },
64 { table: "oauth_flow", column: "target_enc", aad: (r) => s(r, "id") },
65 { table: "prompt_source", column: "token_enc", aad: (r) => `prompt-source:${s(r, "id")}` },
66 { table: "subscription", column: "token_enc", aad: (r) => `subscription:${s(r, "id")}` },
67 { table: "secret", column: "value_enc", aad: (r) => `secret:${s(r, "scope")}:${s(r, "name")}` },
68 { table: "app_env", column: "value_enc", aad: (r) => `${s(r, "app_id")}:${s(r, "name")}` },
69 { table: "oidc_key", column: "private_jwk", aad: (r) => s(r, "kid") },
70 {
71 table: "app_feature_setting",
72 column: "value",
73 aad: (r) => `feature-setting:${s(r, "app_id")}:${s(r, "key")}`,
74 },
75 ];
76
77 /** What a run found for one table. */
78 export interface TableCount {
79 readonly table: string;
80 /** Re-sealed under the newest key (or would be, in a dry run). */
81 moved: number;
82 /** Already under the newest key. */
83 kept: number;
84 /** Sealed-looking, but opens under no key this directory holds: left as it is. */
85 unopened: number;
86 }
87
88 export interface Rotation {
89 readonly applied: boolean;
90 /** True when this run mints the newest key; false when it finishes one under way. */
91 readonly minted: boolean;
92 /** The newest key's id (never the key). */
93 readonly keyId: string;
94 readonly tables: TableCount[];
95 }
96
97 function readKey(path: string): NamedKey | null {
98 if (!existsSync(path)) return null;
99 const mode = statSync(path).mode & 0o777;
100 if ((mode & 0o077) !== 0) throw new Error(`${path} is mode ${mode.toString(8)}; it must be 0600`);
101 const key = readFileSync(path);
102 if (key.length !== 32) throw new Error(`${path} is not a 32-byte key`);
103 return { id: keyId(key), key };
104 }
105
106 function mintKey(): NamedKey {
107 const key = randomBytes(32);
108 return { id: keyId(key), key };
109 }
110
111 function writeKey(path: string, key: Buffer): void {
112 writeFileSync(path, key, { mode: 0o600 });
113 chmodSync(path, 0o600);
114 }
115
116 const looksSealed = (v: unknown): v is string =>
117 typeof v === "string" && (v.startsWith("v1:") || v.startsWith("v2:"));
118
119 function hasTable(db: Database, table: string): boolean {
120 return db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1").get(table) !== null;
121 }
122
123 /** Re-seal every sealed column under the newest key; writes only with `apply`. */
124 export function rotateSecretKey(dataDir: string, apply: boolean): Rotation {
125 const dbPath = join(dataDir, "forge.db");
126 if (!existsSync(dbPath)) throw new Error(`${dbPath} is missing: is FORGE_DATA the forge's data directory?`);
127 // A crash between the two renames of an apply: the commit is done and the
128 // old key already kept, so only the new key still has to take its place.
129 if (apply && !existsSync(secretKeyPath(dataDir)) && existsSync(nextKeyPath(dataDir)) && existsSync(previousKeyPath(dataDir)))
130 renameSync(nextKeyPath(dataDir), secretKeyPath(dataDir));
131 const current = readKey(secretKeyPath(dataDir));
132 if (current === null) throw new Error(`${secretKeyPath(dataDir)} is missing: there is nothing to rotate`);
outdated

Replace this nested IIFE with a small mintKey() helper.

133 const previous = readKey(previousKeyPath(dataDir));
134 // An apply that crashed after its commit, before the files moved, left
135 // the key every value now names; it is the target, never a fresh one.
136 const pending = readKey(nextKeyPath(dataDir));
137 if (pending !== null && previous !== null)
138 throw new Error("secret.key.old and secret.key.next are both here: keep the one the values name and move the other away by hand");
139 const minted = previous === null;
140 const target: NamedKey = pending ?? (minted ? mintKey() : current);
141 const ring = [target, current, ...(previous ? [previous] : [])];
142
143 const db = new Database(dbPath);
144 const tables = new Map<string, TableCount>();
145 try {
146 db.exec("PRAGMA busy_timeout = 5000");
147 if (apply && minted && pending === null) writeKey(nextKeyPath(dataDir), target.key);
148 const work = (): void => {
149 for (const col of SEALED_COLUMNS) {
150 if (!hasTable(db, col.table)) continue;
151 const count = tables.get(col.table) ?? { table: col.table, moved: 0, kept: 0, unopened: 0 };
152 tables.set(col.table, count);
153 const rows = db.query<Row, []>(`SELECT rowid AS __rowid, * FROM "${col.table}"`).all();
154 const update = db.query(`UPDATE "${col.table}" SET "${col.column}" = ?1 WHERE rowid = ?2`);
155 for (const row of rows) {
outdated

Unopenable values are left under the old key but the run still exits 0. Under --apply, exit non-zero or warn loudly that secret.key.old must be kept. Test it.

156 const value = row[col.column];
157 if (!looksSealed(value)) continue;
158 if (sealedKeyId(value) === target.id) {
159 count.kept++;
160 continue;
161 }
162 const aad = col.aad(row);
163 const opened = openWith(ring, value, aad);
164 if (!opened.ok) {
165 count.unopened++;
166 continue;
167 }
168 count.moved++;
169 if (apply) update.run(sealWith(target, opened.value, aad), row.__rowid ?? null);
170 }
171 }
172 };
173 try {
174 if (apply) db.transaction(work).immediate();
175 else work();
176 } catch (error) {
177 // Rolled back whole: no value names the key that would have been new.
178 if (apply && minted && pending === null) rmSync(nextKeyPath(dataDir), { force: true });
179 throw error;
180 }
181 } finally {
182 db.close();
183 }
184
185 if (apply && target.id !== current.id) {
186 // Committed: keep the old key, then put the new one in place.
187 renameSync(secretKeyPath(dataDir), previousKeyPath(dataDir));
188 renameSync(nextKeyPath(dataDir), secretKeyPath(dataDir));
189 } else if (apply && pending !== null) {
190 rmSync(nextKeyPath(dataDir), { force: true });
191 }
192 if (apply) forgetKey(dataDir);
193 return { applied: apply, minted: target.id !== current.id, keyId: target.id, tables: [...tables.values()] };
194 }
195
196 /** The report, one line per table: counts and key ids only. */
197 export function describeRotation(r: Rotation): string {
198 const head = r.applied
199 ? r.minted
200 ? `rotated: every sealed value now opens under key ${r.keyId}; the old key is secret.key.old`
201 : `finished: every sealed value now opens under key ${r.keyId}`
202 : r.minted
203 ? `dry run: would mint a new key and re-seal under it (run with --apply)`
204 : `dry run: secret.key.old is kept, so this finishes a rotation onto key ${r.keyId} (run with --apply)`;
205 const lines = r.tables.map(
206 (t) => ` ${t.table.padEnd(20)} moved ${t.moved}, already current ${t.kept}${t.unopened ? `, unopenable ${t.unopened} (left as is)` : ""}`,
207 );
208 const moved = r.tables.reduce((n, t) => n + t.moved, 0);
209 const lost = unopened(r);
210 const warning = lost
211 ? [`warning: ${lost} value(s) open under no key held and stay as they are: keep secret.key.old until they are resolved`]
212 : [];
213 return [head, ...lines, `${r.applied ? "moved" : "would move"} ${moved} in all`, ...warning].join("\n");
214 }
215
216 /** How many sealed values opened under no key held. */
217 export const unopened = (r: Rotation): number => r.tables.reduce((n, t) => n + t.unopened, 0);
218
219 /** The exit code: 3 when an apply left values it could not open, so nothing deletes secret.key.old after it. */
220 export const exitCode = (r: Rotation): number => (r.applied && unopened(r) > 0 ? 3 : 0);
221
222 if (import.meta.main) {
223 const args = process.argv.slice(2);
224 const unknown = args.filter((a) => a !== "--apply");
225 const dataDir = process.env.FORGE_DATA ?? "";
226 if (unknown.length > 0 || dataDir === "") {
227 console.error("usage: FORGE_DATA=<data dir> bun scripts/rotate-secret-key.ts [--apply] (stop the forge first)");
228 process.exit(2);
229 }
230 try {
231 const rotation = rotateSecretKey(dataDir, args.includes("--apply"));
232 console.log(describeRotation(rotation));
233 process.exitCode = exitCode(rotation);
234 } catch (error) {
235 console.error(`rotate-secret-key: ${error instanceof Error ? error.message : String(error)}`);
236 process.exit(1);
237 }
238 }
src/kernel/secret.ts +31 −9
@@ -152,9 +152,14 @@ function mint(path: string): Buffer {
152152 * write access but no key cannot swap one account's token onto another.
153153 */
154154 export function seal(dataDir: string, plain: string, aad: string): string {
155 const iv = randomBytes(IV_BYTES);
156155 const key = loadKey(dataDir);
157 const cipher = createCipheriv("aes-256-gcm", key, iv, {
156 return sealWith({ id: keyId(key), key }, plain, aad);
157 }
158
159 /** `seal` under a key the caller holds: how a rotation re-seals under a key not yet on disk. */
160 export function sealWith(named: NamedKey, plain: string, aad: string): string {
161 const iv = randomBytes(IV_BYTES);
162 const cipher = createCipheriv("aes-256-gcm", named.key, iv, {
158163 authTagLength: TAG_BYTES,
159164 });
160165 cipher.setAAD(Buffer.from(aad, "utf8"));
@@ -163,7 +168,13 @@ export function seal(dataDir: string, plain: string, aad: string): string {
163168 cipher.final(),
164169 cipher.getAuthTag(),
165170 ]);
166 return `${VERSION}:${keyId(key)}:${iv.toString("base64url")}:${body.toString("base64url")}`;
171 return `${VERSION}:${named.id}:${iv.toString("base64url")}:${body.toString("base64url")}`;
172 }
173
174 /** The key id a sealed value names: a v2 value's id, null for a v1 value or anything else. */
175 export function sealedKeyId(sealed: string): string | null {
176 const parts = sealed.split(":");
177 return parts.length === 4 && parts[0] === VERSION && KEY_ID.test(parts[1]!) ? parts[1]! : null;
167178 }
168179
169180 export type OpenError = "bad_format" | "bad_key";
@@ -177,6 +188,21 @@ export function open(
177188 dataDir: string,
178189 sealed: string,
179190 aad: string,
191 ): Result<string, OpenError> {
192 // Only a sealed-looking value costs a key read.
193 if (!sealed.startsWith(`${LEGACY}:`) && !sealed.startsWith(`${VERSION}:`)) return err("bad_format");
194 const ring = loadKeys(dataDir);
195 return openWith(ring.previous ? [ring.current, ring.previous] : [ring.current], sealed, aad);
196 }
197
198 /**
199 * `open` with the keys the caller holds, tried in order for a v1 value; a
200 * v2 value opens only with the key it names.
201 */
202 export function openWith(
203 keys: readonly NamedKey[],
204 sealed: string,
205 aad: string,
180206 ): Result<string, OpenError> {
181207 const parts = sealed.split(":");
182208 let candidates: Buffer[];
@@ -185,15 +211,11 @@ export function open(
185211 if (parts.length === 3 && parts[0] === LEGACY) {
186212 // A v1 value does not say which key sealed it: the current one first,
187213 // then the previous, so a rotation does not strand values from before.
188 const ring = loadKeys(dataDir);
189 candidates = ring.previous
190 ? [ring.current.key, ring.previous.key]
191 : [ring.current.key];
214 candidates = keys.map((k) => k.key);
192215 ivText = parts[1]!;
193216 bodyText = parts[2]!;
194217 } else if (parts.length === 4 && parts[0] === VERSION && KEY_ID.test(parts[1]!)) {
195 const ring = loadKeys(dataDir);
196 const named = [ring.current, ring.previous].find((k) => k?.id === parts[1]);
218 const named = keys.find((k) => k.id === parts[1]);
197219 // The value names a key this directory no longer holds.
198220 if (!named) return err("bad_key");
199221 candidates = [named.key];
test/rotate-secret-key.test.ts added +189
@@ -0,0 +1,189 @@
1 /**
2 * `bun scripts/rotate-secret-key.ts` (#11473): every sealed column moves
3 * from key A to a fresh key B in one transaction, and opens under B alone
4 * once A is deleted. Dry run writes nothing; a second run moves nothing; a
5 * failed write rolls back whole and keeps A in place.
6 */
7
8 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9 import { Database } from "bun:sqlite";
10 import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { migrations } from "../src/core/schema.ts";
14 import { setSecret } from "../src/core/secret_store.ts";
15 import { sealSetting } from "../src/apps/feature_env.ts";
16 import { signingKey } from "../src/core/oidc.ts";
17 import { openDb } from "../src/kernel/db.ts";
18 import { forgetKey, keyId, loadKey, open, previousKeyPath, seal, secretKeyPath } from "../src/kernel/secret.ts";
19 import { describeRotation, exitCode, nextKeyPath, rotateSecretKey, SEALED_COLUMNS } from "../scripts/rotate-secret-key.ts";
20
21 /** A row in `table` with every required column filled, `column` sealed under the current key. */
22 function plant(db: Database, dir: string, table: string, column: string, values: Record<string, string>, aad: string, plain: string): void {
23 const cols = db.query<{ name: string; notnull: number; dflt_value: string | null }, []>(`PRAGMA table_info("${table}")`).all();
24 const row: Record<string, string | number> = {};
25 for (const c of cols) if (c.notnull && c.dflt_value === null) row[c.name] = 0;
26 Object.assign(row, values, { [column]: seal(dir, plain, aad) });
27 const names = Object.keys(row);
28 db.query(`INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(",")}) VALUES (${names.map(() => "?").join(",")})`).run(
29 ...(Object.values(row) as never[]),
30 );
31 }
32
33 /** Each planted value: where it is, what it is bound to, what it says. */
outdated

PLANTED covers 4 of 13 columns. Add refresh_token_enc, origin_app, the four oauth_flow columns and app_feature_setting so every AAD in SEALED_COLUMNS is proven. Add a drift test against the schema.

34 const PLANTED = [
35 { table: "origin", column: "token_enc", values: { id: "o1", kind: "github", login: "o1" }, aad: "o1", plain: "origin-token" },
36 { table: "origin", column: "refresh_token_enc", values: { id: "o2", kind: "github", login: "o2" }, aad: "o2", plain: "refresh-token" },
37 { table: "origin_app", column: "client_secret_enc", values: { id: "oa1", kind: "github", api_url: "https://oa1" }, aad: "oa1", plain: "app-secret" },
38 { table: "oauth_flow", column: "client_secret_enc", values: { id: "f1", kind: "github", flow: "device", short_code: "f1" }, aad: "f1", plain: "flow-secret" },
39 { table: "oauth_flow", column: "verifier_enc", values: { id: "f2", kind: "github", flow: "device", short_code: "f2" }, aad: "f2", plain: "flow-verifier" },
40 { table: "oauth_flow", column: "device_code_enc", values: { id: "f3", kind: "github", flow: "device", short_code: "f3" }, aad: "f3", plain: "flow-device" },
41 { table: "oauth_flow", column: "target_enc", values: { id: "f4", kind: "github", flow: "device", short_code: "f4" }, aad: "f4", plain: "flow-target" },
42 { table: "prompt_source", column: "token_enc", values: { id: "p1" }, aad: "prompt-source:p1", plain: "prompt-token" },
43 { table: "subscription", column: "token_enc", values: { id: "s1", kind: "oauth" }, aad: "subscription:s1", plain: "sub-token" },
44 { table: "app_env", column: "value_enc", values: { app_id: "a1", name: "DB_URL", scope: "all" }, aad: "a1:DB_URL", plain: "env-value" },
45 {
46 table: "app_feature_setting",
47 column: "value",
48 values: { app_id: "a2", key: "API_KEY" },
49 aad: "feature-setting:a2:API_KEY",
50 plain: "feature-value",
51 },
52 ] as const;
53
54 /** Planted here, or written by the real writers in `beforeEach`. */
55 const WRITTEN = ["secret.value_enc", "oidc_key.private_jwk"];
56
57 const read = (db: Database, p: (typeof PLANTED)[number]): string => {
58 const [k, v] = Object.entries(p.values)[0]!;
59 return db.query<Record<string, string>, [string]>(`SELECT "${p.column}" AS v FROM "${p.table}" WHERE "${k}" = ?1`).get(v)!.v!;
60 };
61
62 describe("rotate-secret-key", () => {
63 let dir = "";
64 let db: Database;
65 beforeEach(async () => {
66 dir = mkdtempSync(join(tmpdir(), "rotate-key-"));
67 db = openDb(join(dir, "forge.db"), migrations);
68 db.exec("PRAGMA foreign_keys = OFF");
69 for (const p of PLANTED) plant(db, dir, p.table, p.column, p.values, p.aad, p.plain);
70 // The feature-setting writer's own seal, so its AAD is the writer's and not a copy.
71 db.query("UPDATE app_feature_setting SET value = ?1 WHERE app_id = 'a2'").run(sealSetting(dir, "a2", "API_KEY", "feature-value"));
72 expect(setSecret(db, dir, "forge", "", "DEPLOY_TOKEN", "secret-value").ok).toBe(true);
73 await signingKey(db, dir);
74 });
75 afterEach(() => {
76 db.close();
77 forgetKey(dir);
78 rmSync(dir, { recursive: true, force: true });
79 });
80
81 test("seals under A, rotates, and every value opens under B with A deleted", () => {
82 const a = keyId(loadKey(dir));
83 const dry = rotateSecretKey(dir, false);
84 expect(dry.applied).toBe(false);
85 expect(keyId(loadKey(dir))).toBe(a); // a dry run writes nothing
86 expect(existsSync(previousKeyPath(dir))).toBe(false);
87 const total = (r: typeof dry): number => r.tables.reduce((n, t) => n + t.moved, 0);
88 expect(total(dry)).toBe(PLANTED.length + 2);
89
90 const done = rotateSecretKey(dir, true);
91 expect(total(done)).toBe(PLANTED.length + 2);
92 expect(done.tables.find((t) => t.table === "secret")!.moved).toBe(1);
93 expect(done.tables.find((t) => t.table === "oidc_key")!.moved).toBe(1);
94 expect(keyId(readFileSync(previousKeyPath(dir)))).toBe(a);
95 expect(done.keyId).not.toBe(a);
96 expect(existsSync(nextKeyPath(dir))).toBe(false);
97
98 // A deleted: only B remains, and everything opens.
99 rmSync(previousKeyPath(dir));
100 forgetKey(dir);
101 expect(keyId(loadKey(dir))).toBe(done.keyId);
102 for (const p of PLANTED) {
103 const value = read(db, p);
104 expect(value.split(":")[1]).toBe(done.keyId);
105 expect(open(dir, value, p.aad)).toEqual({ ok: true, value: p.plain });
106 }
107 const secret = db.query<{ v: string }, []>("SELECT value_enc AS v FROM secret").get()!.v;
108 expect(open(dir, secret, "secret:forge:DEPLOY_TOKEN")).toEqual({ ok: true, value: "secret-value" });
109 const jwk = db.query<{ kid: string; v: string }, []>("SELECT kid, private_jwk AS v FROM oidc_key").get()!;
110 expect(open(dir, jwk.v, jwk.kid).ok).toBe(true);
111
112 // The report says counts and key ids, never a plaintext.
113 const said = describeRotation(done);
114 for (const p of PLANTED) expect(said).not.toContain(p.plain);
115 expect(said).toContain(`moved ${PLANTED.length + 2} in all`);
116 expect(said).not.toContain("warning");
117 expect(exitCode(done)).toBe(0);
118 });
119
120 test("every sealed column is planted, and every *_enc column in the schema is in SEALED_COLUMNS", () => {
121 const listed = SEALED_COLUMNS.map((c) => `${c.table}.${c.column}`).sort();
122 expect([...new Set([...PLANTED.map((p) => `${p.table}.${p.column}`), ...WRITTEN])].sort()).toEqual(listed);
123 const tables = db.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'").all();
124 const inSchema = tables.flatMap(({ name }) =>
125 db
126 .query<{ name: string }, []>(`PRAGMA table_info("${name}")`)
127 .all()
128 .filter((c) => c.name.endsWith("_enc"))
129 .map((c) => `${name}.${c.name}`),
130 );
131 // Sealed columns not named *_enc, checked by name so a rename shows here too.
132 inSchema.push("oidc_key.private_jwk", "app_feature_setting.value");
133 for (const col of inSchema) expect(listed).toContain(col);
134 });
135
136 test("an apply that leaves an unopenable value warns to keep secret.key.old and exits 3", async () => {
137 plant(db, dir, "origin", "token_enc", { id: "lost", kind: "github", login: "lost" }, "not-its-id", "lost-token");
138 const proc = Bun.spawn(["bun", join(import.meta.dir, "../scripts/rotate-secret-key.ts"), "--apply"], {
139 env: { ...process.env, FORGE_DATA: dir },
140 stdout: "pipe",
141 stderr: "pipe",
142 });
143 const out = await new Response(proc.stdout).text();
144 expect(await proc.exited).toBe(3);
145 expect(out).toContain("unopenable 1");
146 expect(out).toContain("keep secret.key.old");
147 expect(existsSync(previousKeyPath(dir))).toBe(true);
148 // A dry run warns too, but only an apply fails.
149 const dry = rotateSecretKey(dir, false);
150 expect(describeRotation(dry)).toContain("keep secret.key.old");
151 expect(exitCode(dry)).toBe(0);
152 });
153
154 test("a second run moves nothing and mints no key", () => {
155 const first = rotateSecretKey(dir, true);
156 const again = rotateSecretKey(dir, true);
157 expect(again.minted).toBe(false);
158 expect(again.keyId).toBe(first.keyId);
159 expect(again.tables.reduce((n, t) => n + t.moved, 0)).toBe(0);
160 expect(keyId(loadKey(dir))).toBe(first.keyId);
161 });
162
163 test("with secret.key.old kept, a run finishes the rotation onto the current key", () => {
164 // The by-hand rotation: A kept as old, a fresh current minted on next use.
165 const a = keyId(loadKey(dir));
166 rotateSecretKey(dir, false);
167 renameSync(secretKeyPath(dir), previousKeyPath(dir));
168 forgetKey(dir);
169 const b = keyId(loadKey(dir));
170 const done = rotateSecretKey(dir, true);
171 expect(done.minted).toBe(false);
172 expect(done.keyId).toBe(b);
173 expect(done.tables.reduce((n, t) => n + t.moved, 0)).toBe(PLANTED.length + 2);
174 expect(keyId(readFileSync(previousKeyPath(dir)))).toBe(a); // never overwritten
175 });
176
177 test("a failed write rolls back whole and leaves key A in place", () => {
178 const a = keyId(loadKey(dir));
179 const before = PLANTED.map((p) => read(db, p));
180 // oidc_key comes after origin, secret and app_env: their writes must not stay.
181 db.exec(`CREATE TRIGGER no_write BEFORE UPDATE ON oidc_key BEGIN SELECT RAISE(ABORT, 'refused'); END`);
182 expect(() => rotateSecretKey(dir, true)).toThrow();
183 expect(PLANTED.map((p) => read(db, p))).toEqual(before);
184 forgetKey(dir);
185 expect(keyId(loadKey(dir))).toBe(a);
186 expect(existsSync(previousKeyPath(dir))).toBe(false);
187 expect(existsSync(nextKeyPath(dir))).toBe(false);
188 });
189 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs