A few years into shipping OpenPGP email at FlowCrypt, a user asked me a question I've been thinking about ever since.

They weren't asking whether we could keep their email secret from our servers. They knew we could: the crypto was textbook, the code was open, and the threat model — the server cannot read your mail — held end to end. That part was solved.

What they wanted to know was different. They said: I travel through a border that compels passwords. If I'm holding your app and they ask me to unlock it, and I do, what then?

The honest answer was: then they read everything. The crypto stops at the password boundary. Once you're forced across that boundary, every design decision we'd made to protect against a hostile server was irrelevant. We'd built a house with excellent walls and no concept of what to do when the attacker already has the keys.

This is the problem that quietly shapes the rest of the privacy stack, and almost no one builds for it. The easy threat model — the server — is where the marketing lives. The hard threat model — the human adversary who has your device and your cooperation — is where most encrypted products turn into ordinary encrypted products, indistinguishable from the unencrypted kind the moment the password comes out.

This essay is about what changes when you design for the second threat model on purpose. I'll walk through the specific decisions it forces — Argon2id parameters, authenticated encryption, fixed-size ciphertext, deterministic slot hashing, and the VeraCrypt-style hidden-volume trick translated into 400 KB of JavaScript. The running example is Flowvault, a zero-knowledge encrypted notepad I've been building as an open-source reference. The thesis applies well beyond notepads.

The ladder of threat models

Privacy products sort themselves, usually without realizing it, along a ladder of increasingly uncomfortable threat models. From easiest to hardest:

Level 1: passive server. The server is honest but curious. It won't tamper, but it might read. Solved by any reasonable TLS-plus-server-side-encryption design. Most "we encrypt your data" marketing sits here.

Level 2: compromised server. Someone owns the server: an insider, a breach, a rogue operator. Your data must remain unreadable even when the server cooperates with the attacker. This is where zero-knowledge and end-to-end encryption live. Signal, ProtonMail, FlowCrypt, Bitwarden — all at this level. Well-understood, well-implemented across the industry.

Level 3: subpoena. A legal authority serves the server with a demand. The server must comply. The design must still hold. This is actually easier than level 2 technically, because a legal attacker usually doesn't also bring engineering capability; a compromised-server-safe design survives a subpoena automatically.

Level 4: stolen device. Someone has your laptop or phone. They can try passwords, dust keyboards, inspect memory. The design must still hold if they have unlimited time with the ciphertext. Local encryption with a memory-hard KDF solves this; it's routine.

Level 5: coerced cooperation. Someone has your device and the ability to demand your password. They might be a border agent, a partner, a court, a mugger, or a state actor. If you unlock for them, you want to unlock something — and only something — you were willing to show. The rest must remain not just encrypted, but unprovable.

The first four levels are what the privacy industry means when it says "encryption." Level 5 is a qualitatively different problem, and the moment you start designing for it, a lot of the standard playbook turns out to be insufficient.

What level 5 actually costs

The central change is metadata. At levels 1 through 4, you hide content. At level 5, you also have to hide existence.

An attacker who can compel you to unlock already assumes there's something to unlock. They've seen the app on your phone. They've watched you type something into it. They may have a copy of the ciphertext. What you need to deny isn't the content — once you unlock, you lose that denial anyway — it's that there's more.

The file structure itself becomes the leak. If your app stores three notes and the attacker demands the key, they see three notes. The number three is the metadata. If you say "those are all I have," but the format has a note_count field or a variable-length file or a list of identifiers, the format contradicts you. The attacker doesn't have to break the crypto; they just have to read the structure.

So the question becomes: can you design a format where the structure itself doesn't betray you? Where nothing about the ciphertext — not its length, not its index, not its metadata — gives any signal about how many secrets it contains?

The answer, worked out thirty years ago in VeraCrypt and TrueCrypt before it, is yes: you accept a fixed-size container, fill the unused space with cryptographic noise that's indistinguishable from real ciphertext, and let different passwords unlock different regions. The container is known to exist. The contents are provably unprovable.

The rest of this post is what it takes to translate that into a browser notepad where there is no filesystem, no container file, no local volume — just a document in a serverless database and whatever crypto the browser can run.

Hidden volumes in a browser

A Flowvault vault is a single ciphertext blob stored as one Firestore document. Inside that blob are 64 fixed-size slots, each one 4 KiB. The whole blob is always exactly 256 KiB on the wire. Not 255.7, not 256 plus a header that reveals how much was used — exactly 256 KiB.

Every slot is in one of two states:

  • Active: encrypted under a real password with AES-256-GCM. Contains a real notebook.
  • Unused: filled with 4 KiB of bytes pulled from the browser's crypto.getRandomValues.

From outside — from the server's view, from a stolen-blob view, from a subpoena view — the two are statistically indistinguishable. There is no flag, no bitmap, no active_slots field. Only slots and guesses.

