All posts
Deep Dive13 min read

How Filarr's optional cloud sync stays zero-knowledge (a look inside the code)

How Filarr's optional cloud sync stays zero-knowledge: files upload already-encrypted, the server stores only opaque blobs, and your key never reaches it in cleartext.

MB

Mathis Belouar-Pruvot

You bought a second laptop, or you want your work notes on the machine at home and the one at the office. The moment your files leave one device, you face the same quiet question every cloud tool asks you to ignore: who can read them on the way, and who can read them once they land?

Filarr's answer is that nobody can, including Filarr. Your files sync between your devices, but the server that moves them never holds a key, never sees a filename, and never touches a byte of readable content. This article walks through exactly how that works, straight from the sync code, because "zero-knowledge" is a claim that only means something if you can see the mechanism behind it.

A note on scope before we start: cloud sync in Filarr is optional. The product is local-first, your files live encrypted on your disk whether or not you ever turn sync on. Everything below describes what happens when you choose to enable it.

Quick Answer

Filarr syncs your files by uploading them exactly as they already sit on your disk: encrypted with AES-256-GCM, one key per file. The encryption key (the FEK) never reaches the server in readable form. The Cloudflare Worker that handles sync only ever stores opaque encrypted blobs under randomized file IDs (a SHA-256 hash of the local path), plus a small manifest that is itself AES-256-GCM encrypted with your FEK. The server can see how many blobs you have and how big they are, but not their names, their folder structure, or their content. Getting your key onto a second device is done device-to-device with an ECDH key exchange, so the FEK travels wrapped and the server only relays public keys.

The problem it actually solves

Most sync is "trust the provider." Notion stores your pages in cleartext on its servers. Dropbox holds the keys to the files it encrypts at rest, which means Dropbox (or anyone who compels Dropbox) can read them. Even tools that encrypt in transit usually decrypt on arrival so they can index, preview, or search your content server-side.

That model is convenient and it is also the whole problem. Convenience server-side means readability server-side. If the provider can generate a thumbnail of your document, the provider can read your document.

Filarr's constraint was the opposite: the sync backend had to be genuinely unable to read anything, while still doing the useful work of moving bytes between your machines and resolving conflicts. "Zero-knowledge" here is not a marketing adjective, it is a design requirement that shaped where encryption happens and what the server is allowed to know.

How it works, step by step

1. Encryption already happened, before sync exists

This is the key idea that makes the rest simple. In Filarr, files are encrypted on disk with AES-256-GCM, one key per file, the moment they enter your vault. Sync did not add encryption. Sync inherits it.

When the sync service reads a file to upload it, it reads the already-encrypted bytes off disk and sends them as-is. There is no decrypt-then-reencrypt step, no plaintext staging area. The comment at the top of the sync service says it plainly: "Files on disk are already encrypted with FEK, uploaded as-is." The server receives ciphertext because ciphertext is all that ever existed outside the app's memory.

2. Filenames become opaque IDs

A blob of ciphertext is useless to an attacker, but a filename like "resignation-letter-final.pdf" is not. So Filarr never sends filenames.

When a file changes, the sync service derives an identifier by hashing its local path: crypto.createHash('sha256').update(localPath).digest('hex').slice(0, 32). The comment in the code is explicit about the intent: "Derive opaque fileId from local path, never expose real file names." Folders are referenced by their UUIDs, which carry no meaning either. On the server, your files live at a path like users/{userId}/profiles/{profileId}/files/{fileId}/chunk_0.enc, where every segment is either a random ID or an opaque hash.

3. Files are chunked

Before upload, files are split into 4 MB chunks (CHUNK_SIZE = 4 * 1024 * 1024). Each chunk is stored as a separate .enc object in Cloudflare R2. Chunking keeps individual uploads small enough to retry cheaply on a flaky connection, and it means a large file that changed does not force a single monolithic transfer. Uploads run in small parallel batches (three at a time) with a persistent retry queue behind them, so a dropped connection resumes rather than restarts.

4. The manifest: how devices agree on reality

Here is the part that is genuinely interesting, because it is where a zero-knowledge system usually leaks. To sync, two devices need to agree on which files exist, which changed, and which won a conflict. That coordination normally requires the server to understand your file structure. Filarr's server does not.

There are two manifests, and the distinction matters:

