This document describes the cryptographic design, key lifecycle, threat model, and known limitations of Vault.
Vault uses Argon2id (via the argon2 crate) to derive a 256-bit encryption key from the user's master password and a random salt.
| Property | Value |
|---|---|
| Algorithm | Argon2id (hybrid of Argon2i and Argon2d), version 0x13 |
| Output length | 32 bytes (256 bits) |
| Salt length | 16 bytes (128 bits), generated by OsRng |
| Parameters | 128 MiB, 3 passes, 1 lane (KdfParams::DEFAULT): about 0.3 s per attempt on a recent laptop |
Argon2id is memory-hard, meaning brute-force attacks are significantly more expensive than with bcrypt or PBKDF2. The salt is unique per vault, and both the salt and the parameters are stored in the vault's header, so the default cost can be raised later without breaking existing vaults.
New master passwords (create, passwd) must have at least 8 characters, counted as characters rather than bytes. There are no composition rules (digits, symbols), following NIST SP 800-63B: what slows down an offline guess is the length, multiplied by the cost of each attempt. Vaults whose password predates the rule still unlock, and passwd can lengthen it.
Vaults written before format v1 used the argon2 crate's defaults at the time (19 MiB, 2 passes, about 10× cheaper to attack). When such a vault, or any vault keyed below the current cost, is unlocked, it is rewritten right away with a fresh salt and a key at the current cost. Costs are never lowered this way.
Vault encrypts the serialized entries map using AES-256-GCM (via the aes-gcm crate).
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM (AEAD) |
| Key length | 256 bits |
| Nonce length | 96 bits (12 bytes) |
| Nonce source | OsRng — fresh random nonce on every commit |
| Authentication tag | 128 bits (GCM default) |
The AEAD tag provides integrity and authenticity guarantees: any tampering with the ciphertext or nonce will cause decryption to fail deterministically. A failed decrypt is treated as an invalid password (the two cases are indistinguishable from the ciphertext alone).
A fresh random nonce is drawn from OsRng on every commit. Reusing a nonce under the same key would be catastrophic for AES-GCM: the XOR of the two plaintexts leaks, and so does the GHASH key, which lets anyone forge tags. With random 96-bit nonces, the chance that any two of n commits share one is about n²/2⁹⁷: around 2⁻⁵⁷ after a million commits. NIST SP 800-38D allows up to 2³² encryptions per key this way; a personal vault stays orders of magnitude below that, and passwd starts a new key anyway.
gen draws every character (or word) with random_below: OsRng, with rejection sampling, so no character is more likely than another. A password picks from 70 characters (62 without symbols, 56 without look-alikes too); a passphrase picks words from the EFF large wordlist (7776 words, about 12.9 bits each). The strength shown is that count in bits: length × log2(characters), or words × log2(7776).
create/unlock
│
▼ Argon2id(password, salt) → key: [u8; 32]
│ derived in place into AesGcmCrypto.key
│
├──► commit: key used in encrypt(), nonce refreshed each time
│
├──► lock: reset() overwrites the key with 0x00 in place;
│ entries wipe themselves as the map is cleared (ZeroizeOnDrop)
│
└──► normal exit (exit, Ctrl-D, an error): ZeroizeOnDrop overwrites the
key and the entries. A killed process (SIGKILL, a crash) wipes nothing.
- The raw key never leaves the process — it is not written to disk, logged, or transmitted.
AesGcmCryptoderivesZeroizeOnDrop— the key is overwritten when the struct is dropped.reset()overwrites the key in place without dropping the struct. It runs onlockand whenunlockfails, so no key (right or wrong) stays in memory. The key lives in a plain array rather than anOption, becauseOption::take()would copy it out and leave the original bytes behind.- Entries wipe themselves when dropped (
ZeroizeOnDroponEntryand its previous passwords), wherever that happens:lock,rm,discard, a failed import, or leaving the program without locking. That includes the copy of the last commit kept forstatusanddiscard, whichlockdrops with the rest. TheirDebugoutput leaves the password, the notes and the history out, so a stray{:?}in a panic message prints no secret. - Password history.
updatemoves the replaced password into the entry's history (inside the encrypted vault) instead of dropping it; past the last 10, the oldest is dropped, which wipes it. Old passwords stay in the vault until then, or until the entry is removed. - Typed secrets (passwords at every prompt, notes, generated passwords) are held in
Zeroizing<String>, wiped on every path out, including a later prompt failing. - Argon2's working memory (128 MiB) holds values derived from the password, and its last block alone rebuilds the key. The adapter supplies that memory itself and wipes it after each derivation; with the
argon2crate'szeroizefeature, the intermediate hashes are wiped too. - AES round keys contain the key itself. A cipher lives for one encrypt or decrypt, and the
aescrate'szeroizefeature wipes its round keys when it is dropped. - Known gaps, in code this project doesn't control:
- The GHASH key (AES of a zero block under the vault key) is not wiped when a cipher is dropped:
polyval0.6 picks its backend at run time and that wrapper never runs the backends' zeroizingDrop. It doesn't reveal the key, but together with the vault file it would let someone alter the file without the tag noticing. Getting it takes reading this process's memory. rpasswordreads through a buffer it doesn't wipe,rustylinekeeps the line being edited (the notes prompt) in its own buffer, the clipboard holds a copied password for up to 30 s, and thecsvcrate buffers rows duringexportandimport.
- The GHASH key (AES of a zero block under the vault key) is not wiped when a cipher is dropped:
The format is defined byte by byte in src/domain/format.rs, with no serialization library in between. Formats v1 to v3 share this layout; v3 is written by this version:
| Bytes | Field |
|---|---|
| 6 | magic VAULT\0 |
| 2 | format version, u16 little-endian |
| 1 | key derivation: 1 = Argon2id |
| 4 + 4 + 4 | Argon2 memory (KiB), passes, lanes, u32 little-endian |
| 16 | salt |
| 12 | AES-GCM nonce |
| rest | AES-256-GCM ciphertext, ending in the 16-byte tag |
The salt, nonce and parameters are stored in plaintext. This is standard and safe: none of them is secret.
- The header is authenticated. Everything before the nonce is passed to AES-GCM as associated data, so changing any header byte (version, cost, salt) makes decryption fail, exactly like a wrong password.
- Costs are bounded before use. The header can only be verified after the key is derived, so a file asking for more than 1 GiB of memory, 64 passes or 16 lanes is rejected as damaged before any derivation. A hostile file can't make unlock allocate gigabytes.
- No size limit, no lying lengths. The plaintext (entry count, then each entry's strings and timestamps; notes exist from v2 on, the password history from v3 on) is decoded by checking every length against the bytes actually present. A vault of any size reopens, and a corrupted length fails without reserving memory for it.
- Version 0 (before the format had a version): salt, nonce, a u64 ciphertext length and the ciphertext, with no associated data. These files, like v1 and v2 files, are read and rewritten in the current format on unlock (see Key Derivation).
- A file from a newer version is refused with its version number, rather than misread.
Every commit, the storage adapter:
- Takes
~/.vault/.lock(flock), so commits from different sessions never interleave - Checks that the vault file is still the version this session last loaded or saved (SHA-256); if another session saved in the meantime, the commit is refused and nothing is written
- Writes the current version to
<name>.bkpand the new one to<name>.vault, each atomically: the data goes to a temporary file (mode0600), which isfsynced and renamed over the target, then the directory isfsynced
A crash or power loss at any point leaves each file either in its old state or its new one, never half-written. Since every save creates a fresh file, vaults written by older versions with looser permissions become 0600 on their next commit.
restore reads the backup back as uncommitted changes. After an upgrade the backup is the old file, so it is opened with its own salt and cost, and the vault's key is put back afterwards.
Note: The SHA-256 here only detects changes made by another session. Vault contents are authenticated by the AES-GCM tag.
| Threat | Mitigation |
|---|---|
| Offline brute force on stolen vault file | Argon2id key derivation makes each attempt expensive; new master passwords need at least 8 characters |
| Ciphertext tampering | AES-GCM authentication tag — decryption fails on any modification |
| Header tampering (cost, salt, version) | The header is AES-GCM associated data; costs from a file are bounded before any key is derived |
| Core dumps, and other programs of the same user reading memory | No core dumps (RLIMIT_CORE = 0 on Unix). On Linux the process is non-dumpable (PR_SET_DUMPABLE = 0): a debugger can't attach and /proc/<pid>/mem can't be read without root |
| An unlocked vault left unattended | Locks itself after 5 minutes at the prompt (VAULT_AUTO_LOCK), wiping the key and the entries; with uncommitted changes it only warns, since locking would throw them away |
| Secrets lingering in RAM after lock | ZeroizeOnDrop on the key and on entries, Zeroizing for typed secrets, Argon2 memory and AES round keys wiped (see Key Material Handling for the gaps) |
| Vault file corruption on crash | Atomic replace: temporary file + fsync + rename + directory fsync |
| Lost updates between sessions | A commit is refused if another session saved since this one loaded the vault |
| Other local users reading vaults or exports | ~/.vault is 0700; vaults, backups and CSV exports are 0600; an export never overwrites an existing file |
| Accidental vault overwrite | create and rename refuse a name that already belongs to a vault |
| Threat | Status |
|---|---|
| Keylogger | Not mitigated — OS-level threat |
| Clipboard snooping | Partly: a copy is marked as a secret (x-kde-passwordManagerHint), which most Linux clipboard managers leave out of their history, and cleared after 30 s or when vault exits, unless something else was copied since. While it is there, any program can read it |
| Root/kernel-level memory access | Not mitigated — memory zeroization is best-effort in most OS environments |
| Side-channel attacks (timing, cache) | Not specifically hardened |
| Swap / hibernation leaking secrets | Not mitigated — use encrypted swap / disable hibernation |
.bkp file left on disk |
Backup is not deleted after a successful write — it holds the previous vault version. After passwd it is rewritten under the new password, so the old password no longer opens anything |
| Crate | Purpose |
|---|---|
aes-gcm |
AES-256-GCM AEAD encryption |
aes |
Not used directly: listed to turn on its zeroize feature (AES round keys wiped on drop) |
argon2 |
Argon2id key derivation |
zeroize |
Memory zeroization of secrets |
rpassword |
Password input without terminal echo |
sha2 |
SHA-256 of the vault file, to detect saves by another session |
rustyline |
Line editing, history, tab-completion |
dialoguer |
Interactive selector for find (arrow keys, fuzzy filter) |
anyhow |
Error propagation in the CLI layer |
thiserror |
Typed error definitions |
chrono |
Timestamps on entries |
csv |
export and import |
arboard |
Clipboard for copy, text only (default features off: they add image support) |
libc |
No core dumps, no same-user debugging (Unix) |