"Your secrets deserve better than a sticky note. They deserve cryptographic paranoia."
This document is the source of truth for PassFX's architecture. It describes how the system is built, why decisions were made, and what you should never, under any circumstances, break.
PassFX is a production-grade, offline-first, terminal-based password manager built with Python and Textual. It stores credentials locally in an encrypted vault at ~/.passfx/, using Fernet authenticated encryption (AES-128-CBC + HMAC-SHA256) with PBKDF2 key derivation.
- Security over convenience. Every architectural decision prioritizes protecting user secrets.
- Offline only. No network code exists. You cannot hack a port that is not open.
- Zero knowledge. The master password is never stored. Lose it, and your data is mathematically irretrievable.
- Defense in depth. Encryption, file permissions, memory wiping, auto-lock, and rate limiting work together.
- Fail secure. When something goes wrong, the vault locks and sensitive data is cleared.
| Priority | Goal | Implementation |
|---|---|---|
| 1 | Security | Fernet encryption, PBKDF2 (480k iterations), constant-time comparison |
| 2 | Correctness | Atomic writes, file locking, integrity verification |
| 3 | Maintainability | Layered architecture, type hints, comprehensive tests |
| 4 | Performance | Lazy-loaded screens, minimal dependencies |
The order matters. We will gladly sacrifice microseconds for security guarantees.
PassFX follows a strict layered architecture where dependencies flow downward. Upper layers may call lower layers, but lower layers must never import from upper layers.
+-----------------------+
| Entry Points |
| cli.py / __main__.py |
+-----------+-----------+
|
v
+-----------------------+
| Application Layer |
| app.py |
| (PassFXApp class) |
+-----------+-----------+
|
+----------------------+----------------------+
| | |
v v v
+-------------------+ +-------------------+ +-------------------+
| Screens Layer | | Widgets Layer | | UI Layer |
| screens/ | | widgets/ | | ui/ |
| (Textual screens) | | (Textual widgets) | | (Rich styling) |
+--------+----------+ +-------------------+ +-------------------+
|
v
+-------------------+
| Utils Layer |
| utils/ |
| (Pure functions) |
+--------+----------+
|
v
+========================================+
|| SECURITY BOUNDARY ||
+========================================+
|
v
+-------------------+
| Core Layer |
| core/ |
| crypto.py | <-- Encryption, key derivation
| vault.py | <-- Encrypted storage, file locking
| models.py | <-- Credential dataclasses
+--------+----------+
|
v
+-------------------+
| Platform Security |
| platform_security |
| (File permissions)|
+--------+----------+
|
v
+-------------------+
| File System |
| ~/.passfx/ |
+-------------------+
- Entry Points instantiate the application and register signal handlers.
- Application Layer owns the
Vaultinstance and manages screen navigation. - Screens Layer presents UI and calls vault CRUD methods. Screens never touch crypto directly.
- Widgets Layer provides reusable Textual components (terminal widget, modals).
- UI Layer handles Rich styling and branding. It has zero awareness of credentials.
- Utils Layer provides stateless helpers (password generation, strength checking, clipboard).
- Core Layer is the security kernel. It handles encryption, persistence, and data models.
- Platform Security abstracts file permission enforcement across Unix and Windows.
| Forbidden Dependency | Reason |
|---|---|
| Core imports Screens | Core must remain UI-agnostic |
| Crypto imports Vault | Crypto is pure; it encrypts bytes, period |
| Utils imports Screens | Utils must remain stateless |
| Any layer imports from above it | Dependency inversion violation |
If you find yourself tempted to add an upward dependency, stop. You are about to make a mistake that future maintainers will curse you for.
Responsibilities:
- Set process title and terminal title
- Register signal handlers (SIGINT, SIGTERM)
- Instantiate and run
PassFXApp - Guarantee cleanup on all exit paths (normal, signal, exception)
What it must never do:
- Contain business logic
- Access credentials directly
- Catch and swallow exceptions from the app layer
The entry point exists to bootstrap the application and ensure cleanup. That is all.
Responsibilities:
- Own the
Vaultinstance (single source of truth for credential state) - Manage Textual screen stack and navigation
- Provide
unlock_vault()andcreate_vault()high-level APIs - Handle graceful shutdown with vault locking
What it owns:
self.vault: TheVaultinstanceself._unlocked: Boolean tracking vault state- Screen lifecycle management
What it must never do:
- Perform encryption/decryption directly
- Access the filesystem except through
Vault - Expose the master password after vault unlock
PassFX has 11 screens, each focused on a specific credential type or function:
| Screen | Purpose |
|---|---|
login.py |
Master password entry, rate limiting, vault creation |
main_menu.py |
Dashboard with security score and navigation |
passwords.py |
Email/password credential management |
phones.py |
Phone PIN storage |
cards.py |
Credit card management with masked display |
notes.py |
Secure free-form notes |
envs.py |
Environment variable storage |
recovery.py |
2FA recovery code storage |
generator.py |
Password/passphrase/PIN generation |
settings.py |
Configuration and import/export |
help.py |
User documentation |
Responsibilities:
- Render UI using Textual widgets
- Handle user input and key bindings
- Call
VaultCRUD methods throughapp.vault - Display credentials with appropriate masking
What screens must never do:
- Import from
core/crypto.py(exceptLoginScreenfor password validation) - Store credentials outside of
Vault - Log or print credential values
- Bypass the Vault API for persistence
Modules:
terminal.py: Interactive in-app terminal (RichLog + Input)id_card_modal.py: Reusable credential display modal
Responsibilities:
- Provide composable, reusable Textual components
- Handle presentation logic only
What widgets must never do:
- Access the vault directly
- Contain business logic
- Store state beyond their immediate rendering needs
Modules:
styles.py: Rich theme and console configurationmenu.py: Interactive terminal menu using simple-term-menulogo.py: ASCII branding and gradient rendering
Responsibilities:
- Define color schemes and visual styling
- Provide input prompts and formatted output
- Display startup/exit messages
What the UI layer must never do:
- Handle credentials (it does not even know they exist)
- Import from core, screens, or widgets
- Contain application state
The UI layer is purely cosmetic. It could be replaced entirely without affecting security.
Modules:
| Module | Purpose |
|---|---|
generator.py |
Cryptographically secure password/passphrase/PIN generation |
strength.py |
Password analysis using zxcvbn, vault health scoring |
clipboard.py |
Copy with 15-second auto-clear, emergency cleanup |
io.py |
JSON/CSV import/export with path validation |
platform_security.py |
Cross-platform file permission enforcement |
Responsibilities:
- Provide pure, stateless utility functions
- Use
secretsmodule for all randomness (neverrandom) - Document side effects explicitly in docstrings
What utils must never do:
- Maintain module-level state (except singleton console)
- Import from screens, widgets, or app
- Access the vault or crypto systems directly
This is the security kernel. Every line of code here is critical.
Responsibilities:
- PBKDF2-HMAC-SHA256 key derivation (480,000 iterations)
- Fernet encryption/decryption (AES-128-CBC + HMAC-SHA256)
- Salt generation using
os.urandom()(32 bytes) - Password hashing for runtime verification (SHA-256)
- Constant-time password comparison using
secrets.compare_digest() - Best-effort key material wiping
What crypto.py must never do:
- Import from vault.py (dependency flows: vault imports crypto)
- Store the master password
- Log, print, or expose any secret material
- Use the
randommodule for any purpose
Responsibilities:
- CRUD operations for all 6 credential types
- Exclusive file locking (fcntl on Unix, msvcrt on Windows)
- Atomic writes using temp file + fsync + os.replace
- Backup creation before overwrites
- Salt integrity verification (symlink attack detection, hash comparison)
- Activity tracking for auto-lock timeout
What vault.py must never do:
- Implement its own encryption (delegates to CryptoManager)
- Expose decrypted data outside its methods
- Skip file locking or atomic writes
- Store credentials in memory after
lock()is called
Credential Types:
EmailCredential: Email/password pairsPhoneCredential: Phone numbers with PINsCreditCard: Card details with masked displayEnvEntry: Environment variable filesRecoveryEntry: 2FA backup codesNoteEntry: Free-form secure notes
Responsibilities:
- Type-safe dataclasses with full type hints
- Serialization to/from JSON-compatible dicts
- Security-aware
__repr__that redacts sensitive fields - Metadata tracking (id, created_at, updated_at)
What models must never do:
- Contain business logic
- Perform I/O operations
- Access the filesystem or network
Here is exactly how sensitive data flows through the system:
USER INPUT MEMORY DISK
----------- -------- ----
Master Password ---> CryptoManager (key) ---> (never)
|
v
PBKDF2 Derived Key ---> (never)
|
v
Fernet Instance ---> (never)
|
Credential Data ---> Python Objects <---> Encrypted JSON
(from UI form) (in vault._data) (vault.enc)
- User enters master password in
LoginScreen - Rate limiter checks for lockout (
~/.passfx/lockout.json) Vault.unlock()acquires exclusive file lock- Salt loaded from
~/.passfx/salt - Salt file checked for symlinks (attack prevention)
CryptoManagerderives key via PBKDF2 (480k iterations)vault.encis read and decrypted with Fernet- JSON is parsed into credential objects
- Activity timestamp is recorded for auto-lock
- Lock is released; vault is now "unlocked" in memory
- User fills form in screen modal
- Credential object is created with generated ID
vault.add_*()method called- File lock acquired
- Salt integrity verified (hash comparison)
- All credentials serialized to JSON
- JSON encrypted with Fernet
- Backup created (
vault.enc.bak) - Atomic write: temp file -> fsync -> os.replace -> directory fsync
- File permissions set to 0o600
- Lock released
vault.lock()called (explicit or via auto-lock)CryptoManager.wipe()overwrites key material with zerosvault._datacleared (all credential objects removed)vault._cryptoset to Nonevault._cached_salt_hashcleared- Clipboard cleared via
emergency_cleanup()
+------------------------------------------------------------------+
| UNTRUSTED: Outside World |
| - User input (keyboard) |
| - Clipboard (shared with other apps) |
| - File system (other processes can read if permissions wrong) |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| BOUNDARY: Input Validation |
| - Master password strength validation |
| - Path validation (no symlinks, home directory bounds) |
| - Credential data sanitization |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| TRUSTED: Core Security Kernel |
| - CryptoManager (encryption, key derivation) |
| - Vault (atomic persistence, file locking) |
| - platform_security (permission enforcement) |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| PROTECTED: Encrypted Storage |
| - ~/.passfx/vault.enc (0o600) |
| - ~/.passfx/salt (0o600) |
| - ~/.passfx/ directory (0o700) |
+------------------------------------------------------------------+
| Location | Why |
|---|---|
| Log files | Attacker with read access gets credentials |
| Exception messages | Stack traces can be logged or displayed |
__repr__ output |
Debug printing can leak secrets |
| Clipboard (after 15s) | Other apps can read it |
| Process arguments | Visible in ps output |
| Environment variables | Inherited by child processes |
| Disk (plaintext) | The entire point of this application |
Tests are not an afterthought. They are load-bearing walls in the architecture.
tests/
+-- unit/ Maps to: Core Layer (isolated functions)
| +-- core/ 100% coverage required
| +-- utils/ 95% coverage target
|
+-- integration/ Maps to: Cross-layer interactions
| Real encryption, real file I/O
|
+-- security/ Maps to: Threat model validation
| Attack scenarios, invariant checks
|
+-- regression/ Maps to: Security contracts
| Lock-in tests for crypto parameters
|
+-- edge/ Maps to: Failure modes
| Disk full, permissions denied, corruption
|
+-- app/ Maps to: Application lifecycle
+-- cli/ Maps to: Entry point behavior
+-- screens/ Maps to: UI component logic
Security tests enforce invariants that code review alone cannot guarantee:
-
test_pbkdf2_iterations_meet_owasp_minimum: If someone changesPBKDF2_ITERATIONSto 1000 "for faster tests," this test fails. The 480,000 value is a security contract. -
test_password_not_logged_on_crypto_init: Captures all log output and asserts passwords are absent. You cannot accidentally add a debug statement. -
test_salt_symlink_detected_on_unlock: Creates a symlink attack scenario and verifies it is blocked. The threat model is executable.
Regression tests prevent security downgrades:
Tests in tests/regression/ use exact value assertions. PBKDF2 iterations must be exactly 480,000, not "at least" 480,000. This ensures any change requires explicit, conscious modification of the test.
| Module | Required Coverage | Rationale |
|---|---|---|
core/crypto.py |
100% | One untested path = potential data loss or breach |
core/vault.py |
100% | Persistence bugs corrupt user data |
core/models.py |
95% | Data integrity is critical |
utils/generator.py |
95% | Weak passwords = weak security |
| Overall | 90% minimum | Defense in depth |
CryptoManager.derive_key()CryptoManager.encrypt()/decrypt()generate_salt()secrets.compare_digest()
Mocking these defeats the purpose of testing. Security tests must use real cryptographic operations.
When something goes wrong, PassFX fails closed:
| Failure | Response |
|---|---|
| Decryption error | Vault locks, memory cleared |
| File permission error | Operation aborted, error surfaced |
| Disk full | Atomic write fails, original vault preserved |
| Corrupted vault | VaultCorruptedError raised, vault remains locked |
| Salt integrity violation | SaltIntegrityError, vault locks immediately |
| Signal received (SIGINT/SIGTERM) | Emergency cleanup, vault locks, clipboard cleared |
Every vault write follows this pattern:
- Write to temporary file in vault directory
fsync()the file (ensure kernel buffer flushed)- Set permissions to 0o600
os.replace()(atomic rename, POSIX guarantee)fsync()the directory (ensure rename persisted)
If power is lost at any step before step 4, the original vault remains intact. There is no window where data is partially written.
Before every write, the current vault is copied to vault.enc.bak. If atomic write fails, the backup is available. Both files have 0o600 permissions.
Concurrent access to the vault is prevented via exclusive file locks:
- Unix:
fcntl.flock(fd, LOCK_EX | LOCK_NB) - Windows:
msvcrt.locking(fd, LK_NBLCK, 1)
Lock acquisition times out after 5 seconds with VaultLockError.
LoginScreen implements persistent rate limiting:
- 3 failed attempts trigger escalating delays
- Maximum lockout: 1 hour
- State persisted in
~/.passfx/lockout.json - File corruption resets to clean state (fail-open for usability)
The UI layer (screens/, widgets/, ui/) is intentionally decoupled from security logic. This enables:
- Testability: Core crypto/vault can be tested without Textual framework
- Portability: Core could be reused in a GUI, web interface, or headless tool
- Auditability: Security review focuses on
core/, not CSS styling
| Protected Property | Why It Cannot Break |
|---|---|
| Encryption strength | UI never touches CryptoManager |
| File permissions | UI goes through Vault, which calls platform_security |
| Credential storage format | Models are in core, not screens |
| Auto-lock behavior | Vault manages timeouts internally |
- Change colors, fonts, and layout
- Add new screens (following existing patterns)
- Modify key bindings
- Add visual flourishes (gradients, ASCII art)
- Import from
core/crypto.py - Bypass
Vaultfor credential access - Store credentials in widget state
- Disable or modify security features
- Log credential values for "debugging"
If a UI change requires modifying core, the UI change is wrong.
These are the rules that must never be broken. They are enforced by tests, CI, and code review.
| Invariant | Value | Enforcement |
|---|---|---|
| Encryption algorithm | Fernet (AES-128-CBC + HMAC-SHA256) | Regression test |
| Key derivation | PBKDF2-HMAC-SHA256 | Regression test |
| KDF iterations | Exactly 480,000 | Regression test (not >=) |
| Salt length | 32 bytes | Regression test |
| RNG source | secrets module only |
Security test, code audit |
| Serialization | JSON only (no pickle) | Security test |
| File | Permission | Enforcement |
|---|---|---|
~/.passfx/ |
0o700 | Created with umask, verified on access |
vault.enc |
0o600 | Set after every write |
salt |
0o600 | Set on creation |
*.bak |
0o600 | Set on backup creation |
- Master password is never stored (used only for key derivation)
- Derived key is wiped when vault locks
- Credential data is cleared when vault locks
- Clipboard is cleared after 15 seconds or on exit
- Core layer has zero UI dependencies
- Utils layer has zero screen/widget dependencies
- Crypto module never imports Vault
- Vault never imports Screens
- Upward dependencies are forbidden
Vault.unlock()is the only way to access credentialsVault.lock()always clears sensitive data- All credential access updates
_last_activitytimestamp - All writes use atomic file operations
Welcome, future maintainer. Here are the mistakes that seem reasonable at 2 AM but will cause pain.
Temptation: "I need to access a credential from the UI layer. I will just import CryptoManager directly."
Reality: You have now bypassed the vault's locking, activity tracking, and file integrity checks. Congratulations, you have created a security hole.
Solution: Always go through app.vault. If the API is missing something, add a method to Vault.
Temptation: "I will just add a print(password) to debug this issue."
Reality: Tests will fail. The security suite captures logs and asserts secrets are absent. Even if you remove it before committing, you have trained your fingers to type dangerous patterns.
Solution: Use breakpoints. Use logging for events, never for values. If you must see a value, use a debugger with explicit acknowledgment.
Temptation: "PBKDF2 is slow. I will reduce iterations to 100 for tests."
Reality: The regression test will fail. Even if you change the test, you have now made it possible for production to ship with weak parameters.
Solution: Use pytest markers to skip slow tests during development. Run the full suite before committing.
Temptation: "I need access to the vault from a utility function. I will add a global reference."
Reality: You have created hidden state. Tests will interfere with each other. The vault lifecycle becomes unpredictable.
Solution: Pass dependencies explicitly. If a utility needs vault access, it is not a utility. Move it to the app or screen layer.
Temptation: "Users forget passwords. I will add a recovery mechanism."
Reality: Any recovery mechanism either stores the password (forbidden) or weakens the encryption (forbidden). The zero-knowledge design is intentional.
Solution: Document the importance of password backup. Recommend users store their master password in a physical location.
Temptation: "This library makes password hashing easier."
Reality: Every dependency is an attack surface. The cryptography library is large, well-audited, and stable. Random PyPI packages are not.
Solution: Use cryptography for crypto. Use stdlib where possible. Any new dependency requires security review.
Temptation: "JSON is slow. I will use pickle for internal serialization."
Reality: Pickle enables arbitrary code execution. It is explicitly banned in security tests. An attacker who can write to your vault file can now execute code on deserialization.
Solution: JSON only. Always. Forever.
~/.passfx/
+-- vault.enc Encrypted JSON blob (Fernet)
+-- vault.enc.bak Backup from last write
+-- salt 32-byte random salt for PBKDF2
+-- lockout.json Rate limiting state
+-- .vault_*.tmp Atomic write temp files (cleaned up)
+-- logs/ Log directory (if enabled)
+-- *.log Log files
All files are created with 0o600 permissions. The directory is 0o700.
Exception
+-- CryptoError
| +-- DecryptionError Wrong password or corrupted data
|
+-- VaultError
+-- VaultNotFoundError Vault file does not exist
+-- VaultCorruptedError JSON parse failed, salt missing
+-- VaultLockError Could not acquire file lock
+-- SaltIntegrityError Salt was modified or is a symlink
All exceptions intentionally hide sensitive details. Error messages are user-safe.
Before approving any PR:
- No logging of passwords, PINs, CVVs, or master passwords
- No use of
randommodule for security (onlysecrets) - No new dependencies without security justification
- No pickle usage anywhere
- File permissions set correctly (0o600/0o700)
- Atomic writes for all persistent data
- Sensitive data cleared on lock/exit
- No upward layer dependencies
- Tests exist for new security-relevant code
- No credential storage in UI layer
Document maintained by the PassFX Engineering Team. Last updated: Based on codebase analysis December 2025.