Designing a Zero-Knowledge Journal App in Flutter

Designing a Journal Where Even the Server Can't Read Your Entries
Most journaling apps ask users to trust them.
I wanted to build one that didn't require trust at all.
That sounds like a subtle difference, but it fundamentally changed the architecture of the application I was building.
When I started working on RozVibe, a privacy-first journaling app, I quickly realized that security isn't just about preventing hackers from accessing user data.
It's also about preventing yourself from becoming a risk.
If a server can read user journals, then anyone who gains access to that server can potentially read them too.
The obvious solution is encryption.
The harder question is:
How do you design a system where even the server cannot decrypt the data it stores?
That's the problem I set out to solve.
What You'll Learn
In this article, I'll explain:
How journal entries are encrypted before leaving the device
How encryption keys are derived and managed
How multi-device sync works without syncing encryption keys
Why encrypted search is difficult
How blind indexes solve the search problem
The trade-offs I accepted while building RozVibe
What "Zero-Knowledge" Means Here
Before going further, it's worth clarifying a term that often causes confusion.
When I use the phrase zero-knowledge architecture, I'm not referring to cryptographic zero-knowledge proofs (ZKPs).
In this article, zero-knowledge simply means:
The server stores encrypted data but cannot decrypt or read the contents of user journal entries.
All sensitive information is encrypted on the user's device before it is transmitted.
The server acts as a synchronization layer, not a trusted vault.
The Threat Model
Every security system should start with a clear threat model.
These are the risks I wanted RozVibe to defend against:
| Threat | Protection |
|---|---|
| Firestore database breach | ✅ |
| Unauthorized server access | ✅ |
| Rogue administrator reading entries | ✅ |
| Network interception | ✅ |
| Lost or stolen device | Partial |
| Weak PIN chosen by user | ❌ |
| Malware running on user device | ❌ |
No security system protects against every possible attack.
The goal is to reduce realistic risks without making the product unusable.
The Traditional Approach
Most cloud applications work like this:
User
↓
Server
↓
Database
The server receives plaintext.
The database stores plaintext.
Access controls determine who can read it.
For many products, that's completely reasonable.
For a private journal, it isn't.
A journal often contains thoughts people wouldn't share with anyone else.
That changes the threat model entirely.
The Core Design Goal
I wanted the architecture to satisfy a simple rule:
Journal entries must be encrypted before they leave the user's device.
If this rule holds, then:
Firestore never receives plaintext
Database administrators cannot read entries
A server breach exposes ciphertext rather than journal content
The cloud becomes a synchronization service rather than a trusted storage provider
Conceptually, the architecture looks like this:
Journal Entry
↓
AES-256-GCM Encryption
↓
Encrypted Payload
↓
Firestore
The server never sees readable content.
Only encrypted data.
Why I Chose AES-256-GCM
Every journal entry is encrypted using AES-256-GCM.
I chose it for three reasons.
Confidentiality
Without the encryption key, the data remains unreadable.
Integrity
AES-GCM includes authentication.
If encrypted data is modified, decryption fails.
Industry Adoption
AES-GCM is widely trusted, heavily analyzed, and used throughout modern secure systems.
Each encryption operation also generates a fresh random 12-byte IV.
This ensures that identical journal entries produce completely different ciphertext.
Even if two users write the exact same sentence, the encrypted output will be different.
What Actually Gets Stored
When a user saves an entry, sensitive information is bundled into a single encrypted payload.
This includes:
Journal content
Mood information
Creation timestamps
Update timestamps
Only a minimal amount of metadata remains unencrypted for querying and sorting.
A simplified representation looks like this:
{
'id': id,
'userId': userId,
'date_index': dateIndex,
'isFavorite': isFavorite,
'data': encryptedPayload
}
Everything meaningful lives inside the encrypted payload.
Firestore receives ciphertext, not journal entries.
The Hardest Problem Wasn't Encryption
The hardest problem wasn't encrypting data.
It was managing the encryption key.
Most security failures don't happen because encryption algorithms are weak.
They happen because key management is weak.
If I stored encryption keys in Firestore, anyone with database access could potentially recover user data.
If I stored keys permanently on devices, a compromised device could expose sensitive information.
So I adopted a strict rule:
Encryption keys should never be stored permanently.
They should only exist in memory while a user is actively authenticated.
Who Manages The Encryption Key?
RozVibe uses two services to manage the entire key lifecycle.
EncryptionService
This service acts as the custodian of cryptographic material.
It:
Derives encryption keys
Holds keys in memory
Encrypts journal entries
Decrypts journal entries
The key material exists only as transient in-memory variables:
encrypt_pkg.Key? _key;
encrypt_pkg.IV? _legacyIv;
Uint8List? _searchKey;
The AES key, blind-index search key, and legacy IV never persist to disk.
They never enter Firestore.
They never enter SQLite.
They never enter SharedPreferences.
They exist only in RAM.
AuthService
AuthService controls the key lifecycle.
When a user signs in:
initializeEncryptionForUser(userId);
When a user signs out:
_encryptionService.clear();
The authentication layer decides when keys exist.
The encryption layer decides how they're used.
How Key Derivation Works
Since the key is never stored, it must be reconstructed every time the user logs in.
This is done using PBKDF2 with HMAC-SHA256 and 100,000 iterations.
The derivation uses three components:
User ID
User PIN
Cryptographic salt
final password =
utf8.encode('\({userId}_\){pin ?? "default_secure_vault"}');
The resulting key material is split into:
AES-256 encryption key
Legacy IV
Blind-index search key
The important detail is that none of these values are downloaded from the server.
They are mathematically reconstructed on the device.
What Happens During Logout?
When the user logs out:
void clear() {
_key = null;
_legacyIv = null;
_searchKey = null;
}
All cryptographic material is immediately removed from memory.
The next time the user signs in, the entire derivation process runs again.
The key effectively disappears until authentication succeeds.
Multi-Device Sync Without Syncing Keys
One question I get frequently is:
If the key isn't stored anywhere, how does a new device decrypt existing entries?
The answer is deterministic reconstruction.
When a user signs in on a new device:
The cryptographic salt is retrieved
PBKDF2 runs again
The same inputs generate the same key
Existing entries become readable
The key is never transferred between devices.
It is recreated locally.
The Search Problem
Encryption creates a difficult problem.
Search.
Databases are excellent at searching plaintext.
They're terrible at searching ciphertext.
A server cannot search data it cannot understand.
Initially, search worked like this:
Download encrypted entries
Decrypt them locally
Search through memory
Simple.
Private.
Not scalable.
Building A Blind Index
To improve performance, I started implementing a blind-index architecture.
When an entry is saved:
final tokens = SearchTokenizer.extractWords(content);
for (final token in tokens) {
final hash =
HMAC_SHA256(searchKey, token);
blindIndex.insert(hash, entryId);
}
Instead of storing plaintext words, the application stores cryptographic hashes.
token_hash → entry_id
When a user searches:
Search Query
↓
HMAC-SHA256
↓
SQLite Lookup
↓
Matching Entry IDs
↓
Decrypt Only Matches
The query never leaves the device.
The server never sees searchable content.
And only relevant entries need to be decrypted.
The Trade-Off I Chose
Every privacy architecture involves trade-offs.
Mine does too.
To support seamless multi-device access, the cryptographic salt is synchronized through Firestore.
This means someone with:
Database access
User credentials
could theoretically derive the same encryption key.
The alternative would be forcing users to manage cryptographic keys manually.
While theoretically stronger, it also dramatically increases the risk of users permanently losing access to their journals.
I chose recoverability over absolute cryptographic purity.
Reasonable people may disagree.
That's the nature of architecture decisions.
What Building This Taught Me
Before building RozVibe, I thought privacy was primarily a security problem.
Now I think it's a design problem.
Security protects data.
Privacy protects people.
The goal wasn't AES-256-GCM.
The goal wasn't PBKDF2.
The goal wasn't even encryption itself.
The goal was creating a space where users could write honestly without needing to trust a server with their most personal thoughts.
Everything else was simply an engineering consequence of that decision.


