Decentralized Web/5 min read
SilentEcho, a diary the server can't read
How I'm building SilentEcho, a zero-knowledge journaling app: AES-256-GCM encryption on the device, a blind Go server, a Next.js web portal with WebCrypto, encrypted search indexes and a key-handoff bug that taught me a lot.
A diary is the most private thing you can write. Most journaling apps still store it in a form their own servers can read. SilentEcho is my answer to that: a journaling app with a zero-knowledge architecture. Everything is encrypted on your device before it leaves, and the server only ever stores data it can't read.
It's my biggest personal project so far: a Flutter app, a Go backend, a Next.js web portal and an admin dashboard, built over hundreds of commits.
The architecture
- Flutter client for Android and iOS. This is where the writing happens, and where encryption and decryption happen.
- Go server built with Fiber, on PostgreSQL and Redis, with JWT authentication. It stores and syncs encrypted blobs and nothing else.
- Web portal in Next.js, so you can read and write from a browser. It decrypts in the browser, never on the server.
- Admin dashboard for operating the service, without access to anyone's entries.
Everything runs in Docker on my own server with Dokploy.
Encryption on the device
Every entry is encrypted with AES-256-GCM, which provides both secrecy and integrity: if a single byte of ciphertext is changed, decryption fails instead of returning garbage. Each encryption uses a fresh random 12-byte nonce, because reusing a nonce with the same key breaks GCM's guarantees.
The key is derived from your password with PBKDF2-HMAC-SHA256 (100,000 iterations) and a per-user salt. The password and the key never leave the device; the server only sees ciphertext. I originally planned to use Argon2id, which is better at resisting GPU attacks, but the pure-Dart implementation was far too slow, and the native plugin had linking problems on some Android architectures. That's still on my list to revisit.
Key derivation and encryption are CPU-heavy, so they run in background isolates (Dart's version of threads, using compute). The UI never freezes while you open a long entry.
The web portal: same crypto, in the browser
The web portal had to produce exactly the same keys and ciphertext as the phone, or entries written on one couldn't be read on the other. It uses the browser's built-in WebCrypto API with the same parameters: PBKDF2 with 100,000 iterations and a 256-bit key, then AES-GCM. Heavy work like importing a large archive runs in a Web Worker, the browser's equivalent of an isolate.
Search when the server is blind
End-to-end encryption breaks the usual tools. The server can't search your entries, filter them by mood or sort them by title, because it can't read any of that.
I explored keeping a local decrypted copy in the browser with IndexedDB and rejected it: downloading and decrypting 10,000 entries on login would make the portal painfully slow, and browser storage quotas vary too much. Instead, I used two patterns:
- Pagination stays on the server. The portal loads 50 entries per page and only decrypts those.
- An encrypted index blob. Next to each entry, the client stores a small encrypted blob with the metadata needed for lists and search: title, mood, cover photo. The server exposes these through a lightweight endpoint. The client downloads and decrypts just the index blobs, and searches them locally.
The server stays completely blind, and the portal stays fast.
Features, not just crypto
A diary has to be pleasant to use, so most of the work went into the app itself:
- an editor built from blocks: text, images and voice notes,
- folders, writing prompts, statistics and streaks,
- On This Day, which brings back what you wrote on this date in earlier years,
- a birthday collection with age-aware features,
- import from ZIP, JSON and Markdown.
Next on the design board is a cassette player for voice notes: tape reels that rotate with the playback position, a mechanical haptic "clack" on the buttons, and a rewind that actually plays the audio backwards.
The bug that taught me the most
Encryption bugs are scary because they fail silently. My worst one went like this:
- On a fresh install, a user imports old entries before logging in.
- There's no derived key yet, so the app quietly creates a temporary guest key and encrypts the imported entries with it.
- The user logs in. Now the real key is active, but the imported entries are still encrypted with the guest key.
- Sync pushes them to the server. The server doesn't care, because it can't read anything anyway.
- On a second device, those entries won't decrypt. Titles, moods and covers come back empty.
The fix had three parts:
- pass the encryption key explicitly through the whole import pipeline, instead of reading a "current key" that can change halfway,
- on login, re-encrypt every local entry that still uses the guest key, then delete the guest key,
- and fail early with a clear error if an import has no valid key, instead of silently inventing one.
The lesson: in an encrypted system, "which key?" must never be implicit.
A smaller sync bug made the same point about units: the client sent timestamps in milliseconds, and one server handler passed them straight to PostgreSQL's to_timestamp(), which expects seconds. Another handler divided by 1000 correctly. Same data, two code paths, one forgotten conversion.
How I work on it
I plan SilentEcho in phases, each with a written context, plan and summary, and I keep a debug log for every non-trivial bug: symptoms, root cause, fix. GitHub Actions check formatting, run flutter analyze and run the test suite with coverage on every push. With a codebase this size, writing things down is what keeps it manageable.
What I've learned so far
- Zero-knowledge changes everything downstream. Search, sorting and sharing all need new designs when the server can't read the data.
- Make keys explicit. Hidden "current key" state is how encrypted data gets corrupted.
- Keep crypto off the UI thread, on every platform.
- Pick crypto you can actually ship. PBKDF2 that works everywhere beats Argon2 that doesn't link, for now.
Blog / Keep reading
Related articles.
Web Development/4 min read
How this blog is built, and how I got it back after a reset
Next.js, Strapi and Dokploy, and the lessons from rebuilding it all.
Web Development/4 min read
CourseCraft, a course website from a folder of videos
A zero-dependency Node.js app that turns course folders into a website.
Web Development/4 min read
Building JamSesh, a place for musicians to find each other
A Go and PostgreSQL backend with real-time chat for a musician platform.