こんにちは。先日娘を某テーマパーク型遊園地に連れて行ったのですが、帰ってきてからあまりその件を話さないので、楽しくなかったのか?と恐る恐る聞いたら「楽しすぎたから心の中で大事にしてるの」と言われてホッとするとともに凄いことを言うようになったなと感心した木村です。
本記事はオルターブース Advent Calendar 2021の23日目の記事です。へーしゃつよつよエンジニアの花岡くんに前後を挟まれてなかなか圧の強いカレンダーになってます。
この記事は
私の前回の記事では、IoT Centralに接続するために通常利用するDevice Provisioning ServiceのためのコードがESP32では動かないので、外部コマンドで接続先のIoT Hubの情報を取得していました。しかし、IoT Centralで使ってるIoT Hubは、負荷分散や障害時のフェイルオーバーなどで別のものになることがあります。そのため、IoT Hubの接続情報をマイコン内に埋め込んでしまうと、ある日突然接続できなくなり再度プログラムを修正しないといけないということが発生する可能性があります。
IoTでは大量のデバイスをあちこちに設置する(今回は目の前の1個だけですが・・・)ことも多く、フェイルオーバーの度に改修になったら大変です。ここはやはりIoT Centralの流儀に従い、ちゃんとDPSしたいところです。 また、できれば対称キーやデバイスIDなど、秘匿情報をできるだけマイコン(プログラム)内に埋めたくないということも併せて修正したいです。
そこで今回のブログでは、秘匿情報をできるだけデバイスに埋め込まず、ESP32でもDPSを行うのをSORACOMとAWSも組み合わせることで実現してみました。
アーキテクチャ
アーキテクチャはこんな感じです。
今回使っているM5Stackには3G拡張ボードがついてるので、起動時にそれを使ってSORACOMに接続します。そして、SORACOM Funkを呼び出し、FunkからAWS Lambdaを呼び出し、LambdaでDPSを行うプログラムを実行します。LambdaはAzure Device Provisioning Serviceからプロビジョニング結果として取得したIoT Hubの情報をFunk経由でM5Stackに戻します。
M5Stackは取得した情報を元に、これ以降はWiFiでIoT Hubに接続してセンサーデータを送信し続けます。
なぜAzure Functionsを使わなかったのか?というと、SORACOM FunkからAzure Functionsを呼び出す際、認証はAPIトークン認証になります。それと比較して、AWS Lambdaの呼び出しだとIAMでの認証かつAWS内に閉じた通信での呼び出しなのでよりセキュアになるからというのが理由の一つです。あとはソラコムのmaxが既にFunctionsでやってるということに後で気がついたので同じことしてもな・・と思ったというのもありました。まぁ、行き着くところは同じなんですが(笑)
Lambdaのコード
Node.jsで動くプロビジョニングのコードがGitHubの公式リポジトリにありますので、こちらを持ってきて一部だけ修正して使います。
今回はregistration Id(device id)をSIMのIMSIとしていますが、ここはお好みで。それ以外の秘匿情報はLambdaの環境変数に渡します。あとは非同期呼び出し部分を同期処理に変更します。
1デバイス=1 Lambda関数にならないように対称キーの環境変数名にregistration Idを埋めていますが、数が増えた場合はこれだと運用が大変なのでその場合は別途DynamoDBやSSMに持つなどなにかしら工夫が必要かと思います。
serverless frameworkで記載した最終的なコードはこうなります。デプロイ前にazure-iot-provisioning-device-mqtt
,azure-iot-device-mqtt
の2つのパッケージをnpm install
でインストールするのを忘れないようにしましょう。
'use strict'; var iotHubTransport = require('azure-iot-device-mqtt').Mqtt; var Client = require('azure-iot-device').Client; var Message = require('azure-iot-device').Message; var ProvisioningTransport = require('azure-iot-provisioning-device-mqtt').Mqtt; var SymmetricKeySecurityClient = require('azure-iot-security-symmetric-key').SymmetricKeySecurityClient; var ProvisioningDeviceClient = require('azure-iot-provisioning-device').ProvisioningDeviceClient; var provisioningHost = 'global.azure-devices-provisioning.net' module.exports.hello = async (event,context) => { var idScope = process.env.PROVISIONING_IDSCOPE; var registrationId = context.clientContext.imsi var symmetricKey = process.env[`PROVISIONING_SYMMETRIC_KEY_${registrationId}`]; var provisioningSecurityClient = new SymmetricKeySecurityClient(registrationId, symmetricKey); var provisioningClient = ProvisioningDeviceClient.create(provisioningHost, idScope, new ProvisioningTransport(), provisioningSecurityClient); // Register the device. provisioningClient.setProvisioningPayload({a: 'b'}); var resultString = ""; var resultStatus = 200; try { const result = await provisioningClient.register(); resultString = 'HostName=' + result.assignedHub + ';DeviceId=' + result.deviceId + ';SharedAccessKey=' + symmetricKey; } catch (err) { resultString = err.message; resultStatus = 500; } return { statusCode: resultStatus, body: resultString } };
コード一式はこちらに置いています(ドキュメントは適当でごめんなさい)。
M5Stackのコードの修正
あとはデバイス側のコードの修正です。ここでは修正した該当部分だけを記載します。
#define TINY_GSM_MODEM_UBLOX #include <TinyGsmClient.h> #include <HTTPClient.h> #include <ArduinoHttpClient.h> #include <ArduinoJson.h> ..... void setup() { M5.begin(); M5.Power.begin(); M5.Lcd.println(F("Initializing...")); M5.Lcd.println(F("ESP32 Device")); M5.Lcd.println(F("Initializing...")); M5.Lcd.println(F("> M5Stack + 3G Module")); M5.Lcd.print(F("modem.restart()")); Serial2.begin(115200, SERIAL_8N1, 16, 17); modem.restart(); M5.Lcd.println(F("done")); M5.Lcd.print(F("getModemInfo:")); String modemInfo = modem.getModemInfo(); M5.Lcd.println(modemInfo); M5.Lcd.print(F("waitForNetwork()")); while (!modem.waitForNetwork()) M5.Lcd.print("."); M5.Lcd.println(F("Ok")); M5.Lcd.print(F("gprsConnect(soracom.io)")); modem.gprsConnect("soracom.io", "sora", "sora"); M5.Lcd.println(F("done")); M5.Lcd.print(F("isNetworkConnected()")); while (!modem.isNetworkConnected()) M5.Lcd.print("."); M5.Lcd.println(F("Ok")); HttpClient http = HttpClient(socket,"funk.soracom.io",80); M5.Lcd.println(F("try to provisioning..")); sprintf(msgText,"{}"); http.post("/","application/json",msgText); int status_code = http.responseStatusCode(); String response_body = http.responseBody(); M5.Lcd.printf("http status:%d\n", status_code); if(status_code == 200){ StaticJsonDocument<512> json_response; deserializeJson(json_response, response_body); const int statusCode = json_response["statusCode"]; const char* body = json_response["body"]; if(statusCode == 200){ strcpy(connectionString,body); } else { M5.Lcd.println(F("fail to device provisioning")); M5.Lcd.printf("statusCode:%d\n", statusCode); M5.Lcd.printf("body:%s\n", body); while(1); } } else { M5.Lcd.println(F("fail to device provisioning")); while(1); } http.stop(); M5.Lcd.println(F("success provisioning.)); M5.Lcd.println(F(" > WiFi")); M5.Lcd.println(F("Starting connecting WiFi.")); ....
コード全体はこちらに置いています。
これでコードの中から接続情報が消えましたね。あとはWiFiの接続情報がなくなれば完璧ですが、そこはこちらに別記事を書きましたので参考にしてください。
まとめ
IoTに限らず、デバイス内に埋め込まれたコードや接続情報を出荷・設置後に更新するのは結構大変です。SORACOMのサービスを組み合わせることで、できるだけ安全な形でそれらの情報を外に出して変更に強いシステムにすることができます。
また、今回の構成ではデバイスの認証自体もSIMという不当な解析、改ざん行為に強い(耐タンパ性が高い、と言います)セキュアエレメントで行うため、より安全にデバイスを接続することができるようになっています。DPSに必要な認証情報はAWS Lambdaに保存してあり、SORACOM FunkからLambdaへのアクセスはIAMで行われ、デバイスからSORACOM Funkに接続するための認証にはSIMが用いられています。このため、デバイスに秘匿情報を直接埋め込むのに比べて非常にセキュアかつ変更に強いシステムになっています。
認証の起点をSIMではない別のセキュアエレメントにするなど他にも実装方法はあるかと思いますが、一つの事例として参考になれば幸いです。