Shipping one Rust crypto core to five platforms with Flutter — and the cargokit gotcha that silently disabled hardware AES in every release build.
Users kept telling us transfers were slow. Our benchmarks kept telling us they were fast. Both were right — and the gap between those two sentences cost us months.
This is a story about shipping one Rust cryptography core to five platforms from a Flutter app, and about the single most instructive bug we hit doing it: a build flag that quietly turned hardware-accelerated AES off in every release build we shipped, while every build we benchmarked had it on.
The setup
I build BIShare, an open-source AirDrop-style app: direct device-to-device file transfer between iPhone, Android, macOS, Windows, and Linux. MIT licensed, Flutter UI, Rust core.
The architecture decision that matters for this story: all cryptography and protocol framing lives in a single Rust crate, bridged into Dart with flutter_rust_bridge. X25519 for key agreement, AES-256-GCM for the data path — implemented once, reviewed once, shipped to all five platforms. The Dart side never touches key material.
On paper this is the clean version of the diagram. One implementation instead of five means one place for bugs instead of five — which is true, and which is exactly why the bug that follows was so effective: we shipped it everywhere, identically.
The symptom
Local-network transfers should saturate Wi-Fi. Our release-build benchmarks on real devices showed 40–50 MB/s on ordinary Wi-Fi 5 hardware — a 1 GB video in about 25 seconds. That's the whole pitch of skipping the cloud.
But App Store users reported transfers crawling at about 2 MB/s. Twenty times slower. Same app, same network conditions, same code — allegedly.
We did the usual rounds: network path, buffer sizes, the Dart/native boundary, thermal throttling. Everything checked out. The transfer pipeline was fine. The cipher was not.
The cause
Modern ARM chips have dedicated AES instructions. With them, AES-256-GCM encrypts multiple gigabytes per second and is never your bottleneck. Without them, the same cipher falls back to a software implementation that is — on the hardware we measured — about 11× slower. Slow enough that encryption, of all things, becomes the ceiling on your transfer speed.
In Rust, enabling those instructions for aarch64 targets took a compiler flag, which lived in our .cargo/config.toml. And here's the trap: cargokit — the build helper that compiles Rust crates inside a Flutter build — was invoking cargo from the build system's working directory (Xcode's DerivedData, in the iOS/macOS case), not from the crate's directory.
Cargo discovers .cargo/config.toml by walking up from the current working directory. From DerivedData, our config file was never on that path. So the flag never applied. So every iOS and macOS release build we ever shipped ran software AES.
Why didn't the benchmarks catch it? Because when you benchmark "the Rust core," you tend to run it from the crate directory — where the config file is found. Our fast numbers and our users' slow numbers were both completely real. They were just measuring two different binaries that happened to share source code.
The fix was one line of understanding: make cargokit set workingDirectory to the crate's manifest directory before invoking cargo. (On Android the same class of problem needed a patch in cargokit's android_environment.dart to inject the aes target feature.) After the fix, on-device throughput matched the benchmarks, verified on a mid-range Galaxy A24 — the kind of phone your users actually own.
What I'd tell any team doing crypto behind FRB
Benchmark the shipped artifact, on a device, in release mode. Not the crate, not the simulator, not a debug build. Debug-mode Dart is roughly 10× slower on hot loops, so debug numbers are noise anyway — and as we learned, even a release crate benchmark can be measuring a different binary than the one in your
.ipa.Treat build-system working directories as hostile. Any configuration that's discovered by walking up from CWD —
.cargo/config.tomlis not the only offender — will betray you the moment a build helper invokes the toolchain from somewhere unexpected. Prefer flags that travel with the invocation (env vars, explicit--config) or pin the working directory yourself.Make performance a test, not a vibe. After this incident we added a benchmark mode to the app itself (
BISHARE_BENCH=1) so throughput on a real device is one command away. If a regression like this ships again, we want a user-visible number to disagree with us immediately.Delete work the cipher already does. While profiling, we also found our Dart layer computing an extra SHA-256 over data that had already passed through per-chunk authenticated encryption. AES-GCM's tags already prove integrity, chunk by chunk — the checksum was proving it again, slower. We verified received bytes were identical with and without it, deleted it, and got a free speed bump. If you're layering integrity checks on top of an AEAD, ask what, exactly, the second check catches.
The part where this is a pitch (briefly)
The app this happened to is open source and on the stores: LAN transfers with mDNS discovery, a browser receive path so the other side needs no app, QR-stream transfer for the no-network case, 13 languages.
- Repo: https://github.com/BIShare-project/bishare-flutter
- The Rust protocol crate: https://github.com/BIShare-project/bishare-protocol
- App/downloads: https://bishare.app
If you've hit your own cursed FRB or cargokit build behavior, I'd genuinely like to hear it — and if you speak a language we don't ship yet, translations are a 15-minute PR.
Top comments (2)
This is a fantastic debugging writeup. The .cargo/config.toml discovery-by-walking-up-from-CWD trap is such a subtle failure mode, and "benchmark the shipped artifact, not the crate" deserves to be pinned above every FRB team's desk.
We hit a similar shape of bug building an offline-first CRM: the code we exercised in our local harness silently diverged from what shipped, because the harness never walked the same storage path production did. Same lesson, different layer, same root cause, benchmark the thing users actually run.
Did you consider baking the AES target-feature check into CI as a runtime assertion, read the CPU feature flag on a real device build and fail loudly if hardware AES isn't active, so a future cargokit regression trips a test instead of a support ticket?
Thanks — and your CRM harness story is the same disease in a different organ: a test rig that quietly doesn't walk the production path. "Benchmark the thing users actually run" apparently has to be re-learned once per layer of the stack.
Great question on the CI assertion, with one subtlety: reading the CPU feature flag on a real device would have passed in our broken builds. The hardware always had AES — it was the binary that wasn't using it. So the assertion has to interrogate the artifact, not the chip. Two tripwires on my list:
A built_with_hw_aes() in the Rust crate that just returns the compile-time cfg!(...) for the target feature, exposed over FFI and asserted by a test that runs against the final build graph — GitHub's arm64 macOS runners make that real hardware, not a simulator.
Dumber and arguably better: llvm-objdump the built library in CI and grep for aese/aesmc. No device needed, and it catches the whole cargokit-CWD class of regression byte-for-byte, at the artifact level.
The throughput floor (BISHARE_BENCH=1) stays as the backstop, but you're right — it should be a test that fails, not a habit I remember.