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:

  1. 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.)
  2. The data keys have full entropy. RK is 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.
  3. Keys can be rotated independently of the password. A re-key generates a new RK under 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 , 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 is keyid, 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 (Kx vs Ka), 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:

  1. Read salt and rounds from the stored record.
  2. DK = PBKDF2-HMAC-SHA-512(password, salt, rounds, 64); split into Kx, Ky.
  3. Check the stored blob is exactly 80 bytes; split into IV, tag, C.
  4. Recompute HMAC-SHA-256(Ky, SHA-256(IV || C)) and compare to tag in constant time. A mismatch ends the unlock — nothing is decrypted.
  5. Decrypt: RK = AES-256-CTR(Kx, IV, C).
  6. Expand RK into Ka, Kb, Kc using expandKeyType (§2.6).
  7. Recompute the challenge (§2.8) and compare to the stored value in constant time. A mismatch ends the unlock.
  8. 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.CTR increments 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.