本ページはOpenSSLのC APIの解説ページです。
内容については予告なく追記・修正することがあります。
本ページの内容について当社は一切の責任を負わないものとします。

OpenSSLに関する技術情報はこちらにもあります。

OpenSSL Tutorial

目次

1 はじめに

OpenSSLはSSL/TLSやそれに関連する暗号技術を実装したツールキットだ(*1)。 opensslコマンドを使えば秘密鍵/証明書の作成やTLS通信など様々なことを行うことができる。 OpenSSLはコマンドだけではなくライブラリとしてC APIも提供しているので、自分のソフトウェアでTLS通信を行う必要がある場合などにも利用できる。 ただ、OpenSSLは膨大な関数を提供しており、Cから使おうとした場合、 manでAPIのリファレンスを読んだだけでは、これらの関数群をどのように使えばよいかわからず、 ネットで検索したりopensslコマンドのソースコードを読んだりする必要があり、相当手間がかかるはずだ。

このドキュメントでは、OpenSSLのC APIの使い方を解説し、OpenSSLを使いこなすためのとっかかりとなる情報を提供する。 なお、解説する分野には偏りがあり、OpenSSLの機能を網羅するものではない。

対象バージョンはOpenSSL1.1.1だ。 サンプルソースのコンパイルはLinux環境で行っている。

2 イントロダクション

いきなりTLS接続の方法などを説明したいところだが、 ここではまず、初期化処理、エラーコードの扱い方やOpenSSLを使う上で知っておいた方がいいデータ構造について説明する。

2.1 初期化処理

OpenSSL 1.1.1では基本的に初期化関数を呼び出す必要はない。

元々、SSL_library_init()という初期化関数があったが、OpenSSL 1.1.0でdeprecatedになった。 1.1.0以降では代わりにOPENSSL_init_ssl()を使う。 ただし、初期化処理はOpenSSLの関数内で自動で行われるので、 デフォルト設定以外の初期化を行いたい時以外は本関数を明示的に呼び出す必要はない。

他にもエラー文字列を読み込むSSL_load_error_strings()、ERR_load_crypto_strings()という関数もあるが、これも1.1.0でdeprecatedになった。 代わりにOPENSSL_init_ssl()、OPENSSL_init_crypto()(*2)を使うが、上述のとおり自動で初期化されるので、エラー文字列についても明示的に初期化処理を行う必要はない。

2.2 エラーメッセージの取得

APIを使っていく上で各所で必要になるエラー処理について、エラーコードやエラーメッセージの取得方法をまとめる。

ERR_get_error()

最も基本的な関数はエラーコードを取得するERR_get_error()だ。 OpenSSLでは発生したエラーはキューに格納されるようになっている。 ERR_get_error()はキューの先頭(つまり最も古いもの)のエラーを取り出し、そのエラーコードを返す。 エラーがなくなるまで(0を返すまで)繰り返し呼び出せば、キューの全エラーを取得できる。

ソースコード2.1 エラーコード取得例

if (BIO_puts(bio, "This triggers an error.") <= 0) {
	fprintf(stderr, "Error Code: %lx\n", ERR_get_error());
        return;
}

ソースコード2.2 ERR_get_error()のリターン例

Error Code: 2007507e

ERR_error_string()

ERR_get_error()で返されたエラーコードを見ても理由は分からないので、ERR_error_string()を使えばエラーコードからHuman readableなエラーメッセージを取得できる。フォーマットは以下のような形式になる。

error:[error code]:[library name]:[function name]:[reason string] (*3)

ソースコード2.3 ERR_error_string()

fprintf(stderr, "%s\n", ERR_error_string(ERR_get_error(), NULL));

ソースコード2.4 ERR_error_string()のリターン例

error:2007507E:BIO routines:mem_write:write to read only BIO

ERR_reason_error_string()

ユーザへの表示用でreason stringだけ欲しいという場合は、ERR_reason_error_string()が使える。

ソースコード2.5 ERR_reason_error_string()

fprintf(stderr, "%s\n", ERR_reason_error_string(ERR_get_error()));

ソースコード2.6 ERR_reason_error_string()のリターン例

write to read only BIO

ERR_print_errors_fp()

ERR_print_errors_fp()はキューの中の全メッセージを出力する。

ソースコード2.7 ERR_print_errors_fp()

ERR_print_errors_fp(stderr);

ソースコード2.8 ERR_print_errors_fp()のリターン例

139914501721216:error:20075073:BIO routines:mem_write:null parameter:crypto/bio/bss_mem.c:224:
139914501721216:error:2007507E:BIO routines:mem_write:write to read only BIO:crypto/bio/bss_mem.c:228:

上記の4つの関数でほぼ事足りるはずだが、以下のmanをみればその他のエラー関数にどのようなものがあるか確認できる。

2.3 BUF_MEM

BUF_MEMはOpenSSL内のバッファライブラリがバッファを管理するための構造体だ。 BUF_MEMは連続領域のメモリを扱い、C++でいうstd::vector<char>のような機能を提供する(こんなに高機能ではないが)。図2.1にBUF_MEMの概要を示す。

BUF_MEMはバッファの先頭アドレス(data)とサイズ情報を持つ。 maxは実際に確保されているバッファサイズ、lengthが実効的なバッファサイズになる。 std::vectorでいうcapacityとsizeに該当するといえばわかりやすいかもしれない。

図2.1 BUF_MEM

BUF_MEMに関する基本的な関数は以下の3つだ。

BUF_MEM *BUF_MEM_new(void);

void BUF_MEM_free(BUF_MEM *a);

int BUF_MEM_grow(BUF_MEM *str, int len);

BUF_MEM_new(void)は空のBUF_MEMを作成する。 BUF_MEM_grow(BUF_MEM *str, int len)はBUF_MEMのサイズ(length)をlenにする。 確保されているバッファサイズが足りなければ(len > max)、メモリが再確保される。 その場合、バッファ内のデータも自動でコピーされる。 使い終わったBUF_MEMはBUF_MEM_free()で解放する。

BUF_MEMはOpenSSLライブラリ内の各所で使われている。 TLS接続をしたいだけならBUF_MEMが表に出て来ることはないのだが、後でメモリBIOの説明をする際に必要になるのでここで説明した。

3 Basic I/O abstraction

OpenSSLでI/O操作を行う場合に必要になるBIOについて説明する。 OpenSSLではI/O操作を行う際、BIOを通す。 BIOはI/O操作を抽象化している。 ファイルディスクリプタ、ソケット、メモリ等のI/O対象の詳細を隠すことで 各種リソースに同じAPI(関数)でI/Oアクセスできるようになっている。

BIOにはsource/sink BIOとフィルターBIOがある。 source/sink BIOはデータ入力元や出力先になるもので、ファイルBIO、ソケットBIO、メモリBIOなどはsource/sink BIOにあたる。 stdin/stdoutやファイルのようにデータの入力元(source)となったり、出力先になってデータを溜める(sink)ためsource/sinkと命名されているようだ。 フィルターBIOはBIOチェーンで他のBIOと組み合わせて使う。 フィルターBIOはデータを受け取り、base64エンコードなど何らかの処理を行い次のBIOへデータを渡す。

BIOの概要はman 7ssl bioで確認できる。

3.1 BIOの作成

BIOを作成するにはBIO_new()関数を使う。 BIO_new()に渡すtype引数でI/O対象の詳細(ファイル、ソケット、メモリ等)が決まる。 type引数はBIO_METHODへのポインタだが、通常はBIO_s_mem()のようなBIO_METHODへのポインタを返す関数を呼び出すことで指定する。

ソースコード3.1 BIOの作成例

BIO *mem = BIO_new(BIO_s_mem());  /* メモリBIOを作成 */

BIOメソッドを返す関数は様々あるが、source/sink BIOに関する関数はBIO_s_で始まり、フィルターBIOに関するものはBIO_f_で始まる。BIOメソッドを返す関数の例としては以下のようなものがある。

他にどのようなものがあるかは、man 7ssl bioで確認できる。

BIOにはメモリBIOのようにBIO_new()で作成後すぐに使えるものもあるが、ファイルBIOのようにBIO_new()で作成後、ファイルディスクリプタの設定等、初期化処理を行わないといけないものもある。 それらを簡単に行うためのヘルパー関数が各BIOに用意されている。 BIO_new_で始まる関数がこれにあたる。これは各BIOの解説時に説明する。

なお、作成したBIOは使い終わったらBIO_free()で解放処理が必要になる。 厳密にはBIOはリファレンスカウンタを持っている。 BIO_free()はリファレンスカウンタを減算し参照がなくなったら解放処理を行っている。 BIOを共有したい場合は、BIO_up_ref()でリファレンスカウンタを増やすことができる。

ソースコード3.2 BIOの作成と解放 bio_new_free.c

#include <openssl/bio.h>

int main()
{
	BIO *mem = BIO_new(BIO_s_mem());
	if (mem == NULL) {
		return 1;
	}

	BIO_free(mem);

	return 0;
}

3.2 BIOへのread/write

BIOのread/write関数には以下のようなものがある。 以下の関数にBIO *を渡せば、BIOを通してファイルやメモリへのread/writeを行える。 BIOを使わずシステムコールやlibcの関数を使ってリソースへのread/writeを行おうとすると、リソースの種類に応じて異なる実装にする必要がある(*4)。BIOを使うことでリソースの種類によらず、統一されたインターフェース(関数)でリソースにアクセスできる。

BIOの種類によってはサポートされていない関数もあるので注意すること。 例えばBase64フィルターBIOではBIO_gets()、BIO_puts()はサポートされていない(*5)。

3.3 メモリBIO

メモリBIOはメモリ上のバッファに対してread/writeするBIOである。 メモリBIOに書き込んだデータはBIO_gets()等のread系の関数を使って読み出すことができる。 メモリBIOのバッファ部分についてはBUF_MEM(2.3節)で実装されている。

図3.1 メモリBIO

BIOへの書き込み時にバッファは動的に確保されるため、バッファ上で文字列を結合したい時などに便利だ。

BIO_s_mem()はメモリBIOメソッドを返す。 このメモリBIOメソッドをBIO_new()に渡すことでメモリBIOを作成できる。

ソースコード3.3 メモリBIOの作成

BIO *mem = BIO_new(BIO_s_mem());

メモリBIOへのwrite/readの例をソースコード3.4に示す。 例ではBIO_puts()でバッファに文字列を書き出している。 BIO_printf()のようにsprintfのようなformatting関数も使える。 バッファの伸長はメモリBIOが行ってくれるので、mallocやsprintfを使って自分で行うよりも簡単に処理できるのがわかる。

例中のprint_bio()関数では書き込んだメモリBIOの中身をBIO_gets()で取得して、標準出力に出力している。標準出力への出力はstdoutを設定したファイルBIOを使っても行えるが、ファイルBIOの説明はまだしていないので、ここではprintf()を使っている。

ソースコード3.4 メモリBIOのwrite/read bio_mem_write_read.c

#include <stdio.h>
#include <openssl/bio.h>
#include <openssl/err.h>

void print_bio(BIO *bio)
{
	char buff[1000];
	int ret;

	while (1) {
		/* メモリBIOに書き込まれたデータの読み込み */
		ret = BIO_gets(bio, buff, sizeof(buff));
		if (ret <= 0) {
			break;
		}
		printf("%s", buff);
	}
}

int main()
{
	BIO *bio = BIO_new(BIO_s_mem());
	if (bio == NULL) {
		goto error;
	}
	if (BIO_puts(bio, "content-type: text/html; charset=UTF-8\r\n") <= 0) {
		goto error;
	}
	/* printfのようなformatting関数もある */
	if (BIO_printf(bio, "content-length: %d\r\n", 100) <= 0) {
		goto error;
	}

	/* 書き込んだデータのread */
	print_bio(bio);

	BIO_free(bio);

	return 0;

error:
	if (bio) {
		BIO_free(bio);
	}

        ERR_print_errors_fp(stderr);

	return 1;
}

既存のバッファをBIOに取り込みたい場合はBIO_new_mem_buf()関数が使える(ソースコード3.5)。 この関数は先に説明したようにヘルパー関数だ。

BIO *BIO_new_mem_buf(const void *buf, int len)

この関数はbufで指定したアドレスからlenバイトを指すメモリBIOを作成する。 bufのデータをBIOの管理するバッファにコピーするのではなく、bufのデータを直接参照する。 このため、このメモリBIOはRead Onlyとなり書込みをしてもエラーとなる。

BIO_free()はBIOを解放する際、内部で使っているデータも解放する。 メモリBIOでは内部のBUF_MEMとそれが管理するバッファの実体も解放するが、Read OnlyなメモリBIOではバッファの実体は解放しない(図3.2)。 このため、BIO_new_mem_buf()で作成したメモリBIOをBIO_free()しても、指定したバッファを誤って解放しようとすることはない(*6)。

ソースコード3.5 BIO_new_mem_buf()の例

#include <stdio.h>
#include <openssl/bio.h>
#include <openssl/err.h>

/* void print_bio(BIO *bio)の定義は省略 */

int main()
{
	char string[] = "Hello\n";

        BIO *bio = BIO_new_mem_buf(string, -1);
	if (bio == NULL) {
		ERR_print_errors_fp(stderr);
		return 1;
	}

	print_bio(bio);

#if 0
	if (BIO_puts(bio, "This triggers an error.") <= 0) {
		ERR_print_errors_fp(stderr);
		BIO_free(bio);
		return 1;
        }
#endif

	BIO_free(bio);

	return 0;
}
図3.2 Read OnlyなメモリBIOの解放処理

BIOとBUF_MEMの扱いになれるためにヘルパー関数BIO_new_mem_buf()を使わずに、BIO_new()を使って同じことをやってみよう。 ソースコード3.6のBIO_new_mem_buf_self()関数がBIO_new_mem_buf()相当の関数になる。

やっていることは、

bio = BIO_new(BIO_s_mem());
buf = BUF_MEM_new();

でメモリBIOとバッファ用のBUF_MEMを作成する。

そして、作成したBUF_MEMをBIOに設定する(この時、BIOに元々あったBUF_MEMは解放される)。

BIO_set_mem_buf(bio, buf, BIO_CLOSE)

3番目の引数はclose flagでBIO_CLOSEかBIO_NOCLOSEの何れかを指定する。 これは、BIO_free()時の挙動を指定する。ファイルBIOの場合は名前から推測できるとおり、BIO_free()時にファイルをcloseするかopenしたままにするかの指定になるが、 メモリBIOにおいては、BIO_free()時にBUF_MEMを解放するかどうかの指定になる。 BIO_NOCLOSEの場合はBUF_MEMの解放処理はしない(図3.3)。BIO作成時の初期値はBIO_CLOSEだ。 ここでは、BUF_MEMも解放させる必要があるのでBIO_CLOSEを指定している。

この時点ではまだBUF_MEMは未設定なので、指定bufferを指すようにBUF_MEMを設定する。

buf->data = (void *) buffer;
buf->length = sz;
buf->max = sz;

最後にBIOをRead Only状態にしている。これによって、BUF_MEMのdataバッファが解放されなくなる。これを忘れると、BIO_free()時にstring[]も解放しようとして動作がおかしくなる。

BIO_set_flags(bio, BIO_FLAGS_MEM_RDONLY);

以上がBIO_new_mem_buf_self()の処理内容だが、ヘルパー関数を使わないとかなり面倒なのがわかる。

ソースコード3.6 BIO_new_mem_buf()と同様の処理の例 bio_new_mem_buf_self.c

#include <stdio.h>
#include <string.h>
#include <openssl/bio.h>
#include <openssl/buffer.h>
#include <openssl/err.h>

/* void print_bio(BIO *bio)の定義は省略 */

/* BIO_new_mem_buf()相当の処理 */
BIO *BIO_new_mem_buf_self(const void *buffer, int len)
{
	BIO *bio = NULL;
	BUF_MEM *buf = NULL;
	size_t sz = (len < 0) ? strlen(buffer) : (size_t) len;

	bio = BIO_new(BIO_s_mem());
	if (bio == NULL) {
		goto error;
	}

	buf = BUF_MEM_new();
	if (buf == NULL) {
		goto error;
	}

	if (BIO_set_mem_buf(bio, buf, BIO_CLOSE) <= 0) {
		/*
		 * BUF_MEM_free()はbufとbuf->dataを解放する。
		 * 先に buf->data = (void *) buffer; を設定していた場合は、
		 * buf->dataを解放させないようbuf->dataをNULLにしてから
		 * BUF_MEM_free()を呼び出す必要がある。
		 */
		BUF_MEM_free(buf);
		goto error;
	}
	/*
	 * bufはbioに接続されたので、これ以降はBIO_free()で
	 * bufも一緒に解放される(BUF_MEM_free()は必要ない)。
	 */

	buf->data = (void *) buffer;
	buf->length = sz;
	buf->max = sz;

	/*
	 * BIOをread onlyにする。
	 * また、BIO_free()でbuf->dataは解放されなくなる。
	 */
	BIO_set_flags(bio, BIO_FLAGS_MEM_RDONLY);

	return bio;

error:
	if (bio) {
		BIO_free(bio);
	}

	return NULL;
}

int main()
{
	char string[] = "Hello\n";

        BIO *bio = BIO_new_mem_buf_self(string, -1);
	if (bio == NULL) {
		ERR_print_errors_fp(stderr);
		return 1;
	}

	print_bio(bio);

	/*
	 * read only BIOの場合、BIO_free()はBUF_MEMのbuf->dataは解放しない。
	 * このため、stringに対して解放処理が動作することはない。
	 * C++やRust風に言えばbuffuerに対する所有権は持たず参照しているだけ。
	 * Ref. crypto/bio/bss_mem.c::mem_buf_free()
	 */
	BIO_free(bio);

	return 0;
}
図3.3 BIO_NOCLOSE設定なメモリBIOの解放処理

3.4 ファイルBIO

ファイルBIOはファイルポインタ(FILE *)を内包し、ファイルへのアクセスを提供する。 ファイルBIOを作成するにはBIO_s_file()が返すメソッドをBIO_new()に渡せばよい。

ソースコード3.7 ファイルBIOの作成

BIO *bio_file = BIO_new(BIO_s_file());

この状態ではまだ空のBIOなので、BIO_set_fp()でファイルポインタを設定する必要がある。以下の例では既にオープン済みのFILE *fpをbioに設定している。BIO_CLOSEを指定するとBIO_free()で解放時にファイルもcloseされる。ファイルを閉じたくない場合はBIO_NOCLOSEを指定すればよい。

ソースコード3.8 ファイルBIOの初期設定

	bio = BIO_new(BIO_s_file());
	if (bio == NULL) {
		fclose(file);
		return NULL;
	}

	BIO_set_fp(bio, fp, BIO_CLOSE | BIO_FP_TEXT);

ファイルBIOに関しては以下のヘルパー関数がある。

BIO *BIO_new_file(const char *filename, const char *mode);
BIO *BIO_new_fp(FILE *stream, int flags);

BIO_new_file()はファイル名を指定してBIOを作成し、BIO_new_fp()はopen済みのファイルのファイルポインタを指定してBIOを作成する。ファイルBIOを作成する際は、わざわざBIO_new()を使わなくても上記ヘルパー関数で事足りるはずだ。

ファイルBIOを使ってファイルをread/writeする例をソースコード3.9に示す。

このサンプルは指定したファイルを読み込んで標準出力に出力する。

./bio_file_read_write any_file

ファイル名の指定がない場合は、標準入力から読み込んで標準出力に出力する。

cat any_file | ./bio_file_read_write

ファイルBIOはファイルポインタのWrapperなので、標準入出力に対してもBIOを通してアクセスできる。

ソースコード3.9 ファイルBIOのread/write例 bio_file_read_write.c

#include <stdio.h>
#include <openssl/bio.h>

static BIO *bio_stdout = NULL;

void print_bio(BIO *bio_out, BIO *bio)
{
	char buff[1000];
	int ret;

	while (1) {
		ret = BIO_gets(bio, buff, sizeof(buff));
		if (ret <= 0) {
			break;
		}
		BIO_puts(bio_out, buff);
	}
}

int main(int argc, char *argv[])
{
	BIO *bio_in;

	bio_stdout = BIO_new_fp(stdout, BIO_NOCLOSE | BIO_FP_TEXT);

	/* Create a BIO by helper function. */
	if (argc >= 2) {
		bio_in = BIO_new_file(argv[1], "r");
	} else {
		bio_in = BIO_new_fp(stdin, BIO_NOCLOSE | BIO_FP_TEXT);
	}

	if (bio_in) {
		print_bio(bio_stdout, bio_in);
		BIO_free(bio_in);
	} else {
		fprintf(stderr, "Can't create a bio.\n");
	}

	BIO_free(bio_stdout);

	return 0;
}

read/writeしているのはprint_bio()関数だ。単にBIO_gets()で読み込んだデータをBIO_puts()で書き出しているだけだ。 ソースコード3.4のprint_bio()ではBIOの内容を出力するのにprintf()を使っていたが、ここでは出力先のBIOを引数で受け取りBIO_puts()で出力している。今回は標準出力のファイルBIOをprint_bio()に渡しているので、標準出力に出力されるが、メモリBIOやソケットBIOを渡せばprint_bio()の実装を変更せずともメモリバッファやソケットに出力するように変更できる。

なお、標準入出力のBIOを作成する際、BIO_NOCLOSEを指定している。

bio_stdout = BIO_new_fp(stdout, BIO_NOCLOSE | BIO_FP_TEXT);

これは、BIO_free()でBIOを解放する際、標準出力を閉じないようにするためのものだ。 BIO_CLOSEだとBIO_free()後、以降の処理で標準出力への出力ができなくなってしまう。

3.5 ファイルディスクリプタBIO

ファイルBIOと似ているがファイルディスクリプタを内包するBIOも存在する。 ファイルBIOと同じように使えるので説明は簡単なものに留める。

BIO作成はファイルBIOのBIO_s_fp()をBIO_s_fd()に変えるだけだ。

ソースコード3.10 ファイルディスクリプタBIOの作成

BIO *bio_file = BIO_new(BIO_s_fd());

作成したBIOには、ファイルBIOでファイルポインタを設定したように、BIO_set_fd()でファイルディスクリプタを設定する必要がある。

int BIO_set_fd(BIO *b, int fd, int c);

ただ、ファイルディスクリプタBIOにも以下のようなヘルパー関数があるので、BIO_new()やBIO_set_fd()を直接呼び出すことはないだろう。

BIO *BIO_new_fd(int fd, int close_flag);

ファイルBIOと同じように使えるので使用例は省略する。

3.6 ソケットBIO

ソケットBIOの作成にはBIO_s_socket()を使う。

ソースコード3.11 ソケットBIOの作成

BIO *bio_socket = BIO_new(BIO_s_socket());

ソケットBIOの初期化には、BIO_set_fd()を使ってソケットのファイルディスクリプタを指定する。ここはファイルディスクリプタBIOと同じだ。

int BIO_set_fd(BIO *b, int fd, int c);

ソケットBIOにも以下のヘルパー関数があるので、基本的にはこれを使ってBIOを作成することになるだろう。

BIO *BIO_new_socket(int sock, int close_flag);

ソースコード3.12にソケットBIOの例を示す。 指定ホスト(この例ではlocalhost)にHTTP Requestを送信し、Responseを受け取っている。

ソースコード3.12 ソケットBIOの例 bio_socket.c

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <netdb.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <openssl/bio.h>
#include <openssl/err.h>

static BIO *bio_stdout;

BIO *connect_to_remote(const char* hostname, unsigned short port)
{
	struct hostent *ent;
	struct sockaddr_in remote;
	int sock;
	BIO *bio;

	ent = gethostbyname(hostname);
	if (ent == NULL) {
		herror("gethostbyname()");
		return NULL;
	}
	if (ent->h_addrtype != AF_INET) {
		fprintf(stderr, "Can' get ipv4 address.\n");
		return NULL;
	}

	memset(&remote, 0, sizeof(remote));
	remote.sin_family = AF_INET;
	remote.sin_port = htons(port);
	memcpy(&remote.sin_addr, ent->h_addr, sizeof(remote.sin_addr));

	sock = socket(AF_INET, SOCK_STREAM, 0); 
	if (sock == -1) {
		perror("socket()");
		return NULL;
	}

	if (connect(sock, (struct sockaddr*) &remote, sizeof(remote)) == -1) {
		perror("connect()");
		close(sock);
		return NULL;
	}

	bio = BIO_new_socket(sock, BIO_CLOSE);
	if (bio == NULL) {
		close(sock);
		return NULL;
	}

	return bio;
}

void http_get(BIO *bio, const char *hostname)
{
	char buff[1024];
	int size;
	BIO *mem;
	char *request;

	mem = BIO_new(BIO_s_mem());
	if (mem == NULL) {
		ERR_print_errors_fp(stderr);
		return ;
	}

	/*
	 * メモリBIOにBIO_printf()で出力すればバッファサイズを気にせず
	 * formattingできる
	 */
	if (BIO_printf(mem, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: Close\r\n\r\n", hostname) < 0) {
		ERR_print_errors_fp(stderr);
		BIO_free(mem);
		return;
	}

	size = BIO_get_mem_data(mem, &request);

	if (BIO_write(bio, request, size) != size) {
		ERR_print_errors_fp(stderr);
		BIO_free(mem);
		return;
	}
	BIO_flush(bio);
	BIO_free(mem);

	while ((size = BIO_read(bio, buff, sizeof(buff) - 1)) > 0) {
		buff[size] = '\0';
		BIO_write(bio_stdout, buff, size);
	}
}

int main()
{
	const char *hostname ="localhost";
	BIO *bio_sock = NULL;

	bio_stdout = BIO_new_fp(stdout, BIO_NOCLOSE | BIO_FP_TEXT);

	bio_sock = connect_to_remote(hostname, 80);
	if (bio_sock == NULL) {
		goto error;
	}

	http_get(bio_sock, hostname);

	BIO_free(bio_sock);

	BIO_free(bio_stdout);

	return 0;

error:
	BIO_free(bio_stdout);

	return 1;
}

3.7 BIOチェーンとフィルターBIO

これまでに紹介したBIOは全てsource/sink BIOだったが、ここではフィルターBIOを説明する。フィルターBIOは入力されたデータに対して何らかの処理をした後、次のBIOにデータを出力する(*7)。

BIOは複数のBIOをつなげてチェーンを構成することができる。 フィルターBIOはチェーンにつなげて他のBIOと組み合わせて使う。 これまでの例ではBIOは単体で使っていた。これはBIOが一つのみのチェーンと見なせる。 フィルターBIOを使う場合は、一つ以上のフィルターBIOをチェーンにつなげて、チェーンの最後にsource/sink BIOをつなげる。 read/writeはチェーン先頭のBIOに対して行う。

図3.4 BIOチェーン

BIOの連結はBIO_push()で行う。

BIO *BIO_push(BIO *b, BIO *append);

BIOチェーンの解放はチェーン先頭のBIOに対してBIO_free_all()を呼び出す。

void BIO_free_all(BIO *a);

チェーンに関してはman 7ssl bioに多少説明がある。

3.8 Base64フィルターBIO

ここでは、Base64フィルターを例にフィルターBIOの使い方を説明する。

Base64フィルターはread/writeによって動作が変わる。 write時はBase64 encodingし、read時はBase64 decodingする。

ソースコード3.13は標準入力から受けたデータをBase64にエンコードして標準出力に出力する。

$ echo "Hello" | ./bio_base64_encode
SGVsbG8K

ここまででBIOに対するread/writeは理解できているはずなので、BIOチェーンについて説明する。 このエンコーダーは図3.5のようなBIOチェーンを作ってwriteしている。

図3.5 Base64 Encoding

ソースコード3.13 Base64フィルタによるエンコード bio_base64_encode.c

#include <stdio.h>
#include <string.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/evp.h>

BIO *create_encoder_chain()
{
	BIO *b64 = NULL;
	BIO *bio = NULL;

	b64 = BIO_new(BIO_f_base64());
	if (b64 == NULL) {
		goto error;
	}
	bio = BIO_new_fp(stdout, BIO_NOCLOSE | BIO_FP_TEXT);
	if (bio == NULL) {
		goto error;
	}

        BIO_push(b64, bio);

	return b64;

error:
	if (bio) {
		BIO_free(bio);
	}

	if (b64) {
		BIO_free(b64);
	}

	return NULL;
}

int main()
{
	char buff[1000];
	BIO *bio_stdin;
	BIO *chain;
	int sz;

	bio_stdin = BIO_new_fp(stdin, BIO_NOCLOSE);

	chain = create_encoder_chain();
	if (chain == NULL) {
		goto error;
	}

	/* binaryデータを読めるようにBIO_gets()ではなくBIO_read()で */
	while ((sz = BIO_read(bio_stdin, buff, sizeof(buff))) > 0) {
		/* base64 filter BIOにwrite操作をするとencodeされる */
		if (BIO_write(chain, buff, sz) <= 0) {
			goto error;
		}
	}

        BIO_flush(chain);

	BIO_free_all(chain);

	BIO_free(bio_stdin);

	return 0;

error:
	if (chain) {
		BIO_free_all(chain);
	}

	BIO_free(bio_stdin);

	ERR_print_errors_fp(stderr);

	return 1;
}

ソースコード3.14は標準入力から受けたデータをBase64にデコードして標準出力に出力する。

$ echo "Hello" | ./bio_base64_encode | ./bio_base64_decode 
Hello

このデコーダーは図3.6のようなBIOチェーンを作ってreadしている。図3.5と異なり、末尾のBIOがstdinになっている。 readの場合、BIOチェーン末尾からデータが逆に流れてくることになる。

図3.6 Base64 Decoding

ソースコード3.14 Base64フィルタによるデコード bio_base64_decode.c

#include <stdio.h>
#include <string.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/evp.h>

BIO *create_decoder_chain()
{
	BIO *b64 = NULL;
	BIO *bio = NULL;

	b64 = BIO_new(BIO_f_base64());
	if (b64 == NULL) {
		goto error;
	}
	/* data source */
	bio = BIO_new_fp(stdin, BIO_NOCLOSE | BIO_FP_TEXT);
	if (bio == NULL) {
		goto error;
	}

        BIO_push(b64, bio);

	return b64;

error:
	if (bio) {
		BIO_free(bio);
	}

	if (b64) {
		BIO_free(b64);
	}

	return NULL;
}

int main()
{
	char buff[1000];
	BIO *bio_stdout;
	BIO *chain;
	int sz;

	bio_stdout = BIO_new_fp(stdout, BIO_NOCLOSE);

	chain = create_decoder_chain();
	if (chain == NULL) {
		goto error;
	}

	/* base64 filter BIOにread操作をするとdecodeされる */
        while ((sz = BIO_read(chain, buff, sizeof(buff))) > 0) {
		BIO_write(bio_stdout, buff, sz);
	}

	BIO_free_all(chain);

	BIO_free(bio_stdout);

	return 0;

error:
	if (chain) {
		BIO_free_all(chain);
	}

	BIO_free(bio_stdout);

	ERR_print_errors_fp(stderr);

	return 1;
}

ここまでの知識で標準入力からだけでなくファイルやメモリのデータを処理することもできるはずだ。

3.9 SSL BIO

SSL BIOはTLS接続を表すSSL*型のリソースを内包するBIOだ。 SSL BIO(もしくはSSL BIOを含むBIOチェーン)に対してread/writeを行うと、TLS通信下でデータの送受信を行うことができる。

BIO_read(bio_ssl, buff, sz);
BIO_write(bio_ssl, buff, sz);

SSL BIOのメソッドを返すBIO_f_ssl()の名前からわかるようにSSL BIOはフィルターBIOだ。 ただし、SSL BIOはフィルターBIOでありながらread/writeに関してはsource/sink BIOのような動きをする例外的なBIOだ。単体で使ったり、チェーンの末端で使うこともできる。

SSL BIOはBIO_gets()による読み込みに対応していない。 HTTPSヘッダの処理などで行単位の読み込みを行いたい場合は、 Buffer BIO→SSL BIOのようにチェーンしてBuffer BIOと組み合わせて使う。

SSL BIOはBIO_new_ssl()で単体で作成したり、クライアント動作ならBIO_new_ssl_connect()で作成することが多い。 具体的な使い方は4.1節以降で説明する。

4 TLS接続

4.1 TLS接続する

この節では、TLS接続するクライアントとサーバーを作成し、TLS接続を行う方法を説明する。 TLS接続にあたり、新しい型SSL_CTXおよびSSLについて学ぶ必要がある。

SSL_CTXはTLS接続に関する設定情報を保持する。SSL_CTXに使用するTLSバージョン、暗号化アルゴリズムのリスト、証明書のパス等、TLS接続に必要な設定をする。

SSL型はTLS接続を表す。 SSLはSSL_new()を使ってSSL_CTXを指定して作成し、SSLは指定したSSL_CTXの設定を引き継ぐ。 SSL_CTXに行う多くの設定はSSLに対して個別に行うこともできるので、基本的な設定をSSL_CTXに行っておき、接続ごとの設定をSSL対して個別に行う使い方をすることが多い。 通常のソケット通信では、ソケットのファイルディスクリプタに対してread()、write()等を呼び出すことでデータの送受信を行うが、TLS通信ではSSL型にはSSL_set_fd()でソケットを関連付けて、SSL *に対してSSL_read()、SSL_write()を呼び出すことで送受信を行う。

まず、TLS接続するクライアントの例をソースコード4.1に示す。 この例はlocalhost(127.0.0.1)のTCP 8000番ポートにTLS接続を行う。 TLS接続後、標準入力から入力されたテキストを相手に送信する。

なお、この例では証明書の検証は行っていない。TLS接続してデータを送受信する方法に絞って説明をする。 証明書の検証については、後のhttps接続の節で一緒に説明する。

接続の方法を順にみていこう。 まず、サーバー(127.0.0.1のTCP 8000ポート)にTCP接続を行う必要がある。 これには、socket()とconnect()を使う。

sock = socket(AF_INET, SOCK_STREAM, 0);

connect(sock, (struct sockaddr*) &sin, sizeof(sin));

これはTCPを使った一般的なネットワークプログラミングと同じだ。 これ以降でTLS接続のための準備を行う。 まず、SSL_CTXの作成だ。 SSL_CTX作成時はクライアント/サーバーいずれかの動作モードを指定する必要がある。 ここではクライアントなので、TLS_client_method()を渡す。

SSL_CTX_new (TLS_client_method());

作成したSSL_CTXに設定を行っていく。 まず、SSL_MODE_AUTO_RETRYを設定しておく。これは何をするものか簡単に説明すると、SSL_read()、SSL_write()はSSL_ERROR_WANT_READ / SSL_ERROR_WANT_WRITEのエラーを返すことがある。 これらのエラーはソケット通信時のEAGAIN/EWOULDBLOCK相当するもので、これらエラーが返された場合は、アプリケーションが自分でread/write処理をリトライする必要がある。SSL_read()、SSL_write()においてはブロッキングI/O時においてもSSL_ERROR_WANT_READ / SSL_ERROR_WANT_WRITEを返すことがあるのだが、SSL_MODE_AUTO_RETRYをオンにしておくと、OpenSSLライブラリの内部で自動でリトライしてくれるので、SSL_read()、SSL_write()がSSL_ERROR_WANT_READ / SSL_ERROR_WANT_WRITEを返すことはなくなる。このため、ブロッキングI/Oを使っている場合では、オンにしておく方がよい(ノンブロッキングI/Oの場合は、SSL_ERROR_WANT_READ / SSL_ERROR_WANT_WRITEに対応する必要がある)。 なお、OpenSSL v1.1.1以降ではSSL_MODE_AUTO_RETRYはデフォルトではオンになっている。 SSL_MODE_AUTO_RETRYの動作の詳細については https://www.bit-hive.com/articles/20220329 を参照のこと。

SSL_CTX_set_mode(ctx, SSL_MODE_AUTO_RETRY)

次に使用するTLSのバージョンを指定する。 現時点(2022年)なら1.2以降でいいだろう 実際に使用されるプロトコルバージョンはサーバーとのネゴシエーションによって決まる。

SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION)

以上でSSL_CTXの設定は終わったので接続用のSSL型のリソースを作成する。 sslにはctxに設定された設定情報が引き継がれている。

ssl = SSL_new(ctx);

作成したSSL型のリソースに、TCP接続したソケットを関連づける。これで、ssl変数を介したread/writeではsockで指定したTCP接続が使われるようになる。

SSL_set_fd(ssl, sock)

SSLリソースの設定も終わったので、TLS接続を行う。 SSL_connect()を呼び出すと、サーバー側とTLSハンドシェイクを行いTLS接続が確立される。

SSL_connect(ssl)

これ以降はSSL_read()、SSL_write()でデータの送受信を行える。 sending_loop()関数では表示入力から読み取った入力をSSL_write()でサーバー側に送信している。 Ctrl+Dで終了すると、SSL_shutdown()でTLS接続を終了する。

ソースコード4.1 TLS接続(Client) tls_client.c

#include <stdio.h>
#include <netdb.h>
#include <netinet/in.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <openssl/x509v3.h>

void sending_loop(SSL *ssl)
{
	char buff[256];

	printf("Press Ctrl+d to quit\n");

	while (fgets(buff, sizeof(buff), stdin) != NULL) {
		SSL_write(ssl, buff, strlen(buff));
	}
}

int main()
{
	int sock = -1;
	const char *host_ip = "127.0.0.1";
	uint16_t port = 8000;
	struct sockaddr_in sin;
	SSL_CTX *ctx = NULL;
	SSL *ssl = NULL;

	sock = socket(AF_INET, SOCK_STREAM, 0);
	if (sock == -1) {
		perror("socket()");
		goto error;
	}

	memset(&sin, 0, sizeof(sin));
	sin.sin_family = AF_INET;
	sin.sin_port = htons(port);
	sin.sin_addr.s_addr = inet_addr(host_ip);

	if (connect(sock, (struct sockaddr*) &sin, sizeof(sin)) == -1) {
		perror("connect()");
		goto error;
	}

	ctx = SSL_CTX_new(TLS_client_method());
	if (ctx == NULL) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	/* SSL_CTXの設定 */
	SSL_CTX_set_mode(ctx, SSL_MODE_AUTO_RETRY);

	if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) == 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	/* 今回は証明書の検証は省略する */
#if 0
	if (SSL_CTX_set_default_verify_paths(ctx) == 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	/* 証明書の検証設定(ホスト名の検証は省略) */
	SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
#endif

	ssl = SSL_new(ctx);
	if (ssl == NULL) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	if (SSL_set_fd(ssl, sock) == 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	/* TLS handshake開始 */
	if (SSL_connect(ssl) <= 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	sending_loop(ssl);

	SSL_shutdown(ssl);

	SSL_free(ssl); 
	SSL_CTX_free(ctx);
	close(sock);

	return 0;

 error:
	if (ssl) {
		SSL_free(ssl); 
	}
	if (ctx) {
		SSL_CTX_free(ctx);
	}
	if (sock != -1) {
		close(sock);
	}

	return 1;
}

次はサーバー側の処理をみていこう。 サーバー側の例をソースコード4.2に示す。 この例ではI/Oの多重化は行っていないので、同時に1つのクライアントしか接続できない。 また、SIGPIPE等のシグナルの処理も省略している。

まずは待ち受け用のTCPソケットを作成する。クライアントの時と同じく、一般的なTCPソケットの作成と同じだ。

socket(AF_INET, SOCK_STREAM, 0)

bind(sock, (struct sockaddr *) &listen_addr, sizeof(listen_addr))

listen(sock, 512)

次に、クライアントと同様にSSL_CTXの作成と設定を行っていく。 SSL_CTXの作成については、今回はサーバー側の動作なので、TLS_server_method()を渡す。

ctx = SSL_CTX_new(TLS_server_method())

SSL_CTXの設定は、サーバー側の動作としては秘密鍵と証明書の設定が必要になる。 今回はクライアント側で証明書の検証は行っていないので、指定するのは自己署名証明書で構わない。 apacheをインストールしてある環境なら/etc/pki/tls/private/localhost.key、/etc/pki/tls/certs/localhost.crtあたりのファイルを使えばよい。

秘密鍵と証明書を登録したら、SSL_CTX_check_private_key()で整合性をチェックしておくとよい。SSL_CTX_check_private_key()は秘密鍵と証明書に含まれる公開鍵のペアが正しいものかを確認してくれる。

SSL_CTX_use_certificate_file(ctx, "localhost.crt", SSL_FILETYPE_PEM)
SSL_CTX_use_PrivateKey_file(ctx, "localhost.key", SSL_FILETYPE_PEM)

SSL_CTX_check_private_key(ctx)

あとは、クライアントと同じくSSL_MODE_AUTO_RETRYとTLSバージョンの指定を行いSSL_CTXの設定は完了だ。

SSL_CTX_set_mode(ctx, SSL_MODE_AUTO_RETRY)

SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION)

いよいよ接続の受付だが、TLS接続を開始する前にTCP接続を受け付ける必要がある。これには、通常のソケットプログラミングと同様accept()で行う。 accpet()はクライアントからTCP接続の要求があれば、TCP接続を行いソケットのファイルディスクリプタを返す。

peer = accept(sock, NULL, NULL)

TCP接続が完了したらTLSのハンドシェイクを行う。まず、TLS接続用にSSL型のリソースをSSL_new()で作成し、SSL_set_fd()でSSLとTCPソケットとを関連付ける。ここはクライアントの処理と同じだ。SSLリソースの作成が完了したら、SSL_accept()を呼び出すことでTLSハンドシェイクが行われ、TLSの接続が確立する。

ssl = SSL_new(ctx)
SSL_set_fd(ssl, peer)

SSL_accept(ssl)

TLS接続完了後は、read_loop()関数でクライアントから送られたデータをSSL_read()で読み出して出力しているだけだ。

ソースコード4.2 TLS接続(Server) tls_server.c

#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <openssl/x509v3.h>

/*
 * return 0   正常終了
 *        -1  エラー
 *        -2  エラー(SSL_shutdown()を呼び出してはいけない)
 */
int read_loop(SSL *ssl)
{
	char buff[1000];
	int size;
	int err;

	while (1) {
		size = SSL_read(ssl, buff, sizeof(buff) - 1);
		if (size <= 0) {
			err = SSL_get_error(ssl, size);
			switch (err) {
			case SSL_ERROR_ZERO_RETURN:	/* EOF */
				return 0;

			case SSL_ERROR_SYSCALL:
				/*
				 * clientをctrl-cで止めたような場合は
				 * SSL_ERROR_SYSCALL(5)が返る。
				 * ECONNRESET("Connection reset by peer")
				 */
				perror("SSL_ERROR_SYSCALL");
				return -2;

			case SSL_ERROR_SSL:
				return -2;
			default:
				printf("Error: %d\n", err);
				break;
			}

			return -1;
		}
		buff[size] = '\0';
		printf("SSL_read(): %d bytes received: %s\n", size, buff);
		fflush(stdout);
	}
	/* not to reach */

	return 0;
}

int main(int argc, char *argv[])
{
	int sock = -1;
	uint16_t port = 8000;
	struct sockaddr_in listen_addr;
	SSL_CTX *ctx = NULL;

	sock = socket(AF_INET, SOCK_STREAM, 0);
	if (sock == -1) {
		perror("socket");
		goto error;
	}

	memset(&listen_addr, 0, sizeof(listen_addr));
	listen_addr.sin_family = AF_INET;
	listen_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	listen_addr.sin_port = htons(port);

	if (bind(sock, (struct sockaddr *) &listen_addr, sizeof(listen_addr)) < 0) {
		perror("bind");
		goto error;
	}

	if (listen(sock, 512) == -1) {
		perror("listen");
		goto error;
	}

	ctx = SSL_CTX_new(TLS_server_method());
	if (ctx == NULL) {
		ERR_print_errors_fp(stderr);
		goto error;
	}


	if (!SSL_CTX_use_certificate_file(ctx, "localhost.crt", SSL_FILETYPE_PEM) ||
	    !SSL_CTX_use_PrivateKey_file(ctx, "localhost.key", SSL_FILETYPE_PEM)) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	if (!SSL_CTX_check_private_key(ctx)) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	/*
	 * v1.1.1ではデフォルトでSSL_MODE_AUTO_RETRYは設定されている
	 * https://github.com/openssl/openssl/issues/7908
	 */
	SSL_CTX_set_mode(ctx, SSL_MODE_AUTO_RETRY);

	if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) == 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	printf("Listening port %d ...\n", port);

	while (1) {
		int peer;
		SSL *ssl = NULL;
		int ret = 0;

		peer = accept(sock, NULL, NULL);
		if (peer == -1) {
			perror("accept");
			goto next;
		}
		printf("Accepted\n");

		ssl = SSL_new(ctx);
		if (ssl == NULL) {
			ERR_print_errors_fp(stderr);
			goto next;
		}

		if (SSL_set_fd(ssl, peer) == 0) {
			ERR_print_errors_fp(stderr);
			goto next;
		}

		/* handshake */
		ret = SSL_accept(ssl);
		if (ret <= 0) {
			ERR_print_errors_fp(stderr);
			goto next;
		}
		printf("Handshaked\n");
		printf("%s\nCipher: %s\n",
		       SSL_get_version(ssl),
		       SSL_get_cipher(ssl));

		ret = read_loop(ssl);

	next:
		if (ssl) {
			/*
			 * SSL_ERROR_SYSCALL/SSL_ERROR_SSLが発生していたら
			 * SSL_shutdown()を呼び出してはいけない。
			 */
			if (ret != -2) {
				SSL_shutdown(ssl);
			}
			SSL_free(ssl); 
		}
		if (peer != -1) {
			close(peer);
		}
		printf("Closed\n");
	}

	SSL_CTX_free(ctx);
	close(sock);

	return 0;

 error:
	if (ctx) {
		SSL_CTX_free(ctx);
	}

	if (sock != -1) {
		close(sock);
	}

	return -1;
}

実行例は以下のとおり。tls_serverを実行すると、TCP 8000番ポートで待ち受けを始める。

$ ./tls_server
Listening port 8000 ...

tls_clientを実行すると、TCP 8000番ポートを接続を行う。TLS接続が完了すれば、"Press Ctrl+d to quit''と表示される。この状態で何かテキストを入力してEnterを押せば、サーバー側に送信され、サーバー側でメッセージが表示されるはずだ。Ctrl+dを押すと接続は閉じられる。

$ ./tls_client
Press Ctrl+d to quit
hello
hello
$ ./tls_server
Listening port 8000 ...
Accepted
Handshaked
TLSv1.3
Cipher: TLS_AES_256_GCM_SHA384
SSL_read(): 6 bytes received: hello

SSL_read(): 6 bytes received: hello

Closed

今回はBIOを使わない形でのTLSの接続方法を説明した。 BIOを使ってTLS接続する例は4.2節で説明する。

ところで、SSL *への直接的なread/writeはSSL_read()、SSL_write()で行う。行単位でデータを読み込むような関数はない。 fgets()のように行単位でデータを取得して処理したい場合もあるだろう。 この場合は、BIO経由でSSL *へアクセスし、BIO_gets()を使う。

SSL *から行単位でデータを読み込む例をソースコード4.3に示す。これは、tls_server.cのread_loop()部分を修正したものだ。

まず、SSL *へアクセスするためのBIOを作成する。これは、create_bio_from_ssl()関数で行っている。 BIOの作成は、BIO_new(BIO_f_ssl())でSSL BIOを作成し、BIO_set_ssl()でSSLリソースを設定するだけでよさそうだが、BIO_f_sslはBIO_gets()をサポートしていない。 このため BIO_f_bufferを通すようにチェーンを作成する。 BIO_f_bufferは標準ライブラリのファイルストリーム(いわゆるFILE *)のようなバッファリング機能を提供してくれ、BIO_gets()によるアクセスが可能になる。

BIOチェーン終端は通常 source/sink BIO になるのだが、BIO_f_sslはfilter BIOである。 BIO_f_sslはBIO_set_ssl()で設定されたSSLに対してSSL_read/SSL_writeするBIOで例外的にチェーンの終端に使用できる。

最初からBIOを使ってTLS接続しておけばこのような作業は不要なのだが、SSL BIOを使ったチェーンの作成方法を説明しておきたかったので、ここで例をあげておいた。

ソースコード4.3 SSL *の行単位の読み込み tls_server_bio_gets.c

BIO *create_bio_from_ssl(SSL *ssl)
{
	BIO *bio = NULL;
	BIO *bio_ssl = NULL;

	bio = BIO_new(BIO_f_buffer());
	if (!bio) {
		goto error;
	}

	bio_ssl = BIO_new(BIO_f_ssl());
	if (!bio_ssl) {
		goto error;
	}

	BIO_set_ssl(bio_ssl, ssl, BIO_NOCLOSE);
	BIO_push(bio, bio_ssl);

	return bio;

error:
	if (bio_ssl) {
		BIO_free(bio_ssl);
	}

	if (bio) {
		BIO_free(bio);
	}

	return NULL;
}

int read_loop(SSL *ssl)
{
	char buff[1000];
	BIO *bio = NULL;

	bio = create_bio_from_ssl(ssl);
	if (!bio) {
		return -1;
	}

	while (BIO_gets(bio, buff, sizeof(buff)) > 0) {
		fputs(buff, stdout);
	}

	BIO_free_all(bio);

	return 0;
}

4.2 https接続する

4.1節ではサーバーにTLS接続する方法を学んだ。 TLS接続したい場合、実際のところhttpsで使うのがほとんどだろう。 ここでは、webサーバーにhttps接続する方法を学ぶ。 といっても、ソースコード4.1をベースに、TLS接続後にHTTPリクエストを送信する処理を追加するだけだ。 このため、ソースコード4.1ではBIOを使わずにTLS接続していたが、本節ではBIOを使ってTLS接続を行うようにし、BIOの使い方も学ぶ。 また、説明していなかったサーバー証明書の検証についても説明する。

この節で学ぶのは以下の項目になる。

指定ホストに対してTLS接続し、HTTPリクエストを送信する(https通信を行う)例をソースコード4.4に示す。 ソースコード4.1との主な違いはTLS接続後、サーバー証明書の検証を行っている点だ。

まず、BIOを使ったTLS接続方法をみていこう。SSL_CTXの作成と設定を行わなければいけないのは、ソースコード4.1と変わらない。 TLS接続用のBIOはBIO_new_ssl_connect()で作成する。 これは、SSL型のリソースを内包したSSL BIOを含むBIOチェーン(*8)を作成する。 BIOチェーンなので、解放時はBIO_free()ではなくBIO_free_all()を使う必要があるので注意する必要がある。

BIO_new_ssl_connect(ctx);

BIO_new_ssl_connect()で作成されたBIOチェーンは、BIO_gets()はサポートされていない。BIO_gets()を使った行読み取りを行いたい場合は、BIO_new_buffer_ssl_connect()でBIOチェーンを作成するとよい。この関数は、BIO_new_ssl_connect()が返すチェーンに、バッファBIO(BIO_f_buffer)を挿入したチェーン(*9)を作成してくれる。今回の例では、読み込みにはBIO_read()しか使っていないので、BIO_new_ssl_connect()を使っている。

次に、作成したSSL BIOに対して接続先のホスト名およびポート番号を指定する。

BIO_set_conn_hostname(bio_ssl, hostname);
BIO_set_conn_port(bio_ssl, "443");

あとは、BIO_do_connect()を呼び出せば、TCP接続、TLS handshake、証明書の検証が行われTLS接続が確立する。

BIO_do_connect()

これでSSL BIOを通してアプリケーションデータをread/writeできる。 https_get()関数をみれば、簡単なHTTPリクエストを送信して、受け取ったレスポンスを出力しているのがわかるだろう。

次に証明書の検証に関する設定を見ていく。 まず、SSL_CTXに信頼するCA証明書のパスを指定する。 通常はSSL_CTX_set_default_verify_paths(ctx)を呼び出して、OpenSSLのデフォルト設定を使用すればよい。

SSL_CTX_set_default_verify_paths(ctx)

次に証明書の検証動作の設定を行う。 SSL_CTX_set_verify()で検証モードをSSL_VERIFY_PEERに設定することで、ハンドシェイク時にサーバー証明書の検証が行われるようになる。

SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, verify_callback);

コールバック関数(verify_callback)を指定しているが、指定しなくても(NULLでも)いい。ここでは、コールバックを指定して証明書の検証過程を出力している。

これで、証明書の検証は行われるようになったが完全ではない。 上記の設定によって、証明書の有効期限や発行者(証明書チェーン)の確認は行われるようになったが、ホスト名(証明書内のcommon name)の検証は行われていない。 このため、サーバーがURLとは別のホスト名の証明書を送ってきた場合でも証明書の検証が成功してしまう。 ホスト名の検証を行うには、以下のようにX509_VERIFY_PARAM_set1_host()でホスト名を設定する必要がある。

