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
Check · Review · Land · Live — Its check is running.
New command `bun scripts/rotate-secret-key.ts` finishes a rotation of `secret.key` in one step. It reads `FORGE_DATA` and is a dry run by default. It prints counts per table (moved, already current, unopenable) and never a value.
6 files · +652 −6
working checking now deploy e2ba1c5
Check running · 36sCheck of e2ba1c5checked against main at 511a92bfast tier: running · full gate runs on land
Website spec: 23 findings (report only)
- / <a href="/admin-ada0/admin/app/production/open"> has no accessible name — names
- / <a href="/bad-name-12dd/bad-name/app/production/open"> has no accessible name — names
- /front <a href="/admin-ada0/admin/app/production/open"> has no accessible name — names
- /front <a href="/bad-name-12dd/bad-name/app/production/open"> has no accessible name — names
- /board http://127.0.0.1:46463/board answered 303 — https://specification.website/
- /notes http://127.0.0.1:46463/notes answered 303 — https://specification.website/
- /feed http://127.0.0.1:46463/feed answered 303 — https://specification.website/
- /review http://127.0.0.1:46463/review answered 303 — https://specification.website/
- /releases http://127.0.0.1:46463/releases answered 303 — https://specification.website/
- /repos http://127.0.0.1:46463/repos answered 303 — https://specification.website/
- /map http://127.0.0.1:46463/map answered 303 — https://specification.website/
- /system http://127.0.0.1:46463/system answered 303 — https://specification.website/
- /talk http://127.0.0.1:46463/talk answered 303 — https://specification.website/
- /flow http://127.0.0.1:46463/flow answered 303 — https://specification.website/
- /resilience http://127.0.0.1:46463/resilience answered 303 — https://specification.website/
- /boards?help http://127.0.0.1:46463/boards?help answered 303 — https://specification.website/
- /settings/crew http://127.0.0.1:46463/settings/crew answered 303 — https://specification.website/
- /settings/crew?help http://127.0.0.1:46463/settings/crew?help answered 303 — https://specification.website/
- /preview-admin/sample expected exactly one <h1>, found 2 — headings
- /preview-admin/sample/next expected exactly one <h1>, found 2 — headings
- /preview-admin/sample/bets expected exactly one <h1>, found 2 — headings
- /preview-admin/sample/waves expected exactly one <h1>, found 2 — headings
- /preview-admin/sample/milestones expected exactly one <h1>, found 2 — headings
Look
9 pages beside main · 0 new to this pull
/apps
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/explore
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/feed
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/front
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/map
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/notes
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/releases
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/repos
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
/review
In the dark
at 7348e52 · · the head has moved since; a new picture follows its deploy
New command bun scripts/rotate-secret-key.ts finishes a rotation of secret.key in one step. It reads FORGE_DATA and is a dry run by default. It prints counts per table (moved, already current, unopenable) and never a value.
--apply does two things:
- Key swap, only when no
secret.key.oldexists. A new key is written tosecret.key.next(0600). The current key is hard-linked tosecret.key.old, and the new key is renamed oversecret.key. Sosecret.keyis never missing, and an existingsecret.key.oldis never overwritten. If one is already there, a rotation is in progress (for example, done by hand) and the run only finishes it. - Re-seal in one
IMMEDIATEtransaction. Every sealed column is opened with the forge's ownopenand sealed again under the current key, with the same AAD each writer uses. Values already under the current key are left alone, so a second run moves 0. Values that open under neither key are left as they are and counted as unopenable.
What to look at:
- Order is the safety argument. The key files move before the database. A rollback therefore leaves every row sealed under the key now kept as
secret.key.old, whichopenstill reads. The forge keeps working, and running again finishes without minting a third key. - The column list.
SEALEDin the script names each table, column and AAD, with the file that writes it. A test checks that every*_enccolumn in the migrated schema is on the list, so a new sealed column can't be missed silently. Two sealed columns don't end in_encand are listed by hand:oidc_key.private_jwkandapp_feature_setting.value. The second holds both plain and sealed values; only values that parse as seals are touched. - One helper added:
sealedBy()insrc/kernel/secret.tsreads the key id a value names without opening it.
Tests (test/rotate-secret-key.test.ts) seed rows through the real writers:
- origin token and refresh token, forge secret, subscription, app variable, prompt token, OIDC signing key, and a secret feature setting.
- After
--applyand deleting key A, all of them open through the real readers. The hand count is 8 moved. - Also covered: the dry run writes nothing, a second run moves 0, old-format
v1values are moved, the report contains no plaintext, and missing database or key is refused. - A failure injected just before commit leaves every row byte-identical and still opening; a re-run then finishes. This test fails if the work runs outside the transaction.
Gate, tsc, and 8 affected test files (101 tests) pass. scripts/fast.ts said the diff was too broad to pick tests itself, so I chose those files by hand.
Not done:
- The script doesn't detect a running forge. A running forge keeps the key it read at boot, so the docs say to stop it first.
- Rotating also changes the keys derived from
secret.key: passports, visitor links, app identities, and each mirror's webhook signing secret. I documented that each mirror's webhook must be registered again after a rotation; nothing re-registers them automatically. - Most of the docs went to the key-rotation note in
docs/ORIGINS.md. A test capsdocs/RECOVERY.mdat 125 lines, so it only gets a two-line pointer: a snapshot doesn't carrysecret.key.old, so finish a rotation before taking one. - The issue mentions "notes", but no notes column is sealed, so there was nothing to re-seal there.
- I had no
keep_plantool in this run, so no plan was posted on the issue.
Try it:
FORGE_DATA=<data> bun scripts/rotate-secret-key.ts
FORGE_DATA=<data> bun scripts/rotate-secret-key.ts --apply
FORGE_DATA=<data> bun scripts/rotate-secret-key.ts --apply # moves 0
🤖 Generated with Claude Code
There is now one admin command that re-seals every sealed value under the newest entry in secret.key. It then says how many values it moved. Values already sealed under the newest key are left alone, so running it twice does nothing the second time. After the run you can retire the old key, because nothing still needs it to open (follows #11488, which lets a sealed value name its key). The change is in src/kernel/secret.ts and covered by test/rotate-secret-key.test.ts.
This push also fixes a check failure that came from main. #11499 added waves, held and forks to the /forge command in plugins/claude-code/commands/forge.md. It did not add them to the description in plugins/claude-code/.claude-plugin/plugin.json, and it left test/plugin-claude-oauth.test.ts expecting 12 verbs. The description now lists the same 15 verbs as the command. I changed the expected count from 12 to 15 because the test was out of date, not the code. The test still checks that every verb in the command is named in the plugin description.
Run locally before pushing: bun scripts/gate.ts and bun x tsc --noEmit both passed, and test/plugin-claude-oauth.test.ts, test/plugins.test.ts and test/rotate-secret-key.test.ts all pass. I did not run the full suite here; the forge's check runs it on this push.
To try it: bun test test/rotate-secret-key.test.ts test/plugin-claude-oauth.test.ts
🤖 Generated with Claude Code
The rotation logic reads sound: files move before the DB, one IMMEDIATE transaction, AADs match the writers, a schema-coverage test, and a rollback test. Two changes are needed, both in one round.
- How-to is in the wrong place. A feature's how-to belongs in
docs/features/<name>.md(e.g.docs/features/rotate-secret-key.md: dry run,--apply, second run moves 0, whensecret.key.oldcan go, stop the forge first, the HMAC-derived keys that change). Today it is a long paragraph inside the security notes ofdocs/ORIGINS.md. Move it to the feature doc, leave one short pointer in ORIGINS.md and RECOVERY.md, and name the AGENTS.md entry if it lists scripts.
- Unrelated change.
plugins/claude-code/.claude-plugin/plugin.jsonandtest/plugin-claude-oauth.test.ts(12 to 15 verbs) fix a red check inherited from main (#11499). That is a separate single-purpose pull and it is what the direction asks for (main green), but not part of this issue. Drop both from this pull and land that fix on its own, or rebase once it is on main.
Not blocking: a plain feature setting whose value is a well-formed v2: seal that fails to open is counted as unopenable, which is right, but say so in the doc. The script does not detect a running forge; the doc warning is enough.
Boxes 1-3 are met by the code and test, so they will be ticked on the next round.
The rotation itself reads sound: key files move before the DB, one IMMEDIATE transaction, AADs match the writers, a schema-coverage test and a rollback test. Three changes, all in this round:
- FIRST: the how-to belongs in
docs/features/rotate-secret-key.md(dry run,--apply, second run moves 0, whensecret.key.oldcan go, stop the forge first, the HMAC-derived keys that change and the mirror webhooks to re-register, and that a plain setting holding a well-formedv2:seal that fails to open is counted unopenable). Leave one short pointer in docs/ORIGINS.md and docs/RECOVERY.md. Name the script in AGENTS.md if it lists scripts. - Drop the unrelated plugin.json and test/plugin-claude-oauth.test.ts change (the red check from #11499). It is a separate single-purpose pull, which the direction asks for; land it alone or rebase once it is on main.
- Not blocking: the script does not detect a running forge; the doc warning is enough.
I did not rate the preview: this is an offline script with no page. Boxes 1-3 are met by the code and tests, so I will tick them when the above is fixed.
scripts/rotate-secret-key.ts finishes a rotation of the data directory's secret.key in one go. It runs as a dry run by default. With --apply it makes a new key (the old one is kept as secret.key.old), then re-seals every sealed column under the new key in one IMMEDIATE transaction. A second run moves nothing. The key files move before the database does, so if the transaction rolls back, every value still opens under secret.key.old. The script prints counts per table, never a value.
This round, after review:
- Feature doc: the how-to now lives in
docs/features/rotate-secret-key.md. It covers:
- stopping the forge first;
- the dry run, --apply, and a second run that moves 0;
- when secret.key.old can be deleted;
- what happens when the transaction rolls back;
- values that open under neither key (counted as unopenable, including a well-formed v2: seal in a plain feature setting);
- the HMAC-derived keys that change with the key, and that each mirror's webhook must be registered again;
- finishing a rotation before a snapshot.
- Pointers:
docs/ORIGINS.mdanddocs/RECOVERY.mdeach keep one short pointer to it.AGENTS.mddoesn't list admin scripts, so it has no entry. - Plugin fix dropped: the
plugins/claude-code/.claude-plugin/plugin.jsonandtest/plugin-claude-oauth.test.tschange (the red check inherited from #11499) is undone with a revert commit, because force pushes are refused. It should land as its own pull. Until then,test/plugin-claude-oauth.test.tsstays red on main and on this branch.
The script still does not detect a running forge; as agreed in review, the doc's warning is the guard.
bun scripts/gate.ts and bun x tsc --noEmit pass. bun scripts/fast.ts said the change was too broad to name tests, so I picked them: test/rotate-secret-key.test.ts, the secret tests, and every test that reads docs/features, ORIGINS.md or RECOVERY.md (150 pass across 23 files).
To try it, with the forge stopped:
FORGE_DATA=<data> bun scripts/rotate-secret-key.tsFORGE_DATA=<data> bun scripts/rotate-secret-key.ts --apply- Run
--applyagain: it moves 0.
🤖 Generated with Claude Code
New command
bun scripts/rotate-secret-key.tsfinishes a rotation ofsecret.keyin one step. It readsFORGE_DATAand is a dry run by default. It prints counts per table (moved, already current, unopenable) and never a value.--applydoes two things:secret.key.oldexists. A new key is written tosecret.key.next(0600). The current key is hard-linked tosecret.key.old, and the new key is renamed oversecret.key. Sosecret.keyis never missing, and an existingsecret.key.oldis never overwritten. If one is already there, a rotation is in progress (for example, done by hand) and the run only finishes it.IMMEDIATEtransaction. Every sealed column is opened with the forge's ownopenand sealed again under the current key, with the same AAD each writer uses. Values already under the current key are left alone, so a second run moves 0. Values that open under neither key are left as they are and counted as unopenable.What to look at:
secret.key.old, whichopenstill reads. The forge keeps working, and running again finishes without minting a third key.SEALEDin the script names each table, column and AAD, with the file that writes it. A test checks that every*_enccolumn in the migrated schema is on the list, so a new sealed column can't be missed silently. Two sealed columns don't end in_encand are listed by hand:oidc_key.private_jwkandapp_feature_setting.value. The second holds both plain and sealed values; only values that parse as seals are touched.sealedBy()insrc/kernel/secret.tsreads the key id a value names without opening it.Tests (
test/rotate-secret-key.test.ts) seed rows through the real writers:--applyand deleting key A, all of them open through the real readers. The hand count is 8 moved.v1values are moved, the report contains no plaintext, and missing database or key is refused.Gate,
tsc, and 8 affected test files (101 tests) pass.scripts/fast.tssaid the diff was too broad to pick tests itself, so I chose those files by hand.Not done:
secret.key: passports, visitor links, app identities, and each mirror's webhook signing secret. I documented that each mirror's webhook must be registered again after a rotation; nothing re-registers them automatically.docs/ORIGINS.md. A test capsdocs/RECOVERY.mdat 125 lines, so it only gets a two-line pointer: a snapshot doesn't carrysecret.key.old, so finish a rotation before taking one.keep_plantool in this run, so no plan was posted on the issue.Try it:
🤖 Generated with Claude Code
Closes #11473