Everything has been building to this
Post 1 established why FFI exists and how the managed/unmanaged boundary works. Post 2 went deep on pointers, string conversion, and the ownership contract. Post 3 covered structs and the alignment rules that, when violated, produce silent data corruption. Post 4 reversed the direction — C calling Dart — and the thread model that makes it safe.
Every one of those posts used simplified examples: functions that add numbers, sort arrays, process text. Useful for learning. Not the thing you'd actually ship.
This post ships something. We're going to integrate libsodium — the cryptography library that Signal, WhatsApp, Cloudflare, and dozens of other security-critical systems trust — into a Flutter app from scratch. No pub.dev package wrapping it for us. No hiding the FFI behind someone else's abstraction. The real thing, built from headers and binaries, wrapped in a clean Dart API, used in a real Flutter feature.
By the end: a Flutter note-taking screen where every note is encrypted before being written to disk, and decrypted on read. The encryption is authenticated — tampering with the ciphertext is detected and rejected. The implementation uses exactly the APIs Signal recommends for symmetric encryption.
Let's build it.
Why libsodium
Cryptography is one of the few domains where "write it yourself" is genuinely dangerous advice. The mathematical primitives are well-understood; the implementation details are where catastrophic vulnerabilities hide. Timing side channels. Integer overflows in length calculations. Incorrect nonce handling. Buffer boundary errors. Any one of these, implemented slightly wrong, can make a cryptographically sound algorithm completely insecure.
libsodium's design philosophy is deliberate: it makes it difficult to use correctly and nearly impossible to use catastrophically wrong. Its API surface is small. It defaults to the most secure options. It doesn't expose low-level primitives that require expert knowledge to combine safely.
The specific operation we're implementing — crypto_secretbox — takes a plaintext message, a 32-byte secret key, and a 24-byte nonce (number used once), and returns an authenticated ciphertext. "Authenticated" means it includes a 16-byte MAC (message authentication code) that proves the ciphertext wasn't tampered with. Anyone who tries to modify the ciphertext — even a single bit — and then attempts to decrypt it will get an authentication failure, not silently corrupted plaintext.
The underlying algorithms: XSalsa20 for encryption, Poly1305 for authentication. The combination was designed by Daniel Bernstein, who also designed the Curve25519 key exchange. It's fast, secure, and the nonce is large enough (192 bits) that randomly generating it for each message is safe — the probability of a collision is negligible even after billions of messages.
This is the right primitive for: encrypting local storage, encrypting data before syncing to a server you don't fully trust, encrypting messages between two parties who have already established a shared secret.
Getting the library
libsodium distributes prebuilt binaries for every major platform. There's no need to compile from source for a standard Flutter integration.
Download the Android binaries:
From the libsodium releases page, download libsodium-X.Y.Z-android.zip. It contains precompiled .so files for every Android ABI:
libsodium-1.0.20-android/
├── libsodium-android-armv7-a/lib/libsodium.so
├── libsodium-android-armv8-a/lib/libsodium.so ← modern 64-bit devices
└── libsodium-android-x86_64/lib/libsodium.so ← emulatorsCopy them into your Flutter project at exactly these paths — Android's build system automatically packages .so files from jniLibs:
android/app/src/main/jniLibs/
├── arm64-v8a/libsodium.so
├── armeabi-v7a/libsodium.so
└── x86_64/libsodium.soDownload the iOS static library:
From the same releases page, download libsodium-X.Y.Z.tar.gz. Inside, find the prebuilt iOS static library:
libsodium-1.0.20/
└── dist/
└── ios/
└── libsodium.a ← static library for all iOS architecturesCopy it to your Flutter project:
ios/
└── libsodium.aThe header file:
You'll need sodium.h for ffigen. It's in the tarball:
libsodium-1.0.20/src/libsodium/include/sodium.hCopy it and the sodium/ subdirectory it depends on into your project:
native/include/
├── sodium.h
└── sodium/
├── crypto_secretbox.h
├── crypto_secretbox_xsalsa20poly1305.h
├── randombytes.h
└── ... (many sub-headers)Android build configuration
Android needs to know about the prebuilt library so it's included in the APK and loadable at runtime. Create android/app/src/main/cpp/CMakeLists.txt:
cmake_minimum_required(VERSION 3.18.1)
project("sodium_flutter")
# Declare the prebuilt libsodium as an imported library
# Android's build system will find it in jniLibs/${ANDROID_ABI}/
add_library(sodium SHARED IMPORTED)
set_target_properties(sodium PROPERTIES
IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libsodium.so
)
# A minimal wrapper library — needed to satisfy the CMake build target
# even though we're just using the prebuilt libsodium directly
add_library(sodium_flutter SHARED
sodium_shim.c # create this as an empty file
)
target_link_libraries(sodium_flutter sodium)Create the empty shim (android/app/src/main/cpp/sodium_shim.c):
// Empty — we don't add any custom C code in this integration.
// libsodium's symbols are loaded directly from libsodium.so.Wire it into android/app/build.gradle:
android {
defaultConfig {
externalNativeBuild {
cmake { cppFlags "" }
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.18.1"
}
}
}iOS build configuration
In Xcode, open ios/Runner.xcworkspace. In the Runner target:
- Go to Build Phases → Link Binary With Libraries
- Click +, then Add Other → Add Files
- Navigate to
ios/libsodium.aand add it
Then, in Build Settings → Header Search Paths, add the path to your native/include/ directory so the compiler can find sodium.h during the build.
Because iOS statically links everything, DynamicLibrary.process() finds libsodium's symbols in the current process — no .so to open at runtime.
Generating bindings with ffigen
Instead of hand-writing Dart bindings for libsodium's API (dozens of functions across many sub-headers), we'll use ffigen to generate them from the header.
Add to pubspec.yaml:
dependencies:
ffi: ^2.1.0
dev_dependencies:
ffigen: ^11.0.0Create ffigen.yaml in your project root:
output: 'lib/src/sodium_bindings.dart'
name: 'SodiumBindings'
description: 'Auto-generated FFI bindings for libsodium'
headers:
entry-points:
- 'native/include/sodium.h'
# Only generate bindings for the functions we need
# Avoids generating hundreds of unused bindings
functions:
include:
- 'sodium_init'
- 'crypto_secretbox_easy'
- 'crypto_secretbox_open_easy'
- 'crypto_secretbox_keygen'
- 'randombytes_buf'
globals:
exclude:
- '.*' # exclude global variables — we'll use constants directly
typedefs:
exclude:
- '.*' # we don't need the typedef aliases
preamble: |
// AUTO-GENERATED — do not edit by hand
// Run: dart run ffigen --config ffigen.yaml
compiler-opts:
- '-I/native/include'Run the generator:
dart run ffigen --config ffigen.yamlffigen reads sodium.h, parses every function declaration, and generates a Dart file with all the typedefs and lookupFunction scaffolding. Here's a trimmed view of what it produces for our needed functions:
// lib/src/sodium_bindings.dart (generated, do not edit)
class SodiumBindings {
final DynamicLibrary _dylib;
SodiumBindings(this._dylib);
// int sodium_init()
late final _sodium_init = _dylib.lookupFunction<
Int32 Function(),
int Function()>('sodium_init');
int sodium_init() => _sodium_init();
// int crypto_secretbox_easy(
// unsigned char* c, const unsigned char* m,
// unsigned long long mlen, const unsigned char* n,
// const unsigned char* k)
late final _crypto_secretbox_easy = _dylib.lookupFunction<
Int32 Function(Pointer<Uint8>, Pointer<Uint8>, Uint64, Pointer<Uint8>, Pointer<Uint8>),
int Function(Pointer<Uint8>, Pointer<Uint8>, int, Pointer<Uint8>, Pointer<Uint8>)>(
'crypto_secretbox_easy',
);
int crypto_secretbox_easy(
Pointer<Uint8> c, Pointer<Uint8> m, int mlen,
Pointer<Uint8> n, Pointer<Uint8> k,
) => _crypto_secretbox_easy(c, m, mlen, n, k);
// ... and so on for the other functions
}The generated file is correct but raw — it maps C types directly to FFI types. We wouldn't expose this to the rest of the app. We build a clean Dart API on top of it.
Initializing the library
libsodium requires sodium_init() to be called exactly once before any other function. It initializes the CPU feature detection (SIMD acceleration for encryption), seeds the random number generator, and performs self-tests. Returns 0 on first init, 1 if already initialized, -1 on failure.
// lib/src/sodium_native.dart
import 'dart:ffi';
import 'dart:io';
import 'sodium_bindings.dart';
SodiumBindings _createBindings() {
final lib = Platform.isAndroid
? DynamicLibrary.open('libsodium.so')
: DynamicLibrary.process(); // iOS: statically linked
return SodiumBindings(lib);
}
// Single instance — library is initialized once, bindings reused
final SodiumBindings _sodium = () {
final bindings = _createBindings();
final result = bindings.sodium_init();
if (result == -1) {
throw StateError(
'sodium_init() failed — libsodium could not initialize. '
'Check that the native library is correctly bundled.',
);
}
return bindings;
}();The immediately-invoked closure (() { ... }()) ensures initialization happens exactly once when the _sodium field is first accessed. If sodium_init fails — which should never happen in a correctly built app — we throw immediately rather than producing mysterious failures later.
The clean Dart API: SecretBox
Now we build the API that the rest of the app actually uses. No Pointer<Uint8>, no calloc, no FFI types visible to callers.
// lib/src/secret_box.dart
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'sodium_native.dart';
/// Symmetric authenticated encryption using XSalsa20-Poly1305.
///
/// Encryption: plaintext + key + nonce → ciphertext (with 16-byte MAC prepended)
/// Decryption: ciphertext + key + nonce → plaintext (or throws if tampered)
///
/// Key: 32 bytes, randomly generated, kept secret
/// Nonce: 24 bytes, randomly generated per message, does not need to be secret
/// but MUST be unique per (key, message) pair
class SecretBox {
SecretBox._(); // not instantiable — all methods are static
/// Size of the secret key in bytes (32).
static const int keyBytes = 32;
/// Size of the nonce in bytes (24).
static const int nonceBytes = 24;
/// Bytes added to ciphertext for the MAC (16).
static const int macBytes = 16;
/// Generate a cryptographically random secret key.
///
/// Store this securely — anyone with this key can decrypt your data.
static Uint8List generateKey() {
return using((arena) {
final keyPtr = arena<Uint8>(keyBytes);
_sodium.crypto_secretbox_keygen(keyPtr);
return Uint8List.fromList(keyPtr.asTypedList(keyBytes));
});
}
/// Generate a cryptographically random nonce.
///
/// Generate a fresh nonce for every message. Reusing a nonce with the
/// same key is catastrophic — it exposes your plaintext.
static Uint8List generateNonce() {
return using((arena) {
final noncePtr = arena<Uint8>(nonceBytes);
_sodium.randombytes_buf(noncePtr.cast(), nonceBytes);
return Uint8List.fromList(noncePtr.asTypedList(nonceBytes));
});
}
/// Encrypt [plaintext] with [key] and [nonce].
///
/// Returns the ciphertext with a 16-byte MAC prepended.
/// The ciphertext is [plaintext.length + macBytes] bytes long.
///
/// Throws [ArgumentError] if key or nonce are the wrong length.
static Uint8List encrypt(
Uint8List plaintext,
Uint8List key,
Uint8List nonce,
) {
_validateKey(key);
_validateNonce(nonce);
final ciphertextLength = plaintext.length + macBytes;
return using((arena) {
final plaintextPtr = arena<Uint8>(plaintext.length);
final ciphertextPtr = arena<Uint8>(ciphertextLength);
final keyPtr = arena<Uint8>(keyBytes);
final noncePtr = arena<Uint8>(nonceBytes);
// Copy Dart data into native memory
plaintextPtr.asTypedList(plaintext.length).setAll(0, plaintext);
keyPtr.asTypedList(keyBytes).setAll(0, key);
noncePtr.asTypedList(nonceBytes).setAll(0, nonce);
final result = _sodium.crypto_secretbox_easy(
ciphertextPtr,
plaintextPtr,
plaintext.length,
noncePtr,
keyPtr,
);
if (result != 0) {
// Should never happen — only fails if pointers are null
throw StateError('crypto_secretbox_easy failed unexpectedly');
}
// Copy result to Dart memory before arena frees native buffers
return Uint8List.fromList(ciphertextPtr.asTypedList(ciphertextLength));
});
}
/// Decrypt and verify [ciphertext] with [key] and [nonce].
///
/// Returns the plaintext if authentication succeeds.
///
/// Throws [SecretBoxException] if the ciphertext was tampered with or
/// the key/nonce are incorrect — these cases are indistinguishable by design.
/// Throws [ArgumentError] if key, nonce, or ciphertext are the wrong length.
static Uint8List decrypt(
Uint8List ciphertext,
Uint8List key,
Uint8List nonce,
) {
_validateKey(key);
_validateNonce(nonce);
if (ciphertext.length < macBytes) {
throw ArgumentError(
'Ciphertext too short: must be at least $macBytes bytes '
'(the MAC), got ${ciphertext.length}',
);
}
final plaintextLength = ciphertext.length - macBytes;
return using((arena) {
final ciphertextPtr = arena<Uint8>(ciphertext.length);
final plaintextPtr = arena<Uint8>(plaintextLength);
final keyPtr = arena<Uint8>(keyBytes);
final noncePtr = arena<Uint8>(nonceBytes);
ciphertextPtr.asTypedList(ciphertext.length).setAll(0, ciphertext);
keyPtr.asTypedList(keyBytes).setAll(0, key);
noncePtr.asTypedList(nonceBytes).setAll(0, nonce);
final result = _sodium.crypto_secretbox_open_easy(
plaintextPtr,
ciphertextPtr,
ciphertext.length,
noncePtr,
keyPtr,
);
if (result != 0) {
// Authentication failed — ciphertext was tampered with,
// or wrong key/nonce. Indistinguishable by design.
throw const SecretBoxException(
'Decryption failed: authentication check did not pass. '
'The data may have been tampered with, or the key/nonce is incorrect.',
);
}
return Uint8List.fromList(plaintextPtr.asTypedList(plaintextLength));
});
}
static void _validateKey(Uint8List key) {
if (key.length != keyBytes) {
throw ArgumentError('Key must be $keyBytes bytes, got ${key.length}');
}
}
static void _validateNonce(Uint8List nonce) {
if (nonce.length != nonceBytes) {
throw ArgumentError('Nonce must be $nonceBytes bytes, got ${nonce.length}');
}
}
}
/// Thrown when decryption authentication fails.
class SecretBoxException implements Exception {
final String message;
const SecretBoxException(this.message);
@override
String toString() => 'SecretBoxException: $message';
}The API is clean: generateKey(), generateNonce(), encrypt(), decrypt(). No FFI types. No Pointer. No calloc. Every detail of the native boundary is contained inside this class. Any other file in the project can use symmetric authenticated encryption with four method calls and no cryptographic knowledge required.
How the whole thing fits together: what each post contributed
Look at the encrypt() method and find every concept from the series:
From Post 1 — DynamicLibrary.open('libsodium.so') on Android, DynamicLibrary.process() on iOS. The platform split. lookupFunction with two type parameters.
From Post 2 — arena<Uint8>(length) allocating native buffers. asTypedList(length).setAll(0, data) copying Dart data into native memory. Uint8List.fromList(ptr.asTypedList(length)) copying results back to Dart memory. using((arena) { ... }) for grouped cleanup with no missed frees. The ownership contract: we allocated the buffers, we free them (via the arena).
From Post 3 — libsodium's functions take unsigned char* — in Dart, Pointer<Uint8>. The type mapping in the generated bindings matches C's unsigned long long for the message length to Dart's Uint64/int. The sizes (keyBytes, nonceBytes, macBytes) are exact C constants matched in Dart.
From Post 4 — not directly used in SecretBox (no callbacks needed for synchronous crypto), but generateKey() and generateNonce() both call libsodium functions, and in a production app, these would be called from a background isolate to keep the UI thread responsive for large operations.
A real Flutter feature: encrypted notes
Let's put it to work. A note-taking service that stores notes encrypted on disk:
// lib/features/notes/encrypted_note_service.dart
import 'dart:convert';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import '../../src/secret_box.dart';
class EncryptedNote {
final String id;
final String title;
final String body;
final DateTime createdAt;
const EncryptedNote({
required this.id,
required this.title,
required this.body,
required this.createdAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'body': body,
'createdAt': createdAt.toIso8601String(),
};
factory EncryptedNote.fromJson(Map<String, dynamic> json) => EncryptedNote(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// On-disk format for a single encrypted note:
/// {
/// "nonce": "<base64>", ← 24 bytes, randomly generated per note
/// "ciphertext": "<base64>" ← encrypted JSON of EncryptedNote
/// }
class EncryptedNoteService {
final Uint8List _key;
EncryptedNoteService(this._key) {
if (_key.length != SecretBox.keyBytes) {
throw ArgumentError('Key must be ${SecretBox.keyBytes} bytes');
}
}
Future<File> _fileFor(String id) async {
final dir = await getApplicationDocumentsDirectory();
return File('${dir.path}/notes/$id.enc');
}
/// Save a note, encrypting it with the provided key.
Future<void> save(EncryptedNote note) async {
// Serialize note to JSON bytes
final plaintext = utf8.encode(jsonEncode(note.toJson()));
// Encrypt — generate a fresh nonce for every save
final nonce = SecretBox.generateNonce();
final ciphertext = await Isolate.run(() =>
SecretBox.encrypt(Uint8List.fromList(plaintext), _key, nonce)
);
// Store nonce + ciphertext together — the nonce is not secret
final envelope = jsonEncode({
'nonce': base64Encode(nonce),
'ciphertext': base64Encode(ciphertext),
});
final file = await _fileFor(note.id);
await file.parent.create(recursive: true);
await file.writeAsString(envelope);
}
/// Load and decrypt a note. Throws [SecretBoxException] if tampered with.
Future<EncryptedNote> load(String id) async {
final file = await _fileFor(id);
final envelope = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
final nonce = base64Decode(envelope['nonce'] as String);
final ciphertext = base64Decode(envelope['ciphertext'] as String);
// Decrypt and verify — throws SecretBoxException if authentication fails
final plaintext = await Isolate.run(() =>
SecretBox.decrypt(ciphertext, _key, Uint8List.fromList(nonce))
);
final json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
return EncryptedNote.fromJson(json);
}
}And a minimal Flutter widget that uses it:
class NoteScreen extends StatefulWidget {
final EncryptedNoteService service;
final String noteId;
const NoteScreen({super.key, required this.service, required this.noteId});
@override
State<NoteScreen> createState() => _NoteScreenState();
}
class _NoteScreenState extends State<NoteScreen> {
EncryptedNote? _note;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final note = await widget.service.load(widget.noteId);
setState(() => _note = note);
} on SecretBoxException {
setState(() => _error = 'This note could not be decrypted. '
'It may have been tampered with.');
} catch (e) {
setState(() => _error = e);
}
}
@override
Widget build(BuildContext context) {
if (_error != null) {
return Scaffold(
body: Center(child: Text('$_error', style: const TextStyle(color: Colors.red))),
);
}
if (_note == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: Text(_note!.title)),
body: Padding(
padding: const EdgeInsets.all(16),
child: Text(_note!.body),
),
);
}
}When a SecretBoxException reaches the UI, you know the ciphertext was tampered with — not which bit, not by whom, just that the data is no longer trustworthy. The right response is to tell the user and not display the corrupted content. That's exactly what the on SecretBoxException handler does.
Testing the crypto
Cryptographic code must be tested against known vectors — specific inputs that produce specific outputs defined by the specification. If your implementation of encrypt + decrypt doesn't produce the correct ciphertext for a known input, something in the binding or the library setup is wrong.
// test/secret_box_test.dart
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/src/secret_box.dart';
void main() {
group('SecretBox', () {
test('encrypt then decrypt returns original plaintext', () {
final key = SecretBox.generateKey();
final nonce = SecretBox.generateNonce();
final plaintext = utf8.encode('Hello, libsodium!');
final ciphertext = SecretBox.encrypt(Uint8List.fromList(plaintext), key, nonce);
final decrypted = SecretBox.decrypt(ciphertext, key, nonce);
expect(utf8.decode(decrypted), 'Hello, libsodium!');
});
test('ciphertext is longer than plaintext by macBytes', () {
final key = SecretBox.generateKey();
final nonce = SecretBox.generateNonce();
final plaintext = Uint8List.fromList([1, 2, 3, 4, 5]);
final ciphertext = SecretBox.encrypt(plaintext, key, nonce);
expect(ciphertext.length, plaintext.length + SecretBox.macBytes);
});
test('decrypt with wrong key throws SecretBoxException', () {
final key = SecretBox.generateKey();
final wrongKey = SecretBox.generateKey();
final nonce = SecretBox.generateNonce();
final plaintext = utf8.encode('sensitive data');
final ciphertext = SecretBox.encrypt(Uint8List.fromList(plaintext), key, nonce);
expect(
() => SecretBox.decrypt(ciphertext, wrongKey, nonce),
throwsA(isA<SecretBoxException>()),
);
});
test('tampered ciphertext throws SecretBoxException', () {
final key = SecretBox.generateKey();
final nonce = SecretBox.generateNonce();
final plaintext = utf8.encode('do not tamper');
final ciphertext = SecretBox.encrypt(Uint8List.fromList(plaintext), key, nonce);
// Flip one bit in the ciphertext
final tampered = Uint8List.fromList(ciphertext);
tampered[tampered.length ~/ 2] ^= 0x01; // XOR with 1 flips a bit
expect(
() => SecretBox.decrypt(tampered, key, nonce),
throwsA(isA<SecretBoxException>()),
);
});
test('different nonces produce different ciphertexts', () {
final key = SecretBox.generateKey();
final nonce1 = SecretBox.generateNonce();
final nonce2 = SecretBox.generateNonce();
final plaintext = utf8.encode('same message');
final ct1 = SecretBox.encrypt(Uint8List.fromList(plaintext), key, nonce1);
final ct2 = SecretBox.encrypt(Uint8List.fromList(plaintext), key, nonce2);
expect(ct1, isNot(equals(ct2)));
});
test('key must be exactly keyBytes', () {
final shortKey = Uint8List(16); // wrong size
final nonce = SecretBox.generateNonce();
expect(
() => SecretBox.encrypt(Uint8List(10), shortKey, nonce),
throwsArgumentError,
);
});
});
}The tamper detection test is the most important one. It verifies that the Poly1305 MAC is working — that any modification to the ciphertext is caught. If this test passes, you know the authenticated encryption is working end-to-end.
The security considerations you must understand
A working implementation is not the same as a secure implementation. The crypto is correct. What the code does not protect is everything outside the crypto.
Key storage is not solved here. The EncryptedNoteService receives a key as a parameter. Where that key comes from — how it's generated, where it's stored, how it survives app restarts — is your responsibility. Storing it in SharedPreferences is not secure; any app with storage access on a rooted device can read it. On Android, use the Android Keystore — it stores keys in hardware, protected by the secure enclave. On iOS, use the Keychain with appropriate access controls.
Nonce uniqueness is critical. The generateNonce() call uses libsodium's randombytes_buf — cryptographically secure random bytes. For XSalsa20 with a 192-bit nonce, the probability of a collision after 2^80 messages (about 1.2 × 10²⁴) is negligible. For practical purposes, random nonces are safe. Never use a counter nonce without understanding the implications.
The threat model. This encryption protects your notes against: an attacker who reads your device's storage (via backup, USB debug access, stolen device without PIN). It does not protect against: an attacker who runs code on your device (malware with your app's permissions can read the key from memory), or Apple/Google (who control the OS and can instrument any app).
Empty the sensitive data when done. The _key field in EncryptedNoteService stays in Dart memory for the lifetime of the object. Dart doesn't have deterministic memory zeroing (the GC can move objects). In high-security contexts, key material should be stored in native memory where you can explicitly zero it before freeing. This is a Post 2 concept: calloc<Uint8>(32) for the key, copy in when needed, zero out with keyPtr.asTypedList(32).fillRange(0, 32, 0) before calloc.free.
The project file layout
After this integration, your project has:
your_flutter_app/
├── android/app/src/main/
│ ├── jniLibs/
│ │ ├── arm64-v8a/libsodium.so
│ │ ├── armeabi-v7a/libsodium.so
│ │ └── x86_64/libsodium.so
│ └── cpp/
│ ├── CMakeLists.txt
│ └── sodium_shim.c
├── ios/
│ └── libsodium.a
├── native/include/
│ ├── sodium.h
│ └── sodium/ (sub-headers)
├── lib/src/
│ ├── sodium_bindings.dart ← generated by ffigen
│ ├── sodium_native.dart ← initialization
│ └── secret_box.dart ← clean Dart API
├── lib/features/notes/
│ └── encrypted_note_service.dart
├── test/
│ └── secret_box_test.dart
└── ffigen.yamlWhat you can now do
Five posts ago, FFI was an abstraction — something you knew existed, something pub.dev packages used internally, something that felt out of reach.
Now you have the complete toolkit:
You understand the managed/unmanaged boundary — two kinds of memory in your process, two sets of rules, and why crossing deliberately requires understanding both sides.
You can work with any C type — primitives, strings with ownership semantics, byte buffers with zero-copy views, structs with exact byte layouts, fixed arrays, packed protocol headers.
You know how C calls Dart — NativeCallable.isolateLocal for synchronous callbacks on the current thread, NativeCallable.listener for thread-safe async event notification, and the lifetime discipline that prevents calling invalidated function pointers.
You know how to use ffigen to generate bindings automatically for large libraries instead of writing them by hand and introducing transcription errors.
And you've done it with a real library — one that Signal trusts for the same operation you just implemented.
The next natural direction from here is the performance series. Now that you can drop into native code when Dart isn't fast enough, the question becomes: how do you know when Dart isn't fast enough? How do you find the bottleneck before reaching for FFI? How do you measure the improvement after? That's where profiling, flame graphs, and DevTools go from optional to essential.
The answers are in the next series.