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.
The new command `bun scripts/rotate-secret-key.ts` moves every sealed value onto the newest key and reports counts per table. Without flags it is a dry run that writes nothing. `--apply` mints a new key, re-seals every sealed column in one immediate transaction, then renames the old key to `secret.key.old` and puts the new one in as `secret.key`. It prints table names, counts and key ids, never a…
shipped production is serving it deploy f21b126
The new command bun scripts/rotate-secret-key.ts moves every sealed value onto the newest key and reports counts per table. Without flags it is a dry run that writes nothing. --apply mints a new key, re-seals every sealed column in one immediate transaction, then renames the old key to secret.key.old and puts the new one in as secret.key. It prints table names, counts and key ids, never a plaintext or a key. It reads FORGE_DATA and should be run with the forge stopped.
What a second run does. While secret.key.old exists, a run treats the rotation as unfinished. It mints no key and only moves values still sealed under an older key onto the current one. So a second run moves nothing, a by-hand rotation (rename the key, restart) is finished by the same command, and the old key is never overwritten while a value may still need it. Deleting secret.key.old lets the next run start a new rotation.
Rollback and crashes. If any update fails, the whole transaction rolls back and the new key is removed, so secret.key stays as it was. The new key is written to secret.key.next before the transaction. If the process dies after the commit, the next run picks that key up instead of minting another.
What to look at:
SEALED_COLUMNSinscripts/rotate-secret-key.tslists the 13 sealed columns and the extra authenticated data (AAD) each writer binds its value to. Each entry was taken from that writer's own AAD function: origins, OAuth flows and apps, prompt sources, subscriptions, secrets, app variables, the OIDC signing key and secret feature settings. A new sealed column has to be added there; the doc says so.src/kernel/secret.tsnow exposessealWith,openWithandsealedKeyId.sealandopenare rebuilt on top of them and behave the same.- The issue lists "notes" among the sealed columns, but no note column is sealed today, so there is nothing to move.
Tests. test/rotate-secret-key.test.ts covers four cases:
- Values are sealed under key A across five tables plus the OIDC key. After rotating and deleting A, every one opens under key B alone.
- A second run moves nothing and mints no key.
- A by-hand rotation gets finished without the old key being touched.
- A trigger forces a failure partway through, and the earlier tables' writes roll back with key A still in place.
These pass, along with gate, tsc, the recovery and doc-link tests, and the secret, sealed, OIDC, origin, subscription, app-env and feature suites near the change (254 tests). scripts/fast.ts called the diff too broad because secret.ts changed, so the forge's full check is the complete run.
Docs. The how-to is in docs/features/rotate-secret-key.md. docs/RECOVERY.md gets only a one-line pointer, because test/recovery-doc.test.ts caps it at 125 lines; that cap is likely what failed try 1. docs/ORIGINS.md no longer says nothing re-seals old values.
Left out:
- Passports and visitor links are signed with keys derived from
secret.key, so they stop working after a rotation; the doc says so. - Values that open under no key the directory holds are counted as "unopenable" and left as they are.
- The command is not reachable from the TUI or MCP.
To try:
FORGE_DATA=<dir> bun scripts/rotate-secret-key.tsfor the dry run… --applyto rotate, and once more to seemoved 0 in all
🤖 Generated with Claude Code
The rotation logic, rollback and crash recovery read soundly, and the dry run, second run and A-deleted behaviours are tested. Two things need fixing before it lands.
- (fix first) The test plants only 4 of the 13 columns, plus
secretandoidc_key.origin.refresh_token_enc,origin_app, all fouroauth_flowcolumns andapp_feature_setting.valueare never rotated in a test, so a wrong AAD inSEALED_COLUMNSwould pass. A wrong AAD makes the value count as 'unopenable' and stay under key A, and it is lost once the operator deletessecret.key.old. Plant every entry ofSEALED_COLUMNSthrough the real writers, or at least with the writers' AAD, and assert each opens under B alone. - Nothing stops
SEALED_COLUMNSgoing stale. The doc says 'a new sealed column must be added', which is a convention and not a guard. Add a test that scans the schema for*_enccolumns plus theoidc_keyandapp_feature_settingones and fails if one is not in the list. - An apply that leaves unopenable values exits 0 with only a count. Under
--apply, make the exit code non-zero, or print a warning in the report that says to keepsecret.key.old. Add a test for it. - Minor: replace the nested IIFE that mints the target key with a small helper. Wrap the over-long line in
docs/ORIGINS.md.
No README change and no TUI or MCP door are needed for an operator script, and the how-to is in docs/features/.
bun scripts/rotate-secret-key.ts [--apply] re-seals every sealed column under the newest key in one transaction and prints how many values it moved per table. It runs dry by default and stops and tells you before writing over secret.key.old. This push addresses the review on #11524.
- Every sealed column is tested: the test now plants a value in all 13 entries of
SEALED_COLUMNS. That includes the two added for this review:origin.refresh_token_enc,origin_app, the fouroauth_flowcolumns andapp_feature_setting.value. The feature setting is sealed by its real writer,sealSetting.secretandoidc_keyare written bysetSecretandsigningKey. After the rotation, key A is deleted and the test checks that each value opens under key B alone. A wrong AAD on one column fails 2 of the 6 tests. - Guard against a stale list: a new test reads the migrated schema and fails if any
*_enccolumn,oidc_key.private_jwkorapp_feature_setting.valueis missing fromSEALED_COLUMNS. Removing one entry from the list fails 3 of the 6 tests. - Unopenable values: when values open under no key held, the report ends with a warning to keep
secret.key.old. An--applyrun that leaves such a value exits 3. A dry run still exits 0 and shows the same warning. A test runs the script as a separate process to check the exit code and the warning. - Minor: the nested IIFE that minted the new key is now a small
mintKey()helper, and the long line indocs/ORIGINS.mdis wrapped.docs/features/rotate-secret-key.mddescribes the exit code and the schema guard.
bun scripts/gate.ts, bun x tsc --noEmit and bun test test/rotate-secret-key.test.ts pass (6 tests).
To try it: stop the forge, run FORGE_DATA=<data dir> bun scripts/rotate-secret-key.ts for the counts, then run it again with --apply.
🤖 Generated with Claude Code
Approved. I could not diff against e623ebc96796 (the commit is not in the clone), so I read the whole change. Earlier asks are met: all 13 sealed columns are planted and opened under key B alone with A deleted, a schema guard fails on any new *_enc column missing from SEALED_COLUMNS, unopenable values warn and make --apply exit 3, and the mint helper and wrapped docs line are done. The command writes to the data directory only after the transaction commits, rolls back whole on failure, crash recovery via secret.key.next is handled, key files are checked at 0600, and nothing prints a plaintext or a key. The how-to is in docs/features, not README. Non-blocking: the issue mentions notes, but no notes column is sealed today, so there is nothing to move.
The new command
bun scripts/rotate-secret-key.tsmoves every sealed value onto the newest key and reports counts per table. Without flags it is a dry run that writes nothing.--applymints a new key, re-seals every sealed column in one immediate transaction, then renames the old key tosecret.key.oldand puts the new one in assecret.key. It prints table names, counts and key ids, never a plaintext or a key. It readsFORGE_DATAand should be run with the forge stopped.What a second run does. While
secret.key.oldexists, a run treats the rotation as unfinished. It mints no key and only moves values still sealed under an older key onto the current one. So a second run moves nothing, a by-hand rotation (rename the key, restart) is finished by the same command, and the old key is never overwritten while a value may still need it. Deletingsecret.key.oldlets the next run start a new rotation.Rollback and crashes. If any update fails, the whole transaction rolls back and the new key is removed, so
secret.keystays as it was. The new key is written tosecret.key.nextbefore the transaction. If the process dies after the commit, the next run picks that key up instead of minting another.What to look at:
SEALED_COLUMNSinscripts/rotate-secret-key.tslists the 13 sealed columns and the extra authenticated data (AAD) each writer binds its value to. Each entry was taken from that writer's own AAD function: origins, OAuth flows and apps, prompt sources, subscriptions, secrets, app variables, the OIDC signing key and secret feature settings. A new sealed column has to be added there; the doc says so.src/kernel/secret.tsnow exposessealWith,openWithandsealedKeyId.sealandopenare rebuilt on top of them and behave the same.Tests.
test/rotate-secret-key.test.tscovers four cases:These pass, along with gate,
tsc, the recovery and doc-link tests, and the secret, sealed, OIDC, origin, subscription, app-env and feature suites near the change (254 tests).scripts/fast.tscalled the diff too broad becausesecret.tschanged, so the forge's full check is the complete run.Docs. The how-to is in
docs/features/rotate-secret-key.md.docs/RECOVERY.mdgets only a one-line pointer, becausetest/recovery-doc.test.tscaps it at 125 lines; that cap is likely what failed try 1.docs/ORIGINS.mdno longer says nothing re-seals old values.Left out:
secret.key, so they stop working after a rotation; the doc says so.To try:
FORGE_DATA=<dir> bun scripts/rotate-secret-key.tsfor the dry run… --applyto rotate, and once more to seemoved 0 in all🤖 Generated with Claude Code
Closes #11473