+------ vault blob (one Firestore doc) ------+
| header:  version | Argon2id salt | nonce   |
|          params  | slot count=64           |
|--------------------------------------------|
| slot 0   [ 4 KiB ciphertext or random    ] |
| slot 1   [ 4 KiB ciphertext or random    ] |
| slot 2   [ 4 KiB ciphertext or random    ] |
| ...                                        |
| slot 63  [ 4 KiB ciphertext or random    ] |
+--------------------------------------------+

How a password finds its slot

The subtle part of the design — the part that took three tries to get right — is that a password must not only decrypt its slot, it must also find its slot without revealing any bit of information about the others.

There is no index. There is no "password → slot" lookup table. The client doesn't ask the server "which slot is this password for." The server never sees the password, and it never learns which slot was decrypted.

What happens instead is a deterministic hash: given a vault's salt and a password, Argon2id produces a master key. HKDF on that master key with a fixed info label produces a slot index in [0, 63]. The same password always lands on the same slot in the same vault, and different passwords land on slots uncorrelated with each other.

When you type a password, the client computes the slot, fetches it, runs AES-GCM decrypt. If the authentication tag verifies, you've found a notebook. If it doesn't, the client says "wrong password." The crucial indistinguishability property: the "wrong password" response is identical whether you hit an empty slot that happened to be random bytes, or an active slot belonging to someone else's different password, or a slot that was never in use at all. The AES-GCM tag check fails the same way in all three cases.

The server is also out of the loop. Every decrypt happens in the browser after one document read. All the server saw was a single fetch of the whole 256-KiB blob.

A 30-second demo, if you want to feel it

Words only go so far here. There's a live public demo you can open without an account, a password of your own, or any commitment — useflowvault.com/s/demo — with two credentials preloaded into the same 256-KiB blob:

A note on what the demo is and isn't: it's a shared credentialed walkthrough, not a vault for real secrets. The server rejects writes at the demo doc id, so your local edits won't persist and won't be visible to the next visitor. Don't put anything sensitive in it — it's instrumentation to make the rest of this essay tangible, nothing more.

The write path is where the secret lives

This is where the design is most unlike anything a normal "edit one record" product would do. When you save a notebook in Flowvault, the client:

  1. Holds the master key of exactly one slot in memory.
  2. Re-encrypts that slot with a fresh nonce.
  3. Generates 4 KiB of fresh random bytes for every other slot, whether or not it's active.
  4. Uploads the entire 256-KiB blob.

Only step 2 produces real ciphertext for the slot that got edited. Steps 3 replaces the unused slots and the active-but-unloaded slots you don't currently have the password to with new random noise.

Wait — replaces the active slots too? Yes. This is the move.

If you only re-randomized the empty slots, then an active slot would keep the same bytes across writes, while empty slots would churn. The server would learn which slots were active by watching diff patterns over time. Instead, Flowvault churns everything. Active slots that aren't currently loaded get replaced with random bytes, which makes them unrecoverable with any password — but you never had their password in memory in this session anyway, so you couldn't have edited them either. The server sees the whole 256-KiB blob change on every save. Every slot looks modified. No slot looks more modified than another.

The cost is obvious and real: you cannot edit a notebook whose password you didn't load. A vault with three notebooks requires you to know which of them you want to edit before you type the password, because loading notebook A and then saving will overwrite notebooks B and C's slots with random bytes if they weren't also loaded in this session.

Flowvault handles this by loading all unlocked notebooks into memory at decrypt time, keeping them in a client-side object, and re-encrypting every currently-held slot on save. If you want to edit notebook B, you unlock it in the same session as A, and both get preserved on write. The deniability property survives because the server still cannot distinguish which slots held real data and which held noise.

The other crypto decisions, briefly

Most of the rest of the design is boring by comparison. That's good; boring crypto is the goal.

AES-256-GCM, not AES-CBC. GCM provides authenticated encryption: the ciphertext carries a tag that detects any tampering, including bitflips and truncations. CBC on its own is malleable — an attacker with write access to the ciphertext can flip bits in ways that change the plaintext without being detected. ProtectedText, the closest prior art to Flowvault, uses CBC. That was the first thing I changed.

Argon2id at 64 MiB / 3 iterations. Argon2id is the memory-hard KDF that won the 2015 Password Hashing Competition; it's the right default. The parameter choice is a trade-off between user-facing latency and attacker brute-force cost. At 64 MiB and 3 iterations, a single password attempt takes about one second on a mid-range laptop. An attacker renting GPUs has to pay for that memory and that time per guess; at scale, against even a fairly weak password, it's in the range of hundreds of dollars to crack. It's not infinite protection, but it's a real slowdown: a naive PBKDF2 at equivalent cost would be orders of magnitude cheaper for the attacker.

I spent a while flirting with 256 MiB, which would raise the attacker cost even more, but users on low-RAM Chromebooks started hitting OOM during Argon2 allocation. 64 MiB is the compromise that seems to run universally.

HKDF-SHA-256 for every derived key. One master key comes out of Argon2id. Every other key used by the client — the slot-index key, the AES key, the handover key, the local-blob integrity key — is derived from the master via HKDF with a unique info label. This is called domain separation: it means a cryptanalytic break in one subsystem doesn't propagate to the others, because they were never sharing key material directly. This is the kind of thing no user will ever see, and a reviewer reading the code notices in five seconds.

