Zero-Knowledge Messaging in Flutter: Implementing X25519 ECDH & AES-256-GCM without Backend State

·8 min read·Gwyne Justin Deterala

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:

  1. Symmetric Layer (AES-256 GCM): Each message or media payload is encrypted with a newly generated single-use 256-bit symmetric AES key.
  2. 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:

dart
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class CryptoEngine {
  static final _algorithm = X25519();
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  /// Generates a local keypair on onboarding
  static Future<SimpleKeyPair> generateAndPersistKeyPair() async {
    final keyPair = await _algorithm.newKeyPair();
    final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
    final publicKey = await keyPair.extractPublicKey();

    // Store private key strictly in hardware-backed secure storage
    await _storage.write(
      key: 'kite_e2ee_private_key',
      value: base64Encode(privateKeyBytes),
    );

    return keyPair;
  }
}

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:

  1. Alice fetches Bob's public key from the server directory (/profile/{bobId}/public-key).
  2. Alice computes the shared Diffie-Hellman secret using her private key and Bob's public key.
  3. Bob computes the exact same shared secret using his private key and Alice's public key:
SharedSecret=ECDH(PrivateAlice,PublicBob)=ECDH(PrivateBob,PublicAlice)\text{SharedSecret} = \text{ECDH}(\text{Private}_{\text{Alice}}, \text{Public}_{\text{Bob}}) = \text{ECDH}(\text{Private}_{\text{Bob}}, \text{Public}_{\text{Alice}})

Using the cryptography package in Dart:

dart
Future<SecretKey> deriveSharedSecret({
  required SimpleKeyPair senderKeyPair,
  required PublicKey recipientPublicKey,
}) async {
  final sharedSecret = await _algorithm.sharedSecretKey(
    keyPair: senderKeyPair,
    remotePublicKey: recipientPublicKey,
  );
  return sharedSecret;
}

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:

dart
class EnvelopeEncryption {
  static final _aesGcm = AesGcm.with256bits();

  /// Encrypts message payload with a single-use AES key, 
  /// and encrypts that key with the shared ECDH secret.
  static Future<EncryptedPayloadDto> sealMessage({
    required String plaintext,
    required SecretKey sharedEcdhKey,
  }) async {
    // 1. Generate an ephemeral random 256-bit symmetric key
    final ephemeralAesKey = await _aesGcm.newSecretKey();
    final ephemeralKeyBytes = await ephemeralAesKey.extractBytes();

    // 2. Encrypt plaintext payload with ephemeral AES-GCM
    final payloadBox = await _aesGcm.encrypt(
      utf8.encode(plaintext),
      secretKey: ephemeralAesKey,
    );

    // 3. Encrypt the ephemeral key with the shared ECDH secret (Envelope)
    final keyEnvelopeBox = await _aesGcm.encrypt(
      ephemeralKeyBytes,
      secretKey: sharedEcdhKey,
    );

    return EncryptedPayloadDto(
      ciphertext: base64Encode(payloadBox.cipherText),
      nonce: base64Encode(payloadBox.nonce),
      macTag: base64Encode(payloadBox.mac.bytes),
      encryptedKeyEnvelope: base64Encode(keyEnvelopeBox.concatenation()),
    );
  }
}

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:

  1. Raw file bytes from camera or microphone are read as a stream.
  2. Encrypted locally via AES-GCM into an encrypted binary blob.
  3. The encrypted blob is uploaded directly to POST /media/upload (backed by MongoDB GridFS).
  4. 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.