02 — Vault Item Encryption
Status: Published
Last reviewed: August 2026
Scope
Document 01 ends with three keys in memory: Ka (encryption), Kb
(authentication) and Kc (generation). This document is what happens next —
how a password, a note, a code, or a key is encrypted with them, how tampering is
detected, and what an attacker learns from a stolen vault without breaking
any of it.
It does not cover key rotation or how items written under a previous root key are read (document 03), nor backups (05). It refers to both where they touch the item format.
Summary
| Parameter | Value |
|---|---|
| Cipher | AES-256-CTR |
| Encryption key | Ka, from the root key expansion (doc 01 §2.6) |
| IV / nonce | 16 bytes, freshly random for every encryption |
| Authentication | Encrypt-then-MAC, HMAC-SHA-256 under Kb |
| MAC input | SHA-256(IV ‖ ciphertext) |
| Stored field | IV(16) ‖ tag(32) ‖ ciphertext(N), base64 |
| Short-value padding | PKCS#7 to exactly 16 bytes, only when plaintext < 16 bytes |
| Item MAC | HMAC-SHA-256 under Kb over the item's JSON minus its mac field |
| Field MAC comparison | Constant-time over raw bytes |
| Item MAC comparison | Base64 string equality — see §8.2 |
| Re-key pressure counters | 2³¹−1 blocks per rollover; 2¹⁶−1 rollovers |
1. Two layers, and why both
Each item is protected twice, at different granularities.
Per field. Every sensitive value — the password, the username, the note body, each tag — is separately encrypted into its own self-contained blob with its own IV and its own MAC.
Per item. The whole item, after field encryption, is serialized to JSON and
that JSON is MAC'd as a unit. The tag goes in the item's mac field.
PasswordItem
├── id, vaultId, keyId, version, cdate, mdate ← plaintext metadata
├── name = IV‖tag‖ciphertext ← own blob
├── username = IV‖tag‖ciphertext ← own blob
├── password = IV‖tag‖ciphertext ← own blob
├── notes = IV‖tag‖ciphertext ← own blob
├── tags[] = [IV‖tag‖ct, IV‖tag‖ct, …] ← one blob each
└── mac = HMAC-SHA-256(Kb, JSON of everything above)
The second layer is not redundant, and the reason is worth being precise about.
A field blob authenticates its own contents and nothing else — it carries no
statement about which item or which field it belongs to. All blobs in a
vault are MAC'd under the same Kb, so a blob lifted from one place verifies
perfectly in another. On its own, the field layer would let an attacker with
write access to the store move your notes blob into the password field, or
swap one item's password blob into another item, and every field-level check
would still pass.
The item MAC is what gives the item overall integrity. It covers id, vaultId, keyId,
version, cdate, mdate and every encrypted field value, so any
rearrangement — within an item or across items — changes the MAC'd JSON and
fails. Field blobs provide confidentiality and integrity of their contents;
the item MAC provides binding. Neither is sufficient alone.
2. Encrypting a field
2.1 What is encrypted and what is not
Encrypted: names, usernames, passwords, note bodies, 2FA URIs, individual tag strings, previous passwords, and private key material.
Not encrypted, and visible to anyone who can read the stored vault:
| Field | What it tells an observer |
|---|---|
id, vaultId |
Which vault, and that this item exists |
keyId |
Which key generation wrote it — see doc 03 |
version |
Item schema version |
cdate, mdate |
When it was created and last changed |
favorite |
Whether you marked it a favourite |
bip39 |
Whether the password is a mnemonic phrase |
| tag count | How many tags, though not what they say |
geoLock presence |
Whether the item is geo-locked |
uri_2fa presence |
Whether the item has 2FA configured |
| ciphertext lengths | Approximate plaintext lengths, above 16 bytes (§2.3) |
This is ordinary structural metadata, but it is not nothing. Modification times across a vault reveal usage patterns; item count reveals scale. §7 treats this as what it is — the residual leak of encrypting values rather than the whole store.
2.2 The blob
IV = 16 fresh random bytes
C = AES-256-CTR(Ka, IV, pad(plaintext))
tag = HMAC-SHA-256(Kb, SHA-256(IV ‖ C))
stored = base64( IV ‖ tag ‖ C )
| Offset | Length | Contents |
|---|---|---|
| 0 | 16 | IV |
| 16 | 32 | HMAC-SHA-256 tag |
| 48 | N | Ciphertext |
Identical in shape and order to the root-key wrapper in doc 01 §2.7 — one construction, used everywhere. Encrypt-then-MAC, the tag covers the IV, and the tag is verified in constant time before anything is decrypted. A field whose MAC fails decrypts to nothing: the function returns an empty string rather than plaintext, so a modified ciphertext can never be turned into a decryption oracle.
The IV is freshly random on every single encryption — editing an item and saving it again re-encrypts every field under a new IV. AES-CTR is a stream cipher, so reusing an IV under the same key would XOR two plaintexts together and destroy the confidentiality of both. With 128 random bits per IV, that does not happen by accident.
2.3 Short values are padded; long ones are not
AES-CTR is a stream cipher, so ciphertext length equals plaintext length. For a password manager that is a leak: a 4-character PIN stored as a password would be visibly 4 bytes long.
Values shorter than 16 bytes are therefore padded with PKCS#7 up to exactly 16 bytes. Values of 16 bytes or more are left alone.
plaintext < 16 bytes → padded to exactly 16
plaintext ≥ 16 bytes → unchanged
The invariant "a padded value is always exactly 16 bytes" is what lets the decrypt side reverse this with no length prefix: only a decrypted value of exactly 16 bytes is a padding candidate, and it is unpadded only if its trailing bytes form a valid PKCS#7 pattern. Anything else is returned untouched. Padding is never treated as an error, so a genuine 16-byte value is never destroyed by a failed unpad.
Two consequences, stated plainly:
- Everything short looks the same length. A 1-byte and a 15-byte value are both stored as 16 bytes of ciphertext. Above that threshold, lengths are visible.
- One residual ambiguity. A genuine, exactly-16-byte value whose own last bytes happen to form a valid PKCS#7 pattern would be over-trimmed. That requires binary or control-byte data, not text, and is the accepted cost of a scheme with no length prefix.
Padding operates on UTF-8 bytes, not characters, so multi-byte text pads by the correct amount.
2.4 Binary values take a different path
Key material and other raw bytes use a separate function that pads short input with zero bytes rather than PKCS#7 — and the corresponding decrypt does not unpad at all. Every current caller passes 32-byte keys, so the padding branch is never taken and nothing is affected in practice. It is documented here because the asymmetry is real: if that path is ever given a value shorter than 16 bytes, the trailing zeros come back with it.
3. Authenticating the item
After every field is encrypted, the item is serialized to JSON excluding its
mac field, and that JSON is authenticated:
mac = base64( HMAC-SHA-256(Kb, item JSON without "mac") )
Excluding mac from its own input is what makes the value well defined.
Verification recomputes it from the stored item and compares.
The MAC is computed over the JSON text, so it depends on key order and formatting as the serializer emits them. This is fine as long as one implementation writes and reads it — which for an offline app it is — but it means the authenticated object is the serialization, not the abstract item.
Items whose keyId does not match the session's current key are verified with
that generation's own authentication key instead; see doc 03.
4. Nonces are random, not counted
Worth stating because the codebase contains both ideas and they serve different purposes.
Item encryption uses random IVs. Every field encryption draws 16 fresh bytes from the platform CSPRNG. Nothing about the IV is derived from a counter, a timestamp or the item's identity.
The counters are usage accounting, not nonce generation. The app separately tracks how many bytes and blocks have been encrypted under the current root key (§5). Those counters never feed an IV. They exist to answer "has this key done enough work that it should be retired?"
Structured, counter-derived nonces do appear elsewhere — the key-nonce record below, and the hardened backup lock in doc 05 — but never for vault items.
5. Block accounting and re-key pressure
Every encryption reports its plaintext length to a running counter, which maintains:
- blocks encrypted in the current rollover window, capped at
2³¹ − 116-byte blocks; - rollovers, incremented each time that cap is passed, capped at
2¹⁶ − 1.
When rollovers reach half the maximum, the app raises an internal "should re-key" flag. That is a recommendation surfaced to you, not an automatic action — re-keying is document 03.
These are enormous numbers; a normal vault will never approach them. The mechanism exists so that the answer to "how much has this key encrypted?" is a measured figure rather than an assumption.
The key-nonce record
So the count survives a backup and restore, it is stored inside the backup as a 16-byte value:
| Offset | Length | Contents |
|---|---|---|
| 0 | 8 | zero |
| 8 | 4 | rollover count, big-endian |
| 12 | 4 | block count, big-endian |
That value is not encrypted under Ka/Kb directly. It is encrypted under a
pair of keys derived from the backup's own metadata:
idHash = SHA-256(idString)
Kmeta_enc = HMAC-SHA-256(Ka, idHash)
Kmeta_auth = HMAC-SHA-256(Kb, idHash)
then wrapped with the same IV ‖ tag ‖ ciphertext construction as everything
else. idString is itself a chain of keyed HMACs over the vault's identifying
metadata — vault id, device id, key id, app version, creation and modification
dates, and vault name — so the counter record is bound to the exact backup it
was written for. Move it to a backup with different metadata and it will not
decrypt.
The chain is order-dependent by construction: each step keys the next. Any reimplementation must feed the fields in the same order the writer used.
6. Reading an item
- Verify the item MAC over the stored JSON minus
mac. A mismatch stops the read — and if the item'skeyIdis not the current one, the previous-key path in doc 03 is tried before giving up. - For each encrypted field: split into IV, tag and ciphertext.
- Recompute
HMAC-SHA-256(Kb, SHA-256(IV ‖ ciphertext))and compare to the tag in constant time. A mismatch yields an empty value, never plaintext. - Decrypt with AES-256-CTR under
Ka. - Unpad if and only if the result is exactly 16 bytes and carries a valid PKCS#7 pattern.
- Decode as UTF-8.
Geo-locked passwords take an additional step, since their value is encrypted under a key that also depends on location data.
7. What this protects against
A stolen vault file or keychain dump, without the master password. Field contents are AES-256 ciphertext under a key derived from a 256-bit random root key. Recovering anything means recovering that root key, which means the password-guessing problem of doc 01 §5. What is exposed without any of that is the metadata in §2.1.
Any modification to an item. Changing a ciphertext byte fails that field's
tag. Changing metadata, reordering fields, or moving a blob between items fails
the item MAC. Substituting a whole item from another vault fails on vaultId,
which the item MAC covers.
Replaying an old version of an item. The item MAC covers mdate, so a
stale item is detectable as stale — though see §8.3 for what this does not do.
Bit-flipping attacks on the ciphertext. AES-CTR is malleable: flipping a ciphertext bit flips the same plaintext bit. Encrypt-then-MAC is what removes this, and the tag is checked before decryption, not after.
8. Limitations and non-goals
8.1 Metadata is not encrypted. §2.1 lists exactly what is visible. Item count, timestamps and structure are readable from a stolen vault.
8.2 The item MAC comparison is not constant-time. Field blob tags are compared byte-wise in constant time; the item-level MAC is compared as base64 strings, which short-circuits on the first differing character. For an offline app with no remote attacker able to time verifications this is not a practical weakness, but it is an inconsistency with the rest of the codebase rather than a deliberate exception.
8.3 Deletion and rollback are not authenticated at the vault level. Each item authenticates itself. Nothing authenticates the set of items, so an attacker with write access could delete an item wholesale, or restore an entire older copy of the store, without any MAC failing. Detecting that needs a signed manifest over the collection; the hardened backup lock (doc 05) does this for backup files, but the live store has no equivalent.
8.4 Lengths above 16 bytes leak. Padding hides short values only. A 40-character passphrase is visibly longer than a 20-character one.
8.5 Tags are encrypted individually. This is what makes tag search possible without decrypting everything, and it means the number of tags on an item is visible, and two items sharing a tag produce different ciphertexts — so tags do not leak equality, but tag counts do.
8.6 One authentication key for the whole vault. Every field blob and every
item MAC uses Kb. Per-field or per-item derived keys would make the field
layer self-binding and remove the reliance on the item MAC described in §1.
The current design is sound because the item MAC does that job, but it is the
kind of layering where removing one piece silently weakens another.
9. Reproducing a field decryption
Given Ka and Kb — recover them via doc 01 §7 — any stored field can be
decrypted independently:
import base64, hashlib, hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
blob = base64.b64decode("<any encrypted field value>")
iv, tag, ct = blob[:16], blob[16:48], blob[48:]
# verify before decrypting — this is the order the app uses
expected = hmac.new(Kb, hashlib.sha256(iv + ct).digest(), hashlib.sha256).digest()
assert hmac.compare_digest(expected, tag), "field failed authentication"
plain = Cipher(algorithms.AES(Ka), modes.CTR(iv)).decryptor().update(ct)
# unpad only an exactly-16-byte result carrying a valid PKCS#7 pattern
if len(plain) == 16:
pad = plain[-1]
if 1 <= pad <= 16 and all(b == pad for b in plain[-pad:]):
plain = plain[:-pad]
print(plain.decode("utf-8"))
And the item MAC:
import json
item = json.loads("<stored item JSON>")
stored_mac = item.pop("mac")
# the app MACs its own serialization — key order matters, so this reproduces
# only if the JSON is re-emitted exactly as the app emitted it
computed = hmac.new(Kb,
hashlib.sha256(json.dumps(item, separators=(",", ":")).encode()).digest(),
hashlib.sha256).digest()
The item MAC is taken over
SHA-256(text)rather than the text directly, matching the "hash, then MAC the hash" convention used throughout the app.
Change log
| Date | Change |
|---|---|
| 2026-08 | Initial version. Documents the per-field blob format, short-value padding, the item MAC, random IVs, and the block-accounting counters. |