Help us understand the problem. What is going on with this article?

スマートロックのSesame miniをnodeでBluetooth連携する

についての記事になります。

追記:ESP32版の記事も書きました。
https://qiita.com/odetarou/items/51424e1963c5d5099f1a

追記:Androidに移植された方が。Great!
https://qiita.com/sakujira/items/93998534d8358b387ee9

Bluetoothで接続するメリット

  • 公式提供のREST APIだと5秒〜10秒かかるのが、1秒〜5秒と高速になる!
  • Bluetooth連携する機器から直接SesameにアクセスするためSesame用Wifiアクセスポイントアダプタが不要

元は海外の先駆者の方のブログを参考にさせてもらいました。ありがたや。
https://itsze.ro/blog/2016/12/18/opensesame-reverse-engineering-ble-lock.html

2年以上前の記事ですが今でも変わらず使えました。

用意するもの

  • Sesame mini(Sesameでも動くと思います)
  • BLEとnodeが動くPCなど(私はmacで確認しました。raspberry piでも動きそうなきはします)
  • root化済みAndroid携帯 Xposedが動く環境を用意。パスワード取得用。 (眠っていたGalaxy Nexusが役立ちました)
  • ↑(追記)コメントで頂いた情報ですがFridaというツールをつかえばroot化端末のみでXposedは不要そうです。Great!
  • ↑(追記)コメントで頂いた情報ですが非root化でもいけたかたが。Great! https://qiita.com/offmon/items/6f24c70ae692a938602e

Config値の用意

userId

Sesameアプリ登録に利用したメールアドレス

deviceId

peripheral.idのこと

コード実行時にScanして付近のBLE機器のperipheral.id一覧がaddress値と一緒にでるため
まずはSesameのaddress値を探します。
macでなければlocalNameが表示されて直接peripheral.idがわかるかもしれません。

address値を探す方法(localNameが表示されない場合)

Macの場合

Bluetooth Explorerを使います。

Apple製開発ツール「Bluetooth Explorer」でBLEデバイスのGATT仕様を確認する
https://qiita.com/shu223/items/46dabad41cf2eed67d13

機器名が表示されないため下記画像のようにUUIDが"000015231212efde1523785feabcd123"のものを探します。(めんどい。。)

Androidの場合

AndroidのBlooth Toolを使ったほうがわかりやすいかもしれません。
BLE Scanner
https://play.google.com/store/apps/details?id=com.macdom.ble.blescanner&hl=ja

SesameUと機器名が表示されるので特定しやすいです。

password

ここがこの記事一番の難関です。
AndroidのSesame公式アプリの内部に保存されているパスワード値を抜き出す必要があります。
抜き出すためにroot化済みAndroid携帯 Xposedが動く環境が必要になります。
(Bluetooth APIが公式にリリースされればアプリからパスワード値を見れたりできると思うので早く公開してほしいですね)

(追記) コメントで頂いた情報ですがFridaというツールをつかえばXposed対応は不要でroot化済みAndroid携帯のみで既存メソッドをフックできそうです。
試していないですが、下記記事を見る限り簡単そうです。すごいツール! (通常のアプリ解析ならAndroid Emulatorのみで良さそうですがAVDはBluetoothに対応していないためroot化済み端末はやはり必要そうです)
https://www.slideshare.net/ken_kitahara/fridaandroid-133011977

(追記) コメントで頂いた情報ですが非root化でもいけるそうです。(アプリをデコンパイルしてsmaliで書き換えにて)
https://qiita.com/offmon/items/6f24c70ae692a938602e

以下はroot化Xposedでの参考になります。Fridaで使う際にも参考になるかと思います。

XposedモジュールはAndroidアプリの任意のメソッドが呼ばれるのをフックして
カスタムの処理を実行できるフレームワークになります。
今回はこの仕組みを利用してSesameアプリがパスワード値を暗号化メソッドに渡す箇所をフックして
パスワード値をぶっこ抜きます。

