Skip to content
Skip to content
Get started
/Security

Security

Encryption model

Everything in zcrypt is encrypted on your device before it touches the network. This page documents exactly which algorithms run, in what order, and where each key lives, no hand-waving.

The primitives

zcrypt uses a small, deliberately boring set of well-understood primitives. There is no custom cipher and no novel construction: just standard authenticated encryption and a standard key-derivation function, run client-side via the Web Crypto API (in the browser) or native Rust (in the desktop and Android apps). The exact parameters below are shared by every client, so a file encrypted in one decrypts in any other.

PurposePrimitiveParameters
Content encryptionAES-256-GCM256-bit key, 12-byte random nonce, 16-byte auth tag
Key derivationPBKDF2-HMAC-SHA256600,000 iterations, 32-byte random salt, 256-bit output
IntegritySHA-256Per-chunk and whole-file digests
Sharing keys to other users (client-side)X25519 + AES-256-GCMECIES sealed-box: ephemeral ECDH, SHA-256-derived wrap key
Token wrapping (server-side)HKDF-SHA256 + AES-256-GCMPer-user KEK derived from a server master key

Why AES-GCM specifically

GCM is authenticated encryption: every ciphertext carries a 16-byte tag bound to the key and nonce. Flip a single bit of a stored chunk and decryption fails loudly rather than returning garbage. You get confidentiality and tamper-detection from one pass.

The last row (token wrapping) is the one place zcrypt holds a key server-side, and it never touches your files. It is covered honestly in the zero-knowledge architecture.

Envelope encryption

Your passphrase does not encrypt your files directly. Instead, every file gets its own random 256-bit Content Encryption Key (CEK). The file's chunks are encrypted with that CEK, and the CEK itself is then wrapped (encrypted) with a Key Encryption Key (KEK) derived from your passphrase. This is called envelope encryption.

  • •The CEK is generated fresh, at random, per file, never derived from anything you type.
  • •The KEK is derived from your passphrase plus the file's salt and is used only to wrap and unwrap the CEK.
  • •The server stores the wrapped CEK (a small base64 ciphertext) and the salt, but holds no key that can unwrap it.

This indirection is what makes secure sharing and per-folder passwords possible without ever exposing your passphrase: zcrypt can re-wrap a file's CEK under a different key: a link's one-time share key, a folder-password key, or a key sealed to another user's public key , so a recipient (or a different password) can decrypt that one file while your master passphrase never moves. The underlying chunks are untouched; only the small wrapped CEK changes. See passphrase & key management for how keys flow end to end.

Legacy files

A small number of files predate envelope encryption and were encrypted directly with the passphrase-derived key (no wrapped CEK). zcrypt detects this automatically on download, a file with no stored wrapped CEK uses the passphrase-derived key as the content key directly, so either path just works and you never have to think about it.

Sealing keys to other people

The same envelope idea extends to sharing with another zcrypt account without ever handing a usable key to the server. Every account is bootstrapped with its own X25519 keypair. The public half is published to a registry the server keeps in the clear, so anyone can wrap a key to you; the private half is wrapped under your passphrase-derived key: the very same PBKDF2 → AES-256-GCM envelope used for file CEKs, so the server stores only ciphertext it cannot open.

To grant access: for example, to a shared space and its files: the client seals the relevant symmetric key to the recipient's public key with an ECIES sealed-box: a fresh ephemeral keypair performs an X25519 Diffie–Hellman with the recipient, the shared secret is hashed with SHA-256 into an AES-256-GCM key, and the ephemeral public key is prepended so the recipient can reconstruct the secret.

sealing a key to a recipient (conceptual)
eph     = X25519 keygen()                          // fresh per seal
shared  = X25519(eph.private, recipientPublicKey)  // ECDH
aesKey  = SHA-256(shared || eph.public || recipientPublicKey)
sealed  = eph.public || AES-256-GCM(key = aesKey, plaintext = spaceKey)

Only the recipient's private key can reproduce the shared secret, so only they can unseal the key. The server stores the sealed blob and the re-wrapped file envelopes and can read neither. Revoking a member is a key rotation: a new space key is generated, re-sealed to the remaining members, and every shared file's CEK re-wrapped under it, so a removed member's old grant opens nothing.

Verify a fingerprint out of band

Each published public key carries a short SHA-256 fingerprint. Compare it with your collaborator over a channel that isn't zcrypt (in person, a phone call) to rule out a substituted key: the only way an honest-but-curious server could interpose itself in the exchange. How the keypair is derived and held is covered in passphrase & key management.

Key derivation

Keys are derived with PBKDF2-HMAC-SHA256 at 600,000 iterations against a cryptographically random 32-byte salt. The high iteration count makes each guess expensive, so brute-forcing a strong passphrase is computationally impractical. The salt is unique per file, so identical passphrases never produce identical keys and a precomputed-table attack buys an adversary nothing.

key derivation (conceptual)
salt = random(32 bytes)                       // unique per file
KEK  = PBKDF2-HMAC-SHA256(passphrase, salt,    // 600,000 iterations
                          iter = 600000,
                          dkLen = 32)
CEK  = random(32 bytes)                        // unique per file
wrappedCEK = AES-256-GCM(key = KEK, plaintext = CEK)

The salt is not secret. It is stored alongside the file's metadata. The passphrase is. It is never transmitted, never written to our database, and exists only in your device's memory for the duration of an unlocked session.

The upload pipeline

When you add a file, all of the heavy lifting happens locally: in a Web Worker in the browser, or on-device in the desktop and Android apps. The server only ever receives finished, encrypted chunks.

  1. Compress. Each chunk is compressed with zstd on your device, before encryption, unless that would not make it smaller. Encrypted data is effectively random and will not compress, so this order matters.
  2. Encrypt. Each compressed chunk is sealed with AES-256-GCM under the file's CEK, producing [12B nonce][ciphertext][16B tag].
  3. Hash. A SHA-256 digest is computed per encrypted chunk for integrity. The whole-file digest is a keyed HMAC-SHA256, so the server holds no plain hash of your content.
  4. Store. Chunks are pushed to your configured storage backend as opaque binary blobs.

Compression is client-side only

The backend does not compress anything. It merely carries a per-chunk compressed flag so the client knows whether to run zstd in reverse on the way back down. Older documentation implied server-side zstd. That was never how it worked.

Chunks & integrity

Files are split into chunks so uploads can run in parallel, resume after interruption, and stay within storage-provider size limits. The plaintext chunk size is chosen for your device: 4 MiB on constrained hardware, 10 MiB for most computers, up to 16 MiB on the strongest machines, and each encrypted chunk adds a 12-byte nonce plus a 16-byte tag of overhead. Encrypted chunks therefore top out around 16 MiB.

  • •Every chunk is independently authenticated by its GCM tag, so tampering with one chunk cannot silently corrupt the file.
  • •Per-chunk and whole-file SHA-256 digests let the client verify that what it downloaded is exactly what it uploaded.
  • •Because each chunk is encrypted on its own, large files stream through memory in bounded windows rather than being held whole.
  • •On the way back down, decryption and zstd decompression run across a device-aware pool of Web Workers, off the main thread: the same client-side pipeline as upload, just in reverse. Plaintext never leaves your device.

On chunk sizes

A nonce and a tag are appended per chunk, not per file, so overhead is a flat ~28 bytes per chunk regardless of file size. The exact plaintext window is picked from your device's reported memory at upload time.

Where to go next