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

opened this

Check · Review · Land · Live — Its check is running. Next: reviewer, pushed since the last review.

Ready to land

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)

Look

9 pages beside main · 0 new to this pull

/apps

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/explore

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/feed

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/front

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/map

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/notes

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/releases

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/repos

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

/review

main

at 511a92b ·

this pull

In the dark

main

at 511a92b ·

this pull

at 7348e52 · · the head has moved since; a new picture follows its deploy

opened this

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.old exists. A new key is written to secret.key.next (0600). The current key is hard-linked to secret.key.old, and the new key is renamed over secret.key. So secret.key is never missing, and an existing secret.key.old is 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 IMMEDIATE transaction. Every sealed column is opened with the forge's own open and 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, which open still reads. The forge keeps working, and running again finishes without minting a third key.
  • The column list. SEALED in the script names each table, column and AAD, with the file that writes it. A test checks that every *_enc column 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 _enc and are listed by hand: oidc_key.private_jwk and app_feature_setting.value. The second holds both plain and sealed values; only values that parse as seals are touched.
  • One helper added: sealedBy() in src/kernel/secret.ts reads 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 --apply and 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 v1 values 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 caps docs/RECOVERY.md at 125 lines, so it only gets a two-line pointer: a snapshot doesn't carry secret.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_plan tool 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

Closes #11473

commented

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.old exists. A new key is written to secret.key.next (0600). The current key is hard-linked to secret.key.old, and the new key is renamed over secret.key. So secret.key is never missing, and an existing secret.key.old is 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 IMMEDIATE transaction. Every sealed column is opened with the forge's own open and 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, which open still reads. The forge keeps working, and running again finishes without minting a third key.
  • The column list. SEALED in the script names each table, column and AAD, with the file that writes it. A test checks that every *_enc column 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 _enc and are listed by hand: oidc_key.private_jwk and app_feature_setting.value. The second holds both plain and sealed values; only values that parse as seals are touched.
  • One helper added: sealedBy() in src/kernel/secret.ts reads 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 --apply and 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 v1 values 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 caps docs/RECOVERY.md at 125 lines, so it only gets a two-line pointer: a snapshot doesn't carry secret.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_plan tool 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

commented

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

Changes requested — before the latest push

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.

  1. 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, when secret.key.old can go, stop the forge first, the HMAC-derived keys that change). Today it is a long paragraph inside the security notes of docs/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.
  1. Unrelated change. plugins/claude-code/.claude-plugin/plugin.json and test/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.

Changes requested — before the latest push

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:

  1. FIRST: the how-to belongs in docs/features/rotate-secret-key.md (dry run, --apply, second run moves 0, when secret.key.old can 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-formed v2: 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.
  2. 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.
  3. 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.

commented

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.md and docs/RECOVERY.md each keep one short pointer to it. AGENTS.md doesn't list admin scripts, so it has no entry.
  • Plugin fix dropped: the plugins/claude-code/.claude-plugin/plugin.json and test/plugin-claude-oauth.test.ts change (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.ts stays 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:

  1. FORGE_DATA=<data> bun scripts/rotate-secret-key.ts
  2. FORGE_DATA=<data> bun scripts/rotate-secret-key.ts --apply
  3. Run --apply again: it moves 0.

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs