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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11473-one-admin-command-re-seals-every-sealed → main 2 commits fork 3 threads · 3 unresolved
6 files
changed +497
−14
docs/ORIGINS.md +7 −5
| @@ -1058,11 +1058,13 @@ 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) | |
| 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) | |
| 1066 | 1068 | change with it. |
| 1067 | 1069 | - **DNS rebinding is not closed.** The SSRF policy checks address _literals_; |
| 1068 | 1070 | 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. | ||
| 112 | 112 | - **key** — restoring the secret key, opened with the passphrase. Likely the |
| 113 | 113 | passphrase is missing or wrong. Check `FORGE_SNAPSHOT_PASSPHRASE`. |
| 114 | 114 | |
| 115 | Replacing a leaked `secret.key`: docs/features/rotate-secret-key.md. | |
| 116 | ||
| 115 | 117 | ## What a snapshot does NOT hold |
| 116 | 118 | |
| 117 | 119 | - 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`); | |
| 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 { | ||
| 152 | 152 | * write access but no key cannot swap one account's token onto another. |
| 153 | 153 | */ |
| 154 | 154 | export function seal(dataDir: string, plain: string, aad: string): string { |
| 155 | const iv = randomBytes(IV_BYTES); | |
| 156 | 155 | 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, { | |
| 158 | 163 | authTagLength: TAG_BYTES, |
| 159 | 164 | }); |
| 160 | 165 | cipher.setAAD(Buffer.from(aad, "utf8")); |
| @@ -163,7 +168,13 @@ export function seal(dataDir: string, plain: string, aad: string): string { | ||
| 163 | 168 | cipher.final(), |
| 164 | 169 | cipher.getAuthTag(), |
| 165 | 170 | ]); |
| 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; | |
| 167 | 178 | } |
| 168 | 179 | |
| 169 | 180 | export type OpenError = "bad_format" | "bad_key"; |
| @@ -177,6 +188,21 @@ export function open( | ||
| 177 | 188 | dataDir: string, |
| 178 | 189 | sealed: string, |
| 179 | 190 | 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, | |
| 180 | 206 | ): Result<string, OpenError> { |
| 181 | 207 | const parts = sealed.split(":"); |
| 182 | 208 | let candidates: Buffer[]; |
| @@ -185,15 +211,11 @@ export function open( | ||
| 185 | 211 | if (parts.length === 3 && parts[0] === LEGACY) { |
| 186 | 212 | // A v1 value does not say which key sealed it: the current one first, |
| 187 | 213 | // 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); | |
| 192 | 215 | ivText = parts[1]!; |
| 193 | 216 | bodyText = parts[2]!; |
| 194 | 217 | } 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]); | |
| 197 | 219 | // The value names a key this directory no longer holds. |
| 198 | 220 | if (!named) return err("bad_key"); |
| 199 | 221 | 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 | }); |
Replace this nested IIFE with a small
mintKey()helper.