FrostVault
A zero-knowledge Windows password manager with deception built in.
The Problem
Mainstream password managers are cloud-first and ask you to trust a third party with your most sensitive secrets. FrostVault keeps everything local and encrypted — and fights back when someone tries to break in.
My Role
Built solo with AI-assisted development — cryptographic design, application logic, the PySide6 UI, and Windows packaging.
Highlights
- AES-256-GCM authenticated encryption for all vault data at rest
- Honey Vault serves believable decoy credentials under a wrong master key or duress
- Freeze Mode and Ice Crystal Fingerprints provide tamper detection
- Ships as a single standalone Windows executable — no installer, no cloud
Stack
Constraints
- Offline-first: vault data must never leave the user's machine.
- Single-binary distribution — no installer, no runtime to ship.
- Threat model includes coerced access, not just remote attackers.
System Architecture
Key Trade-offs
The decisions worth defending — what I chose, what I turned down, and why.
Storage architecture
Chose
Single encrypted local file
Rejected
Cloud-synced vault
Removes the third-party trust dependency entirely; matches the offline-first threat model at the cost of cross-device sync.
Wrong-password behaviour
Chose
Honey Vault returns plausible decoys
Rejected
Hard fail with a clear error
A loud failure tells an attacker they have the wrong key. A believable decoy buys time and frustrates duress scenarios.
Crypto stack
Chose
AES-256-GCM via the `cryptography` library
Rejected
Custom-built primitives
Authenticated encryption out of the box, audited implementation, zero novel crypto risk.
What I'd Do Differently
An honest retrospective — the stuff I'd change with more time, more users, or a second pass.
- 1Add an automated integrity self-test that runs on every launch and surfaces tampering visibly to the user.
- 2Move key derivation parameters to a versioned header so future Argon2 tuning doesn't break old vaults.
- 3Ship a portable Linux build — Windows-only narrows the audience more than I expected.
Technical Deep-Dive
Architecture, specifications, and implementation details.
Cryptographic Specification
FrostVault v1.0.0 — IceLegends
#1. Algorithm Selection Rationale
| Purpose | Algorithm | Rationale |
|---|---|---|
| Master password hashing | Argon2id | OWASP #1 recommendation; memory-hard → defeats GPU/ASIC brute force |
| Vault key derivation | PBKDF2-HMAC-SHA256 | Deterministic from master password; no stored key |
| Credential encryption | AES-256-GCM | AEAD — provides both confidentiality and integrity in one pass |
| Random generation | os.urandom() / secrets | Cryptographically secure RNG backed by OS kernel |
| Password generation | random.SystemRandom | Uses os.urandom() internally |
#2. Argon2id Parameters
# core/crypto.py
ARGON2_TIME_COST = 3 # iterations
ARGON2_MEMORY_COST = 65536 # KB (64 MB)
ARGON2_PARALLELISM = 1 # threads
ARGON2_HASH_LEN = 32 # bytes (256-bit output)
ARGON2_SALT_LEN = 32 # bytes (256-bit salt)
##Why These Values
- Memory 64 MB: Forces attacker to use 64 MB RAM per guess — makes parallel GPU attacks 16–32× more expensive than non-memory-hard algorithms
- Time cost 3: ~200 ms on a modern laptop; imperceptible to user, expensive for brute-force
- Argon2id variant: Hybrid of Argon2i (side-channel resistant) and Argon2d (GPU-resistant)
##OWASP Compliance
These parameters meet or exceed OWASP Password Storage Cheat Sheet minimum recommendations for Argon2id (m≥19456, t≥2).
#3. PBKDF2 Key Derivation
# core/crypto.py — derive_key()
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
PBKDF2_ITERATIONS = 100_000
KEY_LENGTH = 32 # bytes → 256-bit AES key
kdf = PBKDF2HMAC(
algorithm = hashes.SHA256(),
length = KEY_LENGTH,
salt = vault_salt, # 32 bytes from master table
iterations = PBKDF2_ITERATIONS,
)
derived_key = kdf.derive(master_password.encode('utf-8'))
Note: The Argon2 hash and the PBKDF2 salt are separate values stored in the master table. Argon2 is used exclusively for password verification; PBKDF2 produces the encryption key.
#4. AES-256-GCM Encryption
# core/crypto.py — encrypt() / decrypt()
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
NONCE_LEN = 12 # bytes (96-bit) — NIST recommended for GCM
def encrypt(plaintext: str, key: bytes) -> bytes:
nonce = os.urandom(NONCE_LEN)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
return nonce + ciphertext # 12-byte nonce prepended
def decrypt(blob: bytes, key: bytes) -> str:
nonce = blob[:NONCE_LEN]
ciphertext = blob[NONCE_LEN:]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode('utf-8')
##Stored Blob Layout
┌────────────────────────────────────────┐
│ Nonce (12 bytes) │
│ Ciphertext (variable length) │
│ GCM Auth Tag (16 bytes, appended │
│ by cryptography library) │
└────────────────────────────────────────┘
Total overhead per field: 28 bytes
##Per-Entry, Per-Field Encryption
Each field (title, username, password, url, notes) is encrypted independently with a fresh random nonce. This means:
- Compromise of one field's nonce reveals nothing about others
- Field lengths are not correlated across entries
- Modification of any field is detected by GCM tag verification
#5. Salt Management
| Salt | Length | Storage | Purpose |
|---|---|---|---|
argon2_salt | 32 bytes | master.argon2_salt (hex) | Argon2id input |
kdf_salt | 32 bytes | master.kdf_salt (hex) | PBKDF2 input |
| Per-entry nonce | 12 bytes | Prepended to each blob | AES-GCM nonce |
All salts and nonces are generated via os.urandom() at creation time and never reused.
#6. Vault Setup Flow
First launch (no vault exists):
1. User enters master password
2. argon2_salt ← os.urandom(32)
3. kdf_salt ← os.urandom(32)
4. argon2_hash ← argon2id.hash(password, argon2_salt)
5. INSERT INTO master (argon2_hash, argon2_salt, kdf_salt)
6. derive_key(password, kdf_salt) → vault._key
7. Vault is now UNLOCKED
#7. Unlock Flow
Subsequent launches:
1. User enters master password
2. SELECT argon2_hash, argon2_salt, kdf_salt FROM master
3. argon2id.verify(argon2_hash, password) → True/False
└─ False → increment fail count, check honey threshold
└─ True →
4. derive_key(password, kdf_salt) → vault._key
5. Vault is now UNLOCKED
#8. Nonce Collision Probability
With 12-byte (96-bit) random nonces and AES-256-GCM:
P(collision) after n encryptions ≈ n² / 2^97
For n = 1,000,000 entries:
P ≈ (10^6)² / 2^97 ≈ 6.3 × 10^-19
Effectively zero for any realistic vault size.
#9. Password Strength Scoring
# utils/password_strength.py
import zxcvbn
def score(password: str) -> dict:
result = zxcvbn.zxcvbn(password)
return {
"score": result["score"], # 0–4
"label": LABELS[result["score"]], # Weak/Fair/Good/Strong/Excellent
"color": COLORS[result["score"]], # Hex color for UI
"crack_time": result["crack_times_display"]
["offline_slow_hashing_1e4_per_second"],
}
The offline_slow_hashing_1e4_per_second scenario assumes Argon2id at 10,000 guesses/second — the most relevant threat model for this application.