Skip to content

Repository files navigation

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. 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.enc

2. 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 source

3. 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.

Build-time details

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.

The native crypto engine (BoringSSL)

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 nasm

If the error persists, NASM's folder 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 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 rotation and key_id

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.

Runtime details

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 cost — speed and memory

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.

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

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).

About

Encrypt LiteRT (TFLite) models at build time, decrypt in memory at runtime — Flutter, pluggable key providers.

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages