Skip to main content

Command Palette

Search for a command to run...

Designing a Zero-Knowledge Journal App in Flutter

Updated
8 min read
Designing a Zero-Knowledge Journal App in Flutter
K
Hi, I'm Keshav, a solo developer and founder building RozVibe — a privacy-first encrypted journaling app designed to help people reflect honestly in a digital world that often feels too noisy. Learn more about RozVibe → https://rozvibe.uptodown.com/ I write about Flutter development, privacy, startups, indie hacking, app growth, and the lessons I learn while building products from scratch. My goal is simple: build technology that respects people, protects their privacy, and helps them think more clearly.

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:

  1. The cryptographic salt is retrieved

  2. PBKDF2 runs again

  3. The same inputs generate the same key

  4. 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:

  1. Download encrypted entries

  2. Decrypt them locally

  3. 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.

More from this blog

T

Thoughtfully Built | Flutter, Privacy & Startup Engineering

2 posts

Welcome to Thoughtfully Built.

I’m Keshav Chauhan, a solo developer building privacy-first technology and digital products from scratch. Here I share insights on Flutter development, mobile apps, encryption, privacy, startups, indie hacking, and lessons learned while building in public.

I’m currently building RozVibe, an encrypted journaling app focused on emotional safety and user privacy.

Thanks for reading and following the journey.