The Brief
3 minBuild vault.py, a CLI secrets vault:
init— create a new vault locked by a passphrase.add <name>/get <name>/list/remove <name>— manage secret entries.- Everything encrypted at rest with a key derived from the passphrase (Lesson 14).
- Integrity: a wrong passphrase or tampered file is rejected, never silently misread.
This is a real password manager in miniature — and the principles (derive a key from a master passphrase, encrypt the store, never persist the key) are exactly how tools like Bitwarden and KeePass work. You'll use every idea from Lessons 11-17.
Design
5 minvault.dat layout: [ 16-byte salt ][ Fernet token of the encrypted JSON of all secrets ] Flow: passphrase + salt ──KDF──► key ──► Fernet ──► encrypt/decrypt the JSON blob - salt stored in the file (not secret) - key derived in memory each run (NEVER written to disk) - wrong passphrase → InvalidToken → "wrong passphrase", refuse
The vault file holds the salt and ciphertext — never the key or passphrase. Each operation re-derives the key from the passphrase you type. Lose the passphrase and the data is gone forever (by design). This is the zero-knowledge property of a real password manager.
Build It · The Crypto Core
14 minKey derivation & load/save
import base64, os, json, getpass from pathlib import Path from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes VAULT = Path("vault.dat") ITERATIONS = 480_000 # slow KDF — resists brute force def _derive_key(passphrase: str, salt: bytes) -> bytes: kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=ITERATIONS) return base64.urlsafe_b64encode(kdf.derive(passphrase.encode())) def _load(passphrase: str) -> dict: """Decrypt the vault into a dict of secrets, or raise on wrong passphrase.""" blob = VAULT.read_bytes() salt, token = blob[:16], blob[16:] key = _derive_key(passphrase, salt) try: plaintext = Fernet(key).decrypt(token) # authenticated → tamper-safe except InvalidToken: raise ValueError("wrong passphrase or corrupted vault") return json.loads(plaintext) def _save(secrets: dict, passphrase: str, salt: bytes) -> None: key = _derive_key(passphrase, salt) token = Fernet(key).encrypt(json.dumps(secrets).encode()) VAULT.write_bytes(salt + token) # salt + ciphertext
The salt is generated once at init and stored in the file header; every load/save re-derives the key from the passphrase + that salt. Fernet's authentication means a wrong passphrase (wrong key) or a tampered file raises InvalidToken — we turn that into a clear "wrong passphrase" error rather than ever returning garbage.
Build It · The CLI
12 minWire the crypto core into commands, prompting for the passphrase securely (no echo) with getpass.
import argparse, os, getpass def _ask() -> str: return getpass.getpass("Passphrase: ") # not echoed to the terminal def _salt_of() -> bytes: return VAULT.read_bytes()[:16] def cmd_init(a): if VAULT.exists(): print("vault already exists"); return pw = _ask() if pw != getpass.getpass("Confirm: "): print("passphrases don't match"); return salt = os.urandom(16) _save({}, pw, salt) print("vault created 🔒") def cmd_add(a): pw = _ask() try: secrets = _load(pw) except ValueError as e: print(e); return secrets[a.name] = getpass.getpass(f"Secret for '{a.name}': ") _save(secrets, pw, _salt_of()) print(f"stored '{a.name}'") def cmd_get(a): pw = _ask() try: secrets = _load(pw) except ValueError as e: print(e); return print(secrets.get(a.name, f"no entry named '{a.name}'")) def cmd_list(a): pw = _ask() try: secrets = _load(pw) except ValueError as e: print(e); return print("\n".join(secrets) if secrets else "(empty vault)") def cmd_remove(a): pw = _ask() try: secrets = _load(pw) except ValueError as e: print(e); return if secrets.pop(a.name, None) is not None: _save(secrets, pw, _salt_of()); print(f"removed '{a.name}'") else: print("no such entry") if __name__ == "__main__": p = argparse.ArgumentParser(description="Encrypted secrets vault.") sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("init").set_defaults(func=cmd_init) for name, fn in [("add", cmd_add), ("get", cmd_get), ("remove", cmd_remove)]: sp = sub.add_parser(name); sp.add_argument("name"); sp.set_defaults(func=fn) sub.add_parser("list").set_defaults(func=cmd_list) args = p.parse_args(); args.func(args)
$ python vault.py init Passphrase: ········ Confirm: ········ vault created 🔒 $ python vault.py add github-token Passphrase: ········ Secret for 'github-token': ········ stored 'github-token' $ python vault.py get github-token Passphrase: ········ ghp_xxxxxxxxxxxxxxxx $ python vault.py get github-token Passphrase: ········ (wrong) wrong passphrase or corrupted vault
Read the result
The vault file on disk is pure ciphertext — open it in a text editor and you see random bytes, not your secrets. The passphrase is typed via getpass (never echoed, never stored), the key is derived fresh in memory each run via a slow KDF, and Fernet's authentication guarantees a wrong passphrase or any tampering is rejected cleanly. Every crypto concept from this arc is here: hashing/KDF (11-12), symmetric authenticated encryption (14), and the "protect the key" discipline (14, 16). It's a genuinely useful tool you could keep using.
Build It Yourself
13 minBuild init/add/get and store a couple of secrets. Confirm the right passphrase reveals them and a wrong one is rejected. Open vault.dat in an editor to confirm it's unreadable ciphertext.
Flip one byte of vault.dat with a hex editor (or in Python) and confirm the vault refuses to open even with the correct passphrase — Fernet's integrity check at work.
Hint
b = bytearray(Path("vault.dat").read_bytes()) b[-1] ^= 1; Path("vault.dat").write_bytes(b) # corrupt it # now even the right passphrase → "wrong passphrase or corrupted vault"
Add a change-passphrase command: load with the old passphrase, generate a new salt, and re-encrypt all secrets under a key derived from the new passphrase. This is the "rotate the master password" feature real managers have.
Hint
def cmd_change(a): old = getpass.getpass("Current: ") secrets = _load(old) # raises if wrong new = getpass.getpass("New: ") _save(secrets, new, os.urandom(16)) # fresh salt + new key print("master passphrase changed")
Stretch · Harden the Vault
8 minAdd production touches: (1) a metadata header recording the KDF iterations so you can increase them over time and re-derive correctly; (2) auto-clear a retrieved secret from the clipboard after 20 seconds (if using pyperclip); (3) an entry timestamp and optional notes. Pick at least one and explain the security benefit.
Show the iterations-header approach
import json, os, base64 # store iterations in the header so future increases stay decryptable: # file = [1-byte version][16-byte salt][4-byte iterations][token] import struct def _save_v2(secrets, passphrase, salt, iterations): key = _derive_key_iter(passphrase, salt, iterations) token = Fernet(key).encrypt(json.dumps(secrets).encode()) header = bytes([2]) + salt + struct.pack("!I", iterations) VAULT.write_bytes(header + token) def _load_v2(passphrase): blob = VAULT.read_bytes() salt = blob[1:17] (iterations,) = struct.unpack("!I", blob[17:21]) key = _derive_key_iter(passphrase, salt, iterations) return json.loads(Fernet(key).decrypt(blob[21:])) # Benefit: bump iterations as CPUs get faster; old vaults still open # because the iteration count travels WITH the file.
Non-negotiables: one hardening feature working end-to-end, with the security benefit explained.
Recap
3 minThe vault composes the crypto arc into a real password manager: a passphrase + stored salt feed a slow KDF (11-12) to produce a key, used by Fernet (14) to encrypt the secrets blob at rest — with the key living only in memory, never on disk. Fernet's authentication means a wrong passphrase or any tampering is cleanly rejected (no silent corruption). getpass keeps the passphrase off the screen. Lose the passphrase, lose the data — that's the security guarantee, the same zero-knowledge design real managers use. Hardening (iteration headers, rotation, clipboard clearing) turns it production-grade.
Vocabulary Card
- vault / secrets store
- An encrypted-at-rest store unlocked by a master passphrase.
- master passphrase
- The single secret from which the encryption key is derived.
- encrypted at rest
- Data stored as ciphertext on disk, useless without the key.
- zero-knowledge
- The store never holds the key/passphrase; lose it and data is unrecoverable.
Homework
4 minFinish the vault with init/add/get/list/remove plus one stretch feature (passphrase change, iterations header, or clipboard auto-clear). Test that secrets are unreadable on disk, wrong passphrases and tampering are rejected, and the right passphrase works. Write a short security note: where the key lives, what's stored on disk, and why losing the passphrase means losing the data.
Sample · vault security note
On disk (vault.dat): a 16-byte salt + a Fernet token. The token is authenticated ciphertext — opening the file shows random bytes, no secrets, no key, no passphrase. The key: derived in MEMORY each run from (your typed passphrase + the stored salt) via PBKDF2 at 480k iterations. It is never written anywhere. When the program exits, the key is gone. Lose the passphrase → lose the data: there is no key stored to fall back on and no "reset" — that's the whole point (zero-knowledge). So I keep my master passphrase in a separate trusted place. A wrong passphrase or a tampered file → Fernet's auth check fails → "wrong passphrase or corrupted vault", never silent garbage. Stretch: added change-passphrase (re-encrypts under a new salt+key).
Non-negotiables: full CRUD vault + one stretch feature, verified unreadable-on-disk + tamper/wrong-passphrase rejection, and the key-location/zero-knowledge note.