ESP32 および M5Stack で DNS および mDNS の SSL server を作ってみた

SSL ( TLS )

mDNS サーバー用、証明書および秘密鍵の作成

mDNS ネームの場合は、前項の DNS サーバーで作成したものを、esp32.local と変えるだけです。
例として、以下の設定とします。

有効期限:1年
サーバーDNSネーム:esp32.local
CN ( Common Name ) : esp32.local
csrファイル名:esp32lsv.csr
秘密鍵ファイル名:esp32lsv.key
署名付きサーバー証明書ファイル名:esp32lsv.pem

※SPIFFS を使うため、ファイル名は拡張子以外は必ず半角8文字以内にしてください

mDNS サーバー証明書用 cfg ファイルの alt_names

mDNS ネームを使う場合は、また別の cfg ファイルを作成しておきます。
ここでは、ファイル名を
openssl_mdns_server.cfg
としておきます。
この場合のファイル名は8文字を超えても問題ありません。
そして、[ alt_names ]は例として、以下のようにしておきます。

[ alt_names ]
DNS.1 = esp32.local

mDNSサーバー証明書署名要求ファイル ( csr )および秘密鍵の生成コマンド、そして証明書の生成

OpenSSL で mDNS ネームのサーバー証明書署名要求( csr )ファイルおよびサーバー秘密鍵の生成コマンドを入力していきます。
有効期限は出来るだけ短い方がセキュリティ的に良いです。
※DNSとおなじように、サーバー証明書の場合は、mDNS ネームとCNを同じにすることです。

openssl req -new -sha256 -days 365 -newkey rsa:2048 -config openssl_mdns_server.cfg -subj "/C=JP/ST=Tokyo/L=Shinjyuku/O=ESP32_mDNS_server/OU=esp32_mdns_server1/CN=esp32.local” -out esp32lsv.csr -keyout esp32lsv.key

あとは、こちらの記事にあるようにパスフレーズ解除したり、CSR ファイルから CA署名付きサーバー証明書 を生成すれば良いです。
ここでは、最終的に生成されるファイル名は以下とします。

サーバー証明書ファイル名:esp32lsv.pem
秘密鍵ファイル名:esp32lsv.key

以上で、証明書作成の説明は終わりですが、動画にあるように別途 m5stack というサーバーネームにしたい場合は、新たに cfg ファイルを作成して証明書を発行することになります。

ESP32 または M5Stack に証明書および秘密鍵を SPIFFS フラッシュにアップロードする

では、先ほど OpenSSL で作成した証明書や秘密鍵を、ESP32-WROOM-32 開発ボードや M5Stack の SPIFFS フラッシュにアップロードします。
先ほども述べたように、ファイル名は半角8文字以内にしないとうまく動作しません
ここはハマるポイントですので、ご注意ください。

手持ちのデバイスを、DNS サーバーにするものと、mDNS サーバーにするもの、そして、クライアントにするデバイスを割り振ります。
例として、以下のような感じです。

クライアントに割り当てたデバイスにはルート CA 証明書

esp32_ca.crt

だけを SPIFFS アップロードすれば良いです。

DNSサーバー用に割り当てたデバイスには、以下のファイルをアップロードしておきます。

esp32srv.pem
esp32srv.key

mDNSサーバー用に割り当てたデバイスには以下のファイルをアップロードしておきます。

esp32lsv.pem
esp32lsv.key

この写真では m5stack サーバーも作りましたが、同じように証明書を作ってアップロードすれば良いです。

ESP32-WROOM-32 および M5Stack の SPIFFS フラッシュにファイルをアップロードする方法は、以下の記事を参照してください。

ESP-WROOM-32 ( ESP32 ) SPIFFS アップローダープラグインの使い方

スマホやパソコンに ルート CA 証明書をインストールして、ブラウザに信頼させる