Nonces are fresh on every write. AES-GCM catastrophically fails if a nonce is reused under the same key: the attacker recovers the authentication subkey and can forge messages. Flowvault generates a fresh nonce on every encryption with crypto.getRandomValues, and the write path assumes "one key, one nonce, ever." The re-randomization of slots on every save makes this almost automatic, because every slot rewrite picks a new nonce even for the same master key.

None of this is novel. Almost all of it is copying the right boxes off the right shelves. But if any single one is wrong, the hidden-volume property collapses. Plausible deniability is the thing a hundred tiny boring decisions add up to.

Designs I tried and threw away

The hidden-volume format wasn't my first attempt. Three specific mistakes on the way there:

Variable-size slots. My first version let slots grow to fit the note. This was much nicer for users who wanted long notes in one slot and didn't care about the deniability property. It was also a total leak: the ratio of "bytes used" to "total bytes" gave the attacker a near-perfect count of how many notebooks were active, and which were long, and which were short. I went back to fixed 4 KiB slots a week later.

A separate "slot presence" field. The second version had a compact bitmap listing which slots were active, encrypted under a single vault-wide key. The logic was that this would avoid the wasteful re-randomization on every save. It took one careful read to realize the vault-wide key was now the keystone: whoever had it knew exactly how many notebooks existed, which completely undid the deniability property it was meant to preserve. The bitmap came out.

Duress passwords that trigger wipes. I wanted a password that, when entered, would silently delete the vault. VeraCrypt-style decoy felt too passive; a "nuke" seemed strictly better. It isn't. A deletion leaks: "this vault existed yesterday and doesn't today" is itself metadata, and if the attacker has any kind of snapshot capability — which a server-backed design absolutely cannot rule out — the deletion doesn't even succeed at its stated goal. Flowvault supports only decoy semantics, for this reason. Every password unlocks a real notebook. The one you reveal is the one you wanted to reveal. The others remain unproven.

Each of these mistakes made the product worse in the specific direction it claimed to help. The lesson I keep relearning is that in deniability design, any feature that feels like it makes things easier is probably leaking somewhere.

The other primitives in the same codebase

Hidden volumes are the most visible thing Flowvault does, but the same design philosophy — refuse to store anything the server can subpoena, refuse to emit anything the attacker can exploit — shows up across the product. Three worth mentioning:

Time-locked notes with drand. You can write a note that is cryptographically unreadable until a specific future time. The mechanism is drand, a public randomness beacon run by a distributed consortium: it publishes a new BLS signature every 30 seconds, and identity-based encryption against a future beacon round produces a ciphertext that mathematically cannot be decrypted until that round's signature is published. No one — not me, not the server, not the note's author — can open it early. It's a capability you could not build against a private server without trusting that server to withhold the key honestly.

Trusted handover. A dead-man's-switch: you can mark a specific notebook to be released to a beneficiary if you don't sign in for N days. Under the hood, the master key for that slot is wrapped under a beneficiary-side password and stored on the server as encrypted bytes; the server's only role is the timer. The other slots aren't affected, because the handover is scoped to one slot's key — the server doesn't even know how many other slots exist.

Bring Your Own Storage. The same vault format runs against the browser's File System Access API instead of Firestore. You pick a local file, the app writes ciphertext to it, and your vault never leaves your machine. This is the fully offline mode; it also happens to be the deployment target that makes the deniability property strongest, because there is no server to subpoena at all.

All three are the same idea applied in different directions: design so the server holds as little structure as possible, and what it does hold tells no story.

What a month of this taught me

Privacy tools have a tendency to accumulate features that drift upward along the threat-model ladder. You start at level 5 and ship the product; then a user asks for shared notebooks, which slides you back to level 4; then team accounts, and you're at level 3; then an admin dashboard for audit, and you're at level 2 again, essentially where Google Docs was when you started.

This is not a moral failure. It's a product-surface problem. Each feature that helps a real user also gives the server a little more structure to hold, and structure is what attackers at level 5 read.

What I've learned is that the defensible line is not what crypto you use but what the server is allowed to know. Argon2id vs PBKDF2 is a detail. AES-GCM vs ChaCha20-Poly1305 is a detail. Whether the server can distinguish an active slot from a noise slot is not a detail. It's the whole product.

Zero-knowledge, it turns out, wasn't a destination. It was one stop on a longer ladder. The hard part, if you take privacy seriously, is the climb after that.

Flowvault is MIT-licensed and open source: github.com/Flowdesktech/flowvault. You can try the hosted version at useflowvault.com without an account, or open the credentialed demo at useflowvault.com/s/demoCorrectPassword for the real notebook, DecoyPassword for the decoy — to feel the hidden-volume design in about 30 seconds. If you find a crypto mistake in any of this, please open an issue; I'd rather hear about it in public.

I write about privacy engineering, cryptography, and the product side of building tools where getting the details wrong is the whole story. Follow along if that's your thing.