SST連載・解説記事

  1. HOMEHOME
  2. SSTコラムSSTコラム
  3. SSTtechlog
  4. 03 nginxでSNI(Server Name Indication)を使ってみよう!

SSTtechlog

対象読者
Webアプリケーションに関わるエンジニア全般

サンプルコードの動作環境
CentOS 7 (openssl-1.0.1e-42.el7.9.x86_64)
その他に OpenSSL 1.0.2e と nginx 1.9.9 を自分でビルドして使用しています。

はじめに

techlog4コマ02

illustrations by あおい海月

SSTではWebアプリケーションの脆弱性診断サービスを提供しており、診断対象のWebサーバと日々通信しています。その中には、一つのWebサーバで複数のHTTPSサイトを公開しているサーバもあります。
そのような機能は、例えば Apache HTTP Server では「Virtual Host(仮想ホスト)」と呼ばれ、今回紹介する nginx では設定ファイル中の serverブロックを使うことで実現できます。HTTPS上で複数のWebサイトの公開を実現するには何種類かの方法があり、今回は nginx というWebサーバで、TLSの拡張仕様であるSNI( Server Name Indication )を使って複数のHTTPSサイトを公開する例を紹介します。
以下の流れで説明していきます。

  1. nginxを自分でビルドし、実際にHTTPで複数のWebサイトを公開するserverブロックを設定し、telnetコマンドで動作確認します。
  2. HTTPSの設定を行い、HTTPSで複数のWebサイトを公開する際の考慮事項について説明します。
  3. 実際にSNIを使った設定を行い、OpenSSLの s_client コマンドで動作確認します。

なお本記事でのホスト名についての注意事項です。
URL http://www.example.com/ の www.example.com の部分について、URIのフォーマットを定めるRFC3986では Host と表現しています。 本記事ではそれに従い、「ホスト」又は「ホスト名」と表記しています。記事や書籍によっては、「ドメイン」「ドメイン名」と表記する場合もあります。文脈によってどれが適切か変わることがありますので、他のドキュメントを参照する際はご注意ください。

OpenSSLとnginxを自分でコンパイルする

Linuxディストリビューションの提供するnginxを用いても良いのですが、要件によっては自分でコンパイルしたnginxを使いたい、というケースもあると思います。前回はOpenSSLを自分でコンパイルしましたので、今回もnginxを自分でコンパイルしてみます。

なお、2015年12月3日にOpenSSLのSecurity Advisoryが公開され、最新バージョンも 1.0.2e に更新されました。そのため、1.0.2e をダウンロードし、コンパイルしておきます。

$ wget https://www.openssl.org/source/openssl-1.0.2e.tar.gz
$ tar zxf openssl-1.0.2e.tar.gz
$ cd openssl-1.0.2e
$ ./config shared --openssldir=/home/sst/openssl-t2
$ make
$ make install
$ /home/sst/openssl-t2/bin/openssl version -a
OpenSSL 1.0.2e 3 Dec 2015
built on: reproducible build, date unspecified
platform: linux-x86_64
options:  bn(64,64) rc4(16x,int) des(idx,cisc,16,int) idea(int) blowfish(idx)
compiler: gcc -I. -I.. -I../include  -fPIC -DOPENSSL_PIC -DOPENSSL_THREADS -D_REENTRANT -DDSO_DLFCN -DHAVE_DLFCN_H -Wa,--noexecstack -m64 -DL_ENDIAN -O3 -Wall -DOPENSSL_IA32_SSE2 -DOPENSSL_BN_ASM_MONT -DOPENSSL_BN_ASM_MONT5 -DOPENSSL_BN_ASM_GF2m -DSHA1_ASM -DSHA256_ASM -DSHA512_ASM -DMD5_ASM -DAES_ASM -DVPAES_ASM -DBSAES_ASM -DWHIRLPOOL_ASM -DGHASH_ASM -DECP_NISTZ256_ASM
OPENSSLDIR: "/home/sst/openssl-t2"

まず、ソースを入手して展開します。今回は原稿執筆時点の最新である、nginx-1.9.9.tar.gz をダウンロードしました。

$ wget http://nginx.org/download/nginx-1.9.9.tar.gz
$ tar zxf nginx-1.9.9.tar.gz
$ cd nginx-1.9.9/

PCRE(正規表現処理)とzlibの開発用ライブラリが必要なので、予めインストールしておきます。

  $ sudo yum install -y pcre-devel zlib-devel

続いてコンパイルオプションを設定します。
"--prefix"でインストール先を変更し、"--with-http_ssl_module"でSSLを有効にします。

注意:SSLを有効にしてコンパイルするには、OpenSSLのソースコードが必要です。

今回はディストリビューションのOpenSSLではなく、先ほどコンパイルしたOpenSSL 1.0.2eを使いたいと思いますので、そのソースコードディレクトリを "--with-openssl"オプションで指定します。

$ sudo yum install -y zlib-devel
$ ./configure --prefix=/home/sst/nginx-t1 --with-http_ssl_module \
--with-openssl=/home/sst/openssl-1.0.2e

...