X509_VERIFY_PARAM_set1_host(param, hostname, 0))

この際、X509_VERIFY_PARAM_set_hostflags()で検証動作を指定することができる。ビットマップフラグを指定するが、どのようなフラグがあるかは、man 3ssl X509_check_hostで確認できる。通常は、Partial Wildcardを禁止するX509_CHECK_FLAG_NO_PARTIAL_WILDCARDSを指定することが多い(*10)。 Partial Wildcardとはwww*.example.comのように'*'と文字が混在する指定方法のことだが、この形式は現在では推奨されないためだ(*11)。

X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS)

最後にSNI(Server Name Indication)の設定をしておく。 SNIはWebサーバーがName Based VirtualHostで複数のhttps仮想サーバーをホスティングするのに必要になるTLS拡張仕様である。 SSL_set_tlsext_host_name()でホスト名を指定しておくと、ハンドシェイク時に server_name TLS拡張が送信され、サーバー側に接続したい仮想ホストの名前を伝えることができる。

SSL_set_tlsext_host_name(ssl, hostname);

これを忘れると、WebサーバーがName Based VirtualHostで複数のhttps仮想サーバーをホスティングしていた場合、正しい仮想ホストに接続できない(*12)。

以上で、Webサーバーとhttps通信ができるようになった。

ソースコード4.4 TLS接続(Client) tls_https_client.c

#include <stdio.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <openssl/x509v3.h>

static BIO *bio_stdout;

int https_get(BIO *bio_ssl, const char *hostname) {
	char buff[1024];
	int size;

	size = snprintf(buff, sizeof(buff), "GET / HTTP/1.1\r\nHost: %s\r\nConnection: Close\r\n\r\n", hostname);
	if (size + 1 > sizeof(buff)) {
		fprintf(stderr, "Insufficient buffer size.\n");
		return -1;
	}
	BIO_puts(bio_ssl, buff);

	while ((size = BIO_read(bio_ssl, buff, sizeof(buff) - 1)) > 0) {
		buff[size] = '\0';
		BIO_write(bio_stdout, buff, size);
	}
	return 0;
}

/* for Debug */
int verify_callback(int preverified, X509_STORE_CTX *ctx)
{
	X509* cert;
	char subject[1024];

	cert = X509_STORE_CTX_get_current_cert(ctx);
	if (cert == NULL) {
		return 0;
	}
	X509_NAME_oneline(X509_get_subject_name(cert), &subject[0], sizeof(subject));
	printf("%d %s\n", preverified, subject);

	return preverified;
}

void enable_hostname_validation(SSL *ssl, const char *hostname)
{
	X509_VERIFY_PARAM *param;

	param = SSL_get0_param(ssl);

	X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
	if (!X509_VERIFY_PARAM_set1_host(param, hostname, 0)) {
		ERR_print_errors_fp(stderr);
	}
}

int main(int argc, char *argv[])
{
	const char *hostname;
	BIO *bio_ssl = NULL;
	SSL_CTX *ctx = NULL;
	SSL *ssl;

	bio_stdout = BIO_new_fp(stdout, BIO_NOCLOSE | BIO_FP_TEXT);

	if (argc < 2) {
		fprintf(stderr, "Usage:\ntls_https_client <hostname>\n");
		goto error;
	}
	hostname = argv[1];

	ctx = SSL_CTX_new(TLS_client_method());
	if (ctx == NULL) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	SSL_CTX_set_mode(ctx, SSL_MODE_AUTO_RETRY);

	if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) == 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	if (SSL_CTX_set_default_verify_paths(ctx) == 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	/* 証明書の検証設定 */
#if 1
	SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, verify_callback);
#endif

	bio_ssl = BIO_new_ssl_connect(ctx);
	if (bio_ssl == NULL) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	if (BIO_get_ssl(bio_ssl, &ssl) <= 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	enable_hostname_validation(ssl, hostname);

	/* For SNI */
	SSL_set_tlsext_host_name(ssl, hostname);

	BIO_set_conn_hostname(bio_ssl, hostname);
	BIO_set_conn_port(bio_ssl, "443");
	if (BIO_do_connect(bio_ssl) <= 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	if (https_get(bio_ssl, hostname) == -1) {
		goto error;
	}

	BIO_ssl_shutdown(bio_ssl);

	BIO_free_all(bio_ssl);
	SSL_CTX_free(ctx);

	BIO_free(bio_stdout);

	return 0;

error:
	if (bio_ssl) {
		BIO_free_all(bio_ssl);
	}
	if (ctx) {
		SSL_CTX_free(ctx);
	}

	BIO_free(bio_stdout);

	return 1;
}

実行は以下のようにホスト名を指定する。

$./tls_https_client localhost

BIOを使わないで同様の実装をしたものは、https://www.bit-hive.com/articles/20200407 の記事を参照のこと。

4.3 https接続を受け付ける

ここでは簡単なWebサーバーを作り、https接続を受け付ける方法を学ぶ。 とはいっても基本的に4.1節のソースコード4.2でやるべきことの解説はほぼ終わっている。 あとは、TLS接続後にHTTPリクエストを読んで、HTTPレスポンスを返すだけだ。 そこで、ここでは前節と同じようにBIOを使ったTLS接続の方法を説明する。

この節で学ぶのは以下の項目になる。

BIOを使ってTLS接続を待ち受けるWebサーバーの例をソースコード4.5に示す。 このWebサーバーはTCP 8000ポートで待ち受ける。

ここでも、証明書と秘密鍵はlocalhost.crtとlocalhost.keyを使っている。 クライアントが証明書の検証を行っている場合は、証明書のエラーになるので接続時に証明書の検証を無効にするか(*13)、 自分で運用しているhttpsサイトがあるなら鍵と証明書をコピーして使うとよい。その場合、hostsファイルの書き換えも必要になるだろう。

この節での説明は主にBIOを使ったTLS接続の待ち受け方法になる。 接続手順をみていこう。 まずは、BIO_new_accept()でAccept BIOを作成する。

bio_accept = BIO_new_accept("8000")

次にBIO_do_accept()を呼び出すことでソケットをbindする。 関数名がacceptなのにbind?と不思議に思うかもしれないが、BIO_do_accept()は二つの機能をもつ。 初回呼び出し時は指定アドレス、ポートに対してbindし(今回の例では8000 portにbind)、二回目以降はTCP接続の待ち受け(accept)動作をする。 TCPソケットで待ち受けをする場合は、bind()後、accept()するが、その作業がBIO_do_accept()にまとめられていると考えればよい。

BIO_do_accept(bio_accept) /* 初回の呼び出しはbind動作 */

ここでAccept BIOの接続受付時の動作について説明する。 Accept BIOは、新しい接続の受付完了時に新しいSocket BIOを作成しAccept BIOにチェーンする(図4.1)。 ソケットプログラミングに慣れた人には、accept()システムコールが返したファイルディスクリプタを内包するSocket BIOを作成し、Accept BIOにチェーンするといった方がイメージしやすいかもしれない。

TCP接続確立後は、図4.1のAccept BIOのチェーンにread/writeすることでクライアントと通信することができる(*14)。ただし、サーバーでは複数の接続を扱うため通常はAccept BIOを使いまわす。このためBIO_pop()でSocket BIOをチェーンから外して使うことが多い。Socket BIOを切り離したAccept BIOは再度、接続の待ち受けに使うことができる。

図4.1 Accept BIOの動作

ところで、Accept BIOにはBIO_set_accept_bios()で別途BIOチェーンを登録することができる。 登録したBIOチェーンは、accept時に複製されてAccept BIOの後ろに挿入される(図4.2)。 accept時に生成されるBIOのテンプレートを登録しておくイメージだ。

図4.2 Accept BIOへのチェーン登録

TLS通信する場合はここにSSL BIOを登録しておくと、accept完了時に自動でSSL BIOを作成することができる。

ソースコード4.5では、Accept BIOに登録するBIOチェーンをcreate_bio_from_ctx()で作成している。 今回の例では、HTTPリクエストの読み取りにBIO_gets()を使いたいので、Buffer BIO → SSL BIOのチェーンを作成している(*15)。 作成したBIOチェーンをBIO_set_accept_bios()で登録すれば、Accept BIOの準備は完了だ。

BIO_set_accept_bios(bio_accept, bio_ssl)

続いて、作成したAccept BIOを使ってTCP接続の待ち受けを行う。 再度BIO_do_accept()を呼び出すことで、クライアントからのTCP接続を待つ。 TCP接続後は、図4.2のようにBIOがチェーンされているので、 BIO_pop()でチェーンから複製されたBIOを取り出している。

/* bioはbio_accepoの次のBIOになる
 * (Buffer BIO → SSL BIOのチェーン)
 */
bio = BIO_pop(bio_accept)

これで、クライアントとの通信に使うBIOを取得できた。 あとはクライアント側の処理と同じように BIO_do_handshake()でハンドシェイクを行う。

BIO_do_handshake(bio)

ハンドシェイクが完了すれば、bioを通してクライアントとTLS通信を行える。 process_request()関数でHTTPリクエストを読み取って、レスポンスを返している。

以上で、BIOを使ってhttps接続を処理できるようになった。

ソースコード4.5 TLS接続(Server) tls_https_server.c

#include <stdio.h>
#include <string.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <openssl/x509v3.h>

static const char *cert_file = "localhost.crt";
static const char *private_key_file = "localhost.key";

int process_request(BIO *bio)
{
	char buff[1000];
	const char *response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\ntest\r\n";
	int sz;
	int on_going = 0;

	while ((sz = BIO_gets(bio, buff, sizeof(buff))) > 0) {
		if (on_going == 0 &&
		    buff[0] == 0x0d && buff[1] == 0x0a && buff[2] == 0x00) {
			/* empty line */
			break;
		}
		on_going = buff[sz - 1] == 0x0a ? 0 : 1;

		fputs(buff, stdout);
	}

	/* http response */
	BIO_write(bio, response, strlen(response));
	BIO_flush(bio);

	return 0;
}

BIO *create_bio_from_ctx(SSL_CTX *ctx)
{
	BIO *bio_buf = NULL;
	BIO *bio_ssl = NULL;

	bio_buf = BIO_new(BIO_f_buffer());
	if (!bio_buf) {
		goto error;
	}

	bio_ssl = BIO_new_ssl(ctx, 0);
	if (!bio_ssl) {
		goto error;
	}
	BIO_push(bio_buf, bio_ssl);

	return bio_buf;

error:
	if (bio_ssl) {
		BIO_free(bio_ssl);
	}
	if (bio_buf) {
		BIO_free(bio_buf);
	}

	return NULL;
}

int main(int argc, char *argv[])
{
	SSL_CTX *ctx = NULL;
	BIO *bio_ssl = NULL;
	BIO *bio_accept = NULL;

	ctx = SSL_CTX_new(TLS_server_method());
	if (ctx == NULL) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	if (!SSL_CTX_use_certificate_chain_file(ctx, cert_file)) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	if (!SSL_CTX_use_PrivateKey_file(ctx, private_key_file, SSL_FILETYPE_PEM)) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	if (!SSL_CTX_check_private_key(ctx)) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	SSL_CTX_set_mode(ctx, SSL_MODE_AUTO_RETRY);

	if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) == 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}

	bio_ssl = create_bio_from_ctx(ctx);
	if (!bio_ssl) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	bio_accept = BIO_new_accept("8000");
	if (!bio_accept) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	if (BIO_do_accept(bio_accept) <= 0) {	/* bind */
		ERR_print_errors_fp(stderr);
		goto error;
	}

	/*
	 * ここで指定したbio(chain)はbio_accept内に保持される。
	 * bio_accept解放時に一緒に解放されるので、bioを解放してはいけない。
	 *
	 * Accept BIOは通常、accept時に新しいSocket Bio(accept()が返した
	 * socketを格納したBIO)をbio_acceptにチェーンするだけ。
	 *     Accept BIO -> Socket BIO
	 *
	 * BIO(Chain)を登録しておくと、accept時にBIO_dup_chain()で複製して
	 * Accept BIOの後ろに挿入する。
	 *     Accept BIO -> BIO(Chain) -> Socket BIO
	 *
	 * TLS通信する場合に自動でSSL BIOを挟みたい場合に使用する。
	 * SSL BIOを挟んだ場合は、末尾のSocket bioは使われない。
	 */
	if (BIO_set_accept_bios(bio_accept, bio_ssl) <= 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	bio_ssl = NULL;	/* エラー時に解放させないように */

	while (1) {
		BIO *bio = NULL;
		SSL *ssl = NULL;

		if (BIO_do_accept(bio_accept) <= 0) {
			ERR_print_errors_fp(stderr);
			goto error;	/* end */
		}
		printf("Accepted\n");

		/* get new connection */
		bio = BIO_pop(bio_accept);

		if (BIO_do_handshake(bio) <= 0) {
			ERR_print_errors_fp(stderr);
			goto next;
		}
		printf("Handshaked\n");

		if (BIO_get_ssl(bio, &ssl) <= 0) {
			ERR_print_errors_fp(stderr);
			goto error;
		}

		printf("%s\nCipher: %s\n",
		       SSL_get_version(ssl),
		       SSL_get_cipher(ssl));

		process_request(bio);

	next:
		if (bio) {
			BIO_ssl_shutdown(bio);
			BIO_free_all(bio);
		}
	}

	BIO_free_all(bio_accept);

	/* bio_sslの解放は不要 */

	SSL_CTX_free(ctx);

	return 0;

 error:
	if (bio_accept) {
		BIO_free_all(bio_accept);
	}
	if (bio_ssl) {
		BIO_free_all(bio_ssl);
	}
	if (ctx) {
		SSL_CTX_free(ctx);
	}

	return -1;
}

