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
Check · Review · Land · Live done — Landed and live in production.
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
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:
sealwritesv2:<keyid>:<iv>:<ciphertext+tag>, wherekeyidis 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 plussecret.key.oldwhen it exists. The old key is never created by the forge. It is refused unless it is 0600, the same rulesecret.keyalready has, and ignored if it is the wrong length.loadKeybehaves 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:
forgetKeyalso 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_keyonce A is removed; - a
v1value, written exactly as the oldsealwrote it, opening before and after a rotation; bad_formatfor malformed values versusbad_keyfor 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.oldmust 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
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.
Sealed values now record which key sealed them, so
secret.keycan be swapped out while values sealed under the old key keep opening, as long assecret.key.oldstays in place.Changes in
src/kernel/secret.ts:sealwritesv2:<keyid>:<iv>:<ciphertext+tag>, wherekeyidis the first 8 hex characters of SHA-256 of the key.keyId(key)is exported.loadKeys(dataDir)returns the current key plussecret.key.oldwhen it exists. The old key is never created by the forge. It is refused unless it is 0600, the same rulesecret.keyalready has, and ignored if it is the wrong length.loadKeybehaves as before, so the code that derives other keys from it is unchanged.- A
v2value opens only with the key it names. A key id this directory doesn't hold givesbad_key. - Av1value 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 givesbad_format. Plaintext is still never logged or thrown.forgetKeyalso clears the cached old key.To rotate: move
secret.keytosecret.key.oldand restart. The forge creates a freshsecret.keyand new values seal under it.Tests:
test/secret-rotation.test.tsis new and covers:bad_keyonce A is removed;v1value, written exactly as the oldsealwrote it, opening before and after a rotation;bad_formatfor malformed values versusbad_keyfor an unknown key id;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.mdis replaced with how rotation works and its limits.Left out on purpose:
secret.key.oldmust stay until every token has been rewritten (reconnecting an origin does it).secret.key, so a restore loses values that only the old key opens.secret.key(passports, visitor links) change when it rotates.These are written down in
docs/ORIGINS.mdand would each be a follow-up.Checks: gate and
tsc --noEmitare clean.scripts/fast.tsreported 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_planchecklist: that tool isn't available in this run.🤖 Generated with Claude Code
Closes #11472