Configuration summary
  + using system PCRE library
  + using OpenSSL library: /home/sst/openssl-1.0.2e
  + md5: using OpenSSL library
  + sha1: using OpenSSL library
  + using system zlib library

  nginx path prefix: "/home/sst/nginx-t1"
  nginx binary file: "/home/sst/nginx-t1/sbin/nginx"
  nginx configuration prefix: "/home/sst/nginx-t1/conf"
  nginx configuration file: "/home/sst/nginx-t1/conf/nginx.conf"
  nginx pid file: "/home/sst/nginx-t1/logs/nginx.pid"
  nginx error log file: "/home/sst/nginx-t1/logs/error.log"
  nginx http access log file: "/home/sst/nginx-t1/logs/access.log"
  nginx http client request body temporary files: "client_body_temp"
  nginx http proxy temporary files: "proxy_temp"
  nginx http fastcgi temporary files: "fastcgi_temp"
  nginx http uwsgi temporary files: "uwsgi_temp"
  nginx http scgi temporary files: "scgi_temp"

コンパイルオプションの調整が完了しましたので、コンパイルし、インストールします。

$ make && make install

nginxのバージョンを確認してみると、指定した通りOpenSSL 1.0.2eを使用していて、後で使うSNIも有効になっていました。

$ /home/sst/nginx-t1/sbin/nginx -V
nginx version: nginx/1.9.9
built by gcc 4.8.3 20140911 (Red Hat 4.8.3-9) (GCC)
built with OpenSSL 1.0.2e 3 Dec 2015
TLS SNI support enabled
configure arguments: --prefix=/home/sst/nginx-t1 --with-http_ssl_module --with-openssl=/home/sst/openssl-1.0.2e

HTTPでの serverブロック 設定

動作確認を兼ねて、実際にHTTPで複数のWebサイトを公開するserverブロックを設定してみます。 以下のコンテンツと設定ファイルを用意してnginxを起動します。

  1. localhost, localhost2, localhost3の各ホスト毎のコンテンツを用意:
    ※localhost用のコンテンツはnginxをインストールした時のデフォルトのhtmlディレクトリを使用する。
  2. $ cd /home/sst/nginx-t1
    $ mkdir html2 && mkdir html3
    $ echo "hello, this is http contents(2)." > html2/index.html
    $ echo "hello, this is http contents(3)." > html3/index.html
    
  3. /home/sst/nginx-t1/conf/nginx.conf : nginx.conf.defaultから余計なコメントを削除して、listenポートを8080に修正しています。
  4. worker_processes  1;
    events {
        worker_connections  1024;
    }
    http {
        include       mime.types;
        default_type  application/octet-stream;
        sendfile        on;
        server {
            listen       8080;
            server_name  localhost;
            location / {
                root   html;
                index  index.html;
            }
        }
        server {
            listen       8080;
            server_name  localhost2;
            location / {
                root   html2;
                index  index.html;
            }
        }
        server {
            listen       8080;
            server_name  localhost3;
            location / {
                root   html3;
                index  index.html;
            }
        }
    }
    
  5. 設定ファイルのテスト : 成功
  6. $ cd /home/sst/nginx-t1/
    $ ./sbin/nginx -t
    nginx: the configuration file /home/sst/nginx-t1/conf/nginx.conf syntax is ok
    nginx: configuration file /home/sst/nginx-t1/conf/nginx.conf test is successful
  7. nginxの起動:
$ cd /home/sst/nginx-t1/
$ ./sbin/nginx
$ ps ux
...
sst      26350  0.0  0.0  20816   804 ?        Ss   15:36   0:00 nginx: master process ./sbin/nginx
sst      26351  0.0  0.0  21232  1000 ?        S    15:36   0:00 nginx: worker process

これによりlocalhost、localhost2、localhost3の3つのサイトそれぞれが、TCPのポート8080で公開されます。
一方、WebブラウザなどのHTTPクライアントは、アクセスしたいサイトをどのようにしてWebサーバに伝えているのでしょうか?
答えはHTTP/1.1のHostリクエストヘッダにあります。HTTPクライアントはユーザがリクエストしたURLからアクセス先のホスト名を取り出し、Hostリクエストヘッダで送信します。Webサーバは受信したHostリクエストヘッダに応じて、どのホストのコンテンツを返すのか切り替えます。
実際にHostリクエストヘッダをlocalhost、localhost2、localhost3とそれぞれ変更したHTTP/1.1のリクエストをtelnetコマンドで送信し、どのサイトのコンテンツが返されるか確認してみましょう。

  • localhostへのアクセス→正常なTOPページコンテンツが返ってきたことを確認。
  • $ telnet localhost 8080
    (...)
    ↓↓ ここから入力する
    GET / HTTP/1.1
    Host: localhost
    Connection: close
    
    ↑↑ ここまで入力する。空行も含める。
    ↓↓ サーバからのレスポンス
    HTTP/1.1 200 OK
    Server: nginx/1.9.9
    (...)
    
    <!DOCTYPE html>
    <html>
    <head>
    <title>Welcome to nginx!</title>
    (...)
    
  • localhost2へのアクセス→localhost2用のコンテンツが返ってきたことを確認。
  • $ telnet localhost 8080
    (...)
    ↓↓ ここから入力する
    GET / HTTP/1.1
    Host: localhost2
    Connection: close
    
    ↑↑ ここまで入力する。空行も含める。
    ↓↓ サーバからのレスポンス
    HTTP/1.1 200 OK
    Server: nginx/1.9.9
    (...)
    
    hello, this is http contents(2). 
    Connection closed by foreign host.
  • localhost3へのアクセス→localhost3用のコンテンツが返ってきたことを確認。
  • 
    $ telnet localhost 8080
    (...)
    ↓↓ ここから入力する
    GET / HTTP/1.1
    Host: localhost3
    Connection: close
    
    ↑↑ ここまで入力する。空行も含める。
    ↓↓ サーバからのレスポンス
    HTTP/1.1 200 OK
    Server: nginx/1.9.9
    (...)
    
    hello, this is http contents(3).
    Connection closed by foreign host.
    

