Building zero-knowledge file encryption with Tauri and Rust
"Zero-knowledge" is an easy word to put on a landing page and a hard property to actually hold. It means the service — and by extension anyone who compromises it — never has the ability to read your data, because the keys never leave your device. This post walks through the architecture we used to build Kerveros, an encrypted file collaboration desktop app, on top of Tauri v2 and Rust, and the reasoning behind the choices.
Why Tauri v2 and Rust
Encryption code is exactly the kind of work where you do not want a garbage-collected runtime quietly copying secrets around the heap, and you do want a mature, audited cryptography ecosystem. Rust gives both: explicit control over buffers and zeroization, plus the well-maintained RustCrypto crates. Tauri v2 lets us ship a small cross-platform desktop app (macOS, Windows, Linux) where the security-sensitive logic lives in the Rust core and the UI is a thin web frontend that never touches a raw key.
The mental model is a hard boundary: the frontend asks the Rust core to "unlock this vault" or "encrypt and upload this file", and the core returns results. Plaintext keys live only in the Rust process, ideally in memory that is wiped after use. The webview is treated as untrusted display layer.
Key derivation: Argon2id, not a raw passphrase
A passphrase is not an encryption key. It is low-entropy, human-chosen, and needs to be stretched into a uniformly random key in a way that is expensive to brute-force. We use Argon2id — the memory-hard winner of the Password Hashing Competition — to derive the vault key from the user's passphrase and a per-vault salt. Memory-hardness is the point: it makes large-scale GPU and ASIC cracking dramatically more costly than a simple hash would.
// Illustrative shape only — derive a 32-byte key from a passphrase.
// Real parameters are tuned to the target hardware and stored with the vault.
use argon2::{Argon2, Algorithm, Params, Version};
fn derive_key(passphrase: &[u8], salt: &[u8]) -> [u8; 32] {
let params = Params::new(
/* m_cost (KiB) */ 64 * 1024,
/* t_cost */ 3,
/* p_cost */ 1,
Some(32),
).expect("valid params");
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(passphrase, salt, &mut key)
.expect("derivation");
key
}The derived key never leaves the device and is never uploaded. The salt and the Argon2 parameters are stored alongside the vault so the same passphrase reproduces the same key — and that also means there is no recovery path. If the passphrase is lost, the data is unrecoverable by design. That is a deliberate trade and we are loud about it in onboarding.
Encrypting contents with XChaCha20-Poly1305
For the actual file contents we use XChaCha20-Poly1305, an authenticated encryption construction. XChaCha20 is a stream cipher with a 192-bit nonce, and the extended nonce size matters here: it is large enough that random nonces effectively never collide, which removes a whole category of footguns you get with the 96-bit variant when you are encrypting many objects without coordinating a counter. The Poly1305 tag means tampering is detected — a flipped bit in the ciphertext fails authentication rather than silently decrypting to garbage.
Each file gets a fresh random nonce, and the ciphertext blob that lands in storage carries the nonce and the authentication tag with it. The storage provider sees an opaque, authenticated blob and nothing else.
The hard part: encrypting filenames
Encrypting contents is the easy 80%. The leak most "encrypted" tools never close is metadata — filenames, folder structure, sizes, timestamps. A bucket full of opaque blobs named contract-acme-final-v3.pdf tells an observer almost everything they wanted to know without decrypting a single byte.
Kerveros stores files in the bucket under opaque identifiers and keeps the mapping from those identifiers to real names and folder paths in an encrypted manifest. The manifest is itself a file encrypted with the vault key, so the provider sees only another opaque blob. Decrypting the manifest is the first thing that happens after a vault is unlocked; from then on the app knows the real structure, and the provider never does.
bucket/
manifest.enc <- encrypted index: id -> { name, path, size, mtime }
blobs/
9f2c...a1.enc <- XChaCha20-Poly1305 ciphertext, opaque id
4be0...c7.enc <- ditto; the provider can't tell what either isCoordinating edits: atomic locks over S3
Collaboration is where a lot of encrypted tools quietly give up. If two people edit the same file against the same bucket, the naive outcome is a last-write-wins clobber. Because there is no central server we trust with plaintext, the coordination has to happen over the object store itself.
We use atomic lock objects in the bucket: acquiring a lock is a conditional write that only succeeds if the lock does not already exist, so exactly one client can hold it at a time. A client takes the lock before mutating a file, releases it after, and locks carry enough information to be recovered if a client dies mid-edit. It is not a database, but it is enough to make shared editing safe without anyone running a server that can read the data.
Licensing without phoning home
A privacy app that calls a license server on every launch is leaking the one signal it promised to protect: that you, specifically, are using it, and when. So license verification is fully offline. A license key is an Ed25519-signed token; the app verifies the signature against a public key embedded in the binary. No network call, no license server, nothing to be down or to log your usage.
The trade-off of offline verification is that you cannot remotely revoke a key, so the design leans on signed, scoped tokens rather than server checks. For a one-time-purchase desktop tool that is the right balance: the user gets software that works forever and never reports back, which is exactly the promise the rest of the architecture is making.
What this buys the user
Stack these decisions and you get a coherent guarantee rather than a marketing claim. Keys are derived locally with Argon2id and never uploaded. Contents and filenames are both encrypted client-side with XChaCha20-Poly1305 before anything leaves the machine. Storage is a bucket the user owns. Coordination happens over that bucket with atomic locks. The license is verified offline. There is no point in the system where the vendor, the storage provider, or a future acquirer can read the data — because nobody but the user ever holds the key.
Kerveros is the product these decisions ship in: end-to-end encrypted file collaboration on storage you own, €79 one-time with a 14-day trial. The product page has the full feature list and the security FAQ.
See KerverosIf you are building something with a similar threat model — client-side encryption, your own storage, no server you have to trust — these are the load-bearing decisions, and most of the difficulty is in the metadata and coordination details, not the cipher choice. This is also the kind of system work we take on for clients; if you have a problem shaped like this, that is squarely what agile turtles does.