Security

This page covers the Bluetti BLE encryption protocols: the legacy challenge-response handshake, ECDH+ECDSA mutual authentication (protocol v2), the Bluetooth password PIN mechanism, and the complete cryptographic material.

Section 12 — BLE Encryption Key Retrieval

12.1 Overview

The app uses two distinct BLE security protocols depending on device firmware generation. Neither protocol fetches a session key from the cloud at runtime — all cryptographic material is either hardcoded in the APK or derived on-device from a challenge-response exchange.

12.2 Path 1 — Legacy Challenge-Response (Protocol v1)

Applies to older devices where protocolVer < 2000.

Flow:

  1. Device sends a BLE “hello” packet containing a 4-byte random value at bytes 4–7.

  2. App reverses the 4 random bytes, computes MD5 → stores as randomMd5.

  3. App responds with a challenge packet: "2A2A0204" + randomMd5.substring(16, 24) + 1-byte checksum.

  4. Session AES key is derived as:

bleConnAESKey = XOR(randomMd5, LOCAL_AES_KEY)

Hardcoded key (ConnConstantsV2.LOCAL_AES_KEY):

459FC535808941F17091E0993EE3E93D
  1. All subsequent BLE data is encrypted/decrypted with AES-CBC using bleConnAESKey.

Key source files:

  • ConnectManager.java:1909–1925 — challenge-response derivation

  • ConnConstantsV2.java:98LOCAL_AES_KEY constant

  • ProtocolParse.java:1424buildAESCBCCmd(cmd, aesKey, iv)

12.3 Path 2 — ECDH + ECDSA Mutual Authentication (Protocol v2)

Applies to newer devices (protocol v2+, e.g., EPAD, PLP025, and other 2nd-gen IoT modules). This path adds ECDSA-based device identity verification and ECDH-based forward-secret session key derivation.

Flow:

  1. Challenge-response (same as Path 1, establishes bleConnAESKey as a temporary transport key).

  2. Device sends ECDH public key + ECDSA signature (encrypted with bleConnAESKey):

    • Response type 0x04: payload contains iotPkHexStr (64-byte uncompressed SECP-256R1 public key) + signature (64-byte raw ECDSA-SHA256 signature over iotPublicKey || randomMd5).

  3. App verifies signature using hardcoded ECDSA verification key PUBLIC_KEY_K2:

PUBLIC_KEY_K2 =
"3059301306072a8648ce3d020106082a8648ce3d03010703420004
 A73ABF5D2232C8C1C72E68304343C272495E3A8FD6F30EA96DE2F4B3CE60B251
 EE21AC667CF8A71E18B46B664EAEFFE3C489F24F695B6411DB7E22CCC85A8594"

This is an X.509-encoded SECP-256R1 (P-256) public key.

  1. App generates ephemeral ECDH keypair (SECP-256R1), then signs (appPublicKey || randomMd5) with hardcoded private key PRIVATE_KEY_L1:

PRIVATE_KEY_L1 = "4F19A16E3E87BDD9BD24D3E5495B88041511943CBC8B969ADE9641D0F56AF337"
  1. App sends its ephemeral public key + own ECDSA signature to the device (0x2A2A0580 packet).

  2. Device responds with response type 0x06 confirming acceptance.

  3. Session key derived via ECDH:

bleConnShareKey = ECDH_SharedSecret(appEphemeralPrivKey, deviceIoTPublicKey)

bleConnAESKey is discarded; all subsequent BLE traffic is encrypted with bleConnShareKey using AES-CBC.

Key source files:

  • ConnectManager.java:1934–1961 — ECDH key exchange logic

  • ConnectManager$bleEncryptedHandle$2$1.java:67–154 — ECDSA verify + ECDH keypair generation

  • SignatureCrypt.java:32–33 — hardcoded PUBLIC_KEY_K2 and PRIVATE_KEY_L1

  • ECDHUtils.java:29–33 — SECP-256R1 ASN.1 DER prefixes

12.5 Security Observations

Issue

Detail

Hardcoded local AES key

LOCAL_AES_KEY = 459FC535808941F17091E0993EE3E93D is identical across all app installations. Any attacker who captures a BLE session can decrypt it given the randomMd5 (derivable from the challenge packet).