なお今回の設定ではHostリクエストヘッダのホスト名に応じて応答するコンテンツを切り替えることで、単一のWebサーバ上で複数のサイトを公開しています。
これは Apache HTTP Server では「Name-based Virtual Host(名前ベースの仮想ホスト)」と呼ばれています。
Webサーバのソフトウェアによっては、サーバがlistenしているIPアドレスごとに切り替える方式など、複数の方式をサポートしている場合もあります。 要件に合わせてどのような方式を選択すれば良いか、Webサーバのソフトウェアのドキュメントと照らしあわせて検討してみてください。

default_server の指定について


上記の設定例ではHTTP/1.1のHostリクエストヘッダで指定されたホスト名によってサイトを切り替えるよう設定していました。
HTTP/1.1は2015年現在の主要なWebブラウザやHTTPクライアントライブラリが対応しています。 しかしHTTP/1.1に対応していない古いHTTPクライアントでは、Hostヘッダが無いリクエストを送信する場合もあります。
そのような場合、どの server ブロックで応答すれば良いでしょう?
そのような場合、listen 設定に default_server というキーワードを指定することで、デフォルトで応答する server ブロックを明示的に指定することができます。例えば以下の設定では2番目の server ブロックの listen 設定に default_server を指定しています。

...
    server {
        listen       8080;
        server_name  localhost;
...
    }
    server {
        listen       8080 default_server;
        server_name  localhost2;
...
    }
    server {
        listen       8080;
        server_name  localhost3;
...
    }

実際に上記設定をnginxに読み込ませ、 Host リクエストヘッダが無い HTTP/1.0 のリクエストを送信してみます。

$ telnet localhost 8080
(...)
↓↓ ここから入力する
GET / HTTP/1.0

↑↑ ここまで入力する。空行も含める。
↓↓ サーバからのレスポンス
HTTP/1.1 200 OK
(...)

hello, this is http contents(2).
Connection closed by foreign host.

設定した通り、2番目の server ブロックで指定したコンテンツが、デフォルトとして返されました。
なお、default_server の指定が無い場合、最初の server ブロックがデフォルトのサーバ設定として使用されます。実際に default_server の指定が無い状態でHTTP/1.0のリクエストをtelnetで送信すると、以下のように最初の server ブロックで指定したコンテンツが返されました。

$ telnet localhost 8080
(...)
↓↓ ここから入力する
GET / HTTP/1.0

↑↑ ここまで入力する。空行も含める。
↓↓ サーバからのレスポンス
HTTP/1.1 200 OK
Server: nginx/1.9.9
(...)

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title&>
(...)

HTTPSサーバの設定

HTTPでの動作確認ができましたので、続けてHTTPSサーバの設定を行います。まずは、一つのWebサーバで一つのWebサイトを公開するシンプルな形で、HTTPS設定の基本を押さえます。 今回は実験用のWebサイトですので、正式なサーバ証明書ではなく、自分で署名した自己署名証明書を使います。ポート番号はTCPの8443番をlistenすることにします。

まず自己署名証明書と鍵ファイルを生成します。 以下のopensslコマンドにより、CSRファイルの生成や認証局設定をスキップして、その場で証明書と鍵ファイルを生成できます。(黒太字で強調した部分が、自分で入力する部分です)

$ openssl req -new -x509 -sha256 -newkey rsa:2048 -days 3650 -nodes \
-out    /home/sst/nginx-t1/conf/localhost.pem \
-keyout /home/sst/nginx-t1/conf/localhost.key

