Project: Password Manager Vault
Prerequisites: Lesson 2 (ChaCha20-Poly1305), Lesson 6 (Password-Based KDFs). This project directly applies what you just learned.
What is a password vault?
Every time you create an account on a website, you need a unique, strong password. Nobody can remember 200 random passwords. A password manager solves this:
┌──────────────────────────────────────────────────────────┐
│ The problem: │
│ │
│ github.com → need a password │
│ gmail.com → need a different password │
│ bank.com → need another different password │
│ ... × 200 sites │
│ │
│ Reusing passwords? One breach exposes all accounts. │
│ Writing them down? Paper can be stolen/lost. │
│ │
│ The solution: password manager │
│ │
│ One master password → unlocks a vault │
│ Vault contains all your passwords, encrypted │
│ The vault file is useless without the master password │
└──────────────────────────────────────────────────────────┘
This is how KeePass, 1Password, Bitwarden, and LastPass work. You’re building a simplified version.
What you’re building
A CLI password manager. One file on disk, encrypted with your master password:
# Create a new vault:
cargo run -p tls --bin p3-vault -- init
# Enter master password: ********
# Created vault.enc
# Store a password:
cargo run -p tls --bin p3-vault -- add github
# Enter master password: ********
# Username: alice
# Password: s3cret!@#456
# Saved.
# Retrieve it later:
cargo run -p tls --bin p3-vault -- get github
# Enter master password: ********
# Username: alice
# Password: s3cret!@#456
# List all entries:
cargo run -p tls --bin p3-vault -- list
# Enter master password: ********
# github, gmail, ssh-server
# Wrong password:
cargo run -p tls --bin p3-vault -- list
# Enter master password: wrong-password
# Error: wrong password or corrupted vault
How it works — the big picture
Two lessons combine:
Lesson 6 (Password KDF): Lesson 2 (Encryption):
master password → Argon2 → key key + vault → encrypt → ciphertext
Together:
"correct horse battery staple"
│
▼
┌──────────────────────┐
│ Argon2id │ Slow on purpose (Lesson 6)
│ password + salt → key│ Attacker can't brute-force
└──────────┬───────────┘
│
▼ 32-byte key
┌──────────────────────┐
│ ChaCha20-Poly1305 │ AEAD encryption (Lesson 2)
│ key + nonce + data │ Confidentiality + integrity
│ → ciphertext + tag │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ vault.enc on disk │ Random bytes without the password
│ salt | nonce | cipher│
└──────────────────────┘
The vault file format
The .enc file is a simple binary format:
┌────────────┬────────────┬──────────────────────────────┐
│ Salt │ Nonce │ Encrypted JSON + auth tag │
│ 16 bytes │ 12 bytes │ variable length │
└────────────┴────────────┴──────────────────────────────┘
Salt: random, generated once when vault is created
Used by Argon2 to derive the key
Not secret — just prevents rainbow tables
Nonce: random, generated fresh every time the vault is saved
Used by ChaCha20 for encryption
Ensures re-saving with the same password produces different ciphertext
The JSON inside (after decryption):
{
"entries": {
"github": { "username": "alice", "password": "s3cret!@#456" },
"gmail": { "username": "alice@gmail.com", "password": "Tr0ub4d0r&3" }
}
}
Try it with existing tools first
# See how openssl encrypts a file with a password (uses PBKDF2 internally):
echo '{"github": {"user": "alice", "pass": "s3cret"}}' > vault.json
openssl enc -aes-256-cbc -salt -pbkdf2 -in vault.json -out vault.enc
# enter password
# Decrypt:
openssl enc -aes-256-cbc -d -salt -pbkdf2 -in vault.enc
# enter same password → JSON appears
# Wrong password:
openssl enc -aes-256-cbc -d -salt -pbkdf2 -in vault.enc
# enter wrong password → "bad decrypt" error
# Look at the encrypted file — random bytes:
xxd vault.enc | head -5
rm vault.json vault.enc
That’s what we’re building in Rust — but with Argon2 (stronger than PBKDF2) and ChaCha20-Poly1305 (modern AEAD).
Implementation guide
Step 0: Project setup
touch tls/src/bin/p3-vault.rs
Dependencies (add to tls/Cargo.toml):
argon2 = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rpassword = "7" # for reading passwords without echoing to terminal
CLI skeleton:
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "vault", about = "Encrypted password vault")]
struct Cli {
#[command(subcommand)]
command: Command,
/// Path to the vault file
#[arg(long, default_value = "vault.enc")]
vault: String,
}
#[derive(Subcommand)]
enum Command {
/// Create a new empty vault
Init,
/// Add a new entry
Add { name: String },
/// Retrieve an entry
Get { name: String },
/// List all entry names
List,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Init => todo!(),
Command::Add { name } => todo!(),
Command::Get { name } => todo!(),
Command::List => todo!(),
}
}
cargo run -p tls --bin p3-vault -- --help
Step 1: Read password from terminal (without echoing)
When you type a password, it shouldn’t appear on screen:
#![allow(unused)]
fn main() {
fn ask_password(prompt: &str) -> String {
rpassword::prompt_password(prompt).unwrap()
}
fn ask_input(prompt: &str) -> String {
eprint!("{prompt}");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
}
Test it:
fn main() {
let password = ask_password("Master password: ");
println!("You typed {} characters (not shown)", password.len());
}
cargo run -p tls --bin p3-vault -- init
# Master password: (you type, nothing appears)
# You typed 12 characters (not shown)
Step 2: Derive encryption key from password
This is Lesson 6 in action:
#![allow(unused)]
fn main() {
use argon2::Argon2;
fn derive_key(password: &[u8], salt: &[u8; 16]) -> [u8; 32] {
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password, salt, &mut key)
.unwrap();
key
}
}
Test it:
fn main() {
let salt: [u8; 16] = rand::random();
let key = derive_key(b"my-password", &salt);
println!("Salt: {}", hex::encode(salt));
println!("Key: {}", hex::encode(key));
// Same password + same salt = same key (deterministic):
let key2 = derive_key(b"my-password", &salt);
assert_eq!(key, key2);
println!("Deterministic: ✓");
// Different password = different key:
let key3 = derive_key(b"wrong-password", &salt);
assert_ne!(key, key3);
println!("Different password = different key: ✓");
}
Step 3: Encrypt and decrypt the vault
This is Lesson 2 in action:
#![allow(unused)]
fn main() {
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Aead, Nonce, Key};
fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> ([u8; 12], Vec<u8>) {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce_bytes: [u8; 12] = rand::random();
let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce_bytes), plaintext)
.expect("encryption failed");
(nonce_bytes, ciphertext)
}
fn decrypt(key: &[u8; 32], nonce: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>, String> {
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
cipher.decrypt(Nonce::from_slice(nonce), ciphertext)
.map_err(|_| "Wrong password or corrupted vault".to_string())
}
}
Key insight: when the password is wrong, Argon2 produces a different key, and ChaCha20’s AEAD tag verification fails. The user sees “wrong password” — not garbled data. AEAD gives us this for free.
Step 4: Save and load the vault file
#![allow(unused)]
fn main() {
fn save_vault(path: &str, salt: &[u8; 16], nonce: &[u8; 12], ciphertext: &[u8]) {
let mut file_data = Vec::with_capacity(16 + 12 + ciphertext.len());
file_data.extend_from_slice(salt); // bytes 0-15
file_data.extend_from_slice(nonce); // bytes 16-27
file_data.extend_from_slice(ciphertext); // bytes 28+
std::fs::write(path, &file_data).unwrap();
}
fn load_vault(path: &str) -> ([u8; 16], [u8; 12], Vec<u8>) {
let data = std::fs::read(path).expect("Can't read vault file");
assert!(data.len() >= 28, "Vault file too small — corrupted?");
let salt: [u8; 16] = data[..16].try_into().unwrap();
let nonce: [u8; 12] = data[16..28].try_into().unwrap();
let ciphertext = data[28..].to_vec();
(salt, nonce, ciphertext)
}
}
Test the round-trip:
cargo run -p tls --bin p3-vault -- init
# Master password: ********
# Created vault.enc
ls -la vault.enc
# 44 bytes (16 salt + 12 nonce + 2 JSON "{}" + 16 auth tag = 46... close)
xxd vault.enc | head -3
# Random-looking bytes — vault is encrypted
Step 5: The vault data structure
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Default)]
struct Vault {
entries: HashMap<String, Entry>,
}
#[derive(Serialize, Deserialize)]
struct Entry {
username: String,
password: String,
}
}
Step 6: Wire it all together
#![allow(unused)]
fn main() {
/// Open an existing vault (ask password, decrypt, parse JSON)
fn open_vault(path: &str) -> (Vault, [u8; 16]) {
let password = ask_password("Master password: ");
let (salt, nonce, ciphertext) = load_vault(path);
let key = derive_key(password.as_bytes(), &salt);
let plaintext = decrypt(&key, &nonce, &ciphertext)
.expect("Wrong password or corrupted vault");
let vault: Vault = serde_json::from_slice(&plaintext).unwrap();
(vault, salt)
}
/// Save vault (re-encrypt with same salt, fresh nonce)
fn save(path: &str, vault: &Vault, salt: &[u8; 16], password: &str) {
let key = derive_key(password.as_bytes(), salt);
let json = serde_json::to_vec(vault).unwrap();
let (nonce, ciphertext) = encrypt(&key, &json);
save_vault(path, salt, &nonce, &ciphertext);
}
}
Now implement each command:
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Init => {
let password = ask_password("Master password: ");
let confirm = ask_password("Confirm password: ");
if password != confirm {
eprintln!("Passwords don't match!");
return;
}
let salt: [u8; 16] = rand::random();
let vault = Vault::default();
save(&cli.vault, &vault, &salt, &password);
println!("Created {}", cli.vault);
}
Command::Add { name } => {
let password = ask_password("Master password: ");
let (mut vault, salt) = open_vault(&cli.vault);
let username = ask_input("Username: ");
let entry_password = ask_input("Password: ");
vault.entries.insert(name.clone(), Entry { username, password: entry_password });
save(&cli.vault, &vault, &salt, &password);
println!("Saved entry: {name}");
}
Command::Get { name } => {
let (vault, _) = open_vault(&cli.vault);
match vault.entries.get(&name) {
Some(entry) => {
println!("Username: {}", entry.username);
println!("Password: {}", entry.password);
}
None => eprintln!("Entry '{name}' not found"),
}
}
Command::List => {
let (vault, _) = open_vault(&cli.vault);
for name in vault.entries.keys() {
println!("{name}");
}
}
}
}
Step 7: Test it end-to-end
# Create vault:
cargo run -p tls --bin p3-vault -- init
# Master password: test123
# Confirm password: test123
# Created vault.enc
# Add entries:
cargo run -p tls --bin p3-vault -- add github
# Master password: test123
# Username: alice
# Password: gh-s3cret!
cargo run -p tls --bin p3-vault -- add gmail
# Master password: test123
# Username: alice@gmail.com
# Password: gm-p@ssw0rd
# List:
cargo run -p tls --bin p3-vault -- list
# Master password: test123
# github
# gmail
# Get:
cargo run -p tls --bin p3-vault -- get github
# Master password: test123
# Username: alice
# Password: gh-s3cret!
# Wrong password:
cargo run -p tls --bin p3-vault -- list
# Master password: wrongpassword
# Error: Wrong password or corrupted vault
# Inspect the file — just random bytes:
xxd vault.enc | head -5
Security considerations
What this vault gets RIGHT:
✓ Argon2id: brute-forcing the master password is impractical
✓ Random salt: prevents rainbow table attacks
✓ Fresh nonce per save: re-saving doesn't reuse nonces
✓ AEAD tag: wrong password → clean error, not garbled data
✓ AEAD tag: tampered file → clean error, not corrupted data
What a REAL password manager also does:
✗ Zeroize keys in memory after use (zeroize crate)
✗ Lock the vault after a timeout
✗ Clipboard integration (copy password, auto-clear after 30s)
✗ Browser extension for auto-fill
✗ Sync across devices (Bitwarden uses a server, KeePass uses file sync)
✗ Backup/recovery (what if you forget the master password?)
Exercises
Exercise 1: Basic vault
Implement all the steps above. Test with the commands shown in Step 7.
Exercise 2: Password generator
Add a generate command that creates random passwords:
cargo run -p tls --bin p3-vault -- generate --length 20 --symbols
# Generated: k9$mP2@xL5#nQ8wR3&jY
# Or generate and save in one step:
cargo run -p tls --bin p3-vault -- add github --generate --length 16
# Master password: ********
# Username: alice
# Generated password: xK7mN2pQ9rW4tY6a
# Saved.
Exercise 3: Export / import
Add export (decrypt → JSON file) and import (JSON file → encrypted vault):
cargo run -p tls --bin p3-vault -- export --out backup.json
# Master password: ********
# Exported 5 entries to backup.json (PLAINTEXT — delete after use!)
cargo run -p tls --bin p3-vault -- import --in backup.json
# New master password: ********
# Imported 5 entries.
Exercise 4: Zeroize sensitive data
The master password and derived key sit in memory while the program runs. Use the zeroize crate to wipe them immediately after use:
#![allow(unused)]
fn main() {
use zeroize::Zeroize;
let mut password = ask_password("Master password: ");
let mut key = derive_key(password.as_bytes(), &salt);
// ... use key ...
key.zeroize(); // [0, 0, 0, 0, ... 0]
password.zeroize(); // ""
// Memory no longer contains sensitive data
}
Why this matters: if the process crashes or is swapped to disk, the password/key could be recovered from a memory dump.