veilpass

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.

Key hierarchy from passphrase to plaintext vault. your passphrase Argon2id + 32-byte salt master key · 32 bytes HKDF-SHA-256 · info = "…vault-key-wrap" wrap key · 32 bytes XChaCha20-Poly1305 unwrap vault key · 32 bytes (random) XChaCha20-Poly1305 open (body) your entries
The master and wrap keys are never persisted; they exist in memory during unlock and are wiped on lock. Only the vault key persists (in AEAD-sealed form) inside the file.

Why three layers?

Because each layer buys something different.

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:

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

Non-goals

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

A longer form of this document, with call-site citations, lives in docs/CRYPTO.md in the repository.

← back to design overview