The Vibease is a Bluetooth Low Energy (BLE) connected vibrator. This is an outline of my attempt to reverse-engineer its communication protocol.
The vibrator exposes two BLE characteristics using the same UUID. One of them has the property READ
and the other one has the property WRITE_WITHOUT_RESPONSE
.
From here on, these will be referred to as cmd_read
and cmd_write
respectively.
The host is expected to subscribe to notifications on cmd_read
using the standard BLE mechanism. These notifications are how the vibrator sends data to the host.
Any data sent from the host to the vibrator is simply written to cmd_write
after an encryption and fragmentation process outlined below.
The following BLE UUIDs are used, and can help identify a BLE device as a vibease.
DE3A0001-7100-57EF-9190-F1BE84232730 This is the service that contains 803C3B1F-...
803C3B1F-D300-1120-0530-33A62B7838C9 This is cmd_read and cmd_write on my device
00002902-0000-1000-8000-00805f9b34fb This is a descriptor under 803C3B1F-...
00002a4d-0000-1000-8000-00805f9b34fb This is an alternative to 803C3... on some devices
Packets are encrypted using a basic offset-by-one-and-xor method.
There are two hardcoded keys used:
KEY1 = "2iYNPjW9ptZj6L7snPfPWIH5onzQ0V1p".encode("ascii")
KEY2 = "4sRewsha3G54ZqEcjr9Iadexd1sKB8vr".encode("ascii")
There is also a third key, KEY_HS
which is sent from the vibrator during an initial handshake.
During use, one key is used for each data direction, they will be referred to as KEY_TX
for messages sent from the host to the vibrator and KEY_RX
for messages from the vibrator to the host.
In my device, KEY_TX=KEY_HS
and KEY_RX=KEY2
.
Messages to be transmitted are passed through Scramble()
before fragmentation. Received messages are passed through the complementary Descramble()
after being reassembled.
These are python implementations of these rudimentary crypto functions:
# Pass in a byte-array cryptext received from the device
# and a byte-array for a key, which is the same key that was used to Scramble()
# This is just a plain xor cipher with an offset by one. No big deal.
def Descramble(cryptext, key):
plaintext = [ b for b in cryptext ]
for i in range(len(plaintext)):
plaintext[i] = (plaintext[i] - 1) ^ key[i % len(key)]
return bytes(plaintext)
# This is the complementary function, used before transmitting messages
# Pass in a string plaintext message
# and a byte-array for a key
# Returns a byte array
def Scramble(plaintext, key):
cryptext = [ b for b in plaintext.encode("ascii") ]
for i in range(len(cryptext)):
cryptext[i] = (cryptext[i] ^ key[i % len(key)]) + 1
return bytes(cryptext)
There is a slight inconsistency when scrambling and descrambling with KEY_HS
. Instead of using len(key)
for the modulo, the vibrator uses len(key-1)
which means the last character of KEY_HS
is never actually used.
With the python example above, it is sufficient to truncate the very last byte of KEY_HS
and both Scramble()
and Descramble()
should work as expected using all known keys.
For example, if the device sends HS=ABCDEFGHIJKLMNO
during handshake, simply set KEY_HS=ABCDEFGHIJKLMN
This way, the same function can descramble packets transmitted by the host (using the truncated KEY_HS
) and packets transmitted by the device (using the full KEY_TX
).
Once scrambled, the payload is encoded using standard Base64 and passed on for fragmentation.
Payloads are scrambled according to the above, B64-encoded and then fragmented if necessary. A long b64-encoded payload is split into 16-byte chunks which are surrounded with ASCII markers.
With data bytes denoted as DDDD...
, here are the known packet formats:
- A single-chunk payload of data (16 bytes or shorter) is transmitted as
*DDDDD!
- The first character (prefix) of the packet is variable. The host uses
$
or*
. The device uses#
or%
.
- The first character (prefix) of the packet is variable. The host uses
- A multi-chunk payload:
- First 16-byte chunk:
*DDDDDDDDDDDDDDDD>
- The same prefix rules apply
- Following 16-byte chunk(s):
<DDDDDDDDDDDDDDDD>
- Last chunk:
<DDDDDDD!
- First 16-byte chunk:
Each chunk is transmitted as a single write to cmd_write
or received as a single notification on cmd_read
. They must be received in their proper order, so don't send the next chunk before the first chunk has been sent.
Here is a python implementation of the scrambling and fragmentation employed in vibease.bluetoothtest:
# Pass in a plaintext string, get a list of strings for data packets back
# This is how the app breaks a longer payload up for transmission
# in short BLE packets
def ScrambleAndFragment(payload, prefix="*", key=KEY_TX):
scrambled = Scramble(payload,key).decode("ascii").replace("\n", "")
encoded = Base64.b64encode(scrambled)
n_blocks = int(len(encoded) / 16)
if (len(encoded) % 16 != 0):
n_blocks += 1
if (n_blocks == 1):
# Single packet
return [ prefix + encoded + "!" ]
packets = [ ]
for b in range(n_blocks):
chunk = encoded[b*16:(b+1)*16]
if (b == 0):
# First packet
packets += [ prefix + chunk + ">" ]
elif (b == n_blocks - 1):
# Last packet
packets += [ "<" + chunk + "!" ]
else:
# Middle packets
packets += [ "<" + chunk + ">" ]
return packets
Most messages are prepared for transmission this way (scramble, base64, then fragment).
Some interesting BLE dumps are here.
$
appears to signify non-control commands, such as initialization. Scrambling key differs between commands.
*
appears to signify normal commands, these are all scrambled with KEY_TX
%
is used for some sort of version packet, which is entirely unscrambled and not b64 encoded
#
appears to be used for all normal responses, most are scrambled with KEY_RX
.
(The examples below use my KEY_HS=GxJROgt4fnQDVA
and will look different on a different setup.)
Unscrambled example (bytes): 0x53 0x1B
Unscrambled example (ASCII): S<ESCAPE>
Transmitted packets:
$aGK=!
Notes: Since the host does not yet have KEY_HS, I've assumed that this message is scrambled using KEY_RX
. We'll find out once we see KEY_1 used, perhaps those devices expect a different packet.
The device responds with its HS key followed by the OK message:
#fSFwIxA6Oy9VNAJTNS>
<ECNixC!
%1406-OK!
Unscrambled example (ASCII): SN
Transmitted packets:
$FTc=!
Notes:
- Unlike the other
$
prefixed command, this is scrambled using the properKEY_TX
which was just received through key exchange.
The device responds with a message scrambled using KEY_HS
which looks something like #FTd4bH0kNwRYX2Q=!
If descrambled with KEY_HS
, the response message is SN=93DB7102
which appears to be the serial number of the vibrator.
Unscrambled example (ASCII): 3150
Transmitted packets:
*dUqAY2RYRQdX!
Notes: The first digit is the intensity, 0-9. The remaining three digits are a duration in ms.
Unscrambled example (ASCII): 1200,2200,3200,4200,5200,6200,7200,8200,9200,0200
Transmitted packets:
*d0t7Y2RWRwVXQ2N3>
<Z3JsTXljgExCB1df>
<fnNlcnhVfmGAWFkN>
<VV9iaXB0eElnY35Y>
<RQ==!
Notes:
- Each number in the sequence is a
Vibrate Fixed
command as outlined above. - It appears that a valid pattern is anywhere between 2 and 10 steps.
- The "patterns" feature in the vibease app doesn't use this command, it sends timed "Vibrate Fixed" commands instead.
- The actual result of this long pattern is a little unpredictable. The first time after the vibrator is restarted appears to start a stored pattern of some sort (not necessarily the one from the command). Other times, this command just starts a static vibration.
Length-2 vibrations can be used for a simple oscillating pattern, for example 7200,3500
(High for 0.2s, low for 0.5s, repeat)
Unscrambled example (ASCII): 0500,0500
Transmitted packets:
*eE57Y2RYQgVX!
Notes: Given the "vibrate fixed" command follows the same format, this is probably "Zero speed, Zero intensity".
The following steps are necessary to obtain KEY_HS
, which appears to be unique to each vibrator:
1. Open a GATT connection to the vibease and bond to it.
2. Locate service de3a0001-7100-57ef-9190-f1be84232730
3. Find the characteristic 803C3B1F-D300-1120-0530-33A62B7838C9
* There are two of them, one with the READ property and one with the
WRITE_WITHOUT_RESPONSE property. These are cmd_read and cmd_write.
4. Enable change notification on cmd_read, so that you receive messages when the value changes.
5. Perform a write to cmd_write with the payload $aGk=! (bytes [ 0x24, 0x61, 0x47, 0x6b, 0x3d, 0x21 ])
6. Three notifications come in, in order. In my setup they are:
#fSFwIxA6Oy9VNAJTNS>
<ECNixC!
%1406-OK!
The first two are a fragmented single message. Defragment, b64-decode and descramble using KEY_TX
to get the payload:
HS=GxJROgt4fnQDVA3
This key will probably be different for different devices. Truncate the very last byte of this key to get the key:
KEY_RX=GxJROgt4fnQDVA
There is another uuid, 0002a4d-0000-1000-8000-00805f9b34fb
which is used interchangably with 803...
if found. My device does not have this characteristic, but maybe it's for legacy devices or some other product. This might also be the device that uses KEY1
as its KEY_TX
.
If you are implementing the device side of this communication, you should probably choose a KEY_HS
of equal length to the above example, including the extra byte of garbage.
After key exchange has been completed, the device sends an unscrambled and un-base64'd message like
%1406-OK!
which appears to be a version number and a basic status report.
The official apps send the Report Serial Number
command and receive the response right after key exchange has been completed.
This does not appear to be a necessary part of the handshake, and can be left out. It is a good way to make sure KEY_HS
has been received properly.