具体的には
javax.crypto.spec.SecretKeySpec
のコンストラクタにパスワードが渡されるのですが
awsのライブラリなど他の箇所でもこのコンストラクタが利用されているため該当パスワードの判断がしにくいです。
アプリのapkをデコンパイルしてjavax.crypto.spec.SecretKeySpecにパスワードが渡されていると思われる箇所が見つかったのでその付近でgetAlgorithmというメソッドが呼ばれていたのでこちらのメソッドもフックしてログを書かれるようにしました。
こうすることでgetAlgorithmのログが書かれている付近のSecretKeySpecの値がパスワードと推測できます。
ここらへんは書き出されたXposedのログを見ながら探ってみてください。
Sesameアプリにて施錠、解錠を実行してその際に出力されたログの中にあります。
本当は難読化されているメソッドをフックできればより箇所が絞れてわかりやすいのですが、ぐちゃぐちゃのメソッド名の指定方法がわかりませんでした…

下記がデコンパイルしたソースのsign処理箇所になります。(apkpure.comから落としたSesameアプリv1.3.8のapkをapktool、jadxにてデコンパイルして調査)
C0279.java — sources 2019-08-31 02-59-07.png

Xposedモジュールのソース(初めて書いたのですが一応動きました)

MyXposedModule.java
package com.example.sesamepasswordxposedmodule;

import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam;

import static de.robv.android.xposed.XposedHelpers.findAndHookConstructor;
import static de.robv.android.xposed.XposedHelpers.findAndHookMethod;

import de.robv.android.xposed.XC_MethodHook;

public class MyXposedModule implements IXposedHookLoadPackage {
    public void handleLoadPackage(final LoadPackageParam lpparam) throws Throwable {
        //XposedBridge.log("Loaded app : " + lpparam.packageName);
        if (!lpparam.packageName.equals("co.candyhouse.sesame"))
            return;
        XposedBridge.log("Loaded app : " + lpparam.packageName);

        findAndHookConstructor("javax.crypto.spec.SecretKeySpec", lpparam.classLoader, byte[].class, String.class , new XC_MethodHook() {
            @Override
            protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                // this will be called before the clock was updated by the original method
                XposedBridge.log("SecretKeySpec");
                for (Object obj: param.args) {
                    if (obj instanceof byte[])
                    {
                        StringBuilder sb = new StringBuilder();
                        for (byte d : (byte[]) obj) {
                            sb.append(String.format("%02X", d));
                         }
                        XposedBridge.log("arg : " + sb.toString());
                    } else {
                        XposedBridge.log("arg : " + obj);
                    }
                }
            }
//            @Override
//            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
//                // this will be called after the clock was updated by the original method
//            }
        });


        findAndHookMethod("javax.crypto.spec.SecretKeySpec", lpparam.classLoader, "getAlgorithm", new XC_MethodHook() {
            @Override
            protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                // this will be called before the clock was updated by the original method
                XposedBridge.log("getAlgorithm");
            }
        });
    }
}

Xposedモジュールの作り方は公式のTutorialがわかりやすいです。(英語)
https://github.com/rovo89/XposedBridge/wiki/Development-tutorial

Xposed API Reference
https://api.xposed.info/reference/de/robv/android/xposed/XposedHelpers.html#findAndHookMethod(java.lang.String,%20java.lang.ClassLoader,%20java.lang.String,%20java.lang.Object...)

実行方法

コードの最初のほうにあるconfig値を設定し
コードをファイル名main.jsなどで保存して
node main.jsで実行して下さい。
deviceIdは実行しないとわからないため一旦空のままで実行して、その後わかった値を指定して下さい。

すべてのconf値が設定されて実行されるとsesameに接続されます。

あとはコンソールのreadline入力で下記コマンドを入力可能です

  • "lock" ロックします
  • "unlock" アンロック(解錠)します。
  • "toggle" 現在のロック状態の逆の状態にします。
  • "connect" 接続が切れている場合の再接続に使用します。コマンドを入力しないと接続後10秒で切れます。コマンド実行後は1分後に切れます。

node コード

main.js
const crypto = require('crypto');
const noble = require('noble-mac');
const events = require('events');
const Peripheral = require('noble/lib/peripheral');
const os = require('os');
const log4js = require('log4js');
const logger = log4js.getLogger();
logger.level = 'debug';


