03 — Key Rotation and Re-key
Status: Published
Last reviewed: August 2026
Scope
Documents 01 and 02 both defer to this one. 01 says a password change does not
rotate the root key and points here for what does; 02 says items carry a
keyId and points here for what happens when it stops matching.
This document covers both: what a re-key changes, what it deliberately does not, how items written under an older key stay readable, and what happens when a re-key fails halfway. It does not cover backups (05), though a re-key takes one internally.
Summary
| Password change | Re-key | |
|---|---|---|
| Root key | unchanged | new, 32 random bytes |
Working keys Ka/Kb/Kc |
unchanged | new |
| Key id | unchanged | new UUID v4 |
| KDF salt | new | new |
Wrapper (key field) |
re-wrapped | new |
| Challenge | recomputed | new |
| Vault items re-encrypted | no | no — migrated lazily |
| Identity + recovery keys re-encrypted | no | yes, immediately |
| Previous root key retained | n/a | yes, and remains reachable |
| PIN / biometric unlock | kept | deleted |
| Encryption counters | unchanged | reset to zero |
| Cost | one PBKDF2 run | proportional to identity + recovery keys, not vault size |
1. Three operations that get confused
| Changes what you type | Changes the root key | Re-encrypts items | |
|---|---|---|---|
| Password change | yes | no | no |
| Re-key | no | yes | lazily (§4) |
| Backup restore | no | no — adopts the backup's | no |
The distinction that matters after a suspected compromise: changing your password re-wraps the same root key. Anyone who already holds that root key keeps holding it. A re-key is the operation that generates a new one. Doc 01 §6.2 states this from the password side; this document is the other half.
2. Key ids
A key id is an RFC 4122 version 4 UUID identifying one generation of root key. It appears in three places:
- the vault's key material record, naming the current generation;
- every item, naming the generation the item was last written under;
- each entry in the previous-root-key archive.
It is not decoration. Under the shipping configuration the working keys are
derived using SHA-256(key id) as the expansion salt (doc 01 §2.6), so the
key id is an input to Ka and Kb. Two generations with the same root key
and different key ids produce different working keys. This is what makes
"which generation wrote this item" a cryptographically meaningful question
rather than a metadata label.
3. What a re-key does
The sequence is ordered so that everything reversible happens before anything irreversible, and nothing touches the live session keys until the end.
3.1 Snapshot
A complete snapshot of the vault — items, identity, recovery keys, the previous-root-key archive — is serialized and written to a temporary file before any write. Everything in it is still encrypted under the live root key, so it can be written straight back. This is the rollback source (§6).
3.2 A new root key, staged but not live
A new key id is generated, then:
RK_new = 32 random bytes
salt = 32 random bytes
DK = PBKDF2-HMAC-SHA-512(password, salt, 369420, 64)
Kx, Ky = DK[0..31], DK[32..63]
wrapper = IV ‖ HMAC-SHA-256(Ky, SHA-256(IV ‖ C)) ‖ C where C = AES-256-CTR(Kx, IV, RK_new)
Identical in construction to vault creation (doc 01 §2.7) — same iteration count, same fresh salt, same encrypt-then-MAC. The same master password now protects a different root key.
RK_new is then expanded into Ka′, Kb′, Kc′ using SHA-256(new key id)
as the salt, and those land in temporary slots. The live session keys are
untouched. Everything from here until the commit encrypts with the temp keys
and decrypts with the live ones.
The encryption counters (doc 02 §5) are snapshotted and reset — the new key starts with a fresh keystream budget.
3.3 The previous-key archive is re-encrypted first
Every existing archive entry is decrypted under the live keys and re-encrypted under the temp keys. This keeps the invariant that every archived root key is readable from the current one — see §9.1 for what that costs.
3.4 The outgoing root key is archived
entry.keyData = encrypt-then-MAC(Ka′, Kb′, RK_old)
entry.keyId = the outgoing key id
This entry is the only thing keeping items written under the outgoing key readable after the switchover. The re-key aborts if it cannot be written, and its id is recorded before the write attempt — a failed write does not prove nothing was persisted, and a rollback that skipped a half-written entry would leave one that can never be decrypted.
3.5 Identity and recovery keys are re-encrypted immediately
The vault identity's three private keys — X25519 exchange, secp256k1 signing, and the post-quantum key — are each decrypted under the live keys and re-encrypted under the temp keys, and the identity's MAC is recomputed with the temp authentication key. Peer recovery keys are re-derived so peers can still recover the vault without re-exchanging identity codes.
These are done eagerly, unlike items, because they are few and because the recovery path must work the moment the re-key commits.
3.6 Commit
The new key material record is written, the session's key id is set to the new one, and the temp keys are promoted to live in a single step. On success the PIN and biometric unlock records are deleted — both wrap the old root key, and neither is re-derived.
3.7 What is not re-encrypted
Ordinary vault items. All of them. A re-key does not touch a single password, note or key item.
4. Items migrate lazily
After a re-key, every existing item still holds ciphertext under the old
working keys and still carries the old keyId. An item moves to the new
generation only when it is next saved — edited, or otherwise rewritten —
at which point it is encrypted under the current keys and stamped with the
current key id.
This is a deliberate trade, and it is the single most important thing to understand about rotation here:
- What it buys: a re-key costs the same on a vault with ten items and one with ten thousand. It cannot time out, cannot half-finish across thousands of items, and does not require the whole vault to be decrypted into memory.
- What it costs: for as long as you do not edit an item, that item's plaintext remains protected by the old key — and the old key remains recoverable from the new one (§3.3). A vault that is re-keyed and then never edited is, cryptographically, still a vault protected by the old key with an extra layer of indirection.
If your goal is to render an old root key useless, a re-key alone does not do it. §9.1.
5. Reading an item from an older generation
When the session unlocks, every archive entry is decrypted with the current
working keys, yielding each previous 32-byte root key. Each is then expanded
the same way any root key is — PBKDF2-HMAC-SHA-512, 2,048 iterations, salt
SHA-256(that entry's own key id) — producing that generation's 64-byte
expanded key, held in memory as:
bytes 0..31 → that generation's Ka
bytes 32..63 → that generation's Kb
Reading an item then goes:
- Verify the item MAC under the current
Kb. If it passes, decrypt with the currentKa— the ordinary path from doc 02 §6. - If it fails and the item's
keyIddiffers from the session's, look up that key id in the archive and retry the MAC and the decryption under that generation's keys. - If the key id is not in the archive, or the retry fails, the item does not decrypt.
Note the ordering: a MAC failure is what triggers the older-generation attempt, not the key id. An item whose ciphertext has been tampered with fails both paths and is rejected, so the fallback widens which keys are tried, never whether authentication is required.
6. Failure and rollback
A re-key makes three writes that cannot be undone by simply stopping: it re-encrypts the archive under the new key, adds an entry for the outgoing key, and deletes and rewrites the identity. If a later step fails, all of them have to be reversed, or a "failed" re-key leaves the identity and every item bound to an older generation permanently unreadable — while reporting that recovery succeeded.
The rollback restores items, identity, the archive and recovery keys from the snapshot, and puts the encryption counters back. Every step runs even when an earlier one fails: partial progress is strictly better than stopping, because short-circuiting on one unsaveable item would skip the identity and archive restores, which are the losses that cannot be undone. The archive entry added for the outgoing key is deleted — it is encrypted under a temp key that was never committed and can never be read again.
The operation reports one of four outcomes:
| Outcome | Meaning |
|---|---|
success |
The vault is on the new key. |
recovered |
The re-key failed and everything was rolled back. The vault is exactly as it was; nothing to do. |
partiallyRecovered |
The re-key failed and the rollback did not fully restore. The vault may be holding a destroyed identity or archive. Restore from a backup before relying on it. |
failed |
Nothing was attempted. |
partiallyRecovered is reported separately from recovered on purpose: the
two need opposite advice, and presenting the second as success is how a user
ends up trusting a vault that has lost its identity keys.
Because the session keys are never swapped until the commit, a rollback does not need to re-expand anything — the session is holding the correct keys throughout.
7. When to re-key
- After a suspected compromise of the master password, together with a password change. The password change stops the old password from working; the re-key is what makes the old root key stop being the vault's key.
- When the app says the encryption counters are approaching their limit (doc 02 §5).
- Not routinely. A re-key deletes your PIN and biometric unlock, and — per §4 — does not re-encrypt anything you do not subsequently edit.
8. What rotation achieves
A new key for everything written from now on. Every item saved after the re-key is encrypted under a root key that did not exist before it.
A bounded keystream budget. The counters reset, so the "how much has this key encrypted" question starts fresh.
Identity and recovery material moved immediately. The vault's private keys and every peer recovery key are on the new root key before the operation returns.
Verifiable generation boundaries. Because the key id feeds the expansion, an item cryptographically belongs to one generation. This is not a label that can be edited.
9. Limitations and non-goals
9.1 This is not forward secrecy, and it does not destroy the old key. Every previous root key is retained, re-encrypted under each successive root key, so the whole chain is reachable from the current one until all items are re-encrypted under the new key. That is a deliberate requirement — without it, items that have not been re-saved since a rotation would become unreadable — but it means rotation does not limit the damage of a future compromise, and it does not make a previously captured copy of the vault safe.
To actually retire an old key you must re-key and then cause every item to be re-saved, and the app does not currently offer a "re-encrypt everything now" action.
9.2 Lazy migration has no progress indicator. Nothing reports how many items are still on an older generation, so "have I actually finished rotating?" is not a question the app answers.
9.3 PIN and biometric unlock are silently removed. They are deleted on a successful re-key and must be set up again. Nothing warns beforehand.
9.4 A backup taken before a re-key restores the old generation. It carries its own key material, key id and — per doc 01 §2.1 — its own password encoding version. Restoring it puts the vault back on the old root key entirely.
9.5 partiallyRecovered is a real state. The rollback is best-effort. It
is reported honestly rather than being collapsed into success, but a vault in
that state needs a backup restore.
10. Reproducing the archive read
Given the current Ka/Kb (doc 01 §7) and an archive entry:
import base64, hashlib, hmac
from hashlib import pbkdf2_hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
# 1. the entry's keyData is an ordinary field blob (doc 02 §2.2)
blob = base64.b64decode(entry["keyData"])
iv, tag, ct = blob[:16], blob[16:48], blob[48:]
assert hmac.compare_digest(
hmac.new(Kb, hashlib.sha256(iv + ct).digest(), hashlib.sha256).digest(), tag)
old_root = Cipher(algorithms.AES(Ka), modes.CTR(iv)).decryptor().update(ct)
assert len(old_root) == 32
# 2. expand it under ITS OWN key id, not the current one
salt = hashlib.sha256(entry["keyId"].encode("utf-8")).digest()
expanded = pbkdf2_hmac("sha512", old_root, salt, 2048, dklen=64)
old_Ka, old_Kb = expanded[:32], expanded[32:]
# 3. old_Ka / old_Kb now read any item stamped with that keyId
The key id in step 2 is the entry's own, not the vault's current one. Using the wrong id produces valid-looking 32-byte keys that decrypt nothing — the failure is silent, which is why it is worth stating twice.
Change log
| Date | Change |
|---|---|
| 2026-08 | Initial version. Documents the re-key sequence, temp-key staging, the previous-root-key archive, lazy item migration, and the rollback outcomes. |