Generating a 2048 bit RSA private key
..................................................................................................+++
..............................................................................+++
writing new private key to '/home/sst/nginx-t1/conf/localhost.key'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [XX]:JP
State or Province Name (full name) []:Tokyo
Locality Name (eg, city) [Default City]:Chiyoda
Organization Name (eg, company) [Default Company Ltd]:SST
Organizational Unit Name (eg, section) []:rd
Common Name (eg, your name or your server's hostname) []:localhost
Email Address []:abc@localhost.localdomain

続いてnginx.confにSSL/TLSの設定を組み込みますが、IPAより発行されているガイドラインを参考にしたいと思います。

SSL/TLS暗号設定ガイドライン ~安全なウェブサイトのために(暗号設定対策編)~
:IPA 独立行政法人 情報処理推進機構
  http://www.ipa.go.jp/security/vuln/ssl_crypt_config.html

このガイドラインでは、プロトコルや暗号方式について実際にどのように設定すれば良いのか、システムに求められるセキュリティ要件に応じて大きく3パターンに分けて具体的な設定例を解説しています。
SSL/TLSは複雑な仕組みであるため、設定項目全てを完全に理解するのは現場では難しいと思われます。
こちらのガイドラインを参照すれば、専門家が分かりやすい解説とともに、具体的な設定項目と値をまとめてくれていますので、ぜひ参考にしてみてください。

今回は実験なので、古いWebブラウザを考慮する必要はありません。そのため、ガイドラインで「高セキュリティ型」として解説されている設定を組み込んでみます。


  • SSL/TLS設定用の設定ブロックの全容
  • # B.1.  サーバ設定方法例のまとめ
    # → B.1.3.  nginxの場合
    # 設定の大枠
    http {
        server {
            listen 8443 ssl;
            server_name localhost;
            ssl_certificate /home/sst/nginx-t1/conf/localhost.pem;
            ssl_certificate_key /home/sst/nginx-t1/conf/localhost.key;
            ssl_protocols (...);
            ssl_ciphers "(...)";
            ssl_prefer_server_ciphers on;
            (...)
        }
    }
    
  • プロトコルのバージョン
  • # B.2.  プロトコルバージョンの設定方法例
    # → B.2.3.  nginxの場合
    # TLS v1.2 のみを有効化します。
    ssl_protocols TLSv1.2;
  • 暗号スイートの設定
  • # Appendix C:暗号スイートの設定例
    # → C.2.1. Apache, lighttpd, nginxの場合
    # 高セキュリティ型の、絞られた暗号スイートのみを指定
    ssl_ciphers "DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256";
  • 鍵パラメータの設定
    • B.3. 鍵パラメータファイルの設定方法例 → B.3.1. OpenSSLによるDHE、ECDH、ECDHE鍵パラメータファイルの生成
    • $ openssl dhparam -out /home/sst/nginx-t1/conf/dh2048.pem -outform PEM 2048
    • B.3.4. nginxにおけるDHE、ECDH、ECDHE鍵パラメータ設定
    • ssl_dhparam /home/sst/nginx-t1/conf/dh2048.pem;
      ssl_ecdh_curve prime256v1;

また、HTTPSでは別のコンテンツを表示してみたいので、 /home/sst/nginx-t1/html_ssl というディレクトリに以下のようなHTTPS用のTOPページを作成します。

  • /home/sst/nginx-t1/html_ssl/index.html
hello, this is https contents.

最終的な nginx.conf は以下のようになります。

worker_processes  1;
events {
    worker_connections  1024;
}
http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    server {
        listen       8080;
        server_name  localhost;
        location / {
            root   html;
            index  index.html index.htm;
        }
    }
    server {
        listen 8443 ssl;
        server_name localhost;
        ssl_certificate /home/sst/nginx-t1/conf/localhost.pem;
        ssl_certificate_key /home/sst/nginx-t1/conf/localhost.key;
        ssl_protocols TLSv1.2;
        ssl_ciphers "DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256";
        ssl_dhparam /home/sst/nginx-t1/conf/dh2048.pem;
        ssl_ecdh_curve prime256v1;
        ssl_prefer_server_ciphers on;
        location / {
            root   html_ssl;
            index  index.html;
        }
    }
}

nginxで設定ファイルを再読み込みさせます。

$ cd /home/sst/nginx-t1/

$ ./sbin/nginx -t
nginx: the configuration file /home/sst/nginx-t1/conf/nginx.conf syntax is ok
nginx: configuration file /home/sst/nginx-t1/conf/nginx.conf test is successful

$ ./sbin/nginx -s reload

では、OpenSSLの s_client コマンドでアクセスしてみます。

s_clientでは証明書の検証に失敗してもそのままSSL/TLS接続を確立します。

$ openssl s_client -connect localhost:8443
CONNECTED(00000003)
depth=0 C = JP, ST = Tokyo, L = Chiyoda, O = SST, OU = rd, CN = localhost, emailAddress = abc@localhost.localdomain
verify error:num=18:self signed certificate
verify return:1
depth=0 C = JP, ST = Tokyo, L = Chiyoda, O = SST, OU = rd, CN = localhost, emailAddress = abc@localhost.localdomain
verify return:1
---
Certificate chain
 0 s:/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
   i:/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
---
Server certificate
-----BEGIN CERTIFICATE-----
MIID5TCCAs2gAwIBAgIJAJAvUoEi6M9OMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYD
(...)
HgTTC7NlGA/TJgKcqKALmjCQdsxhK6fIT80eza8m7U+/+ALICHg9MZE=
-----END CERTIFICATE-----
subject=/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
issuer=/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
---
No client certificate CA names sent
Server Temp Key: DH, 2048 bits
---
SSL handshake has read 2118 bytes and written 567 bytes
---
New, TLSv1/SSLv3, Cipher is DHE-RSA-AES256-GCM-SHA384
Server public key is 2048 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
SSL-Session:
    Protocol  : TLSv1.2
    Cipher    : DHE-RSA-AES256-GCM-SHA384
    Session-ID: 1091DB2968578A300495053D42A095004C640DD1905C35D30E62CAD174B80501
    Session-ID-ctx:
    Master-Key: DF4D265518C2C4B5E2968BBBA8A4DF6472709C1866A65DE9B329E4A4F06D5384B3EE90A3730C24801FACB44DBF9B3AAD
    Key-Arg   : None
    Krb5 Principal: None
    PSK identity: None
    PSK identity hint: None
    TLS session ticket lifetime hint: 300 (seconds)
    TLS session ticket:
    0000 - ad f6 97 71 ce 76 b3 10-98 2c 1b 6b bf 8f 8f c6   ...q.v...,.k....