The local manifest (sync-manifest.json) lives on your disk and is not encrypted, but it is deliberately empty of anything sensitive. It holds SHA-256 checksums, byte sizes, sync statuses, and opaque file IDs. No filenames, no content. The localPath field that maps an ID back to a real path exists only in this local copy and is stripped before anything goes to the cloud.

The cloud manifest (manifest.enc on R2) is the shared source of truth between devices, and it is encrypted with your FEK using AES-256-GCM (encryptWithFEK). Its on-disk format is a short marker ("fek:") followed by a 12-byte IV and the ciphertext with its GCM authentication tag. The server stores this as one more opaque blob. When a second device wants to know the state of the world, it downloads this blob and decrypts it locally, because it has the FEK. The server never can.

All the merge logic (which file is newer, which changed on both sides and is therefore a conflict) runs on your device, on the decrypted manifest, in mergeWithRemote. The server's only job around the manifest is version control.

5. Optimistic locking, so two devices do not clobber each other

Every profile has a manifest_version counter stored in the Worker's D1 database. When a device wants to upload a new manifest, it tells the server which version it thinks is current. If the server's version does not match, it returns a 409 conflict and the upload is refused (if (profileSync.manifest_version !== body.version) return ... 409). The device then re-fetches, re-merges, and tries again on the next cycle.

This is the elegant trick: the server can enforce "only one writer wins per version" using nothing but an integer. It never needs to understand the manifest's contents to keep two devices from corrupting each other's state. Coordination without comprehension.

6. Uploads and downloads go through a token proxy

Workers cannot mint classic S3-style presigned URLs directly, so Filarr uses a token pattern. The device makes an authenticated request for an upload slot, the Worker checks your storage quota, stores a short-lived intent record in KV (15-minute TTL) keyed by a random token, and hands back a token URL. The device then PUTs the encrypted chunk to that URL, and the Worker streams it into R2 and increments your usage counter. The token is the authorization for that one operation, and it is deleted on use. The bytes that pass through are, again, already-encrypted ciphertext.

7. Getting your key onto a second device without ever sending it

Everything above assumes both devices have the FEK. They cannot, because you only ever typed your password on one machine. So how does the second device get the key without the server ever seeing it?

This is device pairing, and it uses an ephemeral ECDH key exchange over the P-256 curve. The flow, from pairingService.ts:

Device A (the one that already has your vault) generates an ephemeral ECDH keypair and a random 6-digit code. Device B generates its own ECDH keypair. Each device publishes its public key to the server, fetches the other's, and independently derives the same 256-bit shared secret. Device A then wraps the FEK with that shared secret using AES-GCM and uploads the wrapped key. Device B downloads it, unwraps it with the same shared secret, and stores it locally, re-wrapped under its own password.

The FEK crosses the wire exactly once, and only in a form that requires the shared secret to open. The comment states the guarantee: "The FEK never leaves a device in cleartext." The server relays public keys and a wrapped blob. It never has the shared secret, so it never has the FEK.

The pairing session is hardened: the 6-digit code expires after 5 minutes, and the server rate-limits public-key retrieval to 3 attempts before invalidating the session entirely, which blunts brute-forcing the code.

8. Where the FEK actually rests

On each device, the FEK is stored wrapped. It is encrypted by a KEK (key-encryption key) derived from your password with PBKDF2-SHA512 at 600,000 iterations (the OWASP 2024 recommendation), and separately cached in the OS keychain via Electron's safeStorage. Your password unwraps the KEK, the KEK unwraps the FEK, the FEK decrypts your files. The server is nowhere in that chain.

Design choices and their trade-offs

Encrypt-once at the vault boundary, not at the sync boundary. By making files ciphertext on disk from the start, sync becomes a dumb pipe and the zero-knowledge property falls out for free. The trade-off is that the server literally cannot help you with anything content-aware: no server-side search, no thumbnails, no web preview. That is a real feature loss, and it is the honest cost of the guarantee.

Client-side merge instead of server-side. Running the merge on-device keeps the server ignorant, but it means conflict resolution depends on device clocks and on manifests being fetched in the right order. The merge algorithm leans on updatedAt and syncedAt timestamps, and when both sides changed since the last sync it does not silently pick a winner: it forks, writing a _conflict_{timestamp} copy so nothing is lost. Safer, but it can leave you with duplicates to reconcile by hand.

