litert_crypto 0.2.1
litert_crypto: ^0.2.1 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.
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.
Windows: the first run may ask for NASM
On Windows, the first CLI run (or flutter test) can stop with:
No CMAKE_ASM_NASM_COMPILER could be found
The crypto engine is BoringSSL, compiled from source on first use (cached after that), and on Windows that build assembles its x64 sources with NASM.
winget install nasm
If the same error persists, NASM is installed but its folder (usually
%LOCALAPPDATA%\bin\NASM) never made it onto PATH — tool/setup.ps1
does the check, install, and PATH repair in one go.
key_parts_out — generated key source for EmbeddedKeyProvider
With key_parts_out set, encrypt also writes Dart source for the key it just
encrypted with — the .enc files and the key the app decrypts with always match:
// GENERATED by `dart run litert_crypto encrypt` — do not edit by hand.
// key-fingerprint: 3f1a92c7
KeyProvider buildModelKeyProvider() => EmbeddedKeyProvider.fromParts([_partA, _partB]);
Under the same key, re-running encrypt rewrites the .enc files every time (fresh
random IV per run) but not the generated Dart file — it records a key fingerprint and
changes only with the key. Use key_parts_symbol to rename the generated function
(default buildModelKeyProvider).
Key rotation and key_id
Rotating is just encrypting again with a fresh key: one encrypt run re-encrypts every
model and regenerates the key parts together. Ship the new .enc files and the app in
the same release
If two key generations must coexist — users on the previous release still fetch the old
key from your server while new installs need the new one — bump key_id in the config
when you rotate. The number is stamped into each .enc header and reaches your
KeyProvider as KeyContext.keyId, which is how it can tell which generation's key to
return.
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
App builds need no setup — BoringSSL (the crypto engine) is compiled by your
normal Gradle/NDK or Xcode build. Running on a host machine instead
(flutter test, or the CLI on a plain Dart VM) compiles it there, which
needs cmake and a C compiler — plus NASM on Windows x64, covered in
the Windows note above. Only the
first run compiles; after that it is cached.
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 — what actually decides 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 |
File format #
[magic "LRTC" (4B)] [version (1B)] [keyId (2B)] [labelLen (1B)] [label] [IV (12B)] [ciphertext ‖ GCM tag (16B)]
AES-256-GCM, and the entire model file is encrypted — nothing of the original .tflite
survives in the clear. The header is authenticated but readable; the label defaults to
the output file name, so a generic file name is all the discretion you need. Files
written by 0.1.0 (format version 1) must be re-encrypted — one dart run litert_crypto encrypt is the whole migration. Field details and the reasoning behind the cipher
choice: docs/design.
Roadmap #
Current status, planned work, and what was deliberately left out: ROADMAP.