サーバーに対して以下のように接続を試すことができる。

curl -k https://localhost:8000

サーバー側では以下のように出力される。

$ ./tls_https_server
Accepted
Handshaked
TLSv1.3
Cipher: TLS_AES_256_GCM_SHA384
GET / HTTP/1.1
Host: localhost:8000
User-Agent: curl/7.79.1
Accept: */*

Accept BIOの動作に若干癖があるので、通常のソケットプログラミングに慣れた人でも少し難しく感じるかもしれない。

4.4 VirtualHostに対応する

4.3節で作成したWebサーバーに仮想ホスト機能を追加し、 SNI(server_name TLS拡張)のハンドリングの仕方を学ぶ。 仮想ホストに対応したWebサーバーをソースコード4.6に示す。TLS接続の方法などはソースコード4.5と同じなので、仮想ホスト化に関して要点を絞って説明する。

まず、vhosts変数で仮想サーバーの定義を行っている。

struct vhost vhosts[] = {
	{"www.example.com",
	 "example.com.chained.crt",
	 "example.com.key",
	 0,
	 NULL,
	 NULL,
	},
	{"www.example.net",
	 "example.net.chained.crt",
	 "example.net.key",
	 SSL_OP_NO_TLSv1_3,
	 "ECDHE-RSA-AES128-GCM-SHA256",
	 NULL,
	},
};

各仮想サーバーの定義に使っている構造体は以下のとおりだ。 仮想サーバーごとに秘密鍵とサーバー証明書が必要になるのは当然だが、その他の設定として、仮想ホストごとにTLSバージョンと暗号スイートを指定できるようにしている。

struct vhost {
	char *server_name;
	char *cert;	/* サーバー証明書 + 中間証明書 */
	char *private;
	long ssl_options;
	char *cipher_list;
	SSL_CTX *ssl_ctx;
};

なお、certに指定する証明書ファイルは、Nginxと同じようにサーバー証明書と中間証明書を結合したファイルだ。 クライアントからの接続テスト時は、4.3節と同じようにダミーの証明書を設定するなどしてほしい。

SSL_CTXは仮想ホストごとに持つので、vhost構造体にssl_ctxフィールドがある。 仮想ホストごとにTLSの設定が異なる(*16)ので、仮想ホストごとの設定に合わせたSSL_CTXを作成して保持している。このSSL_CTXの設定処理はinit_virtual_hosts()関数で行っている。

SSL_CTXの設定方法で4.3節と異なるのは、仮想ホストに対応するためにSNIの設定を行っている点だ。

SSL_CTX_set_tlsext_servername_callback(ctx, servername_callback);

server_name TLS拡張に関するコールバックを設定しておくと ハンドシェイク時にコールバック関数が呼ばれる(*17)。 通常は、このコールバック内でserver_name TLS 拡張で指定された仮想サーバーの設定(SSL_CTX)をSSL_set_SSL_CTX()で現在のTLS接続(SSL *)に設定する。

servername_callback()がこのコールバック関数だが、 順を追って説明するため、まずUSE_CLIENT_HELLO_CALLBACKのdefineが0の場合で説明する。 このコールバックでは、SSL_get_servername()を使ってserver_name TLS拡張で指定されたサーバー名を取得し、マッチする仮想サーバーを探し選択(select_vhost()関数)している。 select_vhost()関数で選択した仮想サーバーのSSL_CTXからSSL*に設定をコピーしている。

ここで問題になるのは、SSL_CTXに含まれる設定のすべてがSSL *に反映されるわけではない点だ。 ServerNameコールバック呼び出しの時点では、TLSバージョンや暗号スイートをSSL *に設定しても、実際のバージョンや暗号スイートは切り替わらない。 このため、デフォルト仮想ホスト(vhosts[0]のホスト)の設定となってしまう。

これは以前のApacheにおいても同様で、Name Based VirtualHostの場合、仮想ホストごとにプロトコルバージョンや暗号スイートを指定することはできなかった(*18)

これに対応するために、OpenSSL1.1.1以降でClient Hello Callbackというものが追加されている。 Client Hello CallbackはClient Helloを受信後、ServerName Callbackよりも早い段階で呼び出されるコールバックで、ここでならTLSバージョンや暗号スイートを切り替えることができる。

ApacheもOpenSSL 1.1.1を使ってビルドされた2.4.42以降のバージョンなら、Client Hello Callbackを使うようになっており(*19)、仮想サーバーごとにTLSバージョンを指定できるようになっている(*20)。

ソースコード4.6ではUSE_CLIENT_HELLO_CALLBACKのdefineを1にすると、Client Hello Callbackを使うようになり、仮想サーバーごとにTLSバージョン、暗号スイートを設定できるようになる。 Client Hello Callbackの設定はSSL_CTX_set_client_hello_cb()で行う。

SSL_CTX_set_client_hello_cb(ctx, clienthello_callback, NULL);

これで、Client Hello受付時にclienthello_callback()関数が呼び出される。 このコールバック内で指定された仮想サーバーのSSL_CTXの設定をSSL*にコピーしてしまえばよい。 一点問題は、この時点ではSSL_get_servername()でSNIで指定されたサーバー名を取得できない点だ。 このため、Client Helloに含まれるTLS拡張を自分で解析して、SNI(server_name TLS拡張)で指定されたホスト名を取得している(get_servername_from_client_hello()関数)。

以上で4.3節のWebサーバーを、仮想ホストに対応することができた。

ソースコード4.6 TLS接続(Server)仮想ホスト対応 tls_https_server_vhost.c

#include <stdio.h>
#include <string.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <openssl/x509v3.h>

#define USE_SNI    1

 /* vhostごとにprotocol version/cipherを指定できるようにする。
  * 0の場合、default(vhosts[0])の設定が使われる。
  */
#define USE_CLIENT_HELLO_CALLBACK  1

struct vhost {
	char *server_name;
	char *cert;	/* サーバー証明書 + 中間証明書 */
	char *private;
	long ssl_options;
	char *cipher_list;
	SSL_CTX *ssl_ctx;
};

/* Virtual Hosts Configuration */
struct vhost vhosts[] = {
	{"www.example.com",
	 "example.com.chained.crt",
	 "example.com.key",
	 0,
	 NULL,
	 NULL,
	},
	{"www.example.net",
	 "example.net.chained.crt",
	 "example.net.key",
	 SSL_OP_NO_TLSv1_3,
	 "ECDHE-RSA-AES128-GCM-SHA256",
	 NULL,
	},
};

struct vhost *find_vhost(const char *server_name)
{
	int i;

	for (i = 0 ; i < sizeof(vhosts) / sizeof(struct vhost) ; i++) {
		/* ケース非依存なホスト名比較などが必要だが省略 */
		if (strcmp(server_name, vhosts[i].server_name) == 0) {
			return &vhosts[i];
		}
	}

	return NULL;
}

/* ref. apache ssl_find_vhost() */
void select_vhost(SSL *ssl, struct vhost *vh)
{
	/* 証明書設定をコピー */
	SSL_set_SSL_CTX(ssl, vh->ssl_ctx);

	SSL_set_options(ssl, SSL_CTX_get_options(vh->ssl_ctx));
}

