Public: anyone can read this; the crew can change it.

A sealed value names the key that sealed it, so secret.key can rotate and open still reads the old one #11488

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Sealed values now record which key sealed them, so `secret.key` can be swapped out while values sealed under the old key keep opening, as long as `secret.key.old` stays in place.

shipped production is serving it deploy a5a2512

opened this

Sealed values now record which key sealed them, so secret.key can be swapped out while values sealed under the old key keep opening, as long as secret.key.old stays in place.

Changes in src/kernel/secret.ts:

  • New format: seal writes v2:<keyid>:<iv>:<ciphertext+tag>, where keyid is the first 8 hex characters of SHA-256 of the key. keyId(key) is exported.
  • Two keys: a new loadKeys(dataDir) returns the current key plus secret.key.old when it exists. The old key is never created by the forge. It is refused unless it is 0600, the same rule secret.key already has, and ignored if it is the wrong length. loadKey behaves as before, so the code that derives other keys from it is unchanged.
  • Opening:

- A v2 value opens only with the key it names. A key id this directory doesn't hold gives bad_key. - A v1 value names no key, so it tries the current key, then the old one. The issue only required the current key; also trying the old one means existing tokens survive a rotation. - A malformed value gives bad_format. Plaintext is still never logged or thrown.

  • Cache: forgetKey also clears the cached old key.

To rotate: move secret.key to secret.key.old and restart. The forge creates a fresh secret.key and new values seal under it.

Tests: test/secret-rotation.test.ts is new and covers:

  • the format, with the key id checked against a SHA-256 worked out by hand;
  • a value sealed under key A opening once key B is current and A is kept as old, and coming back bad_key once A is removed;
  • a v1 value, written exactly as the old seal wrote it, opening before and after a rotation;
  • bad_format for malformed values versus bad_key for an unknown key id;
  • the permission refusal and the wrong-length case for secret.key.old.

Six existing tests checked that stored values start with "v1:". They now expect "v2:", because they were pinned to the old format, not to a behaviour.

Docs: the "Key rotation is not built" line in docs/ORIGINS.md is replaced with how rotation works and its limits.

Left out on purpose:

  • No re-sealing: nothing re-seals old values, so secret.key.old must stay until every token has been rewritten (reconnecting an origin does it).
  • Snapshots: a snapshot still carries only secret.key, so a restore loses values that only the old key opens.
  • Derived keys: keys derived from secret.key (passports, visitor links) change when it rotates.

These are written down in docs/ORIGINS.md and would each be a follow-up.

Checks: gate and tsc --noEmit are clean. scripts/fast.ts reported the change too broad to scope, so I chose the test files myself: 37 files that seal or open values (secret store, app environment, origins, OIDC, prompts, OAuth, subscriptions, visitors, snapshot/restore and drills). 306 tests pass, 0 fail.

I couldn't record progress on the issue's keep_plan checklist: that tool isn't available in this run.

🤖 Generated with Claude Code

Closes #11472

commented

Sealed values now record which key sealed them, so secret.key can be swapped out while values sealed under the old key keep opening, as long as secret.key.old stays in place.

Changes in src/kernel/secret.ts:

  • New format: seal writes v2:<keyid>:<iv>:<ciphertext+tag>, where keyid is the first 8 hex characters of SHA-256 of the key. keyId(key) is exported.
  • Two keys: a new loadKeys(dataDir) returns the current key plus secret.key.old when it exists. The old key is never created by the forge. It is refused unless it is 0600, the same rule secret.key already has, and ignored if it is the wrong length. loadKey behaves as before, so the code that derives other keys from it is unchanged.
  • Opening:

- A v2 value opens only with the key it names. A key id this directory doesn't hold gives bad_key. - A v1 value names no key, so it tries the current key, then the old one. The issue only required the current key; also trying the old one means existing tokens survive a rotation. - A malformed value gives bad_format. Plaintext is still never logged or thrown.

  • Cache: forgetKey also clears the cached old key.

To rotate: move secret.key to secret.key.old and restart. The forge creates a fresh secret.key and new values seal under it.

Tests: test/secret-rotation.test.ts is new and covers:

  • the format, with the key id checked against a SHA-256 worked out by hand;
  • a value sealed under key A opening once key B is current and A is kept as old, and coming back bad_key once A is removed;
  • a v1 value, written exactly as the old seal wrote it, opening before and after a rotation;
  • bad_format for malformed values versus bad_key for an unknown key id;
  • the permission refusal and the wrong-length case for secret.key.old.

Six existing tests checked that stored values start with "v1:". They now expect "v2:", because they were pinned to the old format, not to a behaviour.

Docs: the "Key rotation is not built" line in docs/ORIGINS.md is replaced with how rotation works and its limits.

Left out on purpose:

  • No re-sealing: nothing re-seals old values, so secret.key.old must stay until every token has been rewritten (reconnecting an origin does it).
  • Snapshots: a snapshot still carries only secret.key, so a restore loses values that only the old key opens.
  • Derived keys: keys derived from secret.key (passports, visitor links) change when it rotates.

These are written down in docs/ORIGINS.md and would each be a follow-up.

Checks: gate and tsc --noEmit are clean. scripts/fast.ts reported the change too broad to scope, so I chose the test files myself: 37 files that seal or open values (secret store, app environment, origins, OIDC, prompts, OAuth, subscriptions, visitors, snapshot/restore and drills). 306 tests pass, 0 fail.

I couldn't record progress on the issue's keep_plan checklist: that tool isn't available in this run.

🤖 Generated with Claude Code

Approved

Clean, small, and does what #11472 asked. v2 seals name the key by SHA-256 prefix; open resolves the named key (current or previous), bad_key for an unknown id, bad_format for malformed; v1 still opens (current, then old). secret.key.old gets the same 0600 refusal and a wrong length is ignored. No plaintext is logged or thrown. The tests cover the format, rotation, v1, and the permission and length cases. The six pinned-format test edits are fair. Nothing else in src depends on the v1 prefix. Non-blocking: a second rotation overwrites secret.key.old and strands values still sealed under the first key, and the ORIGINS.md paragraph doesn't say so. Add a sentence when convenient. No UI surface, so no TUI screen or feature doc applies, and README is untouched.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs