04 — PIN and Biometric Unlock

Status: Published

Last reviewed: August 2026


Scope

Two convenience paths that unlock an already-established vault without typing the master password. Both terminate in exactly the same place doc 01 §3 does — the root key in memory, expanded into Ka, Kb and Kc — so neither is a reduced-privilege mode. A PIN or a fingerprint opens your vault as completely as the master password does.

This document explains what each one actually rests on, because the answer is different for each and in neither case is it primarily the app's cryptography.

Read doc 01 first; the terms RK, Ka, Kb are from there.


Summary

Master password PIN Biometric
Protects the root key with PBKDF2-HMAC-SHA-512 PBKDF2-HMAC-SHA-256 nothing — the OS keychain only
Iterations 369,420 262,144 n/a
Derived key 512 bits, split into Kx/Ky 256 bits, used for both cipher and MAC n/a
Secret entropy whatever you choose ~20 bits for 6 digits n/a
Stored form of RK wrapped wrapped stored as-is, base64
Attempt limit none 3, then the PIN is deleted platform-enforced
Keychain class (iOS) WhenUnlocked AfterFirstUnlockThisDeviceOnly WhenPasscodeSetThisDeviceOnly
Access control (iOS) none none biometryCurrentSet
Survives a re-key yes no — deleted no — deleted
Real security rests on the KDF and your password the keychain + the attempt limit the OS keystore

1. The PIN path

1.1 Setting a PIN

salt    = 32 random bytes
K_pin   = PBKDF2-HMAC-SHA-256(pin, salt, 262144, 32 bytes)
IV      = 16 random bytes
C       = AES-256-CTR(K_pin, IV, RK)
tag     = HMAC-SHA-256(K_pin, SHA-256(IV ‖ C))

Stored in the PIN record: salt, rounds, the encoding version (doc 01 §2.1), the digit count, an attempt counter, and:

Offset Length Contents
0 16 IV
16 32 tag
48 32 wrapped root key

The same 80-byte layout as everything else in the app. Two things differ from the master password path, and both are worth naming:

A different primitive. SHA-256 rather than SHA-512, and a 256-bit output rather than 512.

One key for two jobs - TODO update. The master password path derives 512 bits and splits them into a separate cipher key and MAC key. The PIN path derives 256 bits and uses that single key as both the AES key and the HMAC key. Using one key for encryption and authentication is a deviation from the separation the rest of the app maintains. It is not exploitable here — AES-CTR and HMAC-SHA-256 are different primitives and no known attack lets one leak the other — but it is weaker practice than the design elsewhere and worth knowing if you are reimplementing.

1.2 Unlocking

Re-derive K_pin from the record's own salt, rounds and encoding version; verify the tag in constant time; decrypt to recover RK; expand under the vault's current key id. Identical in ordering to doc 01 §3 — nothing is decrypted before its tag verifies.

1.3 The attempt limit

Three failures and the PIN record is deleted, forcing a master-password login. The vault is untouched — only the PIN wrapper is destroyed.

The counter lives inside the PIN record itself and is rewritten on each attempt. That makes it an application-level control, not a cryptographic one: it stops someone tapping guesses into the running app, and it does nothing at all against someone who has copied the record out of the keychain and is guessing offline. §4 is about that attacker.


2. The biometric path

2.1 What is stored

The root key, base64-encoded. That is the whole record.

There is no app-layer encryption on this path. No KDF, no wrapping key, no MAC. The root key sits in the platform keychain and every bit of its protection comes from the keychain's access controls.

This is a defensible design — hardware-backed keystores are built for exactly this — but it means the biometric path's security is the platform's, precisely and entirely.

2.2 What the platform enforces

Android. The entry is written through the KeyStore's biometric mode with enforceBiometrics: true and strongBiometricOnly. The KeyStore itself refuses to release the key without a strong biometric, and device credentials — the lock-screen PIN or pattern — cannot substitute. This is enforced by the OS at the point of read, and is fully effective on Android 11 (API 30) and above; on older releases the system may still accept a device credential.

iOS and macOS. Two separate mechanisms, and the distinction matters.

The WhenPasscodeSetThisDeviceOnly accessibility class controls when the item is readable: never synced to iCloud, never included in backups, bound to this device, and erased if the user removes their device passcode.

Accessibility says nothing about biometry, so on its own it would let the keychain hand the item to the app whenever the device is unlocked. The entry therefore also carries the biometryCurrentSet access control constraint, which makes a successful biometric match a condition of the read itself. The keychain will not release the key without one, and the app cannot decide otherwise — this is not a prompt the app shows and then proceeds past.

biometryCurrentSet rather than biometryAny because it additionally invalidates the entry whenever the enrolled biometric set changes. That closes a specific hole: someone who knows your device passcode could otherwise enrol their own face or finger and unlock the vault with it. The cost is that a legitimate re-enrollment also invalidates the entry — see §2.3.

Deliberately not userPresence, which would let the device passcode substitute for biometry. Android already rejects device credentials via strongBiometricOnly; userPresence would have made the two platforms disagree about what "biometric unlock" means.

Because the read authenticates, the unlock paths run no app-level biometric prompt of their own — that would be two prompts for one unlock, and would imply the app authorizes the release. The biometric APIs are still used to decide whether to offer the button, which is a capability question rather than an authorization one. The one place an app-level prompt remains is enabling biometric unlock: writing an access-controlled item does not authenticate, so without a prompt there a user could turn the feature on without ever confirming a biometric works on the device.

2.3 Lifecycle

The entry is rewritten after any master-password login, so it always holds the current root key. It is treated as derived rather than authoritative: if it fails to load it is deleted outright and the button hidden, with the master password as the recovery path. Unlike the vault-bearing keychain entries, an error here resets it rather than being preserved — losing it costs convenience, not data.

That recovery path now carries more traffic than it used to, because the entry is invalidated by routine events on both platforms: adding a fingerprint or re-enrolling Face ID on iOS, and the equivalent KeyStore invalidation on Android. A cancelled prompt is treated the same way. In every case the entry is deleted and the next successful master-password login recreates it, so the cost of a re-enrollment is one password entry. Deleting on a mere cancellation is deliberate over-correction: an entry that cannot be read is a button that silently does nothing, which is worse than one extra password prompt.


3. Both paths reach the same place

Neither path gives partial access. Both install the same root key and the same Ka/Kb/Kc, so anything the master password can decrypt, a PIN or a fingerprint can decrypt.

Both are also deleted by a re-key (doc 03 §3.6), because both wrap or hold a root key that no longer exists after one. Neither is re-created automatically.


4. What a PIN is actually worth

A PIN is short by design, so the interesting question is what an attacker who has extracted the PIN record — bypassing the app and its attempt counter entirely — has to do.

The work factor buys about 18 bits on top of the PIN's own entropy. Against a single CPU core, measured at roughly 180 ms per guess:

Digits Search space Entropy One core (expected) 1,000× parallel
4 10⁴ ~13 bits ~15 minutes ~1 second
6 10⁶ ~20 bits ~25 hours ~1.5 minutes
8 10⁸ ~27 bits ~4 months ~2.5 hours
10 10¹⁰ ~33 bits ~29 years ~11 days
12 10¹² ~40 bits ~2,900 years ~3 years

A 6-digit PIN is around 37 bits of total attacker work. That is not a cryptographic barrier; PBKDF2 is not memory-hard (doc 01 §6.1), so GPU parallelism applies directly and the "1,000× parallel" column is conservative for well-resourced hardware.

The conclusion this leads to. The PIN's protection against an offline attacker is weak and cannot be made strong by raising the iteration count — you would need roughly a million times more work to buy the 20 bits that make a 6-digit PIN comparable to a modest password, and the unlock would take hours. What actually protects the PIN record is that it is in a device-bound keychain class that does not sync, does not appear in backups, and is not readable while the device is locked. The PIN is a lock on the app; the keychain is the lock on the PIN.

Longer PINs help, and the app permits up to 100 digits. A 12-digit PIN is a genuinely different proposition from a 6-digit one.


5. What each path protects against

Someone picking up your unlocked phone. Both work. This is the threat these features exist for, and against it they are effective.

A stolen device, powered off or never unlocked since boot. Both records are device-bound and unreadable until first unlock; the biometric record additionally requires a passcode to be set. An attacker gets nothing from either.

A stolen device, unlocked, with keychain extraction. The PIN record falls to the offline search in §4 — nothing about the keychain's protection survives the record leaving it. The biometric record is different: it holds the root key in the clear, but the keychain will not release it without a biometric match on either platform, so extracting it is not the same as reading it. That is a meaningful barrier and not an absolute one — it holds exactly as long as the platform's biometric enforcement does, which is a different assumption from the mathematics the master password rests on.

The master password path (doc 01 §5) is the one that still holds unconditionally here, because it protects the root key with real work against real entropy rather than with an access decision made by something else.

A backup file. Neither record travels in backups. Both are device-local.


6. Limitations and non-goals

6.1 A PIN is not a second factor. It is an alternative first factor — weaker, and sufficient on its own. Enabling a PIN strictly widens the attack surface of the vault; it does not narrow it.

6.2 The attempt limit is advisory. It lives in the record it protects and is bypassed entirely by attacking the record offline (§1.3).

6.3 The PIN's digit count is stored in the clear. An attacker reading the record knows exactly how long the PIN is and does not have to search shorter lengths.

6.4 The PIN path uses one key for both encryption and authentication. Discussed in §1.1. Not exploitable, but inconsistent with the rest of the app.

6.5 The biometric path has no cryptography of its own. The root key is stored unwrapped and the entire guarantee is the keystore's access decision (§2). Access control gates the release of the item; once released the key is plaintext in app memory like any other unlock path. So the biometric path inherits whatever the platform's biometric enforcement is worth on that device and OS version — including the Android caveat in §2.2 — and cannot be stronger than it.

6.6 Access control is fixed when an item is written. A biometric entry created before the constraint was introduced keeps its original policy until it is deleted and rewritten, which happens on the next master-password login. Enabling the constraint does not retroactively protect entries already on a device.

6.7 Biometrics are not secret. Fingerprints and faces can be presented without your cooperation and cannot be changed after compromise. That is a property of biometrics generally, not of this app, but it is the reason biometric unlock should be thought of as a convenience over a device you control rather than as a strong authenticator.

6.8 Neither path re-derives anything. Both hand back a root key that was stored earlier. Neither verifies the master password, and neither can be used to change it.


7. Reproducing a PIN unwrap

Given a PIN record and the PIN:

import base64, hashlib, hmac, unicodedata
from hashlib import pbkdf2_hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

raw     = "123456"
pw_norm = rec.get("pwNorm") or 0                       # doc 01 §2.1
pin     = (unicodedata.normalize("NFKD", raw) if pw_norm == 1 else raw).strip()

K_pin = pbkdf2_hmac("sha256", pin.encode("utf-8"),
                    base64.b64decode(rec["salt"]), rec["rounds"], dklen=32)

blob = base64.b64decode(rec["keyMaterial"])
assert len(blob) == 80
iv, tag, ct = blob[:16], blob[16:48], blob[48:]

# the SAME key verifies and decrypts — see 1.1
expected = hmac.new(K_pin, hashlib.sha256(iv + ct).digest(), hashlib.sha256).digest()
assert hmac.compare_digest(expected, tag), "wrong PIN"

RK = Cipher(algorithms.AES(K_pin), modes.CTR(iv)).decryptor().update(ct)
assert len(RK) == 32
# expand under the vault's CURRENT key id (doc 03 §2) to get Ka / Kb

There is no equivalent for the biometric path: the record is base64.b64decode(item["keyMaterial"]) and that is the root key. Nothing is derived, so there is nothing to reproduce — the only question is whether the keystore hands the item over, and that is answered by the OS rather than by any computation you could repeat here.


Change log

Date Change
2026-08 iOS and macOS biometric entries now carry the biometryCurrentSet access control constraint, making the biometric match a condition of the keychain read rather than an app-level prompt in front of it (§2.2). The unlock paths no longer run their own biometric prompt. §5 and §6 revised accordingly; the previous §6.5, which recorded the app-enforced gate as a limitation, is resolved and replaced.
2026-08 Initial version. Documents the PIN wrap, its single-key construction and attempt limit, the biometric path's reliance on platform keystores, and the offline cost of a short PIN.