01 — Master Key Derivation
Status: Published
Last reviewed: August 2026
Scope
This document describes how a Pyramidion master password becomes the keys that encrypt and authenticate a vault. It covers vault creation, unlock, and what is written to disk in between.
It does not cover how individual vault items are encrypted once those keys exist (document 02), how keys are rotated (03), or the PIN and biometric unlock paths (04). Those paths all terminate in the same key material described here.
Summary
| Parameter | Value |
|---|---|
| Password KDF | PBKDF2-HMAC-SHA-512 (RFC 8018) |
| Iterations (master password) | 369,420 |
| Salt | 32 bytes, from a CSPRNG, unique per vault |
| Derived output | 512 bits (64 bytes) |
| Split | Kx = bytes 0–31, Ky = bytes 32–63 |
Root key RK |
32 bytes, from a CSPRNG — not derived from the password |
| Root key expansion | PBKDF2-HMAC-SHA-512, 2,048 iterations, salt = SHA-256(key id) |
| Expansion output | 512 bits → Ka = bytes 0–31, Kb = bytes 32–63, Kc = Ka ⊕ Kb |
| Root key wrapping cipher | AES-256-CTR, 128-bit IV |
| Wrapping integrity | Encrypt-then-MAC, HMAC-SHA-256 under Ky |
| Stored wrapped key | 80 bytes: `IV(16) |
| Unlock challenge | 48 bytes: `IV(16) |
| Comparisons | Constant-time over raw tag bytes |
For context, the OWASP Password Storage Cheat Sheet currently recommends 220,000 iterations for PBKDF2-HMAC-SHA-512. Pyramidion uses roughly 1.7× that.
1. Why there are two layers
The master password does not encrypt your data. It encrypts a key, and that key's descendants encrypt your data.
master password ──PBKDF2──► Kx , Ky ───────► wrap / verify ──┐
│
random 32 bytes ─► RK (root key) ◄───┘
│
└──PBKDF2──► Ka (encryption)
Kb (authentication)
Kc = Ka ⊕ Kb (generation)
This costs an extra layer of indirection and buys three things:
- Changing your password does not re-encrypt your vault. The root key stays the same; only its wrapper changes. A password change is a few milliseconds of work regardless of whether the vault holds ten items or ten thousand. (This has a security consequence — see §6.2.)
- The data keys have full entropy.
RKis 256 uniformly random bits. Even a weak master password never results in a low-entropy data key; it results in a weakly-protected wrapper around a strong one. That narrows the attack to exactly one place: guessing the password against the stored wrapper. - Keys can be rotated independently of the password. A re-key generates a
new
RKunder the same password (document 03).
2. Vault creation
2.1 Password preparation
The password string is normalized to Unicode NFKD, trimmed of leading and trailing whitespace, then encoded as UTF-8. Those bytes are the PBKDF2 password input.
NFKD matters because visually identical text can have several byte encodings.
café typed on a keyboard that emits U+00E9 and the same word typed on one
that emits e + U+0301 are the same password to a human and two different
passwords to PBKDF2. Normalizing first means the vault opens either way. NFKD
is the compatibility form, so it also folds full-width Latin, ligatures like
fi, and compatibility digits onto their ordinary equivalents — the same rule
BIP-39 mandates for mnemonics, which keeps one normalization rule across the
whole app.
Normalization runs before the trim, so the compatibility spaces (U+00A0, U+2007, U+202F) become ordinary spaces and are stripped uniformly. A password that is nothing but whitespace therefore normalizes to zero bytes; vault creation and password change reject that outright rather than deriving a key from nothing.
Order matters and is fixed: NFKD → trim → UTF-8. " golden2 " and
"golden2" remain the same password.
Versioning. Changing this preparation changes every key derived from it, so
the encoding carries a version — pwNorm on the stored record. Absent means
version 0, the pre-normalization encoding (trim, then UTF-8, no NFKD); records
written now carry version 1. A record's own version always decides how its
password is encoded, never the app's current default, which is what keeps
vaults and backup files created under version 0 openable indefinitely. See §6.5
for how existing records migrate.
2.2 Salt
32 bytes are drawn from the platform CSPRNG (Random.secure()). The salt is
unique per vault, stored alongside the wrapped key in cleartext, and
regenerated on every password change. It is not secret — its job is to make
precomputation (rainbow tables, shared work across vaults) useless.
RFC 8018 asks for at least 8 bytes; 16 is the usual modern floor. 32 is chosen to match the digest sizes used everywhere else in the app.
2.3 Stretching
DK = PBKDF2-HMAC-SHA-512(password, salt, iterations = 369420, dkLen = 64)
The output length is exactly one HMAC-SHA-512 block (64 bytes), so PBKDF2 runs
a single block chain — 369,420 HMAC-SHA-512 evaluations, no more. This matters:
a dkLen larger than the hash output would force PBKDF2 to run a second
independent chain, doubling the defender's cost while an attacker who only
needs the first block to test a guess pays nothing extra. Keeping dkLen == hLen means the attacker's cost per guess is exactly the defender's cost per
unlock, which is the whole point of the exercise.
2.4 Splitting
Kx = DK[0 .. 31] # AES-256 key, wraps the root key
Ky = DK[32 .. 63] # HMAC-SHA-256 key, authenticates the wrapper
Separate keys for confidentiality and integrity, both derived in one pass. No key is used for two purposes.
2.5 Generating the root key
RK = 32 random bytes (CSPRNG)
RK is generated at vault creation and never leaves the device unencrypted. It
is the only secret whose loss compromises the vault.
2.6 Expansion into working keys
RK is not used directly either. It is expanded:
salt₂ = SHA-256( UTF-8( key id ) )
EK = PBKDF2-HMAC-SHA-512(RK, salt₂, iterations = 2048, dkLen = 64)
Ka = EK[0 .. 31] # encryption key — AES-256-CTR over vault items
Kb = EK[32 .. 63] # authentication key — HMAC-SHA-256 over vault items
Kc = Ka ⊕ Kb # generation key — deterministic key/value generation
The 2,048 iterations here are not password stretching, and should not be read as a weak work factor. The input is already a 256-bit uniformly random key; there is nothing to guess and nothing to stretch. This step exists for domain separation: binding the working keys to the vault's key id means the same root key produces different working keys across key generations, so material from one generation cannot be silently reused in another. A single PBKDF2 invocation is a serviceable (if unidiomatic — HKDF is the usual tool) way to get a keyed expansion out of primitives already present.
Configuration note. The salt input for this step is selectable in
AppConstants.expandKeyType. The shipping configuration iskeyid, as documented above. Changing it makes every existing vault permanently unreadable; there is no migration path. In the future, some use cases might need a different expandKeyType.
2.7 Wrapping the root key
Encrypt-then-MAC, with a fresh 128-bit IV:
IV = 16 random bytes
C = AES-256-CTR(Kx, IV, RK) # 32 bytes
tag = HMAC-SHA-256(Ky, SHA-256(IV || C)) # 32 bytes
The MAC covers the IV as well as the ciphertext, so an attacker cannot shift the keystream by editing the IV without invalidating the tag. The MAC is taken over the SHA-256 digest of the blob rather than the blob itself; this is harmless (HMAC's own security does not require it) and simply reflects a uniform "hash, then MAC the hash" convention used throughout the app.
Stored layout — the key field, 80 bytes, base64-encoded:
| Offset | Length | Contents |
|---|---|---|
| 0 | 16 | IV |
| 16 | 32 | HMAC-SHA-256 tag |
| 48 | 32 | Wrapped root key |
2.8 The unlock challenge
Verifying the wrapper tag proves the password was right. It does not prove that the expansion step produced the working keys this vault's items were actually encrypted under — key id and expansion mode are metadata, and metadata can be wrong or substituted. So a second, independent value is stored:
challenge_IV = 16 bytes (in every current write path, the wrapper's IV)
C_chal = AES-256-CTR(Ka, challenge_IV, 0x00 × 16)
tag_chal = HMAC-SHA-256(Kb, SHA-256(challenge_IV || C_chal))
challenge = challenge_IV || tag_chal # 48 bytes, base64
This is an encrypt-then-MAC of a known plaintext under the expanded keys.
Reproducing it proves the session holds the same Ka and Kb the vault was
built with. C_chal itself is discarded — only the IV and the tag are stored,
so nothing derived from Ka's keystream is written to disk.
Reusing the wrapper's IV here is safe because the two encryptions use different keys (
KxvsKa), so no keystream is shared. Reusing an IV under the same key in CTR mode is catastrophic — XOR two such ciphertexts and the keystream cancels.
2.9 What is written to disk
The vault key record is stored in the platform keychain (iOS/macOS Keychain, Android Keystore-backed storage) as JSON:
| Field | Protected? | Notes |
|---|---|---|
salt |
No | Cleartext by necessity — needed before any key exists |
rounds |
No | Iteration count, so the record stays readable if the default changes |
key |
Yes | The 80-byte wrapped root key |
challenge |
Yes (as a MAC) | 48 bytes; reveals nothing without Ka/Kb |
vaultId, keyId, deviceId |
No | Identifiers |
name |
No | Vault name — rendered in the vault picker before unlock |
hint |
No | Password hint, if set |
pwNorm |
No | String normalization version, if set |
Everything in the "No" column is visible to anyone who can read the keychain record or the backup JSON document. The vault name and the password hint travel with backups and, on iOS/macOS, with iCloud Keychain sync when synchronization is enabled. Treat the hint as public: a hint that is useful to an attacker is useful to an attacker who has your encrypted vault.
The iOS keychain item is stored with kSecAttrAccessibleWhenUnlocked, so it is
unreadable while the device is locked.
3. Unlock
The order of operations is the security-relevant part:
- Read
saltandroundsfrom the stored record. DK = PBKDF2-HMAC-SHA-512(password, salt, rounds, 64); split intoKx,Ky.- Check the stored blob is exactly 80 bytes; split into
IV,tag,C. - Recompute
HMAC-SHA-256(Ky, SHA-256(IV || C))and compare totagin constant time. A mismatch ends the unlock — nothing is decrypted. - Decrypt:
RK = AES-256-CTR(Kx, IV, C). - Expand
RKintoKa,Kb,Kcusing expandKeyType (§2.6). - Recompute the challenge (§2.8) and compare to the stored value in constant time. A mismatch ends the unlock.
- Release the user derived session keys.
Two properties fall out of this ordering. The ciphertext is never decrypted before its tag verifies, so a wrong password produces no plaintext to oracle-attack. And both comparisons are constant-time over raw bytes, so neither the tag nor the challenge leaks a byte-position through timing.
Session keys live in process memory only, and are cleared on lock and logout.
Verification-only unlocks
The same derivation runs with the wrapper check as the sole objective in two
other places: verifying the master password against a backup file's key
material (which carries its own salt and rounds, so old backups stay
readable after a parameter change), and re-deriving during a password change or
re-key. Those paths are documented in 03 and 05.
4. Parameter rationale
Why PBKDF2 and not Argon2id? Argon2id is the better primitive — memory
hardness is what actually blunts GPU and ASIC attacks, and PBKDF2 has none.
PBKDF2 was chosen for implementation maturity: it is NIST-specified, has
FIPS-validated implementations, and its behaviour on both target platforms is
predictable and testable. Argon2 support exists in the codebase and the stored
key format carries a kdfAlgo field precisely so this can change without
breaking existing vaults. This is the single biggest open trade-off in the
design, and §6.1 states it plainly.
Why SHA-512 rather than SHA-256? SHA-512 uses 64-bit arithmetic, which narrows the gap between defender hardware (64-bit ARM and x86, where it is fast) and commodity GPU attack hardware (which historically favours 32-bit operations). It is not a large effect and should not be oversold, but it is free.
Why 369,420? Above OWASP's current 220,000 recommendation for PBKDF2-HMAC-SHA-512, and chosen to sit at the edge of what remains tolerable as an unlock delay on the oldest supported hardware. The value is stored per vault rather than compiled in at read time, so it can be raised for new vaults without stranding old ones.
Why 32-byte salts? Consistency with the digest sizes used elsewhere, and headroom above the 16-byte norm at negligible cost.
Why encrypt-then-MAC rather than AES-GCM? The app uses AES-CTR with an explicit HMAC throughout, so item authentication and root-key authentication share one construction. Encrypt-then-MAC is the provably-correct composition order (Bellare–Namprempre), and it is applied here as written: the tag covers the IV and the ciphertext, and it is checked before any decryption occurs.
5. What this protects against
A stolen device, locked. The keychain record is inaccessible until first unlock of the device itself. Nothing in the vault is reachable.
A stolen device, unlocked, or a keychain dump. The attacker gets the salt, the rounds, the 80-byte wrapper, the challenge, the vault name and the hint. Recovering anything else requires guessing the master password at a cost of 369,420 HMAC-SHA-512 evaluations per guess, offline and in parallel. Password strength is the entire defence at that point — a 6-character password falls in minutes regardless of the iteration count, and a genuinely random passphrase of sufficient length does not fall at all.
A stolen backup file. Same position, with the same work factor. Backups carry their own salt and iteration count.
A tampered vault record. Modifying the wrapper fails the tag check. Substituting a different key id or expansion mode fails the challenge check. Modifying an individual item fails that item's own MAC (document 02).
A malicious or buggy unlock caller. The challenge check means a caller that expands under the wrong key id cannot proceed with keys that would silently corrupt writes.
6. Limitations and non-goals
These are stated because a protocol document that only lists strengths is not worth reading.
6.1 PBKDF2 is not memory-hard. An attacker with GPUs or FPGAs gets a much better cost-per-guess ratio against PBKDF2 than against Argon2id or scrypt. The iteration count compensates only partially. Password strength matters more here than it would under a memory-hard KDF.
6.2 Changing your password does not change your root key. It re-wraps the
same RK under a new salt and new Kx/Ky. So if an attacker holds an old
backup and learns the old password, they can recover RK — and RK still
decrypts the current vault. Changing the password after a suspected compromise
is not sufficient; a re-key (document 03) is what generates a new RK and
re-encrypts items under it.
6.3 No zeroization guarantee. Derived keys are held as Dart byte lists and cleared on lock and logout, but a managed runtime with a copying garbage collector cannot guarantee that no copy survives in memory or in a swap file or crash dump. This is a limitation of the platform, not a design choice, and it is shared by essentially every managed-language password manager.
6.4 No server-side factor. There is no pepper, no rate limiting, and no remote lockout, because there is no server. Offline guessing against a stolen vault is bounded only by the KDF cost. This is the deliberate trade for having no account and no cloud dependency.
6.5 Whitespace and Unicode. Passwords are normalized to Unicode NFKD and trimmed. (§2.1).
6.6 The hint is not protected. See §2.9.
6.7 Alternate unlock paths have different properties. PIN unlock uses a lower work factor (262,144 iterations) against a much smaller input space, and biometric unlock delegates to platform key storage. Neither is as strong as the master password path; both are convenience mechanisms layered on top of it. Document 04 covers what they actually protect.
7. Reproducing the derivation
Anyone can verify this document against a real vault record. Given the salt,
rounds and key fields from a keychain record or backup file, and the
password:
import base64, hashlib, hmac
from hashlib import pbkdf2_hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
password = "correct horse battery staple".strip().encode("utf-8")
salt = base64.b64decode("<salt field>")
rounds = 369420 # use the record's own `rounds` value
# 2.3 / 2.4 — stretch and split
dk = pbkdf2_hmac("sha512", password, salt, rounds, dklen=64)
Kx, Ky = dk[:32], dk[32:]
# 2.7 — parse the wrapper and verify before decrypting
blob = base64.b64decode("<key field>")
assert len(blob) == 80
iv, tag, ct = blob[:16], blob[16:48], blob[48:]
expected = hmac.new(Ky, hashlib.sha256(iv + ct).digest(), hashlib.sha256).digest()
assert hmac.compare_digest(expected, tag), "wrong password"
RK = Cipher(algorithms.AES(Kx), modes.CTR(iv)).decryptor().update(ct)
# 2.6 — expand into the working keys
key_id = "<keyId field>" # UUID *text*, hyphens included
salt2 = hashlib.sha256(key_id.encode("utf-8")).digest()
ek = pbkdf2_hmac("sha512", RK, salt2, 2048, dklen=64)
Ka, Kb = ek[:32], ek[32:]
Kc = bytes(a ^ b for a, b in zip(Ka, Kb))
# 2.8 — reproduce the challenge
c_chal = Cipher(algorithms.AES(Ka), modes.CTR(iv)).encryptor().update(b"\x00" * 16)
tag_chal = hmac.new(Kb, hashlib.sha256(iv + c_chal).digest(), hashlib.sha256).digest()
assert base64.b64encode(iv + tag_chal).decode() == "<challenge field>"
Every path that writes key material — vault creation, password change, and
re-key — uses one fresh IV for both the wrapper and the challenge, so reusing
iv above works today. A reimplementation should nonetheless read the IV from
the first 16 bytes of the stored challenge field, which is authoritative and
stays correct if the two ever diverge.
A note on CTR counters. Pyramidion's AES-CTR implementation treats the 16-byte IV as the initial counter block and increments the low 64 bits; Python's
modes.CTRincrements the full 128-bit block. For the 32-byte payloads in this document (two blocks) the two agree except in the vanishingly unlikely case that the IV's low 64 bits are all ones. It matters for long plaintexts, not here.
Change log
| Date | Change |
|---|---|
| 2026-08 | Initial version. Documents PBKDF2-HMAC-SHA-512 at 369,420 iterations, keyid expansion mode, and the root-key challenge. |