yasudacloudの日記

札幌に住むソフトウェアエンジニア

SESAME サイクル2をPythonで開けるよ

久しぶりにお遊びガジェットシリーズ。

CANDY HOUSE JAPAN 株式会社のSESAME サイクル2という製品を購入したので色々いじっていきます。自転車のスマートロックIoTデバイスです。

SESAME サイクル2/ママチャリ2jp.candyhouse.co

概要

SESAME サイクル2はアプリ(Bluetooth)から自転車の鍵を解錠することができます。同社の他の製品にはwifi経由で操作ができるものもあるようですが、サイクル2はBluetoothのみの対応となっています。

解錠方法は物理鍵とアプリ、さらに別製品を買えば指紋や暗証番号でも開けれるようです。本記事ではまず公式アプリから解錠、それから公開されているBluetoothの仕様iOSとAndroid向けのSDKのコードを読み解きながらPythonでどうにか解錠を試みます。

本記事はアプリもSDKも使わずに我が道をいくタイプ向けの記事になります。

レビュー

まずは開封。

公式サイトから購入後2日で届きました。梱包は横25cm、奥行き20cm、高さ5cmのダンボールです。

段ボールの中に商品の箱。付属品は商品ページの記載の通り。

・高性能高容量3.0V  1800mAhのCR123A  電池(1本 1回分)
・φ13、φ16、φ19用取付金具付属 (各2本 各1セット)
・取り付け金具用ネジ(2個 1セット)
・透明のバッテリーカバー(1つ)
・バッテリーカバー用ネジ(2個 1セット)
・目隠し用バッテリーカバー(White:白色カバー、Black:黒色カバー)
・鍵(2本)

写真には写ってませんが、各部品はビニールにちゃんと入っていました。

本体の重さは大体400gくらい。一般的なシェアサイクルの自転車は鍵部分が結構大きくて重たいイメージありますが、電池が小型であるためかシェアサイクルと比べるとなかなかスマートです。

付属の電池は写真で見た感じだとこれかな。

公式アプリから解錠

まずは公式アプリから解錠のパターン。

鍵側(物理)はドライバーでバッテリーカバーを外して絶縁シートを取れば準備OKです。iPhoneアプリはこちらからインストール。

アプリはかなりシンプルで特に補足することはありません。鍵が通電されていれば[新規デバイス]で表示されるので登録すればOKです。

解錠はこんな感じ。特に迷うこともなく簡単でした。

Pythonから解錠

市販のIoT製品は接続仕様が非公開のものが多いですが、セサミのガジェットはWebAPI、Bluetoothの仕様が公開されています。本記事は下記の仕様書のregister、login、initial、Lockを中心に進めていきます。

https://document.candyhouse.co/demo/bluetooth-ja

ドキュメントが公開されていてSDKはGitHub、月額のクラウドプランがあったりYouTubeやブログで情報発信されていてとてもイケてます。こういうデベロッパーフレンドリーなプロダクトは応援したくなりますね。

SDKはiOS、Android、そしてESP32(マイコン)向けのようですが、Bluetoothの仕様を見れば理論上は自前で解錠までいけるはずなので挑戦してみました。

ここから具体的な実装や苦戦ポイントを長く書いていくので、見てられない場合は結果をご覧ください。

デバイスを見つける

Bluetooth実装は久しぶりですが、まずは基本に忠実にiPhoneにLightBlueというアプリを入れてデバイスをスキャン、UUIDを確認します。

仕様にはService UUIDが0xFD81、Company IDが0x055Aとあるので一致するデバイスを探します。

ss5ってやつですね。
まずはここまでをPythonで書くと以下のようになります。

gist.github.com

BLEのライブラリはbleakを使っています。

manufacturer_dataにはSESAMEのモデルやステータス情報が入っているようですが、解錠自体には直接関係ないので飛ばします。

ペアリング

デバイスが見つかったら次はペアリングですが、ここもよくあるBLEのペアリングをするだけなので難しくありません。

ただし、先ほどのスマホアプリと連携したままだとこの後うまくいかないのでデバイスを初期化します。初期化はデバイスの後ろにリセットボタンがあり、長押しすると青く点灯→消灯したらリセット完了です。

リセットは解錠してる状態じゃないと出来ない点に注意です。たしかにそうじゃないと誰でも開けれるようになるので当然なんですが、開発中はこれを見落とすとリセットされてないまま接続できない・・!ってなるのでハマりポイントです。

ペアリングすると2つのCharacteristicsが使用できるようになります。

16860002-a5ae-9856-b6d3-dbb4c676993e(Write)