Hardcoded ECDSA private key

PRIVATE_KEY_L1 is embedded in the APK. An attacker can extract it, sign arbitrary ECDH public keys, and impersonate the official Bluetti app to any device.

Hardcoded ECDSA verification key

PUBLIC_KEY_K2 is the only device-authenticity anchor. All Bluetti devices share the same IoT identity signing key.

No cloud key fetch for sessions

BLE session keys are fully self-contained; revocation or re-keying without an app update is not possible.

getQrCodeEncrypt endpoint

GET /api/blusmartprod/device/basic/v1/getQrCodeEncrypt exists in the API list but is not used in the BLE session key flow — it appears to be related to QR-code-based device pairing, not BLE encryption.

BLE password checked client-side only

The 6-digit PIN is read from the device over BLE, then compared against user input in the app. An attacker who completes the BLE handshake can read the register holding the PIN and bypass the check.

BLE password stored on device, readable over BLE

Once the encrypted session is established, the PIN value is transmitted in the clear (encrypted at the transport layer by the global session key).

12.6 Bluetooth Password — Per-Device PIN Authorization

Separate from session encryption, the app supports an optional per-device 6-digit BLE password that gates access after a successful BLE connection and handshake. This is not an encryption key — it is an authorization PIN.

Config flags (stored in DeviceBaseConfigBean):

  • btPswEnable (int): Whether a BLE password is active on this device

  • btLoginPsw (String): The 6-digit PIN value

How the password is read: Both fields are parsed from the base config response at ProtocolParse.java:97-99:

// btPswEnable from bits 0-1 of hex positions 10+11
deviceBaseConfigBean.setBtPswEnable(Integer.parseInt(
    list.get(1) + list.get(0), 2));
// btLoginPsw from hex positions 12-17 (6 ASCII bytes)
deviceBaseConfigBean.setBtLoginPsw(
    getASCIIStr(dataRes.subList(12, 18), true));

How the password is checked: After BLE connect succeeds, DeviceBluetoothScanActivity.java:961-986 checks btPswEnable == 1. If active, it shows DeviceBluetoothPswCheckDialog (DeviceBluetoothPswCheckDialog.java) — a 6-digit PIN entry popup. The check at line 96 is a simple string comparison:

if (Intrinsics.areEqual(strUserInput, this.bluetoothPassword)) {
    successHandle();  // proceed to device home
} else {
    // show error toast, retry count, disconnect on timeout
}

If the user closes the dialog or fails the check, ConnectManager.disconnectDevice() is called — the BLE connection is terminated. The dialog has a 60-second countdown timer.

How the password is set: The device owner sets it from the device settings screen via DeviceBluetoothPswSetupDialog (DeviceBluetoothPswSetupDialog.java), which calls ProtocolParse.bluetoothPswSetupData(password) (ProtocolParse.java:1164-1196). This encodes a 6-character password as a 12-byte hex string and writes it to the BLUETOOTH_PASSWORD register (address 7 in ProtocolAddr.java:16).

To remove the password: The negativeClick handler in DeviceBluetoothPswSetupDialog.java:242 sends the “no password” message and writes zeroes (6 × “00” bytes) to clear the stored PIN, setting btPswEnable back to 0.

Key source files:

  • ProtocolParse.java:97-99btPswEnable and btLoginPsw parsing from base config

  • ProtocolParse.java:1164-1196bluetoothPswSetupData() password encoding

  • ProtocolAddr.java:16BLUETOOTH_PASSWORD register address (7)

  • DeviceBluetoothScanActivity.java:961-986 — password check trigger after BLE connect

  • DeviceBluetoothPswCheckDialog.java:89-106 — PIN comparison and success/failure handling

  • DeviceBluetoothPswSetupDialog.java:46-58, 204-242 — password setup and clear dialogs


15.2 BLE Connection and Handshake Flow

Step 1: Scan & Connect

  1. Start BLE scan filtered by service 0000ff00-...

  2. On device found, call connectGatt() with transport=LE

  3. Wait for services discovery (500ms delay auto-triggered)

  4. Find BluetoothGattService using service UUID from scanned device

  5. Get write characteristic (ff02) and read/notify characteristic (ff01)

  6. Enable notifications on ff01 with descriptor 00002902-... using ENABLE_NOTIFICATION_VALUE ({0x01, 0x00})

