Whitepaper

How the encryption actually works.

The cryptographic design behind Inktally — written for someone who wants to verify the claims on the security page, not just read them.

For the one-paragraph version, read the security overview. For who we defend against and what we don’t, read the threat model.

Foundation

Zero-knowledge, by construction

The design goal is simple to state and hard to earn: the server must be able to do its job — store your data, sync it, share it, release it on a trigger — while being mathematically unable to read any of it. Every primitive below exists to keep that property true even against an operator with full database access.

All encryption and decryption happen on your device. What the server stores is ciphertext plus public, non-secret key material. Nothing it holds is sufficient to recover your plaintext.

Key hierarchy

From your password to your files

The keys form a chain, each one wrapping the next:

  1. Password → Key-Encrypting Key (KEK)

    Your password is stretched with Argon2id (memory-hard, deliberately slow) against a per-user random salt. The output never leaves your device. Default parameters: 256 MiB of memory and 4 passes — tuned so a single guess is expensive for an attacker but tolerable for you. These parameters are stored with your account and can be rotated upward over time.

  2. KEK → Master Key

    The KEK wraps a random 256-bit Master Key using an authenticated cipher. The server stores only this wrapped blob; without your password-derived KEK it is undecipherable. Separately, a one-way verifier derived from your password lets the server confirm a login attempt without ever learning the password or the KEK.

  3. Master Key → per-resource Data Keys

    Every document and note gets its own random Data Key, wrapped under your Master Key. Per-resource keys are what make sharing possible without exposing anything else: we can hand out one file’s key without touching the rest of the vault.

  4. Data Key → content

    The Data Key encrypts the actual bytes. Decryption walks the chain in reverse — password → KEK → Master Key → Data Key → plaintext — entirely in your browser or app.

Primitives

What we use, and why

  • Argon2id. Password-based key derivation. Memory-hard, so attackers can’t cheaply parallelise guesses on GPUs or ASICs. 256 MiB / 4 passes by default.
  • XChaCha20-Poly1305. Authenticated content encryption. Large files are encrypted as a stream (libsodium’s secretstream) in fixed-size chunks, each independently authenticated, so a single tampered byte fails to decrypt rather than corrupting silently. The 192-bit nonce makes random nonces safe at scale.
  • X25519 sealed boxes. Sharing and triggered release. To share a file we re-wrap its Data Key to the recipient's X25519 public key as a sealed box; only their private key opens it. For owner-initiated sharing while the owner is alive, the plaintext key never exists on the server. For trigger-released shares using server-assisted delivery, the owner pre-escrows the Data Key to our sealing pubkey; we hold an AES-256-GCM-wrapped copy between arming and delivery, then re-seal it to the recipient's key at claim time. After delivery the recipient's envelope is the only useful key material; our transient copy is discarded.
  • Shamir secret sharing. Recovery. Your recovery key is split into shares distributed to trusted contacts, with a threshold you choose — any k of n can recombine it, fewer cannot.
  • SHA-256 hash chain. Audit integrity. Each audit row carries the hash of the previous row’s contents, so any mid-chain edit breaks the link and is detectable.

Decoy vaults

Two vaults, indistinguishable on the wire

Every zero-knowledge account holds two vaults — real and decoy — and exactly two wrapped-key rows. Both passwords produce a verifier of the same shape; both vaults store the same kind of wrapped key. The login response is constant-shape and returns both blobs in randomised order, so the server (and a network observer) cannot tell which password opens which — or whether a decoy is even in use.

We hold the real-password and decoy-password code paths to within 1.5× of each other — continuously measured, and release-blocking if it ever drifts — removing timing as a distinguisher. The full set of decoy invariants — and their honest limits — is in the threat model.

Integrity

A log you can verify yourself

Every meaningful action writes a row to a per-user, per-vault audit log. The rows form a SHA-256 hash chain: each row binds the previous row’s contents (including which vault it belongs to), so a database-level attacker can’t edit, delete, or shuffle rows between the real and decoy chains without breaking verification.

You can verify your own chain from Settings → Audit log → Verify chain, and we also re-verify every chain on our side around the clock and flag any divergence.

Limits

What this design does not do

Cryptography protects data at rest and in transit — it cannot protect a device that is already compromised at the moment you unlock, defend against hardware side-channels, or resist a quantum adversary. The web client is also served from our origin, with no out-of-band integrity check. We enumerate every one of these plainly in the threat model — reading the limits is part of trusting the design.

Verify, don’t trust.

The encryption runs in your browser — open devtools and watch the network tab to confirm we receive ciphertext. Back to the security overview.