Built by Flowdesk — ex‑FlowCrypt (iOS + Chrome Ext.). Privacy apps, E2EE systems, native & mobile.See workcontact@flowdesk.tech
Flowvault

Security & threat model

Flowvault is an honest description of what we protect, what we don't, and why.

What you give up when you use Flowvault

What Flowvault's server sees

The server never sees your password, the keys derived from it, the content of any notebook, or how many notebooks you actually have on a given site.

Key derivation

A 256-bit master key is derived with Argon2id from your password and a random 16-byte salt stored in the site document. Default parameters: 64 MiB memory, 3 iterations, parallelism 1. These are visible in the site document and versioned so we can upgrade them without breaking existing vaults.

Hidden-volume format

Each site stores a fixed-size blob split into N equally-sized slots (default 64 × 8 KiB = 512 KiB). Each slot is encrypted with a per-slot subkey derived via HKDF-SHA256 from the master key. Slots not in use are filled with cryptographically random bytes, indistinguishable from encrypted slots without the corresponding key.

A given password lands in a deterministic slot derived from the master key (slot index = HMAC-based fingerprint mod N). Because different passwords hash to different slots, multiple notebooks can coexist on the same URL with no server-side metadata indicating how many exist. If a slot fails to decrypt, that's cryptographically indistinguishable from “there is nothing there.”

Collision risk between two independent passwords on the same site is ~1/64 (≈1.6%). For M passwords the birthday-style probability of any collision is ~M²/(2·64): 4.7% for 3 passwords, 14% for 5, 54% for 10. Flowvault refuses to register a password whose slot would overwrite the currently-open notebook, but cannot detect collisions with other hidden notebooks (doing so would break deniability).

Notebook bundle inside each slot

A slot's plaintext (after AES-GCM decryption) isn't a single string — it's a JSON-encoded notebook bundle: an ordered list of tabs ({ id, title, content }) plus the currently active tab id. This is what gives one password many tabs without adding any server-side fields. The bundle carries its own version stamp (v: 1) inside the slot's frame, independent of the lower frame-format version, so we can evolve the tab schema without touching the crypto layer.

Security consequences: tab titlesare as sensitive as tab contents — they live in the same AEAD envelope. The tab count, tab names, and active tab are all zero-knowledge; the server sees only the same fixed-size ciphertext blob it saw before. Adding, renaming, or reordering a tab changes the blob exactly like a regular save would, and is subject to the same caveat noted below under “Plausible deniability (and its limits)” — a persistent network observer watching repeated blob snapshots will see the slot mutate.

Soft caps: 32 tabs per slot, 80-character titles. The hard cap is the slot's byte capacity (~8 KiB after AEAD + frame overhead), enforced uniformly against the serialized bundle at save time. A single too-long tab and a dozen medium tabs that collectively overflow are rejected identically.

Plausible deniability (and its limits)

A password you hand over under coercion opens that password's notebook. Other notebooks encrypted under other passwords remain as random-looking bytes in the blob. There is no database field the server could hand over that proves they exist.

Limitations: the total blob size is public, so an adversary who knows Flowvault's default layout knows there could be up to N notebooks on a site. A motivated adversary with repeated snapshots of your blob will see which slot changes after you type — so deniability is strongest in the single-snapshot case (border search, compelled disclosure) and weaker against a persistent network observer who can correlate writes to slots.

Transport & frontend integrity

All traffic is TLS. The frontend is a statically-built Next.js bundle; releases are tagged in Git, and we intend to publish signed release hashes so you can verify the bundle your browser runs matches a reviewable commit. This is the hardest problem for any browser-crypto app; we take it seriously but do not claim to have solved it.

Open-source backend

The frontend is not the only thing you can audit. The Cloud Functions code (the trusted-handover sweep), the Firestore security rules, and the deployment config — i.e. the actual boundary that stops Flowvault operators from reading or mutating your data — are published in the same repository and deployed unmodified. Most zero-knowledge services hide their server; ours is reviewable, forkable, and self-hostable end-to-end.

Trusted handover

You can nominate a trusted beneficiary who can decrypt the vault if you stop checking in for a configurable interval. The scheme is fully client-side:

  1. You pick a beneficiary password (different from your own). The browser derives a beneficiary key with Argon2id and a fresh salt, wraps your master key with AES-256-GCM under it, and uploads the 60-byte wrapped blob. We never see either password or the master key.
  2. Every save bumps deadman.lastHeartbeatAt. Saves require the master key (they re-encrypt the blob with it), so only you can effectively heartbeat. An attacker who only reads the document cannot forge a valid ciphertext.
  3. A scheduled Cloud Function (hourly) marks configured vaults asreleased when now > lastHeartbeatAt + intervalMs + graceMs. Only the Admin SDK can set that flag; the Firestore rules forbid clients from doing so.
  4. After release, the security rules lock the document against further writes. The beneficiary visits the URL, enters the beneficiary password, unwraps the master key client-side, and decrypts the vault.

Honest trade-offs: the existenceof a trusted handover is visible to the server (we need it to schedule the sweep); the interval, grace and last-heartbeat timestamps are visible too. The wrapped key blob and beneficiary salt are opaque ciphertext. Give your beneficiary a password long enough to resist offline brute force if they ever receive the URL — after release, anyone who learns the URL could attempt guesses against the same Argon2id parameters that protect your own password.

Time-locked notes

Flowvault can encrypt a capsule to a future drand beacon round using the tlock scheme (identity-based encryption over BLS). The ciphertext is stored in Firestore and becomes decryptable only after the drand network publishes the corresponding round signature. Nobody — including Flowvault, including the sender, including a subpoena — can decrypt earlier than that moment.

  1. In your browser we compute the target round for your chosen unlock time (30-second granularity against the RFC drand mainnet chain) and encrypt the plaintext to that round.
  2. We store ciphertext, round, chainHash, and a server timestamp in timelocks/{id}. Firestore rules forbid updates or deletes — capsules are write-once.
  3. When anyone opens /t/{id} after the unlock moment, the browser fetches the drand round signature and decrypts locally. The server never sees the plaintext and never holds the unlock key.

Honest trade-offs: the target round(and therefore the unlock wall-clock time, to ~30 s) is visible to the server by necessity — readers need to know when to retry. The share URL is the access credential; treat it like the secret itself (or add an optional password gate, below). Security rests on drand's threshold assumption (a supermajority of node operators must stay honest) and on BLS over BLS12-381; we track the chain parameters and will rotate if drand ever deprecates the current scheme.

Optional password on time-locked notes

You can harden a capsule with a second gate so that even a leaked URL isn't sufficient to read the message after the time-lock releases. When enabled, the plaintext is double-wrapped:

  1. Inner layer (password): a 16-byte random salt is generated, an Argon2id key is derived from your password (same parameters as vaults: 64 MiB memory, 3 iterations), and the plaintext is encrypted with AES-256-GCM under that key. The inner framing is "FVPW" || version || saltLen || salt || iv || ct || tag.
  2. Outer layer (time): those bytes are passed to tlock and sealed to the unlock round exactly like a password-less capsule.

Why the inner layer comes first: before the unlock round releases, the capsule is cryptographically opaque — even a reader who knows the password cannot peek at the AES layer early. After the round, the bytes are still a password-authenticated blob that only the key unlocks. The Firestore document carries a passwordProtected boolean hint so the viewer can prompt during the countdown instead of after; the viewer also detects the inner layer cryptographically from the decrypted bytes, so a forged or missing hint cannot bypass the password. We never store the password, its hash, or a hint; if you lose it the message is unrecoverable.

Encrypted Send