(...)
    00a0 - 00 f1 c1 48 1f bb 48 b1-73 6c f7 44 c4 79 fd d1   ...H..H.sl.D.y..

    Start Time: 1444982668
    Timeout   : 300 (sec)
    Verify return code: 18 (self signed certificate)
---
↓↓ ここで入力待ちになるので、telnetと同様に以下のGETリクエストを入力
    GET / HTTP/1.1
    Host: localhost
    Connection: close
    
    ↑↑ ここまで入力する。空行も含める。
↓↓ サーバからのレスポンス
HTTP/1.1 200 OK
Server: nginx/1.9.9
Date: Fri, 16 Oct 2015 08:04:40 GMT
Content-Type: text/html
Content-Length: 31
Last-Modified: Fri, 16 Oct 2015 08:03:19 GMT
Connection: close
ETag: "5620af47-1f"
Accept-Ranges: bytes

hello, this is https contents.
closed

サーバ証明書も自己署名証明書が返され、HTMLコンテンツもHTTPS用のものが返されることを確認できました。


s_clientで利用するプロトコルバージョンを指定する


今回はnginxをTLS v1.2のみを使用するよう設定しています。 s_clientでは接続に使うプロトコルバージョンを指定するオプションがありますので、わざと古いプロトコルバージョンを指定し、接続に失敗するか確認してみます。 ("man 1 s_client"参照)

TLS v1.1 を使うオプション("-tls1_1")で接続:

$ openssl s_client -msg -tls1_1 -connect localhost:8443
CONNECTED(00000003)
>>> TLS 1.1 Handshake [length 0096], ClientHello
    01 00 00 92 03 02 55 c2 06 cf 2e 39 99 b9 7d 36
(...)
    00 00 0f 00 01 01
write:errno=104
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 0 bytes and written 0 bytes
---
New, (NONE), Cipher is (NONE)
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
SSL-Session:
    Protocol  : TLSv1.1
    Cipher    : 0000
    Session-ID:
    Session-ID-ctx:
    Master-Key:
    Key-Arg   : None
    Krb5 Principal: None
    PSK identity: None
    PSK identity hint: None
    Start Time: 1438779087
    Timeout   : 7200 (sec)
    Verify return code: 0 (ok)
---

意図したとおり、接続が失敗することを確認できました。

一つのWebサーバで複数のHTTPSサイトを公開する時の検討事項

SNIを使った設定について説明する前に、一つのWebサーバで複数のHTTPSサイトを公開する時の検討事項について簡単に紹介します。

HTTPSでアクセスする時の、SSL/TLSまで含めたクライアントとサーバのやり取りは、ポイントだけ絞ると以下のような流れになります。

  1. クライアントはURL https://www.example.com/ のホスト名 www.example.com のIPアドレスをDNSサーバに問い合わせて解決する。(今回はたまたま 127.0.0.1 だったとします)
  2. クライアントは 127.0.0.1 のTCPポート番号443番に接続し、SSL/TLSのハンドシェイクを開始する。
  3. サーバはクライアントからの接続を受け付けたら、サーバ証明書をクライアントに提示する。
  4. クライアントはサーバ証明書を検証し、ハンドシェイクを続ける。
  5. 鍵交換など一通りのハンドシェイクがクライアント/サーバ間で完了し、暗号化通信の準備が整ったら、クライアントからHTTPリクエストをサーバに送信する。

複数のHTTPSサイトを公開するときに、この手順だと問題が発生するのに気づけましたか?

複数のHTTPSサイトを公開するには、それぞれのホスト名毎のサーバ証明書がサーバに登録されている必要があります。しかし、クライアントとサーバが最初にやり取りするSSL/TLSハンドシェイクでは、どのホスト名に関するリクエストなのか、という情報がやり取りされません。そのため、サーバはどのサーバ証明書をクライアントに提示すれば良いのか判断できないのです。

そこで、SSL/TLSハンドシェイクの段階でどのホスト名に接続したいのか判別する必要が生じます。このために、SSL/TLSの仕様とは関係なく対応できる方式や、SSL/TLSの仕様側で対応する方式など何種類かの方法が利用されています。


SSL/TLSの仕様と関係なく対応できる方式(例):

  • 一つのWebサーバで複数のIPアドレス or ポート番号をlistenし、IPアドレス or ポート番号毎にサーバ証明書を切り替える。
  • ワイルドカード証明書を使う。
    例:*.example.com に対して発行してもらったワイルドカード証明書で、host1.example.com, host2.example.com, host3.example.com, ... をまとめて検証できる。
    • 一つのWebサービスの中で、それに属する複数のホスト名をまとめたいときに、これが使える場合もあります。

SSL/TLSの仕様側で対応する方式(例):

  • TLSプロトコルの拡張仕様である SNI(Server Name Indication) を使う。

