Design · Cryptography
How the vault stays sealed
Argon2id turns your passphrase into a key. That key unwraps a random long-lived vault key. The vault key opens the encrypted body. Three layers, three purposes, one file.
The primitives
Nothing exotic. Every choice is a widely-reviewed standard from the last decade.
| Role | Algorithm | Parameters |
|---|---|---|
| Passphrase hardening | Argon2id v1.3 |
256 MiB · 3 iterations · 4 lanes (default) |
| Sub-key derivation | HKDF-SHA-256 |
Purpose-bound info tags |
| Symmetric encryption | XChaCha20-Poly1305 |
192-bit random nonce, 128-bit tag |
| Random source | OsRng (platform CSPRNG) |
getrandom(2), SecRandomCopyBytes, etc. |
| Sync-record signing | Ed25519 (via Veilid) |
Authorizes writes; content confidentiality is the AEAD above |
| TOTP MAC | HMAC-SHA-1/256/512 |
RFC 6238; SHA-1 for interop |
The key hierarchy
Your passphrase never gets used directly as a key. It's fed through Argon2id to make guessing slow, then through HKDF to separate purposes, and only then does it unwrap the long-lived key that actually opens the vault.
Why three layers?
Because each layer buys something different.
- Argon2id is expensive on purpose — hundreds of milliseconds to a full second per attempt — so a stolen file can't be brute-forced quickly. The 256 MiB memory cost makes GPU parallelism much less effective than for a plain hash.
- HKDF binds the key to a purpose. If we ever add a second use for the master key (say, a per-device sync token), it gets its own HKDF branch so a leak of one can't derive the other.
- The random vault key lets you change your passphrase without re-encrypting every entry. Only the wrapper gets rebuilt; the body stays as-is.
What's inside the file
+--------+---------------------------------------------------------+
| magic | 4 bytes ASCII: "VPS1" |
+--------+---------------------------------------------------------+
| body | CBOR-encoded VaultFile |
| | format_version : u16 = 1 |
| | wrapped_vault_key : { |
| | kdf_params : { algorithm, mem, iter, lanes } |
| | salt : [u8; 32] (random per vault) |
| | sealed : { nonce: [u8;24], ct: Vec<u8> } |
| | } |
| | sealed_body : { nonce: [u8;24], ct: Vec<u8> } |
+--------+---------------------------------------------------------+
The header carries the salt and KDF parameters in cleartext — that's fine, they're not secrets. What matters is that the parameters are re-validated against a floor on every unlock, so a tampered file can't downgrade Argon2 to something weak.
What actually changes when you change your passphrase
A common intuition is that changing your passphrase must re-encrypt everything. It doesn't need to, and doesn't.
Only the small wrapper at the top of the file — the four items
in wrapped_vault_key — gets rebuilt: a fresh salt,
a fresh master key, a fresh wrap key, and a re-wrapped copy of
the same underlying vault key. The sealed_body
isn't touched. Your entries don't move.
Two consequences worth knowing:
- Sync isn't disrupted. The vault key is unchanged, so any paired device that already has the vault can keep syncing. It uses its own local passphrase and wrapper.
- Historical vault-key leaks aren't cured. If a raw vault key was ever exfiltrated (say, by malware while the app was unlocked), changing your passphrase does not invalidate that leak. The right response is a vault-key rotation — a supported primitive without a UI trigger yet.
Randomness
Every random value — salts, nonces, the vault key itself, the
words in a pairing token — comes from the platform's
cryptographic RNG: getrandom(2) on Linux,
SecRandomCopyBytes on macOS/iOS,
BCryptGenRandom on Windows, and the same
transitively on Android. There is no fallback path; if the OS
CSPRNG fails, the operation fails.
What we deliberately don't do
No backdoor, no escrow. There is no recovery key we hold, no support ticket that can decrypt your vault. Lose the passphrase, lose the vault.
No post-quantum primitives yet. Argon2 and XChaCha20-Poly1305 remain secure under Grover-search at reduced (still large) effective strength. Ed25519 in the sync path does not; PQ migration is on the roadmap.
No forward secrecy on the DHT. Every push overwrites the previous ciphertext under the same key. A passive observer who collects snapshots today can decrypt all of them if the vault key ever leaks.
No plausible-deniability decoy vault. There is no fake vault you can unlock under coercion.
Where the code lives
crates/veilpass-core/src/crypto/kdf.rs— Argon2id parameters and validation floor.crates/veilpass-core/src/crypto/aead.rs— XChaCha20-Poly1305 seal/open.crates/veilpass-core/src/crypto/vault_key.rs— HKDF wrap layer and passphrase change.crates/veilpass-core/src/vault/file.rs— on-disk file layout.
A longer form of this document, with call-site citations,
lives in docs/CRYPTO.md in the repository.