Skip to content

fix: report an invalid bond when Android says the device keys are missing - #951

Merged
wabicai merged 6 commits into
onekeyfrom
fix/android-ble-key-missing-bond-invalid
Sep 21, 2026
Merged

wabicai merged 6 commits into
onekeyfrom
fix/android-ble-key-missing-bond-invalid

Conversation

@originalix

@originalix originalix commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

React Native BLE on Android: when a device has lost its keys (wiped, or the bond was removed on the device) and Android keeps its side of the bond, report BleBondInvalid (724) instead of BleDeviceNotBonded (705) or BleDeviceDisconnected (720). The app already maps 724 to the "pairing is no longer valid, forget the device" dialog, so the user is no longer stuck retrying behind a "Bluetooth unpaired" toast.

Also bumps every package to 1.2.3-alpha.9 and depends on @onekeyfe/react-native-ble-utils 0.1.8 (OneKeyHQ/react-native-ble-utils#26, published).

Intent and context

Reported on a Pixel 9a (Android 17) with Pro 2 and Neo: after a device wipe the app loops on "Bluetooth unpaired" and never tells the user to forget the device. Phones whose Bluetooth stack still removes the bond on key missing re-pair on the next attempt and do not reproduce it, which is why it looked device-specific. Keeping the bond is the newer Android behaviour, so the affected population grows with Android 16+.

Root cause

  • Android still reports BOND_BONDED, so pairDevice skips pairing. Connect, MTU and GATT setup succeed because none of them need encryption; the first write is held behind link encryption, and the device drops the link (GATT_CONN_TERMINATE_PEER_USER, status 19) when encryption fails with key missing.
  • Android 16+ does not remove the bond. It only broadcasts ACTION_KEY_MISSING. Nothing consumed it, so the transport saw an ordinary disconnect: the V1 probe path mapped it to 705 and the V2 path to 720. isBleStaleBondErrorText only knows ATT 5/15 and iOS CBError 14.
  • Status 19 cannot be the classifier: the same status is reported when the device reboots, e.g. during the wipe itself.
  • Android 17 first re-pairs by itself on the same link. A failed re-pair restores the old bond (BONDING -> BONDED) and only then broadcasts key missing; a successful one replaces the bond (BONDING -> NONE -> BONDING -> BONDED). onDeviceBondState read the first as success and the second as failure.

Design decisions

  • Every new branch hangs on one structured native signal: ACTION_KEY_MISSING for the same device, received after the current link attempt started. Without it the original error is returned unchanged, including the existing write-disconnect -> 705 mapping.
  • resolveAndroidBondInvalid runs at the acquire() and call() boundaries, only for link-loss shaped errors within ANDROID_KEY_MISSING_LINK_WINDOW_MS (10 s) of link start, and waits at most ANDROID_KEY_MISSING_GRACE_MS (500 ms) because the broadcast and the GATT disconnect are delivered separately. A wedged write (710) is excluded: the system is still re-pairing then and has not reported key missing yet.
  • The link-start stamp is cleared at the start of each attempt, so a signal from an earlier link can never reclassify a later ordinary disconnect. This matters because FirmwareUpdateV4 treats 715/719/724 as terminal.
  • onDeviceBondState rejects with 724 when key missing arrives during the wait. The BONDING -> NONE debounce applies only when the system, not this transport, started the bonding (pairDevice().initiated === false); a bonding the transport starts is handled exactly as before.
  • Capability is feature-detected (supportsDeviceKeyMissing). On Android below 16, on a native build without the event, or with an older react-native-ble-utils resolved by the app, nothing waits and behaviour is unchanged.

Compatibility and risk

  • No new error code; 724 already exists and ensureConnected already treats it as terminal, so there is no retry loop.
  • iOS, Electron, WebUSB and other transports are untouched.
  • The native half needs a new app binary. A JS bundle running on an older native build degrades to current behaviour.
  • FirmwareUpdateV3's rethrow list does not include 719/724. Its trigger call happens minutes after link start, outside the window, so it is left unchanged.
  • The version bump touches all 36 package.json files, the same set as the previous alpha bump. alpha.8 is taken by feat/keystone-integration, hence alpha.9.

Hardware coverage

Evidence captured on a Pixel 9a (Android 17) with Pro 2, using logcat, dumpsys activity broadcasts and dumpsys bluetooth_manager:

  • KEY_MISSING is an implicit ordered broadcast requiring only BLUETOOTH_CONNECT, enqueued about 15 ms after the ACL drop.
  • Three failed attempts and one successful system re-pair were recorded, including the bond-state sequences above. The same failure signature is present in app logs for Neo.

Not yet exercised end to end on a device with the new native build. Android 16 (not 17) and non-Pixel 16+ phones have no device data.

Test plan

  • New androidKeyMissing.test.ts (12 cases): upgrade to 724 with the signal, original error without it, a signal from an earlier link or another device is ignored, no waiting outside the link window or when unsupported, full acquire() over a stale bond including the serialized Core response, and the four bond-wait cases.
  • Mutation-checked: removing the sinceMs bound, the debounce, or the link-start stamp each fails the matching case.
  • yarn agent:check --profile commit passed for the transport change and for the dependency bump. After the version bump touched every package the full run passed 47 steps; the remaining failures reproduce on the unmodified base (hwk-trezor-schema-utils DTS build, and hwk-trezor-connector tests needing the unbuilt private hwk-trezor-transport-common).
  • Manual: on Android 16+, wipe the device, connect -> expect the pairing-invalid dialog; forget the device -> reconnect succeeds. On Android 17, accept the system pairing notification -> same attempt recovers. Android 15 and below and iOS behave as before.

Update: re-pair in place instead of failing (alpha.10)

The first build showed the 724 dialog as designed, but the goal is that a lost bond is re-paired in place, as nRF Connect manages. HCI captures (btsnoop) of three attempts on a Pixel 9a / Android 17 with a wiped Pro 2 show why the app could not:

dropped kept re-paired
Android starts encryption #1 (stale key) +33ms +46ms +32ms
App CCCD write reaches device +163ms +283ms +637ms
Device replies Insufficient Authentication +200ms +304ms +680ms
Encryption #1 fails (0x06 key missing) +418ms +171ms +436ms
Encryption #2 with the same stale key +419ms none none
Result peer drops at +978ms link kept pairing succeeded, queued CCCD retried

The framework's auth retry for the rejected CCCD write waits behind encryption #1 and, when it fails, encrypts again with the stale key (Android 17 keeps it: "will not remove the keys"). Pro 2 BLE firmware (ok_ble_peer_manage.c, key_missing_repair_process) keeps the link and sends a Security Request after the first key failure of a link, and drops it on the second. Whether an attempt survives depended only on whether the CCCD write landed before encryption #1 finished.

Change (bleEncryption.ts, waitForAndroidLinkSecurity): when the device was bonded before the acquire, the transport waits for ACTION_ENCRYPTION_CHANGE (react-native-ble-utils 0.1.9) before subscribing to notifications.

  • Success → proceed. Typical added wait 0–300 ms, since MTU and discovery still run in parallel with encryption.
  • Key missing (0x06) → keep GATT idle while the system re-pairs on the same link (heads-up "Pairing request" notification); resolves on the next encryption success, rejects BleBondInvalid on KEY_MISSING, BleDeviceDisconnected if the link drops without it, BleDeviceNotBonded after ANDROID_SYSTEM_REPAIR_TIMEOUT_MS (35 s; the stack's SMP timeout is 30 s).
  • Other failure status, link drop, or no result within ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS (1.5 s) → proceed exactly as before.
  • A link that is still up and was encrypted earlier (no ACL disconnect since) does not wait; a link that worked after an unresolved wait is recorded so its reuse does not wait either.
  • Skipped for firmware-install reconnects and for bonds created by the same acquire. Not active below API 36 or on older native builds.

Known limit: hd-core ensureConnected bounds each attempt by the call's timeout (10 s default, 30 s in onboarding). A user-confirmed re-pair can outlive it; the pairing still completes and fixes the bond, and the next attempt reuses the link.

Tests: androidEncryption.test.ts (20 cases: state machine and acquire ordering — notifications are not subscribed before the result, GATT stays idle during re-pair). Mutation-checked: removing the gate, the 0x06 hold, the ACL-drop invalidation, or the link marking each fails its case. Transport suite 241/241.

Versions: @onekeyfe/react-native-ble-utils 0.1.9, all packages 1.2.3-alpha.10.

@originalix
originalix force-pushed the fix/android-ble-key-missing-bond-invalid branch from 1d5a9ae to 0fc494d Compare September 21, 2026 01:45
@socket-security

socket-security Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​onekeyfe/​react-native-ble-utils@​0.1.9801008191100

View full report

@wabicai
wabicai merged commit de8e0be into onekey Sep 21, 2026
10 checks passed
@wabicai
wabicai deleted the fix/android-ble-key-missing-bond-invalid branch September 21, 2026 06:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants