💨

WireGuard for ESP32の実装的なところ

2021/12/07に公開

WireGuard for ESP32の実装的なところ

この記事は SORACOM Advent Calendar 2021 の7日目の記事です。

前日は @torifukukaiou さんによる Elixir meets SORACOM でした。 NervesでSORACOM使うお話でした。12/14にNervesJPとSORACOM UGの合同イベント があるようなので楽しみです。

概要

2021/07/27に開催されたSORACOM UG Online #6にて ESP32からSORACOM Arcにつなぐ話 をしましたが、時間の都合上実装の話はあまりしなかったので、もうちょっと突っ込んだ部分の話をしておこうと思います。

WireGuardとは

WireGuard はなんかシンプルで速くてモダンなVPNプロトコルだそうです。
筆者もRaspberry Pi 4にWireGuardを入れて自宅PCに外からアクセスするためのVPNを構成していますが、他のVPNよりセットアップが簡単という印象です。

SORACOM Arc

改めて説明する必要は無いと思いますが、SORACOM Arc はインターネット接続経由でSORACOMプラットフォーム内のネットワークに接続するサービスです。Arcは アーク放電 のことらしく、アイコンもなんか放電してそうな絵になってます。

SORACOM Airの場合は各無線通信網経由でSORACOMプラットフォームに接続するので、その間の通信は各無線通信網の機能で暗号化されます。
一方、SORACOM Arcの場合はSORACOMプラットフォームにインターネット経由で接続するため、インターネット上の経路を暗号化する必要があります。
このときの暗号化通信路を確立するために、WireGuardによるVPN接続が用いられます。

つまり、SORACOM ArcはWireGuardによるVPNでSORACOMプラットフォーム内のネットワークに接続するサービスです。(合ってるよね?)

ESP32

これも説明は不要かと思いますが、ESP32 は中国のEspressifが製造・販売している無線LAN + Bluetooth 5.0無線通信機能付のマイコンです。
日本国内でも工事設計認証済みのモジュールが容易に入手できることから、様々なところで利用されています。

ここ数年は M5Stack を初めとした、液晶画面、ボタン、筐体がセットになった開発モジュールも容易に入手可能となっており、適当にインターネットに接続してセンサデータをアップロードすると行ったことが非常に簡単に実現できるようになっています。

lwIP

lwIP は組込み環境などRAMやCPUのリソースの制限が厳しい環境向けのTCP/IPスタックです。
ESP32もTCP/IPスタックとしてlwIPを使っています。

WireGuard for lwIP

WireGuardは様々なプラットフォーム向けに実装されており、代表的なものとしてはLinuxカーネルへの実装があります。

また、前述のlwIP向けの実装として、WireGuard implementation for LwIP stack GitHubで公開されています。

発端

スライドにも書きましたが、某氏がfacebookで前述の WireGuard for lwIP があるからSORACOM Arcにつなげられそうみたいなpostをしたのが発端です。

facebookキャプチャ

この時点で私は WireGuard for lwIP の存在を初めて知ったのでコードを確認したところ、確かに比較的簡単にESP32で動かせそうだったので動くようにしてまとめたのが WireGuard Implementation for ESP32 Arduino です。

実装

WireGuard for lwIPをESP32向けにビルドするには少し調整が必要です。

https://github.com/ciniml/WireGuard-ESP32-Arduino/commit/ae20115c3e8e2fcf6d20fc6cc00ff6ccef47b7ac#diff-25a6634263c1b1f6fc4697a04e2b9904ea4b042a89af59dc93ec1f5d44848a26

IPv4/IPv6両対応版のlwIP向け調整

元のlwIP向け実装は、ところどころIPv4のみのlwIPでないとビルドできない記述があります。このため、IPv4/IPv6の両方が有効となっている、ESP32向けのlwIPではそのままではビルド出来ません。

例えば、IPアドレスを表す ip4_addr_t を引数として取る部分は、IPv4/IPv6両対応版では ip_addr_t を使う必要があります。

https://github.com/ciniml/WireGuard-ESP32-Arduino/commit/ae20115c3e8e2fcf6d20fc6cc00ff6ccef47b7ac#diff-7ce0bd985d8195688a82b062411303f828abb4ae015f38f32761d896b6748578L58

同様の修正が必要な箇所を全て修正します。

WireGuard自体の通信を下位インターフェース経由で行う修正

WireGuard for lwIPは、WireGuardのVPN経由で通信するlwIPのインターフェースを実装し、そのインターフェース経由での通信をWireGuardのVPNに流します。
一方、WireGuard自体の通信は、下位のネットワークに対して通信を行うインターフェース経由で通信する必要があります。

lwIPにおける通信は、明示的にインターフェスを指定しない udp_sendto といったAPIの他に、明示的にインターフェースを指定する udp_sendto_if があります。
WireGuard for lwIPはインターフェースを struct wireguard_device 構造体で管理しています。この構造体に struct netif *underlying_netif フィールドを追加して、WireGuardの通信を行う下位インターフェースを保持できるようにします。

struct wireguard_device {
	// Maybe have a "Device private" member to abstract these?
	struct netif *netif;
	struct udp_pcb *udp_pcb;

	struct netif *underlying_netif;
...

https://github.com/ciniml/WireGuard-ESP32-Arduino/commit/ae20115c3e8e2fcf6d20fc6cc00ff6ccef47b7ac#diff-5d58a4344965315e674bae9978f9b208b3fe729b9d242e73d7264d8e99aac31eR175

また、WireGuard自体の通信での送信処理は、 wireguardif_peer_output wireguardif_device_output 関数で行っています。この内部で実際に udp_sendto を呼び出してリモート側にUDPでWireGuardのパケットを送信しています。この部分を udp_sendto_if に置き換えます。

	// return udp_sendto(device->udp_pcb, q, &peer->ip, peer->port);    // 元のコード
	return udp_sendto_if(device->udp_pcb, q, &peer->ip, peer->port, device->underlying_netif);

https://github.com/ciniml/WireGuard-ESP32-Arduino/commit/ae20115c3e8e2fcf6d20fc6cc00ff6ccef47b7ac#diff-7ce0bd985d8195688a82b062411303f828abb4ae015f38f32761d896b6748578R86

デバッグ用ログの追加

ESP32の標準フレームワークである ESP-IDF には、標準のログシステムがありますので、WireGuardの通信処理のうち有用な部分にログを追加しています。

ESP_LOGI(TAG, "good handshake from %08x:%d", addr->u_addr.ip4.addr, port);

https://github.com/ciniml/WireGuard-ESP32-Arduino/commit/ae20115c3e8e2fcf6d20fc6cc00ff6ccef47b7ac#diff-7ce0bd985d8195688a82b062411303f828abb4ae015f38f32761d896b6748578R214

乱数生成器の実装の置き換え

WireGuard for lwIPでは、サンプル実装としてCの標準関数の乱数生成器 rand を使った乱数生成関数 wireguard_random_bytes が実装されています。
wireguard_random_bytes は、WireGuardの暗号周りでの乱数生成に用いられるので、乱数系列の予測が難しいなど、暗号用の乱数としての要件を満たしている必要があります。
実際、WireGuard for lwIPの元のコードでは、以下のようにきちんとした乱数生成器に置き換えるようにとのコメントが記載されています。

// DO NOT USE THIS FUNCTION - IMPLEMENT A BETTER RANDOM BYTE GENERATOR IN YOUR IMPLEMENTATION
void wireguard_random_bytes(void *bytes, size_t size) {
	int x;
	uint8_t *out = (uint8_t *)bytes;
	for (x=0; x < size; x++) {
		out[x] = rand() % 0xFF;
	}
}

ESP-IDFはTLSの実装として mbedtls を用いているので、mbedtlsに含まれる暗号用の乱数生成器 (CTR-DRBG) を使うように変更します。
CTR-DRBGは暗号的に問題の無いエントロピー源が必要となりますが、ESP32にはハードウェア乱数生成器があります。
esp_fill_random を呼び出してハードウェア乱数生成器から乱数列を生成する関数 entropy_hw_random_source を定義し、 mbedtls_entropy_add_source に渡します。

static struct mbedtls_ctr_drbg_context random_context;
static struct mbedtls_entropy_context entropy_context;

static int entropy_hw_random_source( void *data, unsigned char *output, size_t len, size_t *olen ) {
    esp_fill_random(output, len);
	*olen = len;
    return 0;
}

void wireguard_platform_init() {
	mbedtls_entropy_init(&entropy_context);
	mbedtls_ctr_drbg_init(&random_context);
	mbedtls_entropy_add_source(&entropy_context, entropy_hw_random_source, NULL, 134, MBEDTLS_ENTROPY_SOURCE_STRONG);
	mbedtls_ctr_drbg_seed(&random_context, mbedtls_entropy_func, &entropy_context, NULL, 0);
}

void wireguard_random_bytes(void *bytes, size_t size) {
	mbedtls_ctr_drbg_random(&random_context, bytes, size);
}

タイムスタンプ取得関数の変更

WireGuardはピアへの接続時のハンドシェーク処理にタイムスタンプを用います。WireGuardのピアは以前に接続してきたピアのハンドシェーク処理時のタイムスタンプを記憶しており、タイムスタンプが進んでいない場合はエラーとします。このため、電源投入しなおした後でも確実に前回時点からすすんでいるタイムスタンプを用いる必要があります。

ESP32のArduino環境はNTPによる時刻同期機能を実装していますので、タイムスタンプとしてミリ秒単位の時刻を用いるように変更します。

void wireguard_tai64n_now(uint8_t *output) {
	// See https://cr.yp.to/libtai/tai64.html
	// 64 bit seconds from 1970 = 8 bytes
	// 32 bit nano seconds from current second

	struct timeval tv;
	gettimeofday(&tv, NULL);
	uint64_t millis = (tv.tv_sec * 1000LL + (tv.tv_usec / 1000LL));

	// uint64_t millis = sys_now();
	
	// Split into seconds offset + nanos
	uint64_t seconds = 0x400000000000000aULL + (millis / 1000);
	uint32_t nanos = (millis % 1000) * 1000;
	U64TO8_BIG(output + 0, seconds);
	U32TO8_BIG(output + 8, nanos);
}

https://github.com/ciniml/WireGuard-ESP32-Arduino/commit/e5e11beeb2328d8f201126d29d56047c77cd149d#diff-63c14b0f8b4914a5595af9b8338ac0f5b03327c6dce169f322f5ce43d41bfab4L39

Arduino向けAPIとサンプルの追加

Arduino環境で簡単にWireGuardを使えるようにするために、ラッパーAPIとサンプルコードを追加しています。

https://github.com/ciniml/WireGuard-ESP32-Arduino/commit/e5e11beeb2328d8f201126d29d56047c77cd149d#diff-c3cb56f83ab86bda30d90fc22dcec687c15147880618af803e46f7f529d726f0R1

使い方

使い方GitHubリポジトリのREADMEに記載していますが、同じ内容を記載しておきます。

事前にArduinoのライブラリマネージャで本ライブラリ WireGuard-ESP32 をインストールしておきます。

WireGuard-ESP32

https://github.com/ciniml/WireGuard-ESP32-Arduino

  1. WireGuard.hpp をスケッチの先頭でインクルードする。
#include <WireGuard-ESP32.h>
  1. WireGuard クラスのインスタンスをモジュールレベルで定義する。
static WireGuard wg;
  1. Arduino環境の WiFi クラスを用いてWiFi接続をする。
WiFi.begin(ssid, password);
while( !WiFi.isConnected() ) {
    delay(1000);
}
  1. 時刻をNTPで同期する。 (タイムスタンプ用)
configTime(9 * 60 * 60, 0, "ntp.jst.mfeed.ad.jp", "ntp.nict.jp", "time.google.com");
  1. WireGuardインターフェースを開始する。
wg.begin(
    local_ip,           // ローカルインターフェースのIPアドレス
    private_key,        // ローカルインターフェースの秘密鍵
    endpoint_address,   // 接続先ピアのアドレス
    public_key,         // 接続先ピアの公開鍵
    endpoint_port);     // 接続先ピアのポート番号

サンプルスケッチとして、 Unified Endpoint経由でESP32起動後の時間を送信する uptime_post.ino を用意してありますので、改造すればセンサデータなどをSORACOM Harvest Dataに送ることも簡単にできます。

反響

Arduino向けライブラリとして公開後、数件の使用例を見かけるようになりました。

また、サンプルスケッチではSORACOM Arcへの接続情報をコード上にベタ書きしていましたが、その辺りを解決する方法についての記事 もあるようです。

また、Maker Faire Tokyo 2021のミニプレゼンテーション中で、ソラコム 松下さんによる SORACOM Arc + WireGuard for ESP32 Arduinoを使った例の紹介 がありました。
このセッションでは、MQTT + TLSでの通信をMQTT + WireGuardで通信を行う処理に変更すると -141[KB] のプログラムメモリの節約になる例が紹介されています。このときのサンプル (M5Stack + WireGuard + MQTT) は Qiitaの記事 として公開されています。

また、GitHubリポジトリの方にも、いくつかissueやPRが来ており、あまり作業時間はとれていませんが気が向いたら対応しています。

おわりに

とりあえずESP32でWireGuardが使えるようになったのでいろいろ便利に使える場面があるかなと思います。
といいつつ、やった本人は動くようになったので8割方満足してしまっているので、あまり用途は思いついていないですが…

とはいえ、いろいろ使える場面はあると思いますので、便利に使っていただければと思います。

明日

明日は @n_mikuni さんの番です。よろしくお願いします。

Discussion