litert_crypto 0.2.0
litert_crypto: ^0.2.0 copied to clipboard
Stop shipping your .tflite in plain sight. Encrypt LiteRT (TFLite) models at build time and decrypt them in memory only — with pluggable key providers.
litert_crypto #
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.
The problem #
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.
Usage #
flutter pub add litert_crypto
1. Generate a key and encrypt models (build time) #
dart run litert_crypto init # writes an annotated litert_crypto.yaml
dart run litert_crypto keygen # writes .secrets/model_master.key (gitignore it!)
dart run litert_crypto encrypt # encrypts everything the config lists
init refuses to overwrite an existing config. A filled-in config looks like:
litert_crypto:
key_file: .secrets/model_master.key
key_parts_out: lib/model_master_key.dart # optional, see below
models:
- src: models_src/yolo.tflite
out: assets/tflite_model/yolo.tflite.enc
- src: models_src/detector.tflite
out: assets/tflite_model/detector.tflite.enc
encrypt reads the config named by --config, or the nearest litert_crypto.yaml at or
above the working directory. Paths inside the config resolve relative to the config file.
For a one-off you can skip the config and pass --key, --in, and --out directly (that
path does not regenerate key_parts_out).
Keep plaintext originals (models_src/) outside your assets so they are never bundled, and
register only the .enc files as Flutter assets. keygen restricts the key file to your
user (chmod 600) where the platform allows it; on Windows, and on WSL against a mounted
Windows drive, verify the file's protection yourself.
key_parts_out — generated key source for EmbeddedKeyProvider
If you embed the key in the app, it has to exist in two places: the key file the CLI encrypts with, and Dart source the app decrypts with. Keeping those in sync by hand is how they drift apart, so let the CLI derive one from the other:
// GENERATED by `dart run litert_crypto encrypt` — do not edit by hand.
// key-fingerprint: 3f1a92c7
KeyProvider buildModelKeyProvider() => EmbeddedKeyProvider.fromParts([_partA, _partB]);
The generated file records a fingerprint of the key it was built from, so re-running
encrypt rewrites it only when the key actually changed — no churn in your diffs. Use
key_parts_symbol to rename the generated function (default buildModelKeyProvider).
Because that file is committed source, the key is recoverable from the repository by XOR-ing the parts — the split only keeps a finished key out of the shipped binary. Skip this option entirely if the key does not live in the app (see Where the key lives).
Rotating the key
Delete the key file and run keygen and encrypt again (keygen refuses to overwrite,
so rotation starts with an explicit rm .secrets/model_master.key). There is nothing else to
keep in sync: one encrypt regenerates the key parts and re-encrypts every model
together. Ship the new .enc files and the app together — an old build cannot read newly
encrypted models — and when two key generations must coexist, tell them apart with
key_id in litert_crypto.yaml.
2. Load at runtime #
import 'package:litert_crypto/litert_crypto.dart';
// Before: Interpreter.fromAsset('assets/tflite_model/model.tflite')
final interpreter = await EncryptedModel.fromAsset(
'assets/tflite_model/model.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.
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.
Cost, and where it is paid
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.
The native library
BoringSSL ships as source inside webcrypto and is compiled by your normal
build: Gradle/NDK on Android, Xcode on iOS/macOS — nothing to configure.
Running on a host machine (flutter test, or the CLI on a plain Dart VM)
builds it there too, which needs cmake and a C compiler, plus NASM on
Windows x64 (winget install nasm). CI images without a C toolchain will
fail the host build — the app build is unaffected.
KeyProvider — the key source is your policy #
| 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;
});
Where the key lives — the only thing that changes the strength #
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.
Fetching the key from a server #
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.
Threat model #
| 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 ⚠️ / external key ✅ |
| 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 |
| Access to your source repository | ❌ No, if the key ships with the app — repository access is model access |
File format #
[magic "LRTC" (4B)] [version (1B)] [keyId (2B)] [labelLen (1B)] [label] [IV (12B)] [ciphertext ‖ GCM tag (16B)]
AES-256-GCM with the whole header as additional authenticated data, and per-model working
keys derived with HKDF-SHA256. The entire model file is encrypted — nothing of the
original .tflite survives in the clear. The header (including the label, which defaults
to the output file name) is authenticated but readable. Files written by 0.1.0 (format
version 1) must be re-encrypted — one dart run litert_crypto encrypt is the whole
migration. Layout details and the reasoning behind the cipher choice:
docs/design.
Roadmap #
Current status, planned work, and what was deliberately left out: ROADMAP.