char *get_servername_from_client_hello(SSL *ssl)
{
	const unsigned char *ext;
	size_t ext_len;
	size_t p = 0;
	size_t server_name_list_len;
	size_t server_name_len;

	if (!SSL_client_hello_get0_ext(ssl, TLSEXT_TYPE_server_name,
				       &ext,
				       &ext_len)) {
		return NULL;
	}

	/* length (2 bytes) + type (1) + length (2) + server name (1+) */
	if (ext_len < 6) {
		return NULL;
	}

	/* Fetch Server Name list length */
	server_name_list_len = (ext[p] << 8) + ext[p + 1];
	p += 2;
	if (p + server_name_list_len != ext_len) {
		return NULL;
	}

	/* Fetch Server Name Type */
	if (ext[p] != TLSEXT_NAMETYPE_host_name) {
		return NULL;
	}
	p++;

	/* Fetch Server Name Length */
	server_name_len = (ext[p] << 8) + ext[p + 1];
	p += 2;
	if (p + server_name_len != ext_len) {
		return NULL;
	}

	/* ext_len >= 6 && p == 5 */

	/* Finally fetch Server Name */

	return strndup((const char *) ext + p, ext_len - p);
}

/*
 * SNIで指定されたName based virtual hostのTLS protocol versionを
 * 選択できるようにする。
 * servername_callback()ではTLS protocol versionは既に決まっているので手遅れ。
 * clienthello_callback()で処理する必要がある。
 */
int clienthello_callback(SSL *ssl, int *al, void *arg)
{
	char *servername = NULL;
	struct vhost *vh;

	printf("clienthello_callback\n");

	/* SSL_get_servername()はまだ使えない */

	servername = get_servername_from_client_hello(ssl);
	if (!servername) {
		goto end;
	}

	printf("%s\n", servername);

	vh = find_vhost(servername);
	if (!vh) {
		goto end;
	}

	select_vhost(ssl, vh);

 end:
	if (servername) {
		free(servername);
	}

	return SSL_CLIENT_HELLO_SUCCESS;
}

int servername_callback(SSL *ssl, int *al, void *arg)
{
	struct vhost *vh;

	printf("servername_callback\n");

	const char *server_name = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name);
	if (!server_name) {
		/* Client doesn't support SNI. Select default vhost. */
		return SSL_TLSEXT_ERR_OK;
	}

	printf("%s\n", server_name);

	vh = find_vhost(server_name);
	if (!vh) {
		return SSL_TLSEXT_ERR_ALERT_FATAL;
	}

#if !USE_CLIENT_HELLO_CALLBACK
	select_vhost(ssl, vh);
#endif

	return SSL_TLSEXT_ERR_OK;
}


void cleanup_vhosts()
{
	int i;
	for (i = 0 ; i < sizeof(vhosts) / sizeof(struct vhost) ; i++) {
		if (vhosts[i].ssl_ctx) {
			SSL_CTX_free(vhosts[i].ssl_ctx);
			vhosts[i].ssl_ctx = NULL;
		}
	}
}

int init_virtual_hosts()
{
	int i;
	SSL_CTX *ctx;

	for (i = 0 ; i < sizeof(vhosts) / sizeof(struct vhost) ; i++) {
		ctx = SSL_CTX_new(TLS_server_method());
		if (ctx == NULL) {
			ERR_print_errors_fp(stderr);
			goto error;
		}

		vhosts[i].ssl_ctx = ctx;

		if (!SSL_CTX_use_certificate_chain_file(ctx, vhosts[i].cert)) {
			ERR_print_errors_fp(stderr);
			goto error;
		}
		if (!SSL_CTX_use_PrivateKey_file(ctx, vhosts[i].private, SSL_FILETYPE_PEM)) {
			ERR_print_errors_fp(stderr);
			goto error;
		}

		if (!SSL_CTX_check_private_key(ctx)) {
			ERR_print_errors_fp(stderr);
			goto error;
		}

#if USE_SNI
#if USE_CLIENT_HELLO_CALLBACK
		SSL_CTX_set_client_hello_cb(ctx, clienthello_callback, NULL);
#endif
		SSL_CTX_set_tlsext_servername_callback(ctx, servername_callback);
#endif

		SSL_CTX_set_mode(ctx, SSL_MODE_AUTO_RETRY);

		if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) == 0) {
			ERR_print_errors_fp(stderr);
			goto error;
		}

		if (vhosts[i].ssl_options) {
			SSL_CTX_set_options(ctx, vhosts[i].ssl_options);
		}
		if (vhosts[i].cipher_list) {
			SSL_CTX_set_cipher_list(ctx, vhosts[i].cipher_list);
		}
	}
	return 0;

 error:
	cleanup_vhosts();
	return -1;
}

int process_request(BIO *bio)
{
	char buff[1000];
	const char *response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n";
	SSL *ssl;
	const char *server_name = NULL;
	int sz;
	int on_going = 0;

	if (BIO_get_ssl(bio, &ssl) > 0) {
		server_name = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name);
	}
	if (!server_name) {
		server_name = vhosts[0].server_name;	/* default server */
	}

	while ((sz = BIO_gets(bio, buff, sizeof(buff))) > 0) {
		if (on_going == 0 &&
		    buff[0] == 0x0d && buff[1] == 0x0a && buff[2] == 0x00) {
			/* empty line */
			break;
		}
		on_going = buff[sz - 1] == 0x0a ? 0 : 1;

		fputs(buff, stdout);
	}

	/* http response */
	BIO_puts(bio, response);
	BIO_puts(bio, "This is ");
	BIO_puts(bio, server_name);
	BIO_puts(bio, "\r\n");
	BIO_flush(bio);

	return 0;
}

BIO *create_bio_from_ctx(SSL_CTX *ctx)
{
	BIO *bio_buf = NULL;
	BIO *bio_ssl = NULL;

	bio_buf = BIO_new(BIO_f_buffer());
	if (!bio_buf) {
		goto error;
	}

	bio_ssl = BIO_new_ssl(ctx, 0);
	if (!bio_ssl) {
		goto error;
	}
	BIO_push(bio_buf, bio_ssl);

	return bio_buf;

error:
	if (bio_ssl) {
		BIO_free(bio_ssl);
	}
	if (bio_buf) {
		BIO_free(bio_buf);
	}

	return NULL;
}

int main(int argc, char *argv[])
{
	BIO *bio_ssl = NULL;
	BIO *bio_accept = NULL;

	init_virtual_hosts();

	/* default host(vhosts[0])のSSL_CTXを使う */
	bio_ssl = create_bio_from_ctx(vhosts[0].ssl_ctx);
	if (!bio_ssl) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	bio_accept = BIO_new_accept("8000");
	if (!bio_accept) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	if (BIO_do_accept(bio_accept) <= 0) {	/* bind */
		ERR_print_errors_fp(stderr);
		goto error;
	}

	if (BIO_set_accept_bios(bio_accept, bio_ssl) <= 0) {
		ERR_print_errors_fp(stderr);
		goto error;
	}
	bio_ssl = NULL;	/* エラー時に解放させないように */

	while (1) {
		BIO *bio = NULL;
		SSL *ssl = NULL;

		if (BIO_do_accept(bio_accept) <= 0) {
			ERR_print_errors_fp(stderr);
			goto error;	/* end */
		}
		printf("Accepted\n");

		/* get new connection */
		bio = BIO_pop(bio_accept);

		if (BIO_do_handshake(bio) <= 0) {
			ERR_print_errors_fp(stderr);
			goto next;
		}
		printf("Handshaked\n");

		if (BIO_get_ssl(bio, &ssl) <= 0) {
			ERR_print_errors_fp(stderr);
			goto error;
		}

		printf("%s\nCipher: %s\n",
		       SSL_get_version(ssl),
		       SSL_get_cipher(ssl));

		process_request(bio);

	next:
		if (bio) {
			BIO_ssl_shutdown(bio);
			BIO_free_all(bio);
		}
	}

	cleanup_vhosts();

	BIO_free_all(bio_accept);

	/* bio_sslの解放は不要 */

	return 0;

 error:
	cleanup_vhosts();

	if (bio_accept) {
		BIO_free_all(bio_accept);
	}
	if (bio_ssl) {
		BIO_free_all(bio_ssl);
	}

	return -1;
}

以下は接続例だ。 hostsファイルを書き換えてlocalhost内の通信でテストしている。

$ curl -k https://www.example.net:8000
This is www.example.net

サーバー側の出力

$ ./tls_https_server_vhost
Accepted
clienthello_callback
www.example.net
servername_callback
www.example.net
Handshaked
TLSv1.2
Cipher: ECDHE-RSA-AES128-GCM-SHA256
GET / HTTP/1.1
Host: www.example.net:8000
User-Agent: curl/7.79.1
Accept: */*
$ curl -k https://www.example.com:8000
This is www.example.com

サーバー側の出力

$ ./tls_https_server_vhost
Accepted
clienthello_callback
www.example.com
servername_callback
www.example.com
Handshaked
TLSv1.3
Cipher: TLS_AES_256_GCM_SHA384
GET / HTTP/1.1
Host: www.example.com:8000
User-Agent: curl/7.79.1
Accept: */*

仮想ホストの設定に合わせてTLSバージョン、暗号スイートが選択されているのがわかる。 USE_CLIENT_HELLO_CALLBACKを0にすると、常にTLSv1.3、TLS_AES_256_GCM_SHA384が選択されるのも確認できるだろう。

*1 man opensslより。

*2 OPENSSL_init_crypto()はlibcryptoを初期化する関数だが、OPENSSL_init_ssl()内で一緒に呼び出される。libcryptoのみを初期化したい場合は直接呼び出すがあまり使わない。

*3 man 3ssl ERR_error_string参照

*4 書込みでいえばファイルディスクリプタならwrite()、ファイルポインタならfwrite()、ソケットならsend()/write()を使うといったように。

*5 man 3ssl BIO_f_base64

*6 ソースコード3.5の例ではstring[]に対して解放処理は行われない

*7 read時はチェーン先頭のフィルタBIOには次のBIOは存在しないので、処理したデータはread系関数に渡される。

*8 SSL BIO → Connect BIOのチェーン

*9 Buffer BIO → SSL BIO → Connect BIOのチェーン

*10 https://wiki.openssl.org/index.php/Hostname_validation

*11 https://en.wikipedia.org/wiki/Wildcard_certificate

*12 Apache,Nginxではデフォルト仮想ホストにつながる

*13 curlなら-kオプションをつける

*14 Accept BIOはTLSに限らず、通常のTCP通信でも使うことができる。

*15 これまでに何度か説明したようにSSL BIOはBIO_gets()をサポートしていない。BIO_gets()を使わないならSSL BIOだけを返してもよい。

*16 今回の例では、秘密鍵、証明書、TLSバージョン、暗号スイート。

*17 クライアントがserver_name TLS拡張を設定していない場合でもコールバックは呼ばれる。

*18 仮想ホストごとに異なる設定をしてもデフォルト仮想ホストの設定が使われるはずだ。

*19 https://httpd.apache.org/docs/current/mod/mod_ssl.html#sslprotocol

*20 Nginxはv1.20.2でも仮想ホストごとのTLSバージョン指定には対応していないようだ。