Flowvault started as “an encrypted notepad at a URL.” Version 1.2 adds the other end of the spectrum: an encrypted notepad that lives as a single file on your own disk, with our servers never seeing the ciphertext. Same hidden-volume format, same Argon2id + AES-GCM, same multi-notebook tabs — just written straight to D:\notes\journal.flowvault(or wherever you point it) through your browser's File System Access API. This post is the deep dive: what BYOS changes about the threat model, what the on-disk file looks like, which features work and which don't, and what's coming next (S3, WebDAV, and more).
Why a local-file mode at all?
Flowvault's hosted mode is already zero-knowledge. The server sees a SHA-256-derived site id, an opaque ciphertext blob, your Argon2id salt, and some timestamps — not your password, not your keys, not your plaintext, not even how many notebooks live inside the blob. On paper there's no technical reason to take the ciphertext off the server: it already tells an attacker (and us) nothing useful.
In practice, three things kept coming up in user feedback:
- “I don't want to trust the server even with opaque bytes.”Zero-knowledge is a cryptographic claim about what's onthe server. It doesn't say anything about availability, retention, jurisdiction, subpoenas for the blob itself, or what an attacker with a stolen database could do with 10 years of compute progress. For some threat models the right answer is simply: don't give the server the blob in the first place.
- “I want my notes to work on a laptop I carry around, even offline.”A URL-backed vault needs a live server. A file on disk doesn't.
- “I already pay for encrypted storage; why not use it?”People have external encrypted drives, Nextcloud, S3 buckets with versioning, Proton Drive, you name it. For them, Flowvault's Firestore is an extra piece of infrastructure they don't need.
BYOS is the answer to all three. The editor stays exactly what it was; the storage moves.
Creating or opening a local vault
From the Flowvault home page, below the usual “enter a URL” form, there are two new buttons: Create local vault and Open local vault.
- Click one. Your browser shows a file picker. Choose a path like
journal.flowvault(on create) or select an existing.flowvaultfile (on open). - Enter a password, same way you would for a hosted vault. Argon2id derivation runs locally (64 MiB, 3 iterations).
- You land on a URL that looks like
flowvault.flowdesk.tech/local/<uuid>. The UUID is generated in your browser; it's an opaque identifier the editor uses to look up your file handle. The UUID never leaves your browser— the route is entirely client-side, and neither the URL nor the file name is posted to our servers.
After that, the editor looks and behaves exactly the way a hosted vault does. Save with Ctrl/Cmd+S. Add tabs. Add a decoy password. Export a .fvault backup. Export plaintext Markdown. The only visual difference is the header chip, which reads local: journal.flowvault instead of /s/<slug>.
What's inside a .flowvault file
A .flowvault is a hybrid binary + JSON format. It needs to carry a fixed-size ciphertext blob (hundreds of KiB), so wrapping the whole thing in Base64 (as the .fvault backup format does) would balloon the on-disk size for no gain. Instead, the file has a short binary preamble, a JSON header, and then the raw ciphertext.
offset 0 : "FVLV" (4-byte magic) offset 4 : headerLen (u32, little-endian) offset 8 : JSON header (UTF-8, exactly headerLen bytes) offset 8 + N : raw ciphertext (fixed size, e.g. 512 KiB)
The magic bytes FVLVare there so a mis-opened file fails fast — we check the magic before we try to parse anything, and the open flow refuses a file that doesn't start with it. The JSON header carries everything the editor needs to know about the vault:
{
"formatVersion": 1,
"localSiteId": "b335d33c-355d-4323-a5b2-771c3009ac5d",
"vaultVersion": 42,
"createdAt": 1745272800000,
"updatedAt": 1745276400000,
"kdfSalt": "C0X7...base64url...",
"kdfParams": {
"algo": "argon2id",
"memoryKiB": 65536,
"iterations": 3,
"parallelism": 1
},
"volume": {
"format": "hidden-volume-v1",
"slotCount": 64,
"slotSize": 8192
},
"ciphertextLen": 524288
}Everything after ciphertextLen bytes into the ciphertext region is noise. Every field has a specific reason to exist:
formatVersion— so future files can introduce larger slot sizes or new volume formats without breaking old readers.localSiteId— a per-file UUID. The editor asserts that the file it opens reports the samelocalSiteIdit was registered under, so an accidental overwrite with a different vault file gets caught.vaultVersion— a monotonic counter used for optimistic concurrency. More on this below.kdfSalt,kdfParams,volume— the same three fields that are stored on the Firestore document for a hosted vault. They pin the cryptographic parameters to the vault, not to whatever defaults are current at read time.ciphertextLen— a sanity check against truncation. If the file on disk is shorter than8 + headerLen + ciphertextLen, we refuse to open it rather than silently reading garbage.
No passwords, no derived keys, no plaintext, no per-slot metadata. Opening the file in a text editor shows you the four ASCII magic bytes, the JSON you see above, and then raw binary that looks like noise. Decoy slots stay indistinguishable from active ones, same as in the hosted vault.
What the server sees for a local vault
For the vault itself: nothing. Concretely, when you save a local vault, the browser writes bytes to your disk and no HTTP request touches our backend. You can verify this from your browser's DevTools network tab: type something, save, and watch the tab stay empty.
There are two caveats, both deliberate:
- Static page loads. Vercel serves the Flowvault frontend. Opening
/local/<uuid>fetches the same JS bundle as opening any other page; Vercel sees that request, the same way it sees any other page view. It doesn't see which file you open or what's in it. - Server-dependent features.If you use Encrypted Send or time-locked notes from within a local vault, those specific flows still post documents to Firestore — because those features need a server (to enforce view caps, to wait for drand). The server sees the same opaque ciphertext for those features as it would from any hosted vault. It still does not see your local vault's blob.
One feature is disabled entirely for local vaults: the trusted handover. That one needs a Cloud Function checking a timer, which only makes sense for a document the server owns. The editor hides the Handover button for local vaults rather than offering something it can't keep.
Concurrency: the in-file CAS counter
Hosted vaults use Firestore's conditional writes to prevent two tabs from clobbering each other: every save includes the version you read, and the server rejects the write if that version no longer matches. Local files have no server to arbitrate, so we do the next best thing: the header carries a vaultVersion counter, and writes happen in a read-check-write cycle.
async function writeCiphertext(input) {
const current = await readFileHeader();
if (current.vaultVersion !== input.expectedVersion) {
return { ok: false, currentVersion: current.vaultVersion };
}
const nextVersion = current.vaultVersion + 1;
const payload = encodeLocalVaultFile({
...current,
vaultVersion: nextVersion,
updatedAt: Date.now(),
ciphertext: input.ciphertext,
});
await writeFileBytes(payload);
return { ok: true, newVersion: nextVersion };
}This gets you the same guarantee the hosted backend does within one device: if you open the vault in two browser tabs and save from both, the second writer sees a conflict and is prompted to refresh instead of silently overwriting the first.
Getting back into a vault: handle persistence
The File System Access API hands your browser a FileSystemFileHandle when you pick a file. That handle is what the editor uses to read and write. Two awkward facts about handles:
- They're origin-scoped and not portable: a handle created on
flowvault.flowdesk.techis opaque to any other origin, and can't be serialised into a cookie orlocalStorage. - They're not permission-persistent: even if you kept the handle around, the browser prompts for “allow Flowvault to write to this file?” again when you come back after a while. That's a web platform rule, not us.
The part we can fix is remembering that the handle exists at all. Handles can be stored in IndexedDB (structured clone supports them), so Flowvault keeps a per-origin handleRegistry that maps your localSiteId UUID to the handle you first granted, plus a little metadata (the file name we last saw, the timestamp). When you revisit /local/<uuid> on the same browser profile, the editor recalls the handle, calls queryPermission(), and — if needed — asks the browser to prompt you one more time. You still type your password, because the password has never been stored; but you don't have to re-pick the file from a disk browser unless you want to.
On a new browser profile, a new machine, or after clearing site data, the registry is empty and the normal Open local vault flow picks up the file from disk again. The file itself is all that matters for recovery; the registry is just a convenience cache.
Lock semantics
Clicking Lock on a local vault does three things, in this order:
- Drops the in-memory master key and zeroes the decrypted tab contents.
- Unregisters the per-site storage adapter. The next visit to
/local/<uuid>must go through the recall path again, including a fresh browser permission grant for file access. - Clears the open-vault state in the editor's Zustand store so the password gate shows.
Contrast with a hosted vault's lock, which only needs step (1) and (3) — Firestore doesn't hand us capability-style access, so there's no adapter to tear down. For local vaults the adapter teardown matters: it's the thing that forces the next session to go through an explicit File System Access permission prompt rather than silently reusing a live handle.
Threat-model notes
BYOS is a real reduction in what our backend can see about you. It is not a universal upgrade over the hosted vault, because it moves the trust boundary onto your device.
Things that get strictly better:
- The server doesn't see your ciphertext or any per-vault metadata, so “what if Flowvault gets breached” stops applying to this vault at all.
- Subpoenas, law-enforcement requests, and hosting-provider data requests against Flowvault yield nothing for local vaults, because there's nothing to yield.
- Jurisdiction simply doesn't apply to a file on your own disk.
Things that don't change:
- The ciphertext still requires your password to unlock. Argon2id parameters are the same. A stolen file is equivalent, cryptographically, to a stolen Firestore document.
Things you have to think about that weren't your problem before:
- Local forensic risks. A file on disk is subject to shadow copies, cloud-sync providers, file-system journaling, un-deleted snapshots in swap or hibernation images, and anything else on your machine that might have briefly seen plaintext in memory. If any of that is in your threat model, store the
.flowvaulton an encrypted volume (VeraCrypt, LUKS, BitLocker, FileVault) the same way you would any other sensitive file. - Losing the file means losing the data. There is no copy on our servers. Back it up. An easy rhythm is a periodic
.fvaultexport into a separate folder or cloud drive — the export is still zero-knowledge, still needs your password, so it's fine to store in places you wouldn't trust with plaintext. - Untrusted browser extensions. This caveat applies to hosted vaults too, but it bites harder here: an extension running in the same origin can read the DOM while your vault is open. For high-stakes local vaults, use a dedicated browser profile with no extensions.
Architecture: the VaultStorageAdapter interface
Under the hood, BYOS wasn't a one-off bolt-on. Every vault-blob read and write in Flowvault now goes through a shared adapter interface:
export interface VaultStorageAdapter {
create(input: CreateVaultInput): Promise<void>;
restore(input: RestoreVaultInput): Promise<void>;
read(siteId: string): Promise<VaultRecord | null>;
refresh(siteId: string): Promise<VaultRefreshResult | null>;
writeCiphertext(input: WriteCiphertextInput): Promise<WriteResult>;
}The Firestore backend is one implementation (a thin wrapper over the existing src/lib/firebase/sites.tsfunctions, preserved verbatim so hosted vaults behave identically). The local-file backend is another. A dispatcher — getVaultStorage(siteId) — picks the right one based on the route you're on: URL-routed vaults at /s/<slug> get Firestore, and sites registered under the per-site override map (the local-file case) get their override.
Which means adding a new backend is, roughly, five files:
- An adapter implementation conforming to
VaultStorageAdapter. - A small UI entry point to configure credentials / picker.
- A route (or reuse of an existing one) where the dispatcher hands control to the new adapter.
- Persistence for any handles / credentials that need to survive a reload.
- Docs and a threat-model section.
That's the lever for everything on the roadmap below.
What's next
Priority is driven by demand. If any of these would unblock you, open a GitHub issue so we can see the signal.
S3-compatible backends
AWS S3, Cloudflare R2, Backblaze B2, Wasabi, MinIO, Storj-S3, and every other “speaks the S3 API” provider. The blob stays opaque the same way it does on Firestore; the adapter just shuttles bytes. Versioning on the bucket side gives you essentially-free snapshot history, which is an upgrade over both Firestore and local files. Credentials would live in localStorage, encrypted under a device-local key, so each browser profile configures its own. Conflict detection uses the object's ETag as the CAS token. This is probably the first server-backed BYOS adapter to ship.
WebDAV backends
Nextcloud, ownCloud, Storj-compatible WebDAV gateways, Synology, and anyone else exposing a plain WebDAV endpoint. Same shape as S3, different wire format; CAS via the If-Match header and ETags. Great for people who already self-host and want Flowvault to be purely a client.
IPFS / Storj / Arweave (experimental)
Fully decentralised backends are attractive but come with real downsides (availability, mutability, address persistence). We'll ship these behind a clear “experimental” banner once the design handles the “where does the pointer to the latest version live” question cleanly.
Mobile
File System Access API support on mobile browsers is uneven. A native-PWA or thin native wrapper with scoped storage access is a tracked line of work but depends on the browser platforms moving first.
Frequently asked questions
Which browsers are supported today?
Chromium-based desktop browsers: Chrome, Edge, Brave, Opera, Vivaldi, Arc. Firefox and Safari don't implement the File System Access API yet, so the Create/Open local vault buttons are disabled there. Hosted vaults at /s/<slug> still work everywhere.
Can I move a .flowvault between devices?
Yes. Copy the file — USB stick, cloud sync, encrypted email, whatever fits your threat model. On the second device, click Open local vault, point at the copied file, and enter your password. The file is self-contained; nothing else needs to travel with it.
Can I compose time-locked notes from a local vault?
Yes. Time-locked notes and Encrypted Send live in their own Firestore collections and are unaffected by where your vault is stored. Those features only talk to the server for their own capsule / send documents, never for the local vault.
.fvault vs .flowvault— which do I want?
Both. A .flowvault is the live vault you work out of; a .fvault is a point-in-time backup you stash somewhere safe. The two formats share the underlying ciphertext, so you can export a .fvault from a local vault and restore it to a hosted slug, or vice versa, at any time.
How do I delete a local vault?
Delete the file from your disk. That's the whole state. If you also want to clear the per-browser handle registry, use your browser's site-data settings for flowvault.flowdesk.tech.
See also
- Flowvault home — the Bring your own storage section sits below the usual open-vault form.
- The FAQ — has a dedicated Bring Your Own Storage section with the shorter, question-answer version of this post.
- Hidden volumes explained — the slot format inside a
.flowvaultis the same one described there. - The
.fvaultbackup format — its sibling format; snapshots for cold storage. - Trusted handover — the one feature that doesn't work for local vaults, and why.
- The security page— the canonical list of exact parameter values.