// config block start
const userId = 'メールアドレス';
const deviceId = 'sesameのdevice id。peripheral.idのこと';
const password = 'Android公式アプリ内からハックして取得するパスワード';
const lockMinAngle = 10; // lock状態と認識するmin角度
const lockMaxAngle = 270; // lock状態と認識するmax角度

// 下記はoption。指定したほうがscanがskipできるため早くなる。
const address = ''; 
const manufacturerDataMacData = []; // [0x00,0x00...]のように配列で指定する
// config block end



const CODE_LOCK = 1;
const CODE_UNLOCK = 2;
const serviceOperationUuid = '000015231212efde1523785feabcd123';
const characteristicCommandUuid = '000015241212efde1523785feabcd123';
const characteristicStatusUuid = '000015261212efde1523785feabcd123';
const characteristicAngleStatusUuid = '000015251212efde1523785feabcd123';


// 接続後ロック系コマンドを打たないと10秒で切断される。
// ロック系コマンド実行から1分で切断される。


logger.info('==> waiting on adapter state change');

let status;
let cmd;
let angleStatus;
let peripheral;
let lockStatus = null;

const event = new events.EventEmitter;

noble.on('stateChange', (state) => {
  logger.info('==> adapter state change', state);
  if (state === 'poweredOn') {
    if (address === '') {
      // scanする場合
      logger.info('==> start scanning', [serviceOperationUuid]);
      //noble.startScanning([], true); // 都度advertisementパケット届き次第結果がでるが、余計なのがでたりうまく動かなかった。但し各BLE機器の送出間隔がわかるので一度見るのもよいかも。
      noble.startScanning();
    } else {      
      // 直接接続する場合
      connectSesame();
    }
  } else {
    noble.stopScanning();
  }
});

noble.on('discover', (peripheral) => {
  if (peripheral.id !== deviceId) {
    //logger.info(`BLE Device Found: ${peripheral.advertisement.localName}(${peripheral.uuid}) RSSI${peripheral.rssi}`);
    logger.info('peripheral discovered; id mismatch. peripheral.id:', peripheral.id, "localName:", peripheral.advertisement.localName, "address:", peripheral.address, "addressType:", peripheral.addressType);
    //logger.info(peripheral);
  } else {
    logger.info('ok. peripheral discovered; id match. peripheral.id:', peripheral.id, "localName:", peripheral.advertisement.localName, "address:", peripheral.address, "addressType:", peripheral.addressType, "manufacturerData:", peripheral.advertisement.manufacturerData);
    //logger.info(peripheral);
    noble.stopScanning();
    connect(peripheral);
  }
});

function connectSesame() {
    advertisement = {
      manufacturerData: Buffer.from(manufacturerDataMacData),
      serviceUuids: ['1523']
    }
    //peripheral = new Peripheral(noble, deviceId, address, addressType, connectable, advertisement, rssi);
    peripheral = new Peripheral(noble, deviceId, address, 'random', true, advertisement, -84);
    noble._peripherals[deviceId] = peripheral;
    noble._services[deviceId] = {};
    noble._characteristics[deviceId] = {};
    noble._descriptors[deviceId] = {};

    if (os.platform() !== 'darwin') {
      // linuxの場合は下記も必要とのことでした。thx! warpzoneさん
      noble._bindings._addresses[deviceId] = address;
      noble._bindings._addresseTypes[deviceId] = 'random';
    }

    connect(peripheral);
}

function connect(peripheral) {
  //logger.info('==> connecting to', peripheral.id);
  logger.info('==> connecting start');
  peripheral.connect((error) => {
    if (error) {
      logger.info('==> Failed to connect:', error);
    } else {
      logger.info('==> connected');
      discoverService(peripheral);
    }
  });

  peripheral.once('disconnect', function() {
    logger.info('==> disconnect');
  });
}