Android や iOS などのスマホ、およびパソコンに先ほど作成した自己ルート CA 証明書をインストールして、ブラウザの URL 欄に鍵マークを表示させて信頼させます。
要するに、一般的に言われている「オレオレ証明書」をインストールしていきます。

Windows 10 パソコンにルート CA 証明書をインストール

Windows 10 パソコンに ルート CA 証明書をインストールする方法は、以下の記事を参照してください。

https://www.mgo-tec.com/blog-entry-trusted-ssl-server-esp32.html/5#title14

これで、先ほど作ったサーバー証明書の SSL サーバーは、ブラウザに信頼されます。

Android スマホにルート CA 証明書をインストール

Android スマホの場合、ルート CA 証明書のインストール方法は以下の記事を参照してください。

https://www.mgo-tec.com/blog-entry-trusted-ssl-server-esp32.html/6#title15

これで、先ほど作ったサーバー証明書の SSL サーバーは、ブラウザに信頼されます。

iPhone または iPad 等の iOS にルート CA 証明書をインストールする

iPhone または iPad の iOS にルート CA 証明書をインストールする方法は、只今作成中です。
もうしばらくお待ちください。
完成したらこの記事のこの場所でリンクを紹介いたします。

ザっと説明すると、ブラウザ Safari で Gmail などの Webメールを開き、自分宛てにルート CA 証明書をメールに添付して送っておきます。
その添付ファイルをタップしてインストールすることができます。

アプリのメーラーではインストールできません。
Safari から Webメールを開くことが重要です。
ただ、インストールしただけでは有効にならず、「設定」→「情報」→「証明書信頼設定」で、 インストールした証明書のスイッチボタンをオンにすることを忘れないでください。
こうすれば、この証明書は有効になります。

DNS ネームのサーバーおよびクライアントのスケッチ(プログラム)の入力

では、Arduino IDE にスケッチ(プログラム)を入力していきます。
まずは、DNS ネームのスケッチプログラムを紹介します。

DNS サーバー側スケッチ(プログラム)

DNS サーバー用スケッチは以下です。
soft AP モードのみの使用です
つまり、ESP32 自身が WiFiルーターになるモードですので、インターネットには繋がりません。

【ソースコード】 (※無保証 ※PCの場合、ダブルクリックすればコード全体を選択できます)

/* Use Only WiFi softAP mode.
 * Android Google Chrome and iOS Safari OK!
 * Can not use STA mode.
 * Use SPIFFS.
 */
#include <DNSServer.h> //Arduino-ESP32 default library
#include <ESP32_SSLserver.h> //mgo-tec library

const char *ap_ssid = "xxxxxxxx"; //ESP32 softAP SSID
const char *ap_pass = "xxxxxxxx"; //ESP32 softAP password ※8文字以上

//※ファイル名は半角8文字以内にすること
const char* server_cert_file = "/esp32srv.pem"; //必ずスラッシュ必要
const char* server_prvtkey_file = "/esp32srv.key"; //必ずスラッシュ必要

const char* dns_name = "esp32"; //好きな名前で良い
Esp32SSLServer server(443); //port=443
const uint16_t DNS_PORT = 53;
DNSServer dnsServer;

