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.

Data Models

FrostVault v1.0.0 — IceLegends


#1. Database Overview

  • Engine: SQLite 3 (Python stdlib sqlite3)
  • Location: %APPDATA%\FrostVault\vault.db
  • Access: Single-process, WAL journal mode
  • Encryption: Application-layer (ciphertext stored in BLOB columns)

#2. Schema

##2.1 master table

Stores vault identity and cryptographic parameters. Always contains exactly one row.

CREATE TABLE IF NOT EXISTS master (
    id           INTEGER PRIMARY KEY,
    argon2_hash  TEXT    NOT NULL,   -- Argon2id PHC string
    argon2_salt  TEXT    NOT NULL,   -- hex-encoded 32-byte salt
    kdf_salt     TEXT    NOT NULL,   -- hex-encoded 32-byte PBKDF2 salt
    created_at   TEXT    NOT NULL    -- ISO-8601 UTC timestamp
);
ColumnTypeNotes
argon2_hashTEXTFull Argon2id PHC string (includes params + salt)
argon2_saltTEXTSeparate hex salt for Argon2id
kdf_saltTEXTSeparate hex salt for PBKDF2 key derivation
created_atTEXTVault creation timestamp

##2.2 entries table

Stores encrypted credential fields. Each field is independently encrypted.

CREATE TABLE IF NOT EXISTS entries (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    title        BLOB    NOT NULL,   -- AES-256-GCM ciphertext
    username     BLOB    NOT NULL,   -- AES-256-GCM ciphertext
    password     BLOB    NOT NULL,   -- AES-256-GCM ciphertext
    url          BLOB,               -- AES-256-GCM ciphertext (nullable)
    notes        BLOB,               -- AES-256-GCM ciphertext (nullable)
    category     TEXT    NOT NULL DEFAULT 'General',
    created_at   TEXT    NOT NULL,
    updated_at   TEXT    NOT NULL
);

Note: category is stored in plaintext — it is low-sensitivity metadata used only for UI filtering.

##2.3 honey_entries table

Pre-generated fake credentials shown during honey vault mode.

CREATE TABLE IF NOT EXISTS honey_entries (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    title        TEXT    NOT NULL,
    username     TEXT    NOT NULL,
    password     TEXT    NOT NULL,
    url          TEXT,
    category     TEXT    NOT NULL DEFAULT 'General'
);

Note: Honey entries are stored in plaintext — they are intentional decoys and contain no real data.

##2.4 intrusion_log table

Records failed authentication attempts for forensic review.

CREATE TABLE IF NOT EXISTS intrusion_log (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    attempt_count  INTEGER NOT NULL,
    attempted_at   TEXT    NOT NULL   -- ISO-8601 UTC timestamp
);

##2.5 fail_count table

Tracks the current consecutive failure count in memory across sessions.

CREATE TABLE IF NOT EXISTS fail_count (
    id     INTEGER PRIMARY KEY,
    count  INTEGER NOT NULL DEFAULT 0
);

#3. Entity Relationship Diagram

master (1 row)
  │ id, argon2_hash, argon2_salt, kdf_salt, created_at
  │
  │  (conceptual relationship — master key decrypts entries)
  ▼
entries (0..n rows)
  │ id, title(enc), username(enc), password(enc), url(enc), notes(enc), category, timestamps
  │
  │  (independent tables — no FK constraints)

honey_entries (static seed data)
  │ id, title, username, password, url, category

intrusion_log (0..n rows)
  │ id, attempt_count, attempted_at

fail_count (1 row)
    id, count

#4. Ciphertext Blob Layout (per BLOB column)

Byte offset  Length    Content
───────────  ────────  ─────────────────────────────
0            12        GCM nonce (random, per encrypt call)
12           N         AES-256-GCM ciphertext
12+N         16        GCM authentication tag (appended by library)

Total: N + 28 bytes  (where N = len(plaintext_utf8))

#5. Vault Object Model (Python)

# Decrypted entry as returned by vault.list_entries() / vault.get_entry()
entry: dict = {
    "id":         int,
    "title":      str,
    "username":   str,
    "password":   str,
    "url":        str,
    "notes":      str,
    "category":   str,
    "created_at": str,   # ISO-8601
    "updated_at": str,   # ISO-8601
}

Entries exist as plaintext dicts only while the vault is unlocked and only within the calling function's scope. They are never cached.


#6. Database File Location

# core/database.py

import os
db_path = os.path.join(os.environ["APPDATA"], "FrostVault", "vault.db")

The %APPDATA%\FrostVault\ directory is created on first launch if it does not exist. This location is user-writable without elevated privileges and is excluded from the application install directory so that uninstalling FrostVault does not delete the user's vault.


#7. Data Migration

Version 1.0.0 has no migration system. The schema is created fresh on first launch. Future versions should:

  1. Add a schema_version row to the master table
  2. Apply ALTER TABLE migrations in database.py on startup
  3. Backup vault.db before any destructive migration
~ End of Document ~