function discoverService(peripheral) {
  logger.info('==> discovering services');
  peripheral.once('servicesDiscover', (services) => {
    //services.map((s) => logger.info("uuid:"+s.uuid));

    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(peripheralLocal, opService) {
  logger.info('==> discovering characteristics');
  opService.once('characteristicsDiscover', (characteristics) => {
    const charStatus = characteristics.filter((c) => c.uuid === characteristicStatusUuid);
    const charCmd = characteristics.filter((c) => c.uuid === characteristicCommandUuid);
    const charAngleStatus = characteristics.filter((c) => c.uuid === characteristicAngleStatusUuid);

    if (charStatus.length !== 1 || charCmd.length !== 1 || charAngleStatus.length !== 1) {
      throw new Error('unexpected number of command/status/angleStatus characteristics');
    }
    characteristics.map((c) => logger.info("info uuid:"+c.uuid));

    characteristics.map((c) => {
      if (c.uuid === characteristicStatusUuid
        || c.uuid === characteristicCommandUuid
        || c.uuid === characteristicAngleStatusUuid)
      {
        return
      }
      c.on('data', (data) => {
      logger.info("unknown uuid:"+c.uuid);
      logger.info(data);      
      });
      c.subscribe();
    });

    status = charStatus[0];
    cmd = charCmd[0];
    angleStatus = charAngleStatus[0];
    peripheral = peripheralLocal;

    angleStatus.on('data', (data) => {
      const angleRaw = data.slice(2, 4).readUInt16LE(0);
      const angle = Math.floor((angleRaw/1024*360));
      if (angle < lockMinAngle || angle > lockMaxAngle) {
        lockStatus = true;
      } else {        
        lockStatus = false;
      }

      logger.info("angle: ", data, "angle:"+angle+" lockStatus:"+lockStatus);

      event.emit('lock_status_set'); 

    });
    angleStatus.subscribe();

    status.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"
      ];
      logger.info('status update', data, '[sn=', + sn + ', err=' + errMsg[err+1] + ']');
    });
    status.subscribe();

    // lock(0)だとErrorUnknownCmdになるが、認証することで現在の角度がわかるため実行する
    lock(0); // 0だとErrorUnknownCmd。3だとErrorLength。4だとErrorUnknownCmd。3は何かできそう。

    // 起動時にtoggleしてlock, unlockする場合は下記をコメントアウト
    // event.once('lock_status_set', () => {
    //   lock(lockStatus ? CODE_UNLOCK : CODE_LOCK);
    // });
  });
  opService.discoverCharacteristics();
}

function lock(cmdValue) {
  logger.info('==> reading serial number');
  status.read((error, data) => {
    if (error) { logger.info(error); process.exit(-1); }
    if (data) {
      const macData = peripheral.advertisement.manufacturerData;
      const sn = data.slice(6, 10).readUInt32LE(0) + 1;
      const payload = _sign(cmdValue, '', password, macData.slice(3), userId, sn);
      let cmdName;
      if (cmdValue === CODE_LOCK) {
        cmdName = "cmdValue:lock";
      } else if (cmdValue === CODE_UNLOCK) {
        cmdName = "cmdValue:unlock";
      } else {
        cmdName = "cmdValue:"+cmdValue;
      }
      logger.info('==> ', cmdName, sn);
      write(cmd, payload);
    }
  });
}

function _sign(code, payload, password, macData, userId, nonce) {
  logger.info("macData: ", macData);
  logger.info("pass: ", Buffer.from(password, 'hex'));
  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
  const md5 = hash.digest();
  md5.copy(buf, 38); // len = 16
  logger.info('md5: ', md5);
  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);
  logger.info('buf: ', buf);
  logger.info('buf: ', buf.toString("hex"));

  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);
    logger.info('<== writing:', buf.toString('hex').toUpperCase());
    char.write(buf, false);
  }
}

