Security model
FortPass implements end-to-end zero-knowledge encryption: the server stores ciphertext only. Without the user's PIN, the database is noise.
Encryption layers
- PIN → bcrypt (cost 11). The hash lives in
users.pin. Verified withpassword_verify(). - PIN → PBKDF2-SHA256 (200 000 iterations) + unique salt →
vault_key(32 bytes). Computed when the user enters the correct PIN and stored in$_SESSION['vault_key']. Never in DB. - Each item has its own
encryptionKey(16 bytes, generated on create). Encrypts the item with AES-256-CBC + random IV:- Item password/data → encrypted with
encryptionKey. encryptionKey→ wrapped withvault_keyand stored in DB (encryptionKeys,encryptionKeysCards,encryptionKeysBanks, etc.).
- Item password/data → encrypted with
- Session cookies: AES-256-GCM with
encrypt_key(environment variable). Carriesuser_id+sessionCode.
v2 vault scheme
The current scheme decouples the vault_key from the PIN. This lets the user change their PIN without re-encrypting every item.
vault_key (32 random bytes, constant per user)
pin_key = PBKDF2(pin, pin_salt, 200k)
rec_key = PBKDF2(recovery_code, recovery_salt, 200k)
users.vault_key_pin = AES-256-GCM(vault_key, pin_key)
users.vault_key_recovery = AES-256-GCM(vault_key, rec_key)
users.recovery_hash = bcrypt(recovery_code) // verifies the input
- Change PIN = re-wrap
vault_keywith the newpin_key(instant; data is not touched). - Forgot PIN + recovery code = unwrap with
rec_key, re-wrap with the newpin_key. - Forgot PIN WITHOUT recovery code = full wipe. The data is unrecoverable.
Recovery
- Recovery code: 24 characters, displayed once when the PIN is created. The bcrypt of the code verifies input; a wrapped copy of
vault_keylets the recovery flow re-derive without the PIN. - Without code + forgotten PIN: the system allows resetting the PIN by email, but the encrypted data cannot be recovered — the user starts over with an empty vault.
Anti-abuse
- PIN rate limit: 5 attempts per (IP + user) per 60s. After that: temporary lockout.
- Email passwordless rate limit: 3 codes per (email, IP) per hour. Max 5 attempts per code. Codes valid 10 min.
- CSRF:
csrf-tokenmeta injected on every internal page; AJAX endpoints validate withcsrf_verify(). - Pwned Passwords (HIBP) k-anonymity: only the first 5 characters of the password's SHA-1 leave the server. HIBP returns the list of hashes with that prefix and we compare locally. The password never leaves the server in plain.
Remote logout
/account-and-security exposes "Log out of all devices". Rotates users.sessionCode → all prior cookies are invalidated on the next request. Encrypted data is not touched.
Full account wipe
user_wipe_all($con, $user_id, $delete_account = true) is the central helper. Called from:
- Admin delete:
adminDeleteUser. - PIN reset wipe: when the user resets PIN without a recovery code.
- Self-delete:
userDeleteAccountfrom/account-and-security#status(PIN-confirmed).
It removes the user's row + every related row in the related tables + every encrypted file from uploads/vault/<uid>/.
License lockout
When the Medel Platforms license verification fails and the grace window has passed, license_enforce_or_die() in config.php replaces every non-recovery route with a "License required" lockout page. See License & updates for the cache and grace logic.