A token proxy instead of direct-to-R2 presigning. Routing bytes through the Worker adds a hop and some latency versus a true presigned URL straight to storage. In exchange, quota enforcement and access control stay centralized and simple, and since the payload is already encrypted, the extra hop sees nothing.

Chunking at 4 MB. Small chunks make retries cheap and parallelism easy, at the cost of more objects and more round-trips for large files. For a workspace of notes and documents, that is the right bias.

Threat model: what this protects, and what it does not

What an attacker who fully controls the server (or compels the provider) gets: a pile of opaque .enc blobs, an encrypted manifest they cannot open, opaque file IDs, and a version counter. They cannot read your files, your notes, your filenames, or your folder structure.

What that same attacker can still observe (be honest about this): metadata. Specifically the number of files you have, the size of each blob, the number of chunks, your total storage usage, and the timing of your syncs. File sizes and change frequency can leak patterns. This is the classic limitation of any encrypted storage that stores real object sizes, and Filarr does not pad or pool to hide it. If your threat model includes an adversary doing traffic and size analysis, know that this layer is not designed to defeat that.

The pairing window deserves a specific caveat. During pairing, the server relays both public keys. A malicious server could in principle attempt a man-in-the-middle on the ECDH exchange by substituting its own public keys. The 6-digit code, the 5-minute expiry, and the 3-attempt rate limit narrow that window sharply, but the exchange trusts the relay for the brief moment it runs. It is a short, bounded exposure rather than a standing one, and it is worth naming rather than pretending away.

Known limits

  • Metadata is not hidden. Blob sizes, file counts, and sync timing are visible to the server. Content and names are not.
  • Zero-knowledge cuts both ways. Because the server has no key, there is no "forgot password" reset that recovers your data. If you lose your password and your 24-word recovery phrase, the ciphertext is unrecoverable. That is the price of the server being unable to read you.
  • No server-side features on your content. Search, previews, and web access to file contents cannot exist server-side by design.
  • Pairing trusts the relay for a short window, as described above.
  • Legacy manifests. Older manifests encrypted with a device-local key are auto-detected and migrated, which is handled transparently but is a reminder that the format has evolved.

A licensing note for the technically curious: the Filarr desktop client is open source under BSL 1.1, so the code described here is inspectable. (The website is a separate repo under AGPL-3.0.) You do not have to take the zero-knowledge claim on faith. You can read sync.ts, syncService.ts, and pairingService.ts yourself.

Technical FAQ

Q: Does the encrypted content ever get decrypted on the server, even briefly? No. Files are already AES-256-GCM ciphertext on disk before sync runs, and they are uploaded byte-for-byte in that form. The Worker streams blobs into R2 without ever holding a key. There is no decrypt step anywhere server-side.

Q: How does a second device coordinate sync if the server cannot read the manifest? The cloud manifest is itself encrypted with your FEK. Each device downloads it, decrypts it locally, and runs the full merge on-device. The server only enforces an integer version counter (optimistic locking) to prevent two devices from overwriting each other, which it can do without understanding the contents.

Q: How does the encryption key reach my new device without touching the server in cleartext? Through an ephemeral ECDH exchange on curve P-256. Both devices derive a shared secret, the FEK is wrapped with that secret using AES-GCM, and only the wrapped blob is relayed. The server never learns the shared secret, so it never learns the FEK. The pairing code expires in 5 minutes and is rate-limited to 3 attempts.

Q: Can Filarr read my files if served a legal request? No. Filarr holds no key. The server stores opaque blobs and an encrypted manifest it cannot open. There is nothing readable to hand over. The flip side is that Filarr also cannot recover your data if you lose both your password and your recovery phrase.

Q: Can I use my own storage instead of Filarr's? Yes. Sync supports BYOS (bring your own storage) with any S3-compatible bucket, so the encrypted blobs land in storage you control while the same zero-knowledge properties hold.

Q: What metadata can the server still infer about me? The number of files, individual blob sizes, chunk counts, total storage used, and sync timestamps. It cannot infer filenames, folder names, or content. If hiding sizes and timing matters to you, note that this layer does not pad or obfuscate them.

If you want to see any of this for yourself, the client is open source. The three files to read are infra/cloudflare-worker/src/sync.ts for the server side, electron/sync/syncService.ts for the on-device orchestration, and electron/pairingService.ts for the key exchange. The claim and the code are the same thing.

#security#encryption#filarr#zero-knowledge#sync#ecdh#cloudflare-r2