@onekeyfe/hd-transport-react-native 1.2.3-alpha.9 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ import { Platform } from 'react-native';
2
+ import BleUtils from '@onekeyfe/react-native-ble-utils';
3
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
4
+
5
+ import {
6
+ hasBleKeyMissingSince,
7
+ onBleKeyMissing,
8
+ startBleKeyMissingTracking,
9
+ } from './bleKeyMissing';
10
+
11
+ /**
12
+ * Android 16+ starts encrypting a bonded LE link on its own right after connecting, with the
13
+ * stored key. A GATT request that needs encryption and is sent before that attempt finishes is
14
+ * rejected by the peer, and the framework retries it by encrypting again with the same key. When
15
+ * the peer has lost the bond, that second failure makes firmware that tolerates a single one per
16
+ * link drop the connection, before the system's own re-pairing can finish.
17
+ *
18
+ * Tracking the link's encryption result lets the transport hold GATT until then, so a lost bond
19
+ * is re-paired in place (the system shows a pairing request) instead of failing.
20
+ */
21
+
22
+ /** HCI "PIN or Key Missing": the peer no longer has the keys of the stored bond. */
23
+ export const HCI_PIN_OR_KEY_MISSING = 0x06;
24
+
25
+ export type AndroidLinkEncryption =
26
+ /** This link was encrypted with the stored bond. */
27
+ | 'encrypted'
28
+ /** The link was reused and had already been encrypted. */
29
+ | 'already-encrypted'
30
+ /** The stored bond was lost and the system re-paired on this link. */
31
+ | 're-paired'
32
+ /** No result arrived in time; callers proceed as if nothing was known. */
33
+ | 'unresolved';
34
+
35
+ type EncryptionRecord = { at: number; status: number; enabled: boolean };
36
+
37
+ // An app can still resolve an older react-native-ble-utils, and the JS bundle can run on a native
38
+ // build that predates the events, so none of these members is assumed to exist.
39
+ type EncryptionCapableBleUtils = Partial<
40
+ Pick<
41
+ typeof BleUtils,
42
+ 'supportsDeviceEncryptionChange' | 'onDeviceEncryptionChange' | 'onDeviceAclDisconnected'
43
+ >
44
+ >;
45
+
46
+ type LinkListener = (deviceId: string) => void;
47
+
48
+ const lastEncryption = new Map<string, EncryptionRecord>();
49
+ const lastAclDisconnectAt = new Map<string, number>();
50
+ const listeners = new Set<LinkListener>();
51
+ let unsubscribeNative: Array<() => void> | undefined;
52
+
53
+ const normalizeDeviceId = (deviceId: string) => deviceId.toLowerCase();
54
+
55
+ const isEncrypted = (record: EncryptionRecord) => record.status === 0 && record.enabled;
56
+
57
+ const getCapableBleUtils = (): EncryptionCapableBleUtils | undefined => {
58
+ if (Platform.OS !== 'android') return undefined;
59
+ const bleUtils: EncryptionCapableBleUtils = BleUtils;
60
+ if (
61
+ typeof bleUtils.supportsDeviceEncryptionChange !== 'function' ||
62
+ typeof bleUtils.onDeviceEncryptionChange !== 'function' ||
63
+ typeof bleUtils.onDeviceAclDisconnected !== 'function'
64
+ ) {
65
+ return undefined;
66
+ }
67
+ return bleUtils.supportsDeviceEncryptionChange() ? bleUtils : undefined;
68
+ };
69
+
70
+ const notify = (deviceId: string) => {
71
+ listeners.forEach(listener => listener(deviceId));
72
+ };
73
+
74
+ /** False means the encryption result can never be observed here, so nothing should wait for it. */
75
+ export const isBleEncryptionTrackingSupported = () => getCapableBleUtils() !== undefined;
76
+
77
+ /** Idempotent. Returns whether encryption results are observable on this OS and native build. */
78
+ export const startBleEncryptionTracking = (): boolean => {
79
+ if (unsubscribeNative) return true;
80
+ const bleUtils = getCapableBleUtils();
81
+ if (!bleUtils?.onDeviceEncryptionChange || !bleUtils.onDeviceAclDisconnected) return false;
82
+
83
+ unsubscribeNative = [
84
+ bleUtils.onDeviceEncryptionChange(event => {
85
+ if (typeof event?.id !== 'string') return;
86
+ const deviceId = normalizeDeviceId(event.id);
87
+ lastEncryption.set(deviceId, {
88
+ at: Date.now(),
89
+ status: Number(event.status),
90
+ enabled: event.enabled === true,
91
+ });
92
+ notify(deviceId);
93
+ }),
94
+ bleUtils.onDeviceAclDisconnected(event => {
95
+ if (typeof event?.id !== 'string') return;
96
+ const deviceId = normalizeDeviceId(event.id);
97
+ lastAclDisconnectAt.set(deviceId, Date.now());
98
+ notify(deviceId);
99
+ }),
100
+ ];
101
+ return true;
102
+ };
103
+
104
+ export const stopBleEncryptionTracking = () => {
105
+ unsubscribeNative?.forEach(unsubscribe => unsubscribe());
106
+ unsubscribeNative = undefined;
107
+ lastEncryption.clear();
108
+ lastAclDisconnectAt.clear();
109
+ listeners.clear();
110
+ };
111
+
112
+ /**
113
+ * Records a link that carried requests needing encryption, so reusing it later does not wait for
114
+ * an encryption result that was reported before tracking started.
115
+ */
116
+ export const markBleLinkEncrypted = (deviceId: string) => {
117
+ if (!unsubscribeNative) return;
118
+ lastEncryption.set(normalizeDeviceId(deviceId), { at: Date.now(), status: 0, enabled: true });
119
+ };
120
+
121
+ export type WaitForAndroidLinkEncryptionOptions = {
122
+ deviceId: string;
123
+ /** When the current link attempt began; earlier results describe another link. */
124
+ linkStartedAt: number;
125
+ /** How long to wait for the first encryption result of this link. */
126
+ resultTimeoutMs: number;
127
+ /** How long a system re-pairing may take once the stored bond was reported lost. */
128
+ repairTimeoutMs: number;
129
+ /** After the link drops during re-pairing, how long to wait for the key-missing report. */
130
+ keyMissingGraceMs: number;
131
+ signal?: AbortSignal;
132
+ onRepairStarted?: () => void;
133
+ };
134
+
135
+ /**
136
+ * Resolves once the link can carry requests that need encryption. Rejects with `BleBondInvalid`
137
+ * when the system reports that re-pairing failed, with `BleDeviceDisconnected` when the link drops
138
+ * during re-pairing, and with `BleDeviceNotBonded` when re-pairing does not finish in time.
139
+ */
140
+ export const waitForAndroidLinkEncryption = ({
141
+ deviceId,
142
+ linkStartedAt,
143
+ resultTimeoutMs,
144
+ repairTimeoutMs,
145
+ keyMissingGraceMs,
146
+ signal,
147
+ onRepairStarted,
148
+ }: WaitForAndroidLinkEncryptionOptions): Promise<AndroidLinkEncryption> => {
149
+ if (!startBleEncryptionTracking()) return Promise.resolve('unresolved');
150
+ startBleKeyMissingTracking();
151
+ const target = normalizeDeviceId(deviceId);
152
+
153
+ const previous = lastEncryption.get(target);
154
+ const previousDropAt = lastAclDisconnectAt.get(target);
155
+ if (
156
+ previous &&
157
+ previous.at < linkStartedAt &&
158
+ isEncrypted(previous) &&
159
+ (previousDropAt === undefined || previousDropAt < previous.at)
160
+ ) {
161
+ return Promise.resolve('already-encrypted');
162
+ }
163
+
164
+ return new Promise((resolve, reject) => {
165
+ if (signal?.aborted) {
166
+ reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
167
+ return;
168
+ }
169
+
170
+ /** Set once this link's first encryption failed because the peer lost the bond. */
171
+ let repairStartedAt: number | undefined;
172
+ let droppedDuringRepair = false;
173
+ let timer: ReturnType<typeof setTimeout> | undefined;
174
+ let settled = false;
175
+
176
+ const cleanup = () => {
177
+ settled = true;
178
+ if (timer) clearTimeout(timer);
179
+ listeners.delete(onLinkEvent);
180
+ cleanupKeyMissing();
181
+ signal?.removeEventListener('abort', onAbort);
182
+ };
183
+ const finish = (result: AndroidLinkEncryption) => {
184
+ cleanup();
185
+ resolve(result);
186
+ };
187
+ const fail = (error: Error) => {
188
+ cleanup();
189
+ reject(error);
190
+ };
191
+ const arm = (ms: number, onExpire: () => void) => {
192
+ if (timer) clearTimeout(timer);
193
+ timer = setTimeout(onExpire, ms);
194
+ };
195
+ const bondInvalid = () =>
196
+ ERRORS.TypedError(HardwareErrorCode.BleBondInvalid, undefined, {
197
+ phase: 'connect',
198
+ reason: 'key_missing',
199
+ });
200
+
201
+ const evaluate = () => {
202
+ if (settled) return;
203
+ const record = lastEncryption.get(target);
204
+ const dropAt = lastAclDisconnectAt.get(target);
205
+
206
+ if (repairStartedAt === undefined) {
207
+ if (hasBleKeyMissingSince(target, linkStartedAt)) {
208
+ fail(bondInvalid());
209
+ return;
210
+ }
211
+ if (dropAt !== undefined && dropAt >= linkStartedAt) {
212
+ // The link is gone; the caller's next request reports it with its usual error.
213
+ finish('unresolved');
214
+ return;
215
+ }
216
+ if (!record || record.at < linkStartedAt) return;
217
+ if (isEncrypted(record)) {
218
+ finish('encrypted');
219
+ return;
220
+ }
221
+ if (record.status !== HCI_PIN_OR_KEY_MISSING) {
222
+ finish('unresolved');
223
+ return;
224
+ }
225
+ // The system re-pairs on its own after a lost bond. Nothing may touch GATT until then.
226
+ repairStartedAt = record.at;
227
+ onRepairStarted?.();
228
+ arm(repairTimeoutMs, () =>
229
+ fail(
230
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing timed out', {
231
+ phase: 'bond',
232
+ reason: 'timeout',
233
+ })
234
+ )
235
+ );
236
+ return;
237
+ }
238
+
239
+ if (hasBleKeyMissingSince(target, repairStartedAt)) {
240
+ fail(bondInvalid());
241
+ return;
242
+ }
243
+ if (record && record.at > repairStartedAt && isEncrypted(record)) {
244
+ finish('re-paired');
245
+ return;
246
+ }
247
+ if (!droppedDuringRepair && dropAt !== undefined && dropAt >= repairStartedAt) {
248
+ droppedDuringRepair = true;
249
+ // A failed re-pairing drops the link and reports key missing right after.
250
+ arm(keyMissingGraceMs, () =>
251
+ fail(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected))
252
+ );
253
+ }
254
+ };
255
+
256
+ const onLinkEvent: LinkListener = id => {
257
+ if (id === target) evaluate();
258
+ };
259
+ const onAbort = () => fail(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
260
+
261
+ listeners.add(onLinkEvent);
262
+ const cleanupKeyMissing = onBleKeyMissing(target, evaluate);
263
+ signal?.addEventListener('abort', onAbort, { once: true });
264
+ arm(resultTimeoutMs, () => finish('unresolved'));
265
+ evaluate();
266
+ });
267
+ };
package/src/index.ts CHANGED
@@ -53,6 +53,12 @@ import {
53
53
  stopBleKeyMissingTracking,
54
54
  waitForBleKeyMissing,
55
55
  } from './bleKeyMissing';