本記事ではSNIを使った方式で実際に設定を行い、動作確認してみます。他の方式のサポート状況や設定方法については、利用するWebサーバのソフトウェアのドキュメントを参照してください。

SNI(Server Name Indication)を使ってみる

Server Name Indication (SNI) はTLSプロトコルの拡張仕様の一つで、クライアントがハンドシェイクを開始する際に、どのサーバに接続したいのかホスト名を送信することができます。
SNIに対応したサーバは、クライアントが送信してきたホスト名に応じた証明書を返すことができます。
これにより " 一つのWebサーバで複数のHTTPSサイトを公開する時の検討事項 " で紹介した問題が解決され、複数のWebサイトを一つのWebサーバで公開することが可能となります。

nginxがSNIに対応しているかどうかは、"-V"オプションで確認できます。今回自分でビルドした nginx 1.9.9 + OpenSSL 1.0.2e の組み合わせでは、デフォルトで有効化されていました。

nginx version: nginx/1.9.9
built by gcc 4.8.3 20140911 (Red Hat 4.8.3-9) (GCC)
built with OpenSSL 1.0.2e 3 Dec 2015
TLS SNI support enabled
configure arguments: --prefix=/home/sst/nginx-t1 --with-http_ssl_module --with-openssl=/home/sst/openssl-1.0.2e

それでは、実際に複数の証明書を設定し、s_clientでSNIの有無を切り替えて接続してみましょう。

