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³¹ − 1 16-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

  1. Verify the item MAC over the stored JSON minus mac. A mismatch stops the read — and if the item's keyId is not the current one, the previous-key path in doc 03 is tried before giving up.
  2. For each encrypted field: split into IV, tag and ciphertext.
  3. 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.
  4. Decrypt with AES-256-CTR under Ka.
  5. Unpad if and only if the result is exactly 16 bytes and carries a valid PKCS#7 pattern.
  6. 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.