Zero-Knowledge Messaging in Flutter: Implementing X25519 ECDH & AES-256-GCM without Backend State
The Zero-Knowledge Imperative
When building Kite, our fundamental security requirement was simple yet uncompromising: the backend server must never possess unencrypted message payloads, media streams, or private cryptographic keys. The central server is treated strictly as an untrusted byte relayer.
To achieve this across iOS and Android with maximum performance and minimal battery overhead, we implemented a hybrid two-layer envelope encryption model directly within the Flutter mobile client.
Cryptographic Architecture: Hybrid Envelope Encryption
Public-key cryptography (such as RSA or raw Diffie-Hellman) is computationally too expensive for encrypting multi-megabyte media payloads or continuous chat streams. Symmetric encryption (like AES-256) is blazingly fast, but sharing symmetric keys over an untrusted network is notoriously difficult.
We combined both using hybrid envelope encryption:
- Symmetric Layer (AES-256 GCM): Each message or media payload is encrypted with a newly generated single-use 256-bit symmetric AES key.
- Asymmetric Layer (X25519 ECDH): The single-use symmetric key is sealed inside an asymmetric cryptographic envelope derived from the sender's private key and the recipient's public key.
1. Client-Side Key Generation & Hardware Security
Upon registration, the client generates an X25519 elliptic-curve key pair:
The private key never touches HTTP requests or server persistence. Only the public key (encoded in Base64) is uploaded to POST /auth/sign-up to populate the directory.
2. Deriving Shared Secrets via Diffie-Hellman (ECDH)
When Alice messages Bob:
- Alice fetches Bob's public key from the server directory (
/profile/{bobId}/public-key). - Alice computes the shared Diffie-Hellman secret using her private key and Bob's public key.
- Bob computes the exact same shared secret using his private key and Alice's public key:
Using the cryptography package in Dart:
3. Envelope Encryption & Nonce Safety
For the payload, we use AES-256-GCM (Galois/Counter Mode), which provides both confidentiality and authenticated integrity.
A crucial pitfall in GCM is nonce reuse: if two different messages are encrypted with the same key and the same 96-bit nonce (Initialization Vector), an attacker can easily recover the plaintext.
We generate a cryptographically random 12-byte nonce for every individual payload:
4. Encrypted Media Attachments: Handling Binary Streams
Sending photos, voice notes, and videos through this pipeline required special care. Attempting to encode raw 100MB video bytes to Base64 in Dart causes severe GC memory thrashing.
Instead, Kite uses Stream-based chunked encryption:
- Raw file bytes from camera or microphone are read as a stream.
- Encrypted locally via AES-GCM into an encrypted binary blob.
- The encrypted blob is uploaded directly to
POST /media/upload(backed by MongoDB GridFS). - Only the file's download hash, along with the encrypted envelope key and nonce, is sent across the STOMP WebSocket chat room.
Even if an attacker gains full root access to the MongoDB GridFS media database, all stored media is indistinguishable from random pseudorandom noise without the client-side keys.
Memory Hygiene in Dart
Unlike C/C++ or Rust, Dart is a garbage-collected language without explicit free() or memset_s() to zero out sensitive memory buffers. To minimize exposure in RAM:
- Sensitive byte lists (
Uint8List) containing raw keys are overwritten with zeros (keyBytes.fillRange(0, keyBytes.length, 0)) immediately after cipher completion. - Private keys are only retained in memory during the execution of the cryptographic routine, then left to quick GC reclamation.
By adhering to these principles, Kite provides bank-grade privacy while maintaining the fluid 60fps UX of a modern mobile messenger.