Skip to content
Return to Projects
Case Study

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

Python 3.12PySide6AES-256-GCMCryptographyPyInstaller

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

Interface
PySide6 Desktop UI
Application
Vault Manager
Honey Vault
Freeze Mode
Cryptography
AES-256-GCM
Key Derivation
Ice Crystal Fingerprints
Storage
Encrypted local vault file

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.

  1. 1Add an automated integrity self-test that runs on every launch and surfaces tampering visibly to the user.
  2. 2Move key derivation parameters to a versioned header so future Argon2 tuning doesn't break old vaults.
  3. 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

PurposeAlgorithmRationale
Master password hashingArgon2idOWASP #1 recommendation; memory-hard → defeats GPU/ASIC brute force
Vault key derivationPBKDF2-HMAC-SHA256Deterministic from master password; no stored key
Credential encryptionAES-256-GCMAEAD — provides both confidentiality and integrity in one pass
Random generationos.urandom() / secretsCryptographically secure RNG backed by OS kernel
Password generationrandom.SystemRandomUses 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

SaltLengthStoragePurpose
argon2_salt32 bytesmaster.argon2_salt (hex)Argon2id input
kdf_salt32 bytesmaster.kdf_salt (hex)PBKDF2 input
Per-entry nonce12 bytesPrepended to each blobAES-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.

~ End of Document ~