//*****************************************
void setup(void)
{  
  Serial.begin(115200);
  delay(1000);
  if ( !SPIFFS.begin() ) {
    Serial.println("SPIFFS failed, or not present");
    return;
  }
  WiFi.softAP( ap_ssid, ap_pass );
  delay(100);

  Serial.println("Setup done");

  IPAddress myIP = WiFi.softAPIP();
  Serial.println( myIP );

  dnsServer.start( DNS_PORT, dns_name, myIP );

  server.beginSPIFFS( server_cert_file, server_prvtkey_file );
  delay(3000); 
    Serial.println("TCP server started");
}
//*****************************************
void loop(void)
{
  dnsServer.processNextRequest();
  if( server.available() ){
    String receive_str = server.readStrClient();
    uint16_t len = receive_str.length();
    if( len > 0 ){
      Serial.printf( "receive_str.length = %d\r\n", len );
      Serial.println( receive_str );
      String send_str1, send_str2;
      if( receive_str.indexOf( "GET / HTTP/1.1" ) >= 0 ){
        send_str1 = "HTTP/1.1 200 OK\r\n";
        send_str1 += "Content-type:text/html\r\n";
        send_str1 += "Connection:close\r\n\r\n";

        send_str2 = "<!DOCTYPE html>\r\n<html>\r\n";
        send_str2 += "<head>\r\n";
        send_str2 += "<meta name='viewport' content='initial-scale=1.3'>\r\n";
        send_str2 += "</head>\r\n";
        send_str2 += "<body style='background:#000; color:#fff; font-size:1em;'>\r\n";
        send_str2 += "ESP32 and M5Stack<br>\r\n";
        send_str2 += "SSL server by DNSserver<br>\r\n";
        send_str2 += "Hello World!\r\n";
        send_str2 += "</body>\r\n</html>\r\n\r\n";

        server.writeStrClient( send_str1 );
        server.writeStrClient( send_str2 );
        delay(10);
        server.stopClient();
      }else{
        send_str1 = "HTTP/1.1 404 Not Found\r\n";
        send_str1 += "Connection:close\r\n\r\n";
        server.writeStrClient( send_str1 );
        delay(10);
        server.stopClient();
      }
    }
  }
}

【解説】

●6行目:
Arduino – ESP32 の標準ライブラリのインクルードです。

●7行目:
私の自作ライブラリのインクルードです。

●9-10行目:
soft AP モードなので、ssid とパスワードは何でも構いません。
好きな名前にしてください。
ただ、パスワードは8文字以上でなければなりません。

●13-14行:
先ほど SPIFFS へアップロードした、サーバー証明書と秘密鍵ファイルです。
ルートにアップロードしているので、必ずスラッシュを最初に入れてください。
何度も言いますが、ファイル名は8文字以内です。

●16行目:
DNS ネームです。
これは好きな名前にしてみてください。

●38行目:
ここで、DNS ネームとポート番号、IPアドレスを紐づけます。

●40行目:
beginSPIFFS 関数で、SPIFFS にあるサーバー証明書と秘密鍵を読み込んで、クライアントからの通信受け入れ準備します。

●47行目:
Arduino – ESP32 の DNSServer ライブラリの processNextRequest 関数で、クライアントから DNS ネームによるアクセスを検知して、IPアドレスに変換します。
この関数の疑問なところは、検知したことによる返り値が一切無いことです。
ですから、ライブラリを作る上で、available 関数へつなぐ方法を考えることがとても難しかったです。

●48行目:
この available 関数は、WiFiServer ライブラリの関数を私が改変しました。
改変したところはほんのちょっとです。
先ほど述べたように、processNextRequest 関数にはアクセス検知の返り値が無いので、クライアントから DNS ネームでアクセスされた場合、IP アドレスに変換されたフラグを利用することはできません。
ですが、この available 関数では、クライアントから socket されてデータを送信してくる動作を検知できます。
WiFiServer ライブラリなどの SSL でない通常の http サーバーを使う場合は、
client = server.available();
という形で返り値を client 型へ渡せるようになっていますが、SSL 通信の場合の client の型は全く別物なので、その形式は取りませんでした。
真偽値だけの boolean型 を返すように改変しました。
この方式で正解だったのではないかと個人的に思っています。

●49行目:
readStrClient 関数で、クライアントからのリクエスト文字列を受信します。
クライアントから URL 入力欄にクエリやパスワードを入れて送ってきても、多分、SSL/TLS で暗号化されていると思います
それをここで復号して、String型の文字列で返します。
このあとは標準の WiFiServer 関数と同様、GET などの文字列で必要な物を抽出すれば良いです。

