Working end-to-end against Juggluco's reference vectors on the M1 Mac mini AVD.
| Op | Function | Result |
|---|---|---|
| 1 | reset | ✅ |
| 2 | initECDH | ✅ rc=1 |
| 4 | setPatchCertificate | ✅ rc=1 |
| 5 | generateAppEphemeralPublicKey | ✅ 64-B output (SKB returns X‖Y; the iOS adapter prepends 0x04 before BLE) |
| 6 | deriveKAuthFromPatchEphemeral | ✅ rc=1 |
| 7 | challengeEncrypt | ✅ 40-B ciphertext |
| 8 | challengeDecrypt | needs a real-session ciphertext; rc=0 against Juggluco's static vector (expected — vectors were captured under a different session key) |
| 9 | exportKAuth | ✅ 149 bytes, exactly Juggluco's reference size |
/health reports loaderReady: true after app start.
Three things, in order of importance:
-
Use Juggluco's
liblibre3extension.so, not the one from Abbott's Libre 3 APK. They're different binary builds; Juggluco's loader (and this port of it) is calibrated to the.soversion they ship. Extract fromJuggluco-X.Y.Z-arm64.apk→lib/arm64-v8a/. -
Don't use
System.loadLibrary. A small NDK module (app/src/main/cpp/loader.cpp, ~200 lines) does the load manually:dlopen(soPath, RTLD_NOW | RTLD_LOCAL)dlsym("JNI_OnLoad")- Patches a 4-byte BL instruction at
JNI_OnLoad + 0x2F20from04 2E 05 94toE0 03 00 AA(MOV X0, X0, effective NOP). Verbatim from Juggluco'sloadlibs.cpp. - Invokes
JNI_OnLoadwith a fakeJavaVM/JNIEnvwhose only methods areFindClass(returns the class-name string cast tojclass) andRegisterNatives(capturesprocess1/process2function pointers into globals). - Subsequent calls go through those captured C function pointers
with the real
JNIEnvof the calling thread — byte-array marshaling works, but ART'sRegisterNativestable is never touched.
-
Disable jniLibs uncompressed packaging. AGP's default (
useLegacyPackaging = false) keeps native libs inside the APK as uncompressed pages, wheredlopenby absolute path doesn't reach them. SettinguseLegacyPackaging = trueinapp/build.gradle.ktsmakes Android extract them toapplicationInfo.nativeLibraryDirat install time — the path the loader uses.
┌──────────────────────────────────────────────────────────────────────────┐
│ iOS sample app (stock iPhone) │
│ - Core NFC takeover (0xA8) + CoreBluetooth │
│ - HTTP client (AndroidServerClient.swift) → this shim's port 8080 │
└──────────────────────────────────────────────────────────────────────────┘
│ JSON over HTTP (LAN / adb forward / Tailscale)
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ dev.libre3shim (Android — AVD on M1 Mac mini, or rooted phone) │
│ │
│ MainActivity ─ status screen │
│ ShimService ─ foreground service, wakelock, on-boot init │
│ │
│ HttpServer.kt (NanoHTTPD on 0.0.0.0:8080) │
│ ├─ GET /health │
│ ├─ POST /session → {sessionId} │
│ ├─ POST /session/{id}/process1 {op,a,b} → {rc} │
│ ├─ POST /session/{id}/process2 {op,a,b} → {rc,out} │
│ └─ DELETE /session/{id} │
│ │ │
│ ▼ │
│ SkbBridge.kt ─ ReentrantLock + session bookkeeping; calls NativeLoader │
│ │ │
│ ▼ │
│ NativeLoader.kt (System.loadLibrary "libre3loader") │
│ │ JNI │
│ ▼ │
│ libre3loader.so (the NDK module here, ~200 lines) │
│ - dlopen + dlsym + 4-byte patch │
│ - fake JavaVM/JNIEnv for capturing process1/process2 │
│ - calls captured fn pointers with real JNIEnv │
│ │ │
│ ▼ │
│ liblibre3extension.so (Juggluco-shipped — ~3.94 MB, NOT in repo) │
│ - process1 / process2 opcodes 1-9 │
└──────────────────────────────────────────────────────────────────────────┘
android-server/
├── build.gradle.kts root project — AGP 9.2.1
├── settings.gradle.kts foojay-resolver 1.0.0 for JDK 17 toolchain
├── gradle.properties
├── gradlew + gradle/wrapper/ Gradle 9.5 wrapper
└── app/
├── build.gradle.kts NDK + CMake + useLegacyPackaging
└── src/main/
├── AndroidManifest.xml
├── cpp/
│ ├── loader.cpp Juggluco-derived loader
│ └── CMakeLists.txt builds libre3loader.so
├── jniLibs/arm64-v8a/ (gitignored — sourced from Juggluco APK)
│ ├── liblibre3extension.so
│ ├── libcrl_dp.so
│ ├── libinit.so
│ └── …
├── java/dev/libre3shim/
│ ├── MainActivity.kt status screen, starts the service
│ ├── ShimService.kt foreground service, calls SkbBridge.prepare()
│ ├── HttpServer.kt NanoHTTPD routes
│ ├── SkbBridge.kt mutex + session bookkeeping
│ └── NativeLoader.kt JNI surface for cpp/loader.cpp
└── res/ strings.xml, network_security_config.xml
| Verb | Path | Body | Returns |
|---|---|---|---|
GET |
/health |
— | { "ok": <bool>, "version": "0.1", "port": 8080, "device": "…", "loaderReady": <bool>, "loaderError": <string|null>, "activeSession": <uuid|null> } |
POST |
/session |
— | { "sessionId": "<uuid>" } |
POST |
/session/{id}/process1 |
{ "op": <int>, "a": "<b64>|null", "b": "<b64>|null" } |
{ "rc": <int> } |
POST |
/session/{id}/process2 |
same | { "rc": 1, "out": "<b64>" } |
DELETE |
/session/{id} |
— | 204 |
Opcodes (matching Juggluco's Libre3SKBCryptoLib JNI surface):
| Op | Method | a | b | Out |
|---|---|---|---|---|
| 1 | process1 | null | null | rc=1 (reset) |
| 2 | process1 | appPrivKey blob (165 B) | cached kAuth or null | rc=1 |
| 4 | process1 | patchCert (140 B) | null | rc=1 |
| 5 | process2 | null | null | 64-B X‖Y (P-256 ephemeral; iOS adapter prepends 04) |
| 6 | process1 | patchEphemeral (65 B with 04 prefix) |
null | rc=1 |
| 7 | process2 | nonce1 (7 B) | plaintext (36 B) | 40-B ciphertext |
| 8 | process2 | nonce (7 B) | ct+tag (60 B) | 56-B plaintext (r2‖r1‖kEnc‖ivEnc) |
| 9 | process2 | null | null | ~149-B exported kAuth |
brew install --cask temurin
brew install --cask android-commandlinetools
export ANDROID_HOME="/opt/homebrew/share/android-commandlinetools"
export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH"
yes | sdkmanager --licenses
sdkmanager "platform-tools" "emulator" \
"system-images;android-34;google_apis;arm64-v8a" \
"platforms;android-34"
avdmanager create avd -n libre3srv \
-k "system-images;android-34;google_apis;arm64-v8a" \
-d pixel_6
emulator -avd libre3srv -no-window -no-audio -no-boot-anim &
adb wait-for-devicebrew install socat
# Replace 192.168.1.42 with the Mac mini's LAN IP.
nohup socat TCP-LISTEN:8080,bind=192.168.1.42,fork,reuseaddr \
TCP:127.0.0.1:8080 \
> /tmp/socat-libre3.log 2>&1 &
curl -sS http://192.168.1.42:8080/healthThese don't block the handshake from progressing, but tighten the loop:
04prefix on op 5 / op 6. The SKB returns 64 bytes (X‖Y); downstream code needs the 65-byte uncompressed form (04‖X‖Y). The iOS adapter prepends the byte before sending over BLE and includes it in op 6 input. Handled in iOS-side Swift code.- Op 8 against Juggluco's static vector returns rc=0. Expected: the
reference
bytes60was AES-CCM-encrypted under a specific session key captured during Juggluco's test, not the one this shim derives. Real-sensor traffic produces a matching ciphertext. - NFC
libcrl_dp.soloaded but not yet wired. The iOS app builds its own takeover payload (time ‖ accountId ‖ CRC16, no SKB involvement). This path isn't needed for the BLE handshake; it would matter only for first-time activation, which the iOS app delegates to Abbott's official app. - Session reuse across BLE reconnects. Each new
/sessionopen currently does op 1 (reset) and re-runs the full handshake. Once op 9 returns a real kAuth blob, the iOS app can persist it and on reconnect pass it asbto op 2 (cached path) — server-side this Just Works because the SKB accepts a cached kAuth.