HomeDocumentationAdvanced Flutter and C++ in Dart FFI
Advanced Flutter and C++ in Dart FFI
16

End-to-End Encryption With libsignal in Flutter

Flutter libsignal FFI — Implement Signal Protocol for Encrypted Messaging

April 22, 2026

You're building a messaging app and you need real encryption. Not "we encrypt data at rest on our servers" — that means you can read it. Real end-to-end encryption, where only the sender and recipient can decrypt messages, and the server is a dumb relay that handles ciphertext it can't read.

The Signal Protocol (formerly Axolotl) is the gold standard. It's what Signal, WhatsApp, Google Messages (RCS), Facebook Messenger, and Skype use. It provides forward secrecy (compromising a key doesn't decrypt past messages), future secrecy (compromising a key doesn't decrypt future messages), and deniable authentication. It's been formally verified by cryptographers and deployed to billions of devices.

You are not going to design your own encryption protocol. If you're reading this post, you're going to use Signal's.

The landscape

There are several implementations, and the picture is awkward:

  • libsignal-protocol-c — the original C implementation by Open Whisper Systems. Small, portable, well-understood. Archived. Signal's own repository hasn't received meaningful updates in years. Community forks exist (e.g. nickelc/libsignal-protocol-c) but none have taken over formal stewardship.
  • libsignal (Rust) — Signal's current, actively maintained implementation. This is what Signal Messenger, WhatsApp, and Google Messages actually run in production today. Harder to build for mobile (Rust toolchain + NDK + cross-compilation), but it's where the security fixes land.
  • Pure Dart implementations — exist on pub.dev, but haven't received the same level of cryptographic review.

Which to use in 2026? For new production apps, the honest answer is libsignal (Rust) — it's maintained, audited, and what the reference apps use. The tradeoff is build complexity: you're integrating a Rust staticlib or building a C ABI around it. Several Flutter projects have done this; expect a few days of build-system work.

This post walks through libsignal-protocol-c because (a) the FFI patterns are the same shape for either library, (b) the archived C library is simpler for illustrating the integration mechanics, and (c) if you're maintaining an existing app built on it, you need to know how it works. Don't start new greenfield work here. If you're shipping a new messaging app, invest the extra days in integrating Rust libsignal.

Important disclaimer: Cryptographic code is security-critical. This guide covers the FFI integration mechanics, not a complete security audit. If you're building a production messaging app with E2EE, get a professional security review.

Getting libsignal-protocol-c

bash
git clone https://github.com/nickelc/libsignal-protocol-c.git
cd libsignal-protocol-c
mkdir build && cd build

# For host (testing)
cmake -DCMAKE_BUILD_TYPE=Release ..
make

# For Android arm64
cmake -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_NATIVE_API_LEVEL=24 \
  -DCMAKE_BUILD_TYPE=Release ..
make

This produces libsignal-protocol-c.so (~150KB). Small and self-contained.

For iOS, add the source files directly to your Xcode project or create a framework.

Signal Protocol concepts (minimum viable understanding)

Before writing code, you need to understand four things:

1. Identity keys. Each user has a long-term Ed25519 key pair. This is their identity — it doesn't change. It's generated once when the user first registers.

2. Pre-keys. Each user uploads a set of one-time-use Curve25519 key pairs to the server. When Alice wants to message Bob for the first time, she downloads one of Bob's pre-keys and uses it to establish a session. Pre-keys are consumed on use and replenished periodically.

3. Sessions. A session between two users contains the ratcheting state — the evolving keys used to encrypt and decrypt messages. The Double Ratchet algorithm advances the keys after every message, providing forward and future secrecy.

4. The flow:

javascript
Alice                       Server                      Bob
  │                                                       │
  │ 1. Upload identity key + pre-keys ──────────────────► │
  │                                                       │
  │ ◄──────────────────── 2. Upload identity key + pre-keys │
  │                                                       │
  │ 3. Fetch Bob's pre-key bundle ──────►                 │
  │ ◄───────────────────────────────────                  │
  │                                                       │
  │ 4. Build session from pre-key bundle                  │
  │ 5. Encrypt message with session                       │
  │ 6. Send ciphertext ─────────────────►                 │
  │                            ─────────────────────────► │
  │                                     7. Decrypt with   │
  │                                        session state  │

The C bridge

libsignal-protocol-c has a callback-based architecture. You provide implementations of a "store" interface (for persisting keys, sessions, and pre-keys), and the library calls your functions when it needs to read or write state.

This callback pattern is the trickiest part of the FFI integration. Here's a simplified bridge:

c
// native/src/signal_bridge.c
#include <signal/signal_protocol.h>
#include <signal/key_helper.h>
#include <string.h>
#include <stdlib.h>

// Global context (in production, manage per-user)
static signal_context* global_context = NULL;

// Initialize the Signal Protocol library
int32_t signal_bridge_init(void) {
    if (global_context != NULL) return 0;

    int result = signal_context_create(&global_context, NULL);
    if (result != 0) return result;

    // Set up crypto provider (using built-in OpenSSL or platform crypto)
    signal_crypto_provider provider = { /* ... platform crypto callbacks ... */ };
    result = signal_context_set_crypto_provider(global_context, &provider);

    return result;
}

// Generate an identity key pair
int32_t signal_bridge_generate_identity_key_pair(
    uint8_t** public_key_out, int32_t* public_key_len,
    uint8_t** private_key_out, int32_t* private_key_len
) {
    ratchet_identity_key_pair* key_pair = NULL;
    int result = signal_protocol_key_helper_generate_identity_key_pair(
        &key_pair, global_context
    );
    if (result != 0) return result;

    // Serialize public key
    ec_public_key* pub = ratchet_identity_key_pair_get_public(key_pair);
    signal_buffer* pub_buf = NULL;
    ec_public_key_serialize(&pub_buf, pub);
    *public_key_len = signal_buffer_len(pub_buf);
    *public_key_out = malloc(*public_key_len);
    memcpy(*public_key_out, signal_buffer_data(pub_buf), *public_key_len);
    signal_buffer_free(pub_buf);

    // Serialize private key
    ec_private_key* priv = ratchet_identity_key_pair_get_private(key_pair);
    signal_buffer* priv_buf = NULL;
    ec_private_key_serialize(&priv_buf, priv);
    *private_key_len = signal_buffer_len(priv_buf);
    *private_key_out = malloc(*private_key_len);
    memcpy(*private_key_out, signal_buffer_data(priv_buf), *private_key_len);
    signal_buffer_free(priv_buf);

    SIGNAL_UNREF(key_pair);
    return 0;
}

// Generate pre-keys (batch)
int32_t signal_bridge_generate_pre_keys(
    int32_t start_id,
    int32_t count,
    uint8_t** keys_out,      // Serialized pre-keys, concatenated
    int32_t* keys_len_out
) {
    signal_protocol_key_helper_pre_key_list_node* pre_keys = NULL;
    int result = signal_protocol_key_helper_generate_pre_keys(
        &pre_keys, start_id, count, global_context
    );
    if (result != 0) return result;

    // Serialize and concatenate pre-keys
    // (simplified — in production, use a structured format)
    // ...

    signal_protocol_key_helper_key_list_free(pre_keys);
    return 0;
}

// Free a buffer allocated by the bridge
void signal_bridge_free(void* ptr) {
    free(ptr);
}

The real implementation requires significantly more code — session builders, store interfaces, message encryption/decryption. The pattern is the same throughout: call libsignal's C functions, serialize results into byte buffers, return pointers and lengths to Dart.

Dart side

dart
class SignalProtocol {
  static final DynamicLibrary _lib = Platform.isAndroid
      ? DynamicLibrary.open('libsignal_bridge.so')
      : DynamicLibrary.process();

  static final _init = _lib.lookupFunction<
      Int32 Function(), int Function()>('signal_bridge_init');

  static final _generateIdentityKeyPair = _lib.lookupFunction<
      Int32 Function(Pointer<Pointer<Uint8>>, Pointer<Int32>,
                     Pointer<Pointer<Uint8>>, Pointer<Int32>),
      int Function(Pointer<Pointer<Uint8>>, Pointer<Int32>,
                   Pointer<Pointer<Uint8>>, Pointer<Int32>)
  >('signal_bridge_generate_identity_key_pair');

  static final _free = _lib.lookupFunction<
      Void Function(Pointer<Void>),
      void Function(Pointer<Void>)
  >('signal_bridge_free');

  static void initialize() {
    final result = _init();
    if (result != 0) throw Exception('Signal Protocol init failed: $result');
  }

  static ({Uint8List publicKey, Uint8List privateKey}) generateIdentityKeyPair() {
    final pubPtr = calloc<Pointer<Uint8>>();
    final pubLen = calloc<Int32>();
    final privPtr = calloc<Pointer<Uint8>>();
    final privLen = calloc<Int32>();

    try {
      final result = _generateIdentityKeyPair(pubPtr, pubLen, privPtr, privLen);
      if (result != 0) throw Exception('Key generation failed: $result');

      final publicKey = Uint8List.fromList(
        pubPtr.value.asTypedList(pubLen.value),
      );
      final privateKey = Uint8List.fromList(
        privPtr.value.asTypedList(privLen.value),
      );

      // Free the C-allocated buffers
      _free(pubPtr.value.cast());
      _free(privPtr.value.cast());

      return (publicKey: publicKey, privateKey: privateKey);
    } finally {
      calloc.free(pubPtr);
      calloc.free(pubLen);
      calloc.free(privPtr);
      calloc.free(privLen);
    }
  }
}

The store interface challenge

The hardest part of this integration isn't the crypto — it's the store. libsignal needs to persist:

  • Identity key store: your identity and trusted identities of peers
  • Pre-key store: your unused pre-keys
  • Signed pre-key store: your signed pre-keys
  • Session store: active sessions with each peer

In C, you implement these as function pointer callbacks. In Dart FFI, this means using NativeCallable to create C-callable Dart functions — or implementing the store entirely in C (with SQLite, for example) and only crossing the FFI boundary for high-level operations.

The second approach is cleaner for production:

javascript
Dart: "encrypt this message for user X"
  → C bridge: looks up session in SQLite, encrypts, returns ciphertext
Dart: sends ciphertext over network

The Dart side never touches raw crypto state. The C bridge + SQLite store handles all persistence. This limits the FFI surface and keeps the security-critical code in C where it's been audited.

Common errors

"untrusted identity" error when establishing a session

Cause: The identity key for the remote user changed since the last session. This could mean the user reinstalled the app (legitimate) or a man-in-the-middle attack.

Fix: Your app needs a trust decision UI: "Bob's security number has changed. Verify in person or accept the new identity." Signal's UX is the model here. Never silently accept new identities.

Pre-keys exhausted — can't establish new sessions

Cause: All uploaded pre-keys have been consumed and the server has none left. This happens if the user is popular (many new contacts) and the app doesn't replenish.

Fix: Replenish pre-keys when the count drops below a threshold. Check the server-side count periodically and upload a new batch when it's low (e.g., below 20).

Memory leak from signal_buffer not freed

Cause: libsignal uses reference-counted objects (SIGNAL_REF/SIGNAL_UNREF) and signal_buffer for byte arrays. Forgetting to free/unref leaks memory.

Fix: Every signal_buffer* from a serialize operation needs signal_buffer_free(). Every object from a create/generate operation needs SIGNAL_UNREF(). The C bridge must handle this — Dart should never hold raw libsignal pointers.

Decryption fails with "message too old"

Cause: The Double Ratchet has advanced past the key that encrypted this message. This happens if messages arrive very far out of order (hundreds of messages late).

Fix: The protocol has a window for out-of-order messages (typically 2000 message keys are retained). If messages arrive beyond this window, they can't be decrypted. This is a feature, not a bug — it limits the damage from key compromise. In practice, network reordering rarely exceeds a handful of messages.

Crypto provider not set — crash on first operation

Cause: signal_context_set_crypto_provider was never called, or the platform's crypto library isn't available.

Fix: libsignal needs a crypto provider for the underlying primitives (AES, SHA-256, HMAC, Curve25519). On Android, use OpenSSL (bundled with the NDK). On iOS, use CommonCrypto. The crypto provider setup is platform-specific and must happen before any key generation or encryption.

A word on alternatives

If this integration feels heavy (it is), consider:

  • `libsignal` (Rust) — the actively-maintained Signal implementation. If you need Signal Protocol semantics for a new app, this is the right target. The integration shape is identical to what's above (C ABI → Dart FFI), just with Rust's cbindgen-generated headers on the native side.
  • `cryptography` package (pub.dev) — pure Dart crypto primitives. Good for simpler encryption needs (AES-GCM for data at rest), not for the full Signal Protocol.
  • `libsodium` via FFI — covered in the FFI foundations series. Good for authenticated encryption, key exchange, and signing. Simpler than the Signal Protocol but doesn't provide the Double Ratchet's forward/future secrecy.
  • Matrix SDK — if you want an encrypted messaging protocol with a ready-made Flutter SDK, the Matrix protocol (used by Element) has a Dart SDK that handles E2EE internally.

The Signal Protocol is the right choice when you need the strongest messaging encryption guarantee and you're building the messaging infrastructure yourself. For anything less demanding, simpler tools exist.

This is Post 19 of the FFI series. Next: Geospatial Processing With PROJ and GEOS.

Related Topics

flutter end to end encryptionsignal protocol flutterlibsignal flutter ffiflutter encrypted messagingflutter e2eesignal protocol dartflutter secure chatdouble ratchet flutter

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.