Skip to content

Security: vortex2jm/vault

Security

docs/SECURITY.md

Security Model

This document describes the cryptographic design, key lifecycle, threat model, and known limitations of Vault.


Cryptographic Primitives

Key Derivation — Argon2id

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.

Symmetric Encryption — AES-256-GCM

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).

Nonce Reuse Prevention

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.

Generated Passwords

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).


Key Lifecycle

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.

Key Material Handling

  • The raw key never leaves the process — it is not written to disk, logged, or transmitted.
  • AesGcmCrypto derives ZeroizeOnDrop — the key is overwritten when the struct is dropped.
  • reset() overwrites the key in place without dropping the struct. It runs on lock and when unlock fails, so no key (right or wrong) stays in memory. The key lives in a plain array rather than an Option, because Option::take() would copy it out and leave the original bytes behind.
  • Entries wipe themselves when dropped (ZeroizeOnDrop on Entry and 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 for status and discard, which lock drops with the rest. Their Debug output leaves the password, the notes and the history out, so a stray {:?} in a panic message prints no secret.
  • Password history. update moves 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 argon2 crate's zeroize feature, the intermediate hashes are wiped too.
  • AES round keys contain the key itself. A cipher lives for one encrypt or decrypt, and the aes crate's zeroize feature 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: polyval 0.6 picks its backend at run time and that wrapper never runs the backends' zeroizing Drop. 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.
    • rpassword reads through a buffer it doesn't wipe, rustyline keeps the line being edited (the notes prompt) in its own buffer, the clipboard holds a copied password for up to 30 s, and the csv crate buffers rows during export and import.

On-Disk Format

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.

Backup & Integrity

Every commit, the storage adapter:

  1. Takes ~/.vault/.lock (flock), so commits from different sessions never interleave
  2. 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
  3. Writes the current version to <name>.bkp and the new one to <name>.vault, each atomically: the data goes to a temporary file (mode 0600), which is fsynced and renamed over the target, then the directory is fsynced

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 Model

Protected Against

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

Out of Scope / Limitations

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

Dependencies

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)

There aren't any published security advisories