Baniloo Baniloo

August 2, 2026

Rotating the key that isn't the key you think it is

A vault passphrase derives two different keys — a signing key and an AES-256 encryption key. Key rotation only wanted to change one of them

Phase 4-lite Session 2 starts from a regression that never shipped because it was caught in design instead of in production: the obvious way to rotate a signing key — re-derive it from a new passphrase — quietly breaks decryption of every .lmc file the vault has ever written.

Two keys, one salt, one bad idea

derive_keypair takes a passphrase and an Argon2id salt (argon2_salt) and produces a signing keypair. The same passphrase and salt also feed AES-256-GCM key derivation for encrypting every record on disk. Change the passphrase to rotate the signing key, and the encryption key changes with it — permanently, since there’s no re-encryption step to catch every historical .lmc file up to the new key. That’s not a documented limitation to write around; it’s a real bug, caught before loomed key rotate existed to ship it.

The fix decouples the two derivations. VaultMetadata gains a second, independent salt — signing_salt: Option<String>None before any rotation (the signing key still derives from argon2_salt, byte-for-byte identical to Phase 1, so existing vaults need zero migration), Some(salt) after one (a freshly generated salt, unrelated to argon2_salt). argon2_salt itself is never touched by rotation, so AES and every historical record stay exactly as encrypted as they always were. The passphrase doesn’t change either — changing it would still break AES decryption of history, so rotation only ever changes which signing key that same passphrase happens to derive.

A chain that no longer has one key

Decoupling the salts creates a second problem: verify_chain assumed one public key for an entire chain, and that assumption breaks the moment a chain can contain a rotation. resolve_signing_keys() walks the chain forward from genesis, switching the active key whenever it crosses a KeyRotation commit carrying a new_public_key — except the genesis commit’s own KeyRotation payload, which only carries public_key and so never triggers a switch on itself. verify_chain now resolves one key per commit instead of one key for the whole chain.

loomed verify had to become rotation-aware for the same reason. --chain mode now reads the genesis commit’s own embedded public key as its starting point, not vault.metadata.public_key — which reflects the current key after any rotation, and would be the wrong key to check pre-rotation commits against. Single-commit verification now has to load the full chain and use resolve_signing_keys to find which key was active at that commit’s position, since no commit can be verified in isolation anymore once rotation exists in the chain at all.

The part that stays deliberately narrow

loomed key rotate writes a self-signed key_rotation commit — the old key signs, attesting to the new one, per spec §12.1 step 3 — and also writes a token_revocation commit for every still-active consent token, reusing the same token_chain::scan_all_tokens the audit-and-revocation session built earlier that day. Invalidating every outstanding token on rotation is deliberate and auditable, not an incidental side effect of a signature that would no longer match. What’s explicitly out of scope, stated in key.rs’s module doc and printed by the command itself: historical records stay encrypted under the original key, vault re-encryption (spec §12.1 step 5) is deferred past v1.0, and rotation is patient-initiated only — no custodian quorum, no re-authentication tier, consistent with Tier 0.

240 tests pass, up from 213 at the start of the day. Three sessions — audit and revocation, the identity trait, key rotation — land in one commit, each solving a problem the one before it exposed.