const rl = require('readline')
const rli = rl.createInterface(process.stdin, process.stdout)
rli.on('line', function(line) {
  logger.info(line);
  if (line === 'lock') {
    lock(CODE_LOCK);
  } else if (line === 'unlock') {
    lock(CODE_UNLOCK);
  } else if (line === 'toggle') {
    lock(lockStatus ? CODE_UNLOCK : CODE_LOCK);
  } else if (line === 'connect') {
    connectSesame();
  }
  rli.prompt();
}).on('close', function() {
  logger.info('close');
  process.stdin.destroy();
});
rli.prompt();
package.json
{
  "name": "sesame_bt_test",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "dependencies": {
    "log4js": "^4.4.0",
    "noble-mac": "https://github.com/Timeular/noble-mac.git",
    "readline": "^1.3.0",
    "yarn": "^1.16.0"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

動作環境

macOS Mojave
node v8.16.0

コードについて

元コードからの変更点

  • advertisementパケットをScanしないで、直接事前にScanした結果のAddress値指定でSesameに接続しにくようにして高速化。
  • console.logをやめてloggerを使用。日時がでるように。
  • Sesameの回転角度変更イベントを取得できるように。(Characteristic UUIDの一覧をだして受信値を解析したら特定できました。)
  • consoleのreadlineにて接続、ロック、ロック解除をできるように。

nobleのAddress値指定で直接つなぐ部分

nobleのソースを見た感じですとScanして接続するような使われかたしか想定していないように見えました。
ソースを見てアンダースコアで始まるような内部変数に値セットしてScan無しの直接接続を実現しているので今後のnobleのバージョンアップには弱い実装になります。OSごとのbindingにも依存しており動かない環境もありそうです。
ESP32でも同様にScan必須の前提だったのですが、なにかあるんですかね。。
BLE初めて触ったので無知なだけなのかもですが、1ヶ月過ぎてもAddress値変わったりはしないので特に直接接続できても問題ないようには考えています。

古い記事(英語)ですがiOSだとscan結果をキャッシュしていたりするようです
https://stackoverflow.com/questions/19404503/is-there-a-way-to-discover-ble-peripheral-service-faster

Sesame使用のBLEのUUID値

Service UUID : 000015231212efde1523785feabcd123

Characteristic UUID

  • Command(lock, unlockコマンドを送る際に使用) : 000015241212efde1523785feabcd123
  • Status(lock,unlockなどのコマンドの結果受信に使用) : 000015261212efde1523785feabcd123
  • AngleStatus(回転角度の受信に使用) : 000015251212efde1523785feabcd123

angleについて

0〜360の範囲にしています。
我が家の環境では上側に電池がある向きの取り付けにて、0が←向きになり、左回りで増えていき360度回って←に戻るイメージです。なので↓向きが90度です。
←で施錠(0)、↓で解錠(90)で使っています。
ここらへんは設置向きと初期設定次第で0がどの向きかは変わるかと思います。

コードを見るとわかりますが元データのangleRawは0〜1024の範囲になります。

コメントいただいた情報ですと
angleRawで0xfc00~0xffff(64512〜65535)を返す場合もあるそうです(時々0x0000も返す)
おそらく初期角度設定に応じてoffset調整する必要がありそうです。

注意点

  • Mac用のためnoble-macを利用しています。linuxなどのmac以外の場合でもnoble-macがosを見てnobleをrequireするのでこのままのコードでも動くと思われます。(未確認)

  • 最初のstatus updateにてUnsupportedになるのは正常です(少しきになりますが)。2つめのコマンド送信時にErrorUnknownCmdがでれば正常です、この際にErrorSignatureがでる場合はAndroidから抜き出したパスワードが違う場合になります。

その他

  • 今回利用していないパラメータやCharacteristicの中にアプリで実現している電池残量や、ロック履歴などのデータもありそうです。

  • 公式Bluetooth API用意すると日本のCandyHouseからは野良告知ありましたが
    海外のCandyHouseでは予定はないとのことで、先行きは怪しそうですね…

  • これができたからといってWifiアクセスポイントアダプタが完全に不要かといえばそうではなく、外出先から確実にアクセスできるメリットと、解錠ログなどの送信にまだまだ必要そうです。完全なプロトコル解析して互換性つけたり安定性があればでしょうが難しそうです。

  • CandyHouseさんのサポートは最高でした。Sesameの今後にも期待しているのでJermingさん頑張って〜。

odetarou
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away