●70-71行:
クライアントからの文字列を受信したら、サーバーからクライアントへ文字列を送信します。
これも、多分、SSL/TLS で暗号化されます

●75-79行:
Google Chrome の場合、コネクション終了後に即 Favicon リクエストが来るので、その場合は 404 エラーをクライアントに返します。

クライアント側のスケッチ(プログラム)

ESP32 および M5Stack 同士で、soft AP モードで SSL 通信する場合の、クライアント側のスケッチ(プログラム)は、以下の様な感じです。
クライアントの場合は STA モードで良いです。

前回の記事で述べたように、ルート CA を setCACert 関数でセットした方がセキュリティ的に良いです。
それは SPIFFS から読み込んでいます。
そして、前回の記事で述べたように、クライアント側の WiFiClientSecure ライブラリではサーバー証明書の有効期限チェックや失効リストの検証はできませんので、ご了承ください。

【ソースコード】 (※無保証 ※PCの場合、ダブルクリックすればコード全体を選択できます)

/*  No expiration date validation.
 *  No CRL verification.
 *  Use SPIFFS.
 */
#include <WiFiClientSecure.h>
#include <SPIFFS.h>

const char* ssid     = "xxxxxxxx";     // your network SSID (name of wifi network)
const char* password = "xxxxxxxx"; // your network password

const char*  host = "esp32";  // DNS server name

//※ファイル名は半角8文字以内にすること
const char* CA_filePath = "/esp32_ca.crt";

//*************************************
void setup() {
  Serial.begin(115200);
  delay(1000);

  if ( !SPIFFS.begin() ) {
    Serial.println("SPIFFS failed, or not present");
    return;
  }

  Serial.print("Attempting to connect to softAP SSID: ");
  Serial.println( ssid );
  WiFi.begin( ssid, password );

  // attempt to connect to Wifi network:
  while ( WiFi.status() != WL_CONNECTED ) {
    Serial.print(".");
    delay(500);
  }

  Serial.print("Connected to softAP ");
  Serial.println(ssid);
  delay(2000);
  IPAddress myIP = WiFi.localIP();
  Serial.println( myIP );

  String root_ca = readCAfile( CA_filePath );
  WiFiClientSecure client;
  client.setCACert( root_ca.c_str() );

  Serial.println("################# ca ####################");
  Serial.println( root_ca );
  Serial.println("#########################################");

  Serial.println("\nStarting connection to host...");
  uint32_t time_out = millis();
  while( 1 ){
    if ( client.connect( host, 443 ) ){
      Serial.print( host ); Serial.print( F("-------") );
      Serial.println( F("connected") );
      Serial.println( F("-------Send HTTPS GET Request") );
      String str1 = F("GET / HTTP/1.1\r\n");
             str1 += F("Host: ");
             str1 += String( host ) + F("\r\n");
             str1 += F("User-Agent: ESP32orM5Stack\r\n");
             str1 += F("Connection: close\r\n\r\n"); //closeを使うと、サーバーの応答後に切断される。最後に空行必要
             str1 += "\0";

      client.print( str1 ); //client.println にしないこと。最後に改行コードをプラスして送ってしまう為
      client.flush(); //client出力が終わるまで待つ
      Serial.printf( "%s", str1.c_str() );
      break;
    }
    if( ( millis() - time_out ) > 20000 ){
      Serial.println( F("time out!") );
      Serial.println( F("Host connection failed.") );
      break;
    }
    delay(1);
  }

  time_out = millis();
  if( client ){
    String dummy_str;
    uint16_t from, to;
    Serial.println( F("-------Receive HTTPS Response") );

    while( client.connected() ){
      if( ( millis() - time_out ) > 30000 ){
        Serial.println( F("time out!"));
        Serial.println( F("Host HTTPS response failed.") );
        break;
      }
      while( client.available() ){
        if( ( millis() - time_out ) > 30000 ) break; //60seconds Time Out
        char c = client.read();
        Serial.print(c);
        delay(1);
      }
      Serial.print('.');
    }
    delay(10);
    client.stop();
    delay(10);
    Serial.println( F("-------Client Stop") );
  }
  Serial.println();
  if( client ){
    delay(10);
    client.stop();
    delay(10);
    Serial.println( F("-------Client Stop") );
  }
}
//******************************************
void loop() {
  // do nothing
}
//******************************************
String readCAfile( const char * path ){
  Serial.print( F("SPIFFS file reading --- ") );
  Serial.println( path );
  File file = SPIFFS.open( path, FILE_READ );
  if( !file ){
    Serial.println( F("Failed to open file for writing") );
    return "";
  }

  char cert[4096];
  uint16_t i = 0;
  while( file.available() ){
    cert[i] = file.read();
    if( cert[i] == '\0' ) break;
    i++;
  }
  cert[i] = '\0';

  delay(10);
  file.close();
  delay(10);
  return String( cert );
}

