← Architecture studies

Rotate keys without losing saved secrets

PR #5758 adds previous keys for decryption, moves saved values to the current key, and blocks destructive settings and secrets writes when no configured key can read the existing data.

OpenHands Agent Server · PR #5758 · fixes #5622 · 16 October 2026

Before this PR: a key change could become permanent data loss.

  1. Replace encryption key A with B
  2. Old secrets cannot decrypt; reads show them as unset
  3. A later edit returns 200 and saves nulls over the ciphertext

After that overwrite, restoring A cannot recover the erased values.

After: two deliberate paths

Old key available · rotate

  1. Current key B + previous key ATry B first, then each previous key in order.
  2. Read the original valuesOld ciphertext stays usable through the shared cipher and secret serializers.
  3. Encrypt every new save with BRe-encryption moves readable old values onto the current key.

Old key missing · preserve

  1. No configured key decrypts a valueOrdinary reads still show the secret as unavailable.
  2. Strict load before changing a storeSettings and secrets updates stop if existing encrypted values cannot be read.
  3. Refuse the write; keep the file intactSettings PATCH: 409. Secret PUT/DELETE: 500. Restore or supply the missing key to recover.

Supply the old key, then restart

OH_SECRET_KEY="<new-key-B>"
OH_PREVIOUS_SECRET_KEYS='["<old-key-A>"]'

Previous keys are a JSON list; indexed variables such as OH_PREVIOUS_SECRET_KEYS_0 also work. These placeholders illustrate the config.

When existing ciphertext moves to B
WhenWhich files
Startup / deferred initsettings.json and secrets.json: rewrite only when stale tokens exist and all values decrypt, under a lock with atomic writes.
On the next saveConversations, LLM profiles and provider connections.

Keep previous keys while older files still need them. This PR does not perform a full archive migration. Set OH_SECRET_KEY explicitly to separate encryption from session auth; otherwise rotating the first session key also rotates the cipher key.

Warm pools use the same rotation path

POST /api/init accepts previous_secret_keys. Changed config keys rebuild the cipher; startup-created stores must also be refreshed before services use them.

Stop boot telemetry → reset cached stores → re-encrypt → start new consumers

The cache reset preserves the files. This ordering fixes the follow-up bug where init returned 200 but stores still held the boot cipher.