アプリからデバイスへデータ送信する際に使用。送信するデータ形式が正しい場合はNotifyの方から結果内容を受け取れる。Read専用のCharacteristicsはない模様。

16860003-a5ae-9856-b6d3-dbb4c676993e(Notify)

デバイスからアプリへデータ送信。アプリからWriteコマンドを受け取って結果を返すパターンと、デバイス側のタイミングでアプリにデータ送信するパターンがある。この2つのパターンの違いはレスポンスデータのtype(0x07か0x08)で判定可能。

ペアリングができたらとりあえずNotifyを受け取るようにして、コールバック関数まで書いたら一区切り。

gist.github.com

通知をサブスクライブすると早速デバイス側から初期化(応答コマンド0x0e)のデータが受信できればOK。先ほどのtype: 0x08のパターンです。

各レイヤーの理解

ここが難所の一つ。実際の送受信のデータを見比べていったので仕様を理解するのに結構時間がかかりました。

まず、アプリからSESAMEデバイスへデータ送信するにはApplication→Security→Transportレイヤーの順にパケットを変換していく必要があります。逆にSESAMEデバイスからアプリへデータを受け取る場合は、Transport→Security→Applicationの順に変換します。

各レイヤーについて。

Application Layerのデータでは主にitem_codeとそれ以外のデータ(ペイロード)で構成され、レスポンス(SESAMEデバイス→アプリ)の場合はtypeも先頭0バイト目に含まれます。registerやunlockなどの各機能の仕様説明では0バイト目にitem_code、1バイト目に〜という表現になっていますが、これはApplication Layer内の数え方です。

Security Layerは暗号化/復号の変換について。暗号化が必須の機能もあればペアリング直後の初期化(initial)や登録(register)では平文でやりとりします。具体的な実装は後述。

Transport Layerは簡単にいうとパケットを分割して送受信するための取り決め。分割した各データには0バイト目にマークがつきます。このマークによって暗号か平文なのか、分割されているのかどうか、パケットの開始か途中か終わりなのかを判定できます。これが0x00〜0x05の6パターンで表現されてるのがとても美しいですね。

分割単位はマークの1バイト込みで20バイトと記載があるので送りたいデータ(または受け取りたいデータ)を19バイトごとに分割してそれぞれマーク1バイトを付与してループさせるような流れになります。ただ、実際試したところ20バイトより大きい値でもちゃんと送信できてそうでした。

初期化(initial)のレスポンスを理解する

先ほどの実装に戻り、受け取ったデータを仕様書と突き合わせてみます。

03 08 0e d5 0c 08 67

ちょっとややこしいですが、16進数表記で一番左から0バイトで1バイト毎にスペース区切りです。

まず最初の03はTransport Layerのマーク、0x03を意味しています。これは平文であり、分割されているデータがないことを意味しています。(20バイト以下なので)

加えてマーク0x03ということは平文なのでSecurity Layerを素通りできます。

08はtype、つまりSESAMEデバイス側のタイミングで送られたという意味。0eはitem_codeで"初期化"を意味しています。16進数と10進数の表記揺れで混乱しないように注意です。。

残りのd5 0c 08 67が"初期化"のペイロード部分になります。この4バイトは後で使うので実装的にも簡単に参照できるようにしておくと良いです。

Register(リクエスト)を実装

ここまでの実装はBLEの触りの部分だけでしたが、いよいよSESAMEデバイスと連携します。Registerでの最終的な成果物はログインに必要なトークンを得ることです。Registerを行うとログインができ、デバイスの操作ができるようになります。

まずはシーケンス図を見ましょう。中国語(繁体字?)が難しく泣きそうになるくらい辛いところですが、読み取った感じの手順はこうです。

1. アプリ側でSECP256R1準拠の秘密鍵/公開鍵を作る

2. タイムスタンプ(現在時刻)をbytes型で作る

3. SESAMEに送信

4. SESAMEの公開鍵を受け取る

5. SESAMEの公開鍵とアプリの秘密鍵でシークレットを作る

6. シークレットや諸々(後述)を使ってトークンを作る

この辺りが超鬼門でした。私の場合は手順5の鍵周りでずっとエラーが解決できず、1〜4のどのステップが間違っているのか判断が出来なかったためです。

鍵の作成は以下のように作ることができます。

gist.github.com

一回開けることが目的なら別ですが、できれば鍵は永続化した方が良いでしょう。その場合はprivate_bytes.hex()、public_bytes.hex()して保存し、bytes.fromhexでbytes型に戻すと楽です。

タイムスタンプは下記のように実装。

gist.github.com

これで処理は通りますが、あとでログインのレスポンスでタイムスタンプを取得できるので元に戻す処理も一緒に載せておきます。

上記が出来たらあとは0バイト目にitem_codeの0x01を加算、つまりb'\x01' + public_bytes + current_time_to_byte()としてApplication Layerは完了。Registerは平文なのでSecurity Layerを飛ばして、Transport Layerでパケットを分割して送信。

送信(Write)は単純にループで連続して送ってますが、await付けてますし変にsleepとか入れずに逐次実行で問題なさそうです。

gist.github.com

Register(レスポンス)を実装

リクエストが完了するとRegisterのレスポンスを複数回受信するはずです。レスポンス内容にデバイス側の公開鍵が含まれているため20バイトを超えており、Transport Layerでパケットを合体させる必要があります。

Application Layerのレスポンスデータについて、0と1バイト目はこれまで通りの扱いですが2バイト目 コマンド処理状態にはハンドリングが必要です。一度Registerが成功すると2回目以降はresが0x09(エラー扱い?)の登録済み扱いになります。

次に、Application Layerのペイロード(3バイト目以降)を見ていきます。

仕様書によればペイロードはさらにmechStatus(7バイト)、mechSetting(6バイト)、publicKeyS(64バイト)で構成されているとあります。publicKeySというのがSESAME側の公開鍵で、これを取得したいので14バイト目から64バイトを抽出したのですが一向にうまくいきませんでした。

結論を言うとここは仕様書がおそらく間違っており、正しくは4バイト目(インデックスで言うと0始まりなので3)から64バイトを取る必要があります。

これにたどり着くのに非常に時間がかかりました。仕様書とPythonのコードを何度も見比べ、iOSのSDKを追っかけたり(でもよく分からなかった)、Androidのコードを見てようやくわかりました。仕様書の記述もデータサイズとしては整合性が取れていますが、ペイロードの後ろの方が不自然に0埋めされていて気づくことができました。

gist.github.com

いよいよトークンを作成します。SESAMEの公開鍵、アプリの秘密鍵、"初期化"で受け取った4バイトのランダム値が正しくなければ途中でエラーになります。secret_key[0:16]はアプリの秘密鍵、公開鍵と同じく永続化しておくことで2回目以降は登録をスキップできます。random_codeは永続化せず、"初期化"のレスポンスを受けとる度に最新の値を使います。

Login(リクエスト/レスポンス)の実装

LoginはRegisterに比べて非常に簡単です。ログインを意味するitem_code: 0x02と先ほどのトークンを送信するだけです。b'\x02' + tokenみたいな感じです。

ここも平文なのでSecurity Layerを素通し、Transport Layerもサイズが小さいので分割不要。ただし、"平文で分割しない"というマークが必要なので0バイト目に0x03をつけることをお忘れなく。ログインのレスポンス内容はそれほど重要ではありませんが、先ほどのRegisterで送ったタイムスタンプ値が返ってきているはずです。

Unlockの実装

ようやく解錠するフェーズです。

Application Layerのデータは0バイト目にitem_code: 0x53、それ以降は履歴(history)で参照するための情報を送れるらしいのですが解錠を優先するので0x00で送ります。(Android SDKも空で送ってたし・・。)つまりb'\x53' + b'\x00'です。

Unlockはリクエスト/レスポンスの暗号化/復号が必要です。

gist.github.com

暗号化はこのようにこれまでのrandom_codeやtokenを使用します。AESのIVの中にカウンターが入っているため初期値0からインクリメントする変数が必要になります。ここもAndroidのSDKをみて気づいたんですがカウンターはリトルエンディアンなので注意。

Transport Layerでは分割は不要ですが、今までと違って暗号化なのでマークは0x05になります。

結果

長くなりましたが、こんな感じに。

 

解錠できましたʅ(◞‿◟)ʃ

公開/秘密鍵とシークレットを保存してるので分岐を入れれば2回目以降もRegisterをスキップして開けることができます。

まとめ

以前仕事でシェアサイクルのアプリ開発をやってたこともあり、面白そうだったのでつい買っちゃいました。何年か前に仕様公開されていないエアロバイクのBLE連携を試みて失敗したので、今回絶対に開けてやろうというリベンジ精神で挑戦しました。

先週の土曜にデバイスが届いて土日丸ごと費やし、火曜の昼に開きましたが思ったより苦労しました。セサミの他のIoTデバイスも気になったものがあるので、また機会があれば紹介していきたいと思います。

ちなみに私、自転車持ってないんですがね・・( ´Д`)y━・~~