Encrypt and load LiteRT (TensorFlow Lite / TFLite) models — a build-time encryption CLI plus an in-memory decryption loader with pluggable key providers.
This package does not guarantee protection. It provides encryption tooling and a key injection point (
KeyProvider); the actual protection strength is determined by how you manage your keys. Read the threat model first.
A .tflite model bundled in a Flutter app can be extracted verbatim by unzipping the
APK / IPA / desktop install folder. This package encrypts models at build time and decrypts
them in memory only at runtime before handing them to an Interpreter. The plaintext
model never touches the disk.
flutter pub add litert_crypto1. Add a litert_crypto: section and the .enc assets to your
pubspec.yaml:
litert_crypto:
key_file: .secrets/model_master.key
# Generated key source — any path under lib/ you like. Only needed when the
# key ships inside the app (EmbeddedKeyProvider); remove this line if the
# key comes from a server, license, or secure storage.
key_parts_out: your_path/model_master_key.dart
models:
- src: models_src/yolo.tflite
out: assets/tflite_model/yolo.tflite.enc
flutter:
assets:
# Only the encrypted outputs ship — keep the plaintext sources
# (models_src/) out of your assets.
- assets/tflite_model/yolo.tflite.enc2. Generate the key and encrypt:
dart run litert_crypto keygen # writes .secrets/model_master.key (gitignore it!)
dart run litert_crypto encrypt # encrypts every listed model, (re)generates the key source3. Load at runtime:
import 'package:litert_crypto/litert_crypto.dart';
// Before: Interpreter.fromAsset('assets/tflite_model/yolo.tflite')
final interpreter = await EncryptedModel.fromAsset(
'assets/tflite_model/yolo.tflite.enc',
keyProvider: buildModelKeyProvider(), // from the generated key_parts_out file
build: Interpreter.fromBuffer,
);
// You get back exactly what your runtime returns — the inference code that
// follows stays unchanged.That is the whole setup — rerun dart run litert_crypto encrypt whenever a
model changes.
The config section can live in a dedicated litert_crypto.yaml instead
(dart run litert_crypto init writes an annotated one). Config discovery —
first match wins:
1. `--config` argument
2. nearest `litert_crypto.yaml`
3. `litert_crypto:` section in `pubspec.yaml`
Each models: entry also takes label: (stored in the clear, defaults to
the out file name) and key_id: (key rotation, below). For a one-off you
can skip the config and pass --key, --in, and --out directly. A model
your app downloads at runtime is encrypted the same way — load it with
EncryptedModel.fromFile, same key.
App builds need nothing — Gradle/NDK and Xcode compile BoringSSL themselves.
The host side (flutter test, the CLI) compiles a host copy on first
run — cached after that — and needs cmake and a C compiler. On Windows
x64 it also needs NASM, or the first run stops with:
No CMAKE_ASM_NASM_COMPILER could be found
winget install nasmIf the error persists, NASM's folder never made it onto PATH —
tool/setup.ps1 does the check, install, and PATH repair
in one go.
With key_parts_out set in the config, encrypt (re)generates Dart source
that rebuilds the current key from XOR parts — dart run litert_crypto keyparts does the same standalone.
// GENERATED by the litert_crypto CLI (`keyparts` / `encrypt`) — do not edit by hand.
// key-fingerprint: 3f1a92c7
KeyProvider buildModelKeyProvider() => EmbeddedKeyProvider.fromParts([_partA, _partB]);Rerunning it is a no-op until the key (or the configured symbol) changes. Use
key_parts_symbol to rename the generated function (default buildModelKeyProvider).
key_id stamps which key generation encrypted a file; your KeyProvider
receives it as KeyContext.keyId. Rotating keys — including a build-cache
caveat worth knowing — is covered in
docs/key-management.
This package depends on no inference runtime. You hand it the buffer
constructor, so the same call works with flutter_litert, tflite_flutter, or
anything else that accepts model bytes. Nothing here pins you to a runtime
version, and its bugs are not yours to inherit.
The decrypted buffer is zeroed as soon as build returns, since runtimes copy
the model into their own memory. If yours keeps the buffer alive instead, use
EncryptedModel.decryptAsset() and manage the lifetime (and the wipe) yourself.
Decryption runs on BoringSSL (via package:webcrypto), which uses the CPU's
AES instructions — every ARMv8 phone has them. Measured on an AES-NI desktop:
1.2 ms/MB, a 72 MB model in 87 ms. Expect the same order of magnitude on
phones.
Even fast decryption is solid CPU work, so it runs on a worker isolate by
default and the calling isolate keeps rendering through it. Pass
inIsolate: false to any loader entry point to keep it on the calling
isolate.
Plan for a load to briefly hold a few model-sized buffers at once — the ciphertext, the plaintext, and the copy the inference runtime makes for itself. Only the runtime's copy survives the load.
| Provider | Key source | Strength | Use case |
|---|---|---|---|
EmbeddedKeyProvider |
Embedded in the app (XOR part-combining helper) | Low — the key ships with the app | Minimum defense — only stops unzip extraction |
CallbackKeyProvider |
Your callback (license file, custom storage, ...) | Up to you | App-specific policies such as license binding |
RemoteKeyProvider |
Your fetch callback, with retries, single-flight and optional caching | Up to your server's gate | Keeping the key out of the binary entirely |
FallbackKeyProvider |
Tries providers in order | — | Combinations like cache → server |
// Example: pull the key from a signed license file.
final provider = CallbackKeyProvider((context) async {
final license = await License.loadAndVerify();
return license.modelKey;
});Encrypting the model is the easy half. What decides how much protection you actually get is whether the key ships with the app:
- The key is in the build (
EmbeddedKeyProvider, native-code derivation, ...) — whoever holds the app holds the key. Hiding it raises the extraction effort, but this tier only stops casual extraction from the distributed artifact; a debugger reads the key out of memory no matter how it was hidden. - The key does not ship (license file, server delivery, device-bound secure storage) — the qualitative jump: someone holding only your app has nothing to decrypt with. Access is gated by your issuing process, your server's checks, or the OS.
Moving up does not touch the encrypted assets or the load call — only the keyProvider
argument changes. The full effort ladder and per-option trade-offs:
docs/key-management.
The transport is yours — this package has no HTTP dependency. Bring package:http,
dio, or a platform channel; RemoteKeyProvider adds the retry, single-flight and
caching plumbing around it.
final provider = RemoteKeyProvider(
fetch: (ctx) async {
final res = await http.get(
Uri.parse('https://keys.example.com/model-key'
'?keyId=${ctx.keyId}&label=${ctx.label}'),
headers: {'Authorization': 'Bearer ${await session.token()}'},
);
if (res.statusCode == 403) {
throw const KeyUnavailableException('not entitled'); // permanent: no retry
}
if (res.statusCode != 200) throw StateError('HTTP ${res.statusCode}');
return decodeKeyBytes(res.bodyBytes); // JSON {"key": base64} / base64 / raw
},
cache: InMemoryKeyCache(ttl: const Duration(hours: 12)),
);The callback receives keyId and label, so one service can serve several models and
key generations. Throw KeyUnavailableException for permanent failures (rejected auth,
unknown key) — anything else counts as transient and is retried.
A server moves the secret out of your binary; it does not decide who deserves it. That gate is your server's job — attestation on mobile, a user credential elsewhere. See docs/key-management.
KeyCache is an interface, not a plugin: InMemoryKeyCache keeps a fetched key for the
life of the process and never touches disk. To survive restarts, implement KeyCache on
top of flutter_secure_storage or your own channel — the package carries no
platform-channel plugins of its own, so it works anywhere your inference runtime does.
| Attack | Defended? |
|---|---|
| Extracting the model by unzipping the bundle (APK / install folder) | ✅ Yes — only ciphertext ships |
| Model tampering (backdoored model injection) | ✅ Detected — GCM tag authenticates header + ciphertext |
| Copying ciphertext + app to another machine and decrypting offline | Depends on your KeyProvider — Embedded |
| Memory dump while the app is running | ❌ No — inherent limit of on-device inference; the exposure window is narrowed by zeroing keys and buffers after use |
| Patching / reverse engineering the decryption logic | ❌ No — combine with --obfuscate |
AES-256-GCM over the entire model file — nothing of the original .tflite survives in
the clear, and the authenticated header carries only a key id and a label (the model's
file name by default).