OpenSesame: Reverse Engineering BLE Lock

Open Sesame. Sesame is a smart door lock from the CandyHouse. It uses Bluetooth Low Energy to communicate wirelessly with smartphone apps. We are going to explain its BLE protocol and how we can write a script to control it. The protocol is reverse engineered from its Android app. This is not a full protocol documentation. I only reversed it just enough to lock/unlock the door.

BLE Services

The device exposes two BLE services that we can discover:

  • Operations: Normal operation service 00001523-1212-EFDE-1523-785FEABCD123
  • DFU: Device Firmware Upgrade service

DFU service is for upgrading firmwares while the operations service is where all the fun happens and it exposes a few characteristic that we can use to read / write data.

  • Command: 00001524-1212-EFDE-1523-785FEABCD123
  • Status: 00001526-1212-EFDE-1523-785FEABCD123

The packet format

Before we can send anything to the lock, we need to first understand the format of its packet.

HMAC macData md5(userID) serialNumber OP payload
32 6 16 4 1 optional

Where:

  • macData is the manufacturerData you can read from the BLE advertisement packet: it is the Sesame’s MAC address with 3 bytes of zeroes prepending it, which you will need to strip
  • userID is your email address
  • serialNumber is a number read from the Status characteristic
  • OP is a number indicating operations: LOCK = 1, UNLOCK = 2
  • payload is not needed for locking and unlocking

The HMAC

HMAC is a standard way to authenticate the authenticity of a message. Sesame used SHA-256 as the hash function. The password can be a bit hard to extract. I believe (which means I traced the reversed code, but I have not verified if my assumption is correct) it came from a password which can be retrieved by logging into their XMPP server and chat with the server for user profile. However, it will need to be decrypt with a hard-coded key from the app. I was lazy to going through this so I wrote a Xposed module to extract it from the app. I hooked on to the SecretKeySpec constructor and wait for it to be initialized with the HMAC password.

Reading the Status

The serialNumber is an incrementing, rollover counter that we need to read from the device and include in the packet. It is located at bytes 6 ~ 10 of the response of the Status characteristic. You will need to plus one before you use it. Byte 14 is somewhat interesting as well, it is the error code for last command. You can find a list of error codes in the example code.

Wrapping it up

Pun intended. Before you can send out the constructed packet, you will need to break it down to a series of 20 bytes-sized packets. The first bytes is PT, indicating which part is the packet and then 19-bytes of the original payload.

  • PT indicates a series of packets: 01 for the first one, 02 for the rest and 04 to finalize it

With that ready, you can simply write the wrapped packet to the Command characteristic. It’s a write-and-ack endpoint.

Notes & Trivia

  • The reversing was done with apktool, JADX and Android Studio
  • Interestingly, the Sesame app use XMPP protocol to talk to its cloud counterparts
  • The early version app contains a lot of Taiwanese internet memes in it. #TaiwanNo1

Example Code

Here’s an example snippet for unlocking a Sesame. Have fun!

const crypto = require('crypto');
const noble = require('noble');

const userId = 'REDACTED';
const deviceId = 'REDACTED';
const password = 'REDACTED';

const CODE_UNLOCK = 2;
const serviceOperationUuid = '000015231212efde1523785feabcd123';
const characteristicCommandUuid = '000015241212efde1523785feabcd123';
const characteristicStatusUuid = '000015261212efde1523785feabcd123';

console.log('==> waiting on adapter state change');

noble.on('stateChange', (state) => {
  console.log('==> adapter state change', state);
  if (state === 'poweredOn') {
    console.log('==> start scanning', [serviceOperationUuid]);
    noble.startScanning();
  } else {
    noble.stopScanning();
  }
});

noble.on('discover', (peripheral) => {
  if (peripheral.id !== deviceId) {
    console.log('peripheral discovered; id mismatch:', peripheral.id);
  } else {
    noble.stopScanning();
    connect(peripheral);
  }
});

function connect(peripheral) {
  console.log('==> connecting to', peripheral.id);
  peripheral.connect((error) => {
    if (error) {
      console.log('==> Failed to connect:', error);
    } else {
      console.log('==> connected');
      discoverService(peripheral);
    }
  });
}

function discoverService(peripheral) {
  console.log('==> discovering services');
  peripheral.once('servicesDiscover', (services) => {
    const opServices = services.filter((s) => s.uuid === serviceOperationUuid);
    if (opServices.length !== 1) {
      throw new Error('unexpected number of operation services');
    }

    discoverCharacteristic(peripheral, opServices[0]);
  });
  peripheral.discoverServices();
}

function discoverCharacteristic(peripheral, opService) {
  console.log('==> discovering characteristics');
  opService.once('characteristicsDiscover', (characteristics) => {
    const charStatus = characteristics.filter((c) => c.uuid === characteristicStatusUuid);
    const charCmd = characteristics.filter((c) => c.uuid === characteristicCommandUuid);

    if (charStatus.length !== 1 || charCmd.length !== 1) {
      throw new Error('unexpected number of command/status characteristics');
    }

    unlock(peripheral, charStatus[0], charCmd[0]);
  });
  opService.discoverCharacteristics();
}

function unlock(peripheral, charStatus, charCmd) {
  console.log('==> reading serial number');
  charStatus.on('data', (data) => {
    const sn = data.slice(6, 10).readUInt32LE(0) + 1;
    const err = data.slice(14).readUInt8();
    const errMsg = [
      "Timeout",
      "Unsupported",
      "Success",
      "Operating",
      "ErrorDeviceMac",
      "ErrorUserId",
      "ErrorNumber",
      "ErrorSignature",
      "ErrorLevel",
      "ErrorPermission",
      "ErrorLength",
      "ErrorUnknownCmd",
      "ErrorBusy",
      "ErrorEncryption",
      "ErrorFormat",
      "ErrorBattery",
      "ErrorNotSend"
    ];
    console.log('status update [sn=', + sn + ', err=' + errMsg[err+1] + ']');
  });
  charStatus.subscribe();
  charStatus.read((error, data) => {
    if (error) { console.log(error); process.exit(-1); }
    if (data) {
      const macData = peripheral.advertisement.manufacturerData;
      const sn = data.slice(6, 10).readUInt32LE(0) + 1;
      const payload = _sign(CODE_UNLOCK, '', password, macData.slice(3), userId, sn);
      console.log('==> unlocking', sn);
      write(charCmd, payload);
      setTimeout(() => process.exit(0), 500);
    }
  });
}

function _sign(code, payload, password, macData, userId, nonce) {
  const hmac = crypto.createHmac('sha256', Buffer.from(password, 'hex'));
  const hash = crypto.createHash('md5');
  hash.update(userId);
  const buf = Buffer.alloc(payload.length + 59);
  macData.copy(buf, 32); /* len = 6 */
  hash.digest().copy(buf, 38); /* len = 16 */
  buf.writeUInt32LE(nonce, 54); /* len = 4 */
  buf.writeUInt8(code, 58); /* len = 1 */
  Buffer.from(payload).copy(buf, 59);
  hmac.update(buf.slice(32));
  hmac.digest().copy(buf, 0);
  return buf;
}

function write(char, payload) {
  const writes = [];
  for(let i=0;i<payload.length;i+=19) {
    const sz = Math.min(payload.length - i, 19);
    const buf = Buffer.alloc(sz + 1);
    if (sz < 19) {
      buf.writeUInt8(4, 0);
    } else if (i === 0) {
      buf.writeUInt8(1, 0);
    } else {
      buf.writeUInt8(2, 0);
    }

    payload.copy(buf, 1, i, i + 19);
    console.log('<== writing:', buf.toString('hex').toUpperCase());
    char.write(buf, false);
  }
}