Encrypted Send is a separate primitive for one-shot, ephemeral sharing — the self-destructing link flavour. It does not share a storage collection or a rules block with the vault pipeline; the threat model is different and the code paths are deliberately isolated.

  1. Outer encryption: the browser generates a fresh 256-bit AES-GCM key, encrypts the plaintext, and places the key in the URL fragment (after #k=, base64url-encoded). Browsers don't transmit URL fragments, so the key never reaches our servers, our logs, or our database backups. The only bytes Firestore holds are the opaque ciphertext, anexpiresAt timestamp, a maxViews integer, and a viewCount starting at zero.
  2. Optional inner password layer: identical to the time-lock password frame ("FVPW" || version || saltLen || salt || iv || ct || tag). Argon2id with the same 64 MiB / 3 iteration parameters derives a key from the password; the plaintext is wrapped with AES-256-GCM under that key before the outer AES wrap. A leaked URL alone is not enough to read the note.
  3. Server-enforced view cap: clients cannot read sends/{id} documents directly — the Firestore rules deny it. Reads go through the readSend callable Cloud Function, which runs a transaction: (1) fetch the doc, (2) refuse if expired or exhausted, (3) increment viewCount or delete the document when this read consumes the final view, (4)return the ciphertext. Atomic, so concurrent openers can't both see the last view.
  4. TTL sweep: a scheduled sendsSweep function runs hourly and hard-deletes anything past expiresAt. A Firestore TTL policy on the same field is the secondary safety net. The shorter of (all views consumed, expiry reached) wins.

Limits and leaks we acknowledge: the server seesexpiresAt, maxViews,viewCount, ciphertext size, and creation / deletion timestamps — that's enough to know “a send existed at this ID, was opened N times, and expired at T.” It cannot see the plaintext, the key, the password, or whether an inner password was set beyond a hint flag. If the recipient opens the link before you intended, the view is consumed — use the password gate when that matters, or lower the expiry. We hard-delete on the last view but rely on Firestore's deletion semantics (overwritten in the index immediately; backup-retention per Firebase's own schedule); we do not operate additional snapshots.

Encrypted File Send

Encrypted File Send is the file-shaped sibling of Encrypted Send: same threat model, same URL-fragment-keyed AES-GCM wrap, sized for documents and screenshots up to 10 MiB with a hard 7-day retention ceiling. The encrypted file bytes live in Cloud Storage at fileSends/{id}; metadata, counters, and the secure-delete-token hash live in Firestore at the same id.

  1. Outer encryption: the browser generates a random 256-bit key K and a separate random 256-bit deleteToken. K goes into the download URL fragment (#k=…); thedeleteToken goes into a separate secure delete URL fragment (#t=…). Browsers never transmit fragments, so the server sees neither.
  2. Optional password layer: an Argon2id-derived key from the password (same 64 MiB / 3-iter parameters as the rest of the product) is concatenated with K before HKDF. So both the file content and the metadata blob require both the URL fragment and the password to decrypt. A 16-byte salt is stored alongside the document (the salt alone is useless without the password).
  3. Domain-separated subkeys: HKDF derives a contentKey (used to AEAD-encrypt the file bytes, uploaded to Cloud Storage) and a separate metadataKey (used to AEAD-encrypt a small JSON blob with the original filename, MIME type, and size, stored in Firestore). The viewer decrypts metadata first, so a wrong-password / wrong-key failure surfaces before burning bandwidth on the full ciphertext.
  4. Server-enforced download cap: clients cannot read fileSends/{id}documents and cannot read the storage object directly — the Firestore rules and the storage rules both deny it. A download goes through the readFileSend callable, which atomically validates expiry, increments viewCount (or marks the doc consumed on the final download), then issues a 5-minute v4 signed URL so the browser can pull the bytes directly from Cloud Storage. Two concurrent recipients on the last download cannot both succeed.
  5. Secure delete link (sender-controlled kill switch): the server only stores SHA-256 of the deleteToken. The deleteFileSend callable accepts a token from the URL fragment, recomputes the digest in constant-ish time, and on match deletes the storage object plus the Firestore doc. Possession of the original delete link is the only thing that authorizes destruction; we cannot re-derive it because we never had it.
  6. TTL sweep: a scheduled fileSendsSweepruns hourly and cleans up three classes of stale state — expired documents, documents marked consumed past the signed-URL grace window, and orphan storage objects whose Firestore companion never landed (failed creates). Storage rules cap incoming uploads at 10 MiB and require application/octet-stream as a defensive stop.

Limits and leaks we acknowledge: the server sees ciphertext size (so encrypted file size leaks, the same way it does for Bitwarden Send / OneTimeSecret), expiry, download counters, the SHA-256 of the delete token, and (when password mode is on) the Argon2id salt and a hint flag. It does not see the filename, MIME type, file content, AES key, password, or the raw delete token. The deep dive at /blog/encrypted-file-send-zero-knowledge-uploads walks through the protocol with the exact source pointers.

Responsible disclosure

Security issues: please report via GitHub security advisories or email the maintainer before public disclosure.