56
+ import {
57
+ markBleLinkEncrypted,
58
+ startBleEncryptionTracking,
59
+ stopBleEncryptionTracking,
60
+ waitForAndroidLinkEncryption,
61
+ } from './bleEncryption';
56
62
  import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from './bleNativeDisconnect';
57
63
  import {
58
64
  isBleStaleBondHardwareError,
@@ -68,6 +74,7 @@ import type { Deferred } from '@onekeyfe/hd-shared';
68
74
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
69
75
  import type EventEmitter from 'events';
70
76
  import type { BleAcquireInput, TransportOptions } from './types';
77
+ import type { AndroidLinkEncryption } from './bleEncryption';
71
78
 
72
79
  type FirmwareInstallBleAcquireInput = BleAcquireInput & {
73
80
  /**
@@ -202,6 +209,16 @@ const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
202
209
  export const ANDROID_KEY_MISSING_LINK_WINDOW_MS = 10_000;
203
210
  /** The broadcast and the GATT disconnect it explains travel separately; either can land first. */
204
211
  export const ANDROID_KEY_MISSING_GRACE_MS = 500;
212
+ /**
213
+ * Android reports a bonded link's first encryption result within about half a second of
214
+ * connecting. Past this, the result is treated as unknown and the link is used as before.
215
+ */
216
+ export const ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS = 1500;
217
+ /**
218
+ * A system re-pairing needs the user to accept a pairing request and confirm the code on the
219
+ * device. The Bluetooth stack gives up after 30s and then reports key missing.
220
+ */
221
+ export const ANDROID_SYSTEM_REPAIR_TIMEOUT_MS = 35_000;
205
222
  /**
206
223
  * How a link dropped by a device that refuses a stale bond reaches JS. A wedged write is
207
224
  * excluded: the system is still re-pairing then and has not reported key missing yet.
@@ -635,6 +652,11 @@ export default class ReactNativeBleTransport {
635
652
  init(logger: any, emitter: EventEmitter) {
636
653
  setBleLogger(logger);
637
654
  this.emitter = emitter;
655
+ if (Platform.OS === 'android') {
656
+ // Link security events are only meaningful if they were observed since the link came up.
657
+ startBleKeyMissingTracking();
658
+ startBleEncryptionTracking();
659
+ }
638
660
  }
639
661
 
640
662
  configure(signedData: any) {
@@ -1172,6 +1194,8 @@ export default class ReactNativeBleTransport {
1172
1194
 
1173
1195
  let device: Device | null = null;
1174
1196
  const isAndroid = Platform.OS === 'android';
1197
+ // Only a bond that existed before this acquire can have been lost by the device.
1198
+ let androidBondedBeforeConnect = false;
1175
1199
  // A firmware-install reconnect always refreshes: the new firmware may expose a different table.
1176
1200
  const refreshAndroidGattCache =
1177
1201
  isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
@@ -1205,11 +1229,13 @@ export default class ReactNativeBleTransport {
1205
1229
  if (Platform.OS === 'android') {
1206
1230
  // Subscribe before the link exists: key missing is broadcast while Android encrypts it.
1207
1231
  startBleKeyMissingTracking();
1232
+ startBleEncryptionTracking();
1208
1233
  // Failures before the new link starts must not be read against the previous link.
1209
1234
  this.androidLinkStartedAt.delete(uuid);
1210
1235
  // Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
1211
1236
  try {
1212
1237
  const bondState = await pairDevice(uuid);
1238
+ androidBondedBeforeConnect = !bondState.bonding && bondState.bonded;
1213
1239
  if (bondState.bonding) {
1214
1240
  await onDeviceBondState(uuid, this.bondAbortController.signal, {
1215
1241
  systemInitiated: bondState.initiated === false,
@@ -1413,7 +1439,13 @@ export default class ReactNativeBleTransport {
1413
1439
  this.acquiringProtocolV2.add(uuid);
1414
1440
  }
1415
1441
 
1442
+ let linkEncryption: AndroidLinkEncryption | undefined;
1416
1443
  try {
1444
+ // A firmware-install reconnect keeps its existing sequence.
1445
+ if (androidBondedBeforeConnect && !skipProtocolProbe) {
1446
+ linkEncryption = await this.waitForAndroidLinkSecurity(uuid);
1447
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1448
+ }
1417
1449
  await this.installTransportForAcquire(uuid, acquiredDevice, {
1418
1450
  writeCharacteristic,
1419
1451
  notifyCharacteristic,
@@ -1465,6 +1497,11 @@ export default class ReactNativeBleTransport {
1465
1497
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1466
1498
  }
1467
1499
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1500
+ if (linkEncryption === 'unresolved') {
1501
+ // Notifications and the probe only work on an encrypted link, so the next reuse of this
1502
+ // link needs no result that was reported before tracking started.
1503
+ markBleLinkEncrypted(uuid);
1504
+ }
1468
1505
  return { uuid, protocolType };
1469
1506
  } catch (error) {
1470
1507
  // A failed acquire must retire the physical link before Core retries. Logical
@@ -1773,6 +1810,39 @@ export default class ReactNativeBleTransport {
1773
1810
  });
1774
1811
  }
1775
1812
 
1813
+ /**
1814
+ * Android 16+ encrypts a bonded link on its own right after connecting. Subscribing to
1815
+ * notifications before that finishes gets the CCCD write rejected, and the framework's retry
1816
+ * encrypts again with the same stale key; firmware that allows one failure per link then drops
1817
+ * it before the system can re-pair. Holding GATT until the result lets a lost bond be re-paired
1818
+ * in place. Without the platform signal this resolves immediately.
1819
+ */
1820
+ private async waitForAndroidLinkSecurity(uuid: string): Promise<AndroidLinkEncryption> {
1821
+ const linkStartedAt = this.androidLinkStartedAt.get(uuid);
1822
+ if (linkStartedAt === undefined) return 'unresolved';
1823
+ const waitStartedAt = Date.now();
1824
+ const result = await waitForAndroidLinkEncryption({
1825
+ deviceId: uuid,
1826
+ linkStartedAt,
1827
+ resultTimeoutMs: ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS,
1828
+ repairTimeoutMs: ANDROID_SYSTEM_REPAIR_TIMEOUT_MS,
1829
+ keyMissingGraceMs: ANDROID_KEY_MISSING_GRACE_MS,
1830
+ signal: this.bondAbortController.signal,
1831
+ onRepairStarted: () => {
1832
+ Log?.debug(
1833
+ '[ReactNativeBleTransport] Android bond lost, waiting for system re-pairing:',
1834
+ uuid
1835
+ );
1836
+ },
1837
+ });
1838
+ Log?.debug('[ReactNativeBleTransport] Android link encryption', {
1839
+ connectIdSuffix: uuid.slice(-8),
1840
+ result,
1841
+ waitedMs: Date.now() - waitStartedAt,
1842
+ });
1843
+ return result;
1844
+ }
1845
+
1776
1846
  private async callProtocolV1(
1777
1847
  uuid: string,
1778
1848
  name: string,
@@ -2040,6 +2110,7 @@ export default class ReactNativeBleTransport {
2040
2110
  this.androidHighPriorityDevices.clear();
2041
2111
  this.androidLinkStartedAt.clear();
2042
2112
  stopBleKeyMissingTracking();
2113
+ stopBleEncryptionTracking();
2043
2114
  const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2044
2115
  this.runPromise?.reject(error);
2045
2116
  this.runPromise = null;