【解説】

●8-9行目:
接続する相手の ssid と パスワードに書き換えます。

●11行目:
接続する相手の DNS ネームを入力します。

●44行目:
前回の記事で述べたように、setCACert 関数でルート CA 証明書をセットしておきます。
これをセットすることによって、相手サーバーがこのルート CA とペアでないサーバー証明書に差し替えると検知して、Verify が通らず、SSL通信できなくなります。
セキュリティ的により良いです。

●52-75行:
53行の connect 関数でサーバーとコネクション確立し、サーバー証明書と Verify します。
前回の記事で述べたように、残念ながら有効期限チェックや失効リストの Verify はせず、スルーします。
Verify が通ると、クライアントから GET コマンドをサーバーに送信します。
これは暗号化されます。
client.connect は while ループで何度もトライするところが個人的に行き着いた使い方です。
これについては、以下の記事で理由を解説していますので参考にしてみてください。
ESP32 および M5Stack で数時間後に Web 記事取得失敗する問題について

●78-108行:
サーバーが GET リクエストを受信すると、サーバーが暗号化された HTML 文字列を送信してくるので、91行の read 関数で復号して受信します。
ここで、重要なのは、受信し切ったら、もしくはフリーズしてタイムアウトしたら、98行や105行のように stop() して、コネクションを切断することです。
そして、WiFiClientSecure ライブラリのこの stop() 関数は、その前に delay(1) 以上が必要のようです。
以前はそれが無いとうまく切断できませんでした。
私は念のため前後に delay(10) を入れています。
この stop() 関数で、socket ハンドルが切断されて、サーバー側に渡り、サーバー側も切断するのです。
ただ、この辺は正直言って詳しくないので、間違えていたらご連絡ください。

●115-137行:
ここで、SPIFF Flash から証明書を読み取っています。
かなりのメモリを食うので、できるだけ直ぐメモリを解放した方が良いと思います。

では、次では mDNS ネームのスケッチを説明します。

コメント

  1. 匿名 より:

    わたしもAndroidでmDNSしたかったのですが、Android9になってもmDNSに対応してくれなかったので、自分でアプリを作ってみました。
    https://qiita.com/maccadoo/items/48ace84f8aca030a12f1
    https://play.google.com/store/apps/details?id=com.dokoden.dotlocalfinder&hl=ja
    デザインはデフォルトのまま、なんもいじっていないのはご容赦を

    • mgo-tec mgo-tec より:

      匿名さん

      コメント投稿ありがとうございます。

      これは良いかも知れませんね。
      私はまだ Android8 なのですが、Android9になってもmDNS対応していないんですね。
      それは残念です。
      でも、このアプリがあればすべてmDNSで統一できそうで、すばらしいですね。
      今は忙しくて、なかなか試せませんが、時間が空いた時に試してみようと思います。
      ありがとうございました。
      m(_ _)m

タイトルとURLをコピーしました