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
torque/forge:torque/11473-one-admin-command-re-seals-every-sealed → main 8 commits fork 3 threads · 3 unresolved
9 files
changed +658
−9
docs/ORIGINS.md +2 −6
| @@ -1058,12 +1058,8 @@ carries token material, a `state`, or a `code`. | ||
| 1058 | 1058 | 0600 — the forge refuses either file wider than that) and restart: a fresh |
| 1059 | 1059 | `secret.key` is minted, new values seal under it, and `open` uses whichever |
| 1060 | 1060 | 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. | |
| 1067 | 1063 | - **DNS rebinding is not closed.** The SSRF policy checks address _literals_; |
| 1068 | 1064 | a hostname that resolves into a private range still gets through, because |
| 1069 | 1065 | 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. | ||
| 117 | 117 | - Sealed values, unless the passphrase was set: they stay sealed and are set |
| 118 | 118 | again by hand (the restore lists them by name). |
| 119 | 119 | - 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. | |
| 120 | 122 | - The crew's clones: working copies that agents make again from the |
| 121 | 123 | repositories. |
| 122 | 124 | - 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "forge", |
| 3 | 3 | "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. |
||
| 5 | 5 | "mcpServers": { |
| 6 | 6 | "forge": { |
| 7 | 7 | "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 { | ||
| 166 | 166 | return `${VERSION}:${keyId(key)}:${iv.toString("base64url")}:${body.toString("base64url")}`; |
| 167 | 167 | } |
| 168 | 168 | |
| 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 | ||
| 169 | 180 | export type OpenError = "bad_format" | "bad_key"; |
| 170 | 181 | |
| 171 | 182 | /** |
src/looks/backoff.ts +4 −1
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | * and fire the retries itself. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { during } from "../perf/jobs.ts"; | |
| 12 | ||
| 11 | 13 | /** Time, and a way to run something later: the real ones unless a test says otherwise. */ |
| 12 | 14 | export interface BackoffClock { |
| 13 | 15 | now(): number; |
| @@ -18,7 +20,8 @@ export interface BackoffClock { | ||
| 18 | 20 | export const realClock: BackoffClock = { |
| 19 | 21 | now: () => Date.now(), |
| 20 | 22 | 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); | |
| 22 | 25 | timer.unref?.(); |
| 23 | 26 | return () => clearTimeout(timer); |
| 24 | 27 | }, |
test/plugin-claude-oauth.test.ts +1 −1
| @@ -17,7 +17,7 @@ test("the description names every verb the command handles", async () => { | ||
| 17 | 17 | const md = await Bun.file(`${dir}/commands/forge.md`).text(); |
| 18 | 18 | const list = md.match(/^description: Talk to your forge: (.*)$/m)![1] as string; |
| 19 | 19 | 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. |
||
| 21 | 21 | for (const v of verbs) expect(plugin.description).toContain(v); |
| 22 | 22 | }); |
| 23 | 23 |
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 | }); |
Move this rotation how-to into docs/features/rotate-secret-key.md and leave a two-line pointer here.