Available for hire — privacy-first apps, engineering, and business-idea work.contact@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.

Responsible disclosure

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