複数の証明書の設定

  1. CN=localhost2 の証明書準備:
  2.   $ openssl req -new -x509 -sha256 -newkey rsa:2048 -days 3650 -nodes \
      -out    /home/sst/nginx-t1/conf/localhost2.pem
      -keyout /home/sst/nginx-t1/conf/localhost2.key
      
      ...
      Common Name (eg, your name or your server's hostname) []:localhost2
      ...
      
  3. CN=localhost3 の証明書準備:
  4.   $ openssl req -new -x509 -sha256 -newkey rsa:2048 -days 3650 -nodes \
      -out    /home/sst/nginx-t1/conf/localhost3.pem \
      -keyout /home/sst/nginx-t1/conf/localhost3.key
      
      ...
      Common Name (eg, your name or your server's hostname) []:localhost3
      ...
      
  5. 見分けが付くよう、それぞれのindex.htmlを用意:
  6.   $ cd /home/sst/nginx-t1
      $ ls -F
      ...  html_ssl/  html_ssl2/  html_ssl3/ ...
      
      $ cat html_ssl/index.html
      hello, this is https contents.
      
      $ cat html_ssl2/index.html
      hello, this is https contents(2).
      
      $ cat html_ssl3/index.html
      hello, this is https contents(3).
      
  7. /home/sst/nginx-t1/conf/nginx.conf への組み込み:
  8.   worker_processes  1;
      events {
          worker_connections  1024;
      }
      http {
          include       mime.types;
          default_type  application/octet-stream;
          sendfile        on;
          server {
              listen       8080;
              server_name  localhost;
              location / {
                  root   html;
                  index  index.html index.htm;
              }
          }
      
          ssl_protocols TLSv1.2;
          ssl_ciphers "DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256";
          ssl_dhparam /home/sst/nginx-t1/conf/dh2048.pem;
          ssl_ecdh_curve prime256v1;
          ssl_prefer_server_ciphers on;
      
          server {
              listen 8443 ssl;
              server_name localhost;
              ssl_certificate /home/sst/nginx-t1/conf/localhost.pem;
              ssl_certificate_key /home/sst/nginx-t1/conf/localhost.key;
              location / {
                  root   html_ssl;
                  index  index.html;
              }
          }
      
          server {
              listen 8443 ssl;
              server_name localhost2;
              ssl_certificate /home/sst/nginx-t1/conf/localhost2.pem;
              ssl_certificate_key /home/sst/nginx-t1/conf/localhost2.key;
              location / {
                  root   html_ssl2;
                  index  index.html;
              }
          }
          server {
              listen 8443 ssl;
              server_name localhost3;
              ssl_certificate /home/sst/nginx-t1/conf/localhost3.pem;
              ssl_certificate_key /home/sst/nginx-t1/conf/localhost3.key;
              location / {
                  root   html_ssl3;
                  index  index.html;
              }
          }
      }
      
  9. 設定ファイルのチェックと、再読み込み:
  10.   $ cd /home/sst/nginx-t1
      
      $ ./sbin/nginx -t
      nginx: the configuration file /home/sst/nginx-t1/conf/nginx.conf syntax is ok
      nginx: configuration file /home/sst/nginx-t1/conf/nginx.conf test is successful
      
      $ ./sbin/nginx -s reload
      

SNI無しでs_client接続


"s_client"サブプログラムは、デフォルトではSNIを使いません。
そこで、デフォルトのSNI無しで接続し、リクエストのHostヘッダでは "localhost3" を指定してみます。
クライアントがSNI未対応と判断したnginxが、どのサーバ証明書を返すのか注意して見てみましょう。

$ openssl s_client -connect localhost:8443
CONNECTED(00000003)
depth=0 C = JP, ST = Tokyo, L = Chiyoda, O = SST, OU = rd, CN = localhost, emailAddress = abc@localhost.localdomain
verify error:num=18:self signed certificate
verify return:1
depth=0 C = JP, ST = Tokyo, L = Chiyoda, O = SST, OU = rd, CN = localhost, emailAddress = abc@localhost.localdomain
verify return:1
---
Certificate chain
 0 s:/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
   i:/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
---
Server certificate
-----BEGIN CERTIFICATE-----
(...)
-----END CERTIFICATE-----
subject=/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
issuer=/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
---
(...)
---
↓↓ ここから入力する
    GET / HTTP/1.1 Host: localhost3 Connection: close
    
    ↑↑ ここまで入力する。空行も含める。
↓↓ サーバからのレスポンス
HTTP/1.1 200 OK
Server: nginx/1.9.9
Date: Fri, 16 Oct 2015 08:19:16 GMT
Content-Type: text/html
Content-Length: 34
Last-Modified: Fri, 16 Oct 2015 08:13:53 GMT
Connection: close
ETag: "5620b1c1-22"
Accept-Ranges: bytes

hello, this is https contents(3).
closed

実験してみたところ、サーバ証明書はCN=localhostとなっており、localhostの証明書がデフォルトとして返されることを確認しました。
しかしコンテンツの方は "localhost3" 用のものが返されましたので、HTTP処理のレベルではSNIの結果に依存せず、HTTPリクエストヘッダに従った処理をしていることが推測できます。

なおlocalhostの証明書が返されたのは default_server の指定が無かったため、最初の server ブロックが応答したのが理由です。
設定例や動作確認結果は省略しますが、localhost2の server ブロックの listen に default_server を指定すれば、localhost2の証明書が返されます。

SNI有りでs_client接続


ではSNI有りを実験してみます。"-servername" コマンドラインオプションでSNIで送るホスト名を指定できます。

  • "-servername localhost" を指定:先ほどと同様、CN=localhostの証明書が返されています。
  • $ openssl s_client -servername localhost -connect localhost:8443
    CONNECTED(00000003)
    depth=0 C = JP, ST = Tokyo, L = Chiyoda, O = SST, OU = rd, CN = localhost, emailAddress = abc@localhost.localdomain
    verify error:num=18:self signed certificate
    verify return:1
    depth=0 C = JP, ST = Tokyo, L = Chiyoda, O = SST, OU = rd, CN = localhost, emailAddress = abc@localhost.localdomain
    verify return:1
    ---
    Certificate chain
     0 s:/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
       i:/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
    ---
    Server certificate
    -----BEGIN CERTIFICATE-----
    (...)
    -----END CERTIFICATE-----
    subject=/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
    issuer=/C=JP/ST=Tokyo/L=Chiyoda/O=SST/OU=rd/CN=localhost/emailAddress=abc@localhost.localdomain
    ---
    (...)
    
  • "-servername localhost2" を指定:今度は CN=localhost2 の証明書が返されています。
    すなわち、SNIによりサーバ側でサーバ証明書を切り替えています。
  • $ openssl s_client -servername localhost2 -connect localhost:8443
    CONNECTED(00000003)
    depth=0 C = JP, ST = Tokyo, L = Chiyoda-ku, O = SST, OU = rd, CN = localhost2, emailAddress = abc@localhost.localdomain
    verify error:num=18:self signed certificate
    verify return:1
    depth=0 C = JP, ST = Tokyo, L = Chiyoda-ku, O = SST, OU = rd, CN = localhost2, emailAddress = abc@localhost.localdomain
    verify return:1
    ---
    Certificate chain
     0 s:/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost2/emailAddress=abc@localhost.localdomain
       i:/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost2/emailAddress=abc@localhost.localdomain
    ---
    Server certificate
    -----BEGIN CERTIFICATE-----
    (...)
    -----END CERTIFICATE-----
    subject=/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost2/emailAddress=abc@localhost.localdomain
    issuer=/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost2/emailAddress=abc@localhost.localdomain
    ---
    (...)
    
  • "-servername localhost3" を指定:今度は CN=localhost3 の証明書が返されています。
    すなわち、SNIによりサーバ側でサーバ証明書を切り替えています。
  • $ openssl s_client -servername localhost3 -connect localhost:8443
    CONNECTED(00000003)
    depth=0 C = JP, ST = Tokyo, L = Chiyoda-ku, O = SST, OU = rd, CN = localhost3, emailAddress = abc@localhost.localdomain
    verify error:num=18:self signed certificate
    verify return:1
    depth=0 C = JP, ST = Tokyo, L = Chiyoda-ku, O = SST, OU = rd, CN = localhost3, emailAddress = abc@localhost.localdomain
    verify return:1
    ---
    Certificate chain
     0 s:/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost3/emailAddress=abc@localhost.localdomain
       i:/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost3/emailAddress=abc@localhost.localdomain
    ---
    Server certificate
    -----BEGIN CERTIFICATE-----
    (...)
    -----END CERTIFICATE-----
    subject=/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost3/emailAddress=abc@localhost.localdomain
    issuer=/C=JP/ST=Tokyo/L=Chiyoda-ku/O=SST/OU=rd/CN=localhost3/emailAddress=abc@localhost.localdomain
    ---
    (...)
    

以上より、nginxでのSNIによるサーバ証明書の切り替えを確認できました。

まとめ

以下のテクニックを今回は紹介しました。

  • nginxを自分でビルド
  • nginxのserverブロックを使い、複数のHTTPサイトを公開
  • nginxのセキュアなSSL/TLS設定
  • 一つのWebサーバで複数のHTTPSサイトを公開する時の検討事項
  • nginxでSNIを使った複数のHTTPSサイトの公開

Webサーバの設定や、HTTPS接続がうまくいかない時の原因調査でお役に立てれば幸いです。

参考資料

おまけ

nginxのビルドで驚いたのが、 nginxは "--with-openssl" で指定したソースコードを独自にビルドしている点です。気づいたきっかけはmakeの出力です。makeの出力に、以下のように OpenSSL 1.0.2eのソースコードディレクトリに移動し、ビルドを実行するコマンドが含まれていたのです。

make -f objs/Makefile
make[1]: Entering directory `/home/sst/nginx-1.9.9'
cd /home/sst/openssl-1.0.2e \
&& if [ -f Makefile ]; then make clean; fi \
&& ./config --prefix=/home/sst/openssl-1.0.2e/.openssl no-shared  \
&& make \
&& make install LIBDIR=lib
...

"objs/Makefile"を確認してみると、確かに以下のようにOpenSSLのビルドが組み込まれていました。"./config" のオプションとして "no-shared" が指定されています。

...

/home/sst/openssl-1.0.2e/.openssl/include/openssl/ssl.h:        objs/Makefile
        cd /home/sst/openssl-1.0.2e \
        && if [ -f Makefile ]; then $(MAKE) clean; fi \
        && ./config --prefix=/home/sst/openssl-1.0.2e/.openssl no-shared  \
        && $(MAKE) \
        && $(MAKE) install LIBDIR=lib

...

nginxのビルドが完了すると、OpenSSLのソースディレクトリである /home/sst/openssl-1.0.2e/ の下に ".openssl/" というディレクトリが作成され、そこにnginxがビルドしたOpenSSLがインストールされました。

$ cd /home/sst/openssl-1.0.2e/.openssl/
$ ls -F
bin/  include/  lib/  ssl/

lddで確認してみると、前回自分でビルドした時はリンクされていたlibssl, libcryptoライブラリが動的リンクされていません。libディレクトリを確認すると、動的リンクで使う ".so" ファイルが存在せず、静的リンク用の ".a" ファイルしかインストールされていませんでした。

$ ldd /home/sst/openssl-1.0.2e/.openssl/bin/openssl
        linux-vdso.so.1 =>  (0x00007ffe0bdef000)
        libdl.so.2 => /lib64/libdl.so.2 (0x00007f1712c70000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f17128af000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f1712e87000)
        
$ ls -la /home/sst/openssl-1.0.2e/.openssl/lib/
total 5796
drwxrwxr-x. 4 sst sst      69 Dec 10 12:59 .
drwxrwxr-x. 6 sst sst      50 Dec 10 12:59 ..
drwxrwxr-x. 2 sst sst       6 Dec 10 12:59 engines
-rw-r--r--. 1 sst sst 5099164 Dec 10 12:59 libcrypto.a
-rw-r--r--. 1 sst sst  833160 Dec 10 12:59 libssl.a
drwxrwxr-x. 2 sst sst      58 Dec 10 12:59 pkgconfig
        

これらの".a"ファイルはnginxのビルドでどう使われているのでしょうか? "objs/Makefile" を確認すると、nginxの実行ファイルをリンクする時に以下のように静的リンクするよう指定されていました。

objs/nginx:     objs/src/core/nginx.o \
        objs/src/core/ngx_log.o \
...
        objs/ngx_modules.o

        $(LINK) -o objs/nginx \
        objs/src/core/nginx.o \
...
        objs/ngx_modules.o \
        -lpthread -lcrypt -lpcre /home/sst/openssl-1.0.2e/.openssl/lib/libssl.a /home/sst/openssl-1.0.2e/.openssl/lib/libcrypto.a -ldl -lz

OpenSSLのリンクで興味深い動きをしているものの、nginxのインストールは成功しています。nginxの実行ファイルの共有ライブラリを調べてみると、OpenSSL関連のライブラリは動的リンクされていないことが分かります。

$ ldd /home/sst/nginx-t1/sbin/nginx
        linux-vdso.so.1 =>  (0x00007ffc7ca91000)
        libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f8af03d8000)
        libcrypt.so.1 => /lib64/libcrypt.so.1 (0x00007f8af01a1000)
        libpcre.so.1 => /lib64/libpcre.so.1 (0x00007f8aeff3f000)
        libdl.so.2 => /lib64/libdl.so.2 (0x00007f8aefd3b000)
        libz.so.1 => /lib64/libz.so.1 (0x00007f8aefb25000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f8aef763000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f8af05fb000)
        libfreebl3.so => /lib64/libfreebl3.so (0x00007f8aef560000)

静的にリンクされているため、もしOpenSSLのバージョンを上げた場合、nginx側もビルドし直す必要がある点に注意してください。

(2015年12月11日更新)

今までのコラム

Webセキュリティをざっくり理解するための3つのMAP

Page Top