This file is the map. By the end you should be able to draw the project from memory: which file holds what, how the layers depend on each other, what the file on disk actually contains, and the step-by-step flow of every CLI command.
- The five-file layout (and why)
- Dependency direction β who imports whom
- The vault file format on disk
- Flow:
pv init - Flow:
pv add - Flow:
pv getandpv list - Flow:
pv change-password - Flow:
pv gen(no vault) - Atomic + durable + concurrent-safe writes, drawn out
- Lifecycle of an
UnlockedVault
src/password_manager/
βββ __init__.py package entry β re-exports the public API
βββ __main__.py lets `python -m password_manager` work
βββ constants.py every magic number and fixed string
βββ crypto.py Argon2id + AES-256-GCM primitives
βββ generator.py cryptographically secure password generation
βββ vault.py file format, atomic writes, locking, entry CRUD
βββ main.py CLI commands (Typer): init, add, get, list, β¦
Compared to hash-identifier (one file, 680 lines), this project is split across five source files (~1,400 lines total). The split isn't decoration β each file has a strict reason to exist:
| File | Talks to | Doesn't talk to | Job |
|---|---|---|---|
constants.py |
Nothing | Anything | Single source of truth for numbers, strings, and tunables |
crypto.py |
constants only |
Filesystem, network, CLI | Pure cryptography. Bytes in, bytes out. No I/O. |
generator.py |
constants only |
Filesystem, network, CLI | Random password generation. Pure function, no I/O. |
vault.py |
crypto, constants |
The terminal, command-line | File format, atomic writes, file locking, entry add/get/delete |
main.py |
All of the above | β | Glue layer between user keyboard and the rest of the code |
Why these boundaries matter:
- The crypto file calls no I/O functions. No file reads, no
print, noinput. This means it's trivial to test (just callencrypt(b"hello", key)) and impossible to introduce a "let me just print the key for debugging" bug at the wrong layer. - The vault file knows nothing about the terminal. It raises typed exceptions (
VaultNotFoundError,WrongPasswordError, etc.). The CLI layer catches them and turns them into colored error messages. A future GUI or web frontend could be built onvault.pywithout changing any of it. - The CLI file knows nothing about cryptography. It calls
UnlockedVault.create(...)andvault.add_entry(...)andvault.save(). If we swap Argon2id for something newer next year,main.pydoesn't change.
This is called layered architecture. The lower layers don't import from higher layers. Cryptography is at the bottom; the CLI is at the top.
βββββββββββββββββββββββ
β main.py β the CLI (Typer + Rich)
β (commands, glue) β
ββββββββββββ¬βββββββββββ
β
ββββββββββββββββΌβββββββββββββββ
βΌ βΌ βΌ
βββββββββββββββββ ββββββββββββ ββββββββββββββ
β vault.py β β crypto β β generator β
β (file β β .py β β .py β
β format) β β β β β
βββββββββ¬ββββββββ ββββββ¬ββββββ βββββββ¬βββββββ
β β β
βββββββββ¬ββββββββ΄βββββββ¬ββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββ
β constants.py β no imports of our code
β (numbers + strings) β
βββββββββββββββββββββββββ
Arrows point in the direction of imports. main.py imports from vault.py, crypto.py, generator.py, and constants.py. vault.py imports from crypto.py and constants.py. Nothing imports back the other way. No cycles.
If you ever see an arrow pointing the wrong way (e.g. crypto.py importing from vault.py), that's a code smell β it usually means a piece of logic landed in the wrong layer. The compiler/linter won't stop you, but the design will start to rot.
The vault is a single JSON file. By default it lives at ~/.password-vault/vault.json with file permissions 0600 (owner-only).
Here's what the file looks like roughly (the base64 fields are abbreviated):
{
"version": 1,
"kdf": {
"name": "argon2id",
"salt": "X3lkR1d2hcKLwk0PXfQpPg==",
"time_cost": 3,
"memory_cost": 65536,
"parallelism": 4
},
"cipher": {
"name": "aes-256-gcm",
"nonce": "8tNTPwoq8uTXkpKt",
"ciphertext": "Yk7eEVTSfA9wL...<lots more base64>...kw=="
}
}Two layers of JSON live here, and that's important:
Outer layer (the envelope): plain JSON containing the metadata needed to decrypt the inner layer. Anybody who steals the file can read this β it tells them which KDF and cipher were used, the salt, the nonce. None of this is secret; cryptographic security depends on the key, not on hiding the algorithm.
Inner layer (the ciphertext): when decrypted, this is another JSON document β a dictionary of credential entries:
{
"github": {
"username": "alice",
"password": "hunter2-but-better",
"url": "https://github.com",
"notes": "",
"created_at": "2026-05-13T14:22:10+00:00",
"updated_at": "2026-05-13T14:22:10+00:00"
},
"email": {
"username": "alice@example.com",
"password": "another-secret",
"url": "",
"notes": "personal Fastmail",
"created_at": "2026-05-13T14:30:01+00:00",
"updated_at": "2026-05-14T09:11:42+00:00"
}
}So: JSON envelope wrapping encrypted JSON. Boring, inspectable, portable. Boring is good in security β fewer custom things to get wrong.
Why JSON specifically?
- Human-inspectable. You can
cat vault.jsonand at least confirm the structure. Useful for debugging. - Trivially portable. Every language has a JSON parser. If you ever wanted to write a reader for this format in Rust or Go, you'd have it working in an hour.
- Forward-compatible. The
"version": 1field lets future versions know how to read today's vaults β and lets us refuse to read vaults from a future version we don't understand yet.
Why base64?
JSON has no way to represent raw bytes. The standard fix is base64: a way to write any binary data as a string of printable ASCII characters (A-Z, a-z, 0-9, +, /, =). It bloats the data by ~33% but lets us round-trip bytes through JSON cleanly. Salts, nonces, and ciphertexts are all stored base64-encoded.
This creates a brand-new empty vault. Trace through what happens step-by-step:
user types: `pv init`
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β main.init() β
β - parse --vault flag (or env, or default path) β
β - exists check: refuse if vault.json exists β
β - prompt for master password (twice, confirm) β
β - validate: non-empty, >= 8 chars, matches β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β UnlockedVault.create(path, master) β
β - generate fresh 16-byte salt (secrets) β
β - derive 32-byte key from master + salt β
β via Argon2id (~0.5s on modern laptop) β
β - build empty entries dict β
β - call self.save() β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β vault.save() β
β - serialize entries (empty {}) to JSON β
β - generate fresh 12-byte nonce (secrets) β
β - AES-256-GCM encrypt the inner JSON β
β - build outer JSON envelope β
β - atomic write to vault.json.tmp β
β - fsync data β
β - os.replace onto vault.json β
β - fsync parent directory β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
`Vault created at ~/.password-vault/vault.json`
Two key things to notice:
- The salt is generated once, at
create()time, and never changes for the life of the vault. Even afterchange-passwordre-encrypts everything under a new key, the salt itself is regenerated only because the password changed β for a given password, the salt is stable. - The nonce is generated every save, never reused. The slowest path (Argon2id) happens once per session; the second-slowest path (AES-GCM encrypt) happens on every save and uses a fresh nonce each time.
This unlocks the vault, adds an entry, and saves it back. The Argon2id cost is paid once on unlock, then add and save are both fast.
user types: `pv add github`
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β main.add() β
β - prompt for master password β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β UnlockedVault.unlock(path, master) β
β - read vault.json from disk β
β - parse JSON envelope β
β - validate version + algorithm names β
β - validate Argon2 parameters (sanity floors) β
β - extract salt, KDF params, nonce, ciphertext β
β - derive_key(master, salt, params) β slow β
β - AES-256-GCM decrypt(ciphertext, nonce, key) β
β β if auth tag fails: raise β
β WrongPasswordError β CLI exits with msg β
β - parse inner JSON β entries dict β
β - return UnlockedVault(path, salt, params, β
β key, entries) β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β main.add() body, inside `with` block β
β - prompt for username (visible) β
β - if --generate: generate_password(length) β
β else: getpass for password (hidden) β
β - prompt for url, notes (optional) β
β - build Entry(username, password, url, notes, β
β created_at=now, updated_at=now) β
β - vault.add_entry(name, entry, force=...) β
β β if name exists and not force: β
β EntryAlreadyExistsError β CLI exits β
β β if name is empty or has whitespace: β
β ValueError β CLI exits β
β - vault.save() (atomic, durable, locked) β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β end of `with` block β vault.__exit__() β
β - vault.entries = {} β
β - vault.key = bytes(32) (zero-filled) β
β (best-effort wipe; Python bytes are immutable, β
β but we drop the references at minimum) β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
`Added entry: github`
Notice the two failure modes after decryption:
- "Wrong password" gets one error message.
- "Vault file is corrupted" gets the same error message ("Wrong master password (or vault file is corrupted)").
This is on purpose. GCM authentication failure means one of three things and we can't tell which: wrong password, tampered file, corrupted file. From the user's perspective they're indistinguishable, and exposing the difference helps an attacker (who'd know whether their guess was "almost right" vs "definitely wrong key"). We collapse them into one honest message.
Both follow the same pattern: unlock, read, render, close. The vault is unlocked just long enough to grab the data and is dropped immediately after rendering.
pv get github
β
βββΊ prompt master password
β
βββΊ UnlockedVault.unlock(...) (slow once, Argon2id)
β
βββΊ entry = vault.get_entry("github")
β β if not found:
β EntryNotFoundError β CLI exits 1
β
βββΊ console.print(rich.Panel(...)) β colored panel
β
βββΊ end of `with` β wipe key + entries
pv list
β
βββΊ prompt master password
β
βββΊ UnlockedVault.unlock(...)
β
βββΊ names = vault.names() (sorted alphabetically)
β
βββΊ if not names: print "vault is empty" message
β
βββΊ build a rich.Table with one row per entry
β columns: name, username, updated_at
β (passwords are NOT shown in `list`)
β
βββΊ console.print(table)
β
βββΊ end of `with` β wipe key + entries
list shows usernames and update times but not passwords. The user has to explicitly ask for a password with get <name>. This is a small friction that reduces the chance of leaking an entire credential set to anyone watching your terminal.
This is the most cryptographically interesting command. It rotates the master password by:
- Unlocking the vault with the old password (full Argon2id cost).
- Generating a fresh salt and deriving a new key from the new password.
- Saving the vault β
save()will encrypt the existing entries under the new key, with a fresh nonce.
pv change-password
β
βββΊ prompt: "Current master password: "
β
βββΊ UnlockedVault.unlock(path, current_password)
β β if wrong: WrongPasswordError β exit 1
β
βββΊ prompt: "New master password: " (twice, confirm)
β β validate: non-empty, >= 8 chars, matches
β
βββΊ vault.change_master_password(new_password)
β - new_salt = secrets.token_bytes(16)
β - new_key = derive_key(new, new_salt, defaults)
β - self.salt = new_salt
β - self.kdf_parameters = defaults()
β - self.key = new_key
β (only mutates in-memory state β disk untouched)
β
βββΊ vault.save()
β - serializes the SAME entries dict (preserved)
β - generates a NEW nonce
β - encrypts under the NEW key
β - atomic write replaces the old file
β
βββΊ "Master password changed. Vault re-encrypted at <path>"
Why this is interesting: the vault file stores the KDF parameters and salt next to the ciphertext. That's exactly why this operation is possible. If the KDF params lived only in the code, then "change my password" would have no way to also "upgrade my Argon2 parameters from last year's defaults to this year's." Putting them in the file makes the upgrade path possible. The kdf_parameters argument on change_master_password is the hook for it.
Crash safety: if the process dies between mutating the in-memory state and the atomic save completing, the file on disk still has the old salt and old ciphertext β fully readable with the old password. The new key only "wins" after os.replace lands. This is the entire reason atomic writes matter for password managers: a botched rotation must never lock you out.
The simplest command. Doesn't touch the vault at all. Doesn't prompt for the master password. Just generates a strong random password and prints it.
pv gen 32
β
βΌ
generate_password(length=32,
use_lowercase=True,
use_uppercase=True,
use_digits=True,
use_symbols=True)
β
βββΊ length >= MIN (8)?
βββΊ at least one pool enabled?
βββΊ length >= number of enabled pools?
β (need to fit one char from each)
β
βββΊ required = [secrets.choice(pool) for pool in pools]
β one char guaranteed from each enabled pool
β
βββΊ fill = [secrets.choice(combined) for _ in range(length - len(required))]
β
βββΊ chars = required + fill
β
βββΊ _secure_shuffle(chars)
β Fisher-Yates with secrets.randbelow
β (NOT random.shuffle β predictable)
β
βββΊ return "".join(chars)
The output goes to stdout via plain print() (not the rich console), so it's pipe-friendly:
pv gen 32 | pbcopy
PASSWORD=$(pv gen 32)This is the only command that uses print instead of console.print. The reason is exactly piping: we don't want rich's color escape codes inside the password that gets piped to pbcopy.
The save() method does more work than you'd guess. The "just write the file" version of this would be one line; ours is a few dozen. Here's why each piece is there.
# DON'T DO THIS
path.write_bytes(envelope_bytes)This has three problems, all of which we've seen happen in real systems:
- Crash mid-write β corrupt file. If the process dies after writing 4096 bytes of a 6000-byte file, the file is half-written. Next time the user tries to unlock, JSON parsing fails and they think their vault is destroyed.
- Power loss β 0-byte file (or worse). Even if the process completes, the bytes live in the kernel's page cache. The OS will write them to disk eventually, but a power loss between write and disk-write means the file appears to exist but contains nothing.
- Two
pvinstances racing. User runspv add githubin one terminal andpv add emailin another simultaneously. Both unlock the vault (slow), both add their entry, both save. Whichever saves second loses the other's entry β silently. No error.
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. acquire advisory flock on vault.json.lock β
β (POSIX systems β Windows skips this) β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 2. os.open(vault.json.tmp, β¦, mode=0600) β
β file created world-unreadable from the β
β very first syscall (no chmod race) β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 3. os.write(fd, envelope_bytes) β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 4. os.fsync(fd) β
β forces kernel page cache β disk. β
β without this, "we wrote it" is a story β
β the page cache tells; a power loss erases β
β it. β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 5. os.close(fd) β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 6. os.replace(vault.json.tmp, vault.json) β
β atomic rename. after this instant, β
β readers see EITHER the old file OR the β
β new file. never half of either. β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 7. fsync the parent directory (POSIX) β
β so the rename itself survives power loss. β
β without this, an OS crash right after the β
β rename can revert the directory entry. β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β 8. release advisory flock β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Each step plugs one hole:
| Step | Plugs |
|---|---|
| 1, 8 | Two pv processes racing |
| 2 | Brief window where tmp file is world-readable |
| 4 | "Power loss right after write" loses data |
| 6 | "Crash mid-write" corrupts the live file |
| 7 | "Power loss right after rename" reverts |
The advisory lock is interesting: it's not enforced by the OS, only by code that opts in. A process that ignores flock can still write the file. But every save() in our code opts in, so two pv invocations can't race against each other. An external editor (vim, sed) doesn't lock, but a user editing the vault file by hand has already left the tool's contract.
Windows doesn't have fcntl.flock or directory fsync, so we skip both there. NTFS gives us atomic os.replace regardless. The trade-off is: on Windows we lose cross-process serialization (rare edge case for a single-user tool) and we lose the absolute-guarantee directory durability (NTFS journaling covers most cases).
An UnlockedVault is a Python object that holds:
- The path to the vault file on disk.
- The 16-byte salt.
- The Argon2 parameters that were used.
- The 32-byte AES key (sensitive!).
- The decrypted entries (also sensitive! they contain plaintext passwords).
Holding the key in memory means subsequent operations (add, get, delete, save) don't have to re-derive it β they'd otherwise pay the Argon2 cost on every save. But it also means we want a clear "I'm done with this" signal.
Python's with statement (a "context manager") is exactly that signal:
with UnlockedVault.unlock(path, master) as vault:
vault.add_entry("github", entry)
vault.save()
# at this point, vault.__exit__ has been called
# vault.entries is now {}
# vault.key is now b"\x00" * 32__enter__ runs when the block starts. __exit__ runs when the block ends β whether normally or by exception. So even if vault.save() raises, the cleanup still happens.
The cleanup itself (vault.close()) replaces the entries dict with {} and the key with 32 zero bytes. This is a best-effort wipe. Python's bytes objects are immutable, so the original key bytes might still live in memory until the garbage collector runs. True wipe-on-free in Python requires bytearray plus ctypes tricks that this teaching project deliberately avoids β the discipline of "drop secrets explicitly when done" is the more important habit.
ββββββββββββββββββββββββββββββββββββββββββββββ
β UnlockedVault.unlock(path, master) β
β β slow: Argon2id derives 32-byte key β
β β AES-GCM decrypts ciphertext β
β β returns instance: { path, salt, params, β
β key, entries } β
ββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββ
β __enter__ β returns self β
ββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββ
β body of `with` block β
β β get_entry, add_entry, delete_entry β
β β save() (fast: key already in memory, β
β just AES-GCM + atomic write) β
ββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββ
β __exit__ β close() β
β β self.entries = {} β
β β self.key = b"\x00" * 32 β
β (best-effort wipe; Python bytes are β
β immutable, GC may still hold copies) β
ββββββββββββββββββββββββββββββββββββββββββββββ
This is the same pattern Python's open() uses (with open("x.txt") as f:). The vault is just a more security-sensitive resource than a file handle.
You now have the shape of the project in your head: which file does what, what the file on disk looks like, what each command does step-by-step.
03-IMPLEMENTATION.md walks every source file line-by-line. Open crypto.py, vault.py, and main.py in a second window and read along.