Step 2: Read Device Snapshot

After connection, the app reads the base config to determine protocol version and capabilities:

  1. Read protocol version from Modbus register 16 (v1: 0103 0010 0001 ...) or V2 equivalent

  2. Read base config from register 1

  3. Read device SN from register 21

  4. Read real-time data from register 10

Step 3: Encryption Handshake (for encrypted devices)

For devices where isESP32Encrypted == true or isBLEEncrypted == true:

3a. Challenge-Response:

  1. Device sends: 2A 2A 01 followed by 4 random bytes (position 4-7)

  2. App reverses the 4 random bytes, computes MD5 → randomMd5

  3. App responds: 2A 2A 02 04 + randomMd5.substring(16, 24) + 2-byte checksum

  4. App derives temporary key: bleConnAESKey = XOR(randomMd5, LOCAL_AES_KEY)

3b. ECDH Key Exchange (protocol v2+):

  1. Device sends 2A 2A 04 ... (AES-CBC encrypted with bleConnAESKey, IV=MD5(randomMd5))

    • Bytes 4-67: device’s SECP-256R1 public key (64 bytes, raw X+Y, no 04 prefix)

    • Bytes 68 to (end-2): raw ECDSA signature (r||s, 64 bytes) over (devicePublicKey || randomMd5)

    • Last 2 bytes: checksum (little-endian sum of preceding bytes)

  2. App verifies ECDSA signature using hardcoded PUBLIC_KEY_K2

  3. App generates ephemeral SECP-256R1 keypair

  4. App signs (appPublicKey || randomMd5) with hardcoded PRIVATE_KEY_L1

  5. App sends 2A 2A 05 80 ... with app public key + signature

  6. Device responds 2A 2A 06 00 ... confirming acceptance

  7. App derives shared secret via ECDH: bleConnShareKey = ECDH(appPrivKey, devicePubKey)

  8. bleConnAESKey discarded; all subsequent traffic encrypted with bleConnShareKey

3c. Post-handshake encryption: All subsequent Modbus frames are AES-CBC encrypted using bleConnShareKey (or bleConnAESKey if ECDH not completed):

  • Written via buildAESCBCCmd(cmd, aesKey, iv)

  • The IV for the first block is derived from MD5(randomMd5), then chained from previous ciphertext

  • Each 16-byte block padded to exactly 16 bytes (no PKCS padding)

Step 4: Modbus Data Protocol (post-handshake)

Once encrypted channel is established, device communication uses standard Modbus RTU-style frames.


15.8 Complete Crypto Material

All keys are hardcoded in the APK and identical across all installations.

Key

Value

Source File

LOCAL_AES_KEY

459FC535808941F17091E0993EE3E93D

ConnConstantsV2.java:98

PRIVATE_KEY_L1

4F19A16E3E87BDD9BD24D3E5495B88041511943CBC8B969ADE9641D0F56AF337

SignatureCrypt.java:34

PUBLIC_KEY_K2

3059301306072a8648ce3d020106082a8648ce3d03010703420004A73ABF5D2232C8C1C72E68304343C272495E3A8FD6F30EA96DE2F4B3CE60B251EE21AC667CF8A71E18B46B664EAEFFE3C489F24F695B6411DB7E22CCC85A8594

SignatureCrypt.java:35

Key derivation formulas:

# Legacy challenge-response:
random_bytes = data[4:8]                           # from device hello packet
randomMd5 = MD5(reverse(random_bytes))              # 32 hex chars
bleConnAESKey = XOR(randomMd5, LOCAL_AES_KEY)       # 32 hex chars → 16 bytes

# ECDH (protocol v2+):
ecdh_shared_secret = ECDH_secp256r1(app_ephemeral_privkey, device_iot_pubkey)
bleConnShareKey = ecdh_shared_secret                # 32 hex chars → 16 bytes

Cipher: AES-128-CBC, IV chained from MD5(randomMd5), 16-byte blocks, no padding.