ヤフー株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。LINEヤフー Tech Blog

テクノロジー

細かすぎて伝わらないSSL/TLS

「細かいと言うより長いよね」

はじめに

こんにちは。ATS の脆弱性を発見した小柴さんや ATS に HTTP/2 の実装を行っている大久保さんと同じチームの一年目、匿名社員M さんからいじられている新人です。今回ありがたい事に、こういったすごい方々を含めモヒカン諸先輩方より「何か書かないの?」「いつ書くの?」という数々のプレッシャーお言葉をいただきました。

というわけで、SSL/TLS の Session 再開機能に関して書いていこうかと思います。

SSL/TLS は機密性、完全性そして真正性に対して安全な通信を行うための仕組みです。しかし、この仕組みは暗号技術を多用し特に接続において複雑なプロトコルを用い、Client, Server に対して負荷を与えその処理で遅延をもたらします。SSL/TLS は Session の再開が定義されており、この接続時の処理を軽減する事が出来ます。

TLS の Session 再開機能(TLS Session resumption)

TLS は切断した session を再開する TLS Session resumption と呼ばれる機能があります。この機能は一度確立された TLS Session の情報を、その Session によって定義された ID に対してキャッシュし、接続時にキャッシュされた情報を利用してセッションを再接続ではなく前回の接続を回復する機能です。TLS Session resumption を利用したときのプロトコルの動作は TLS 1.2 の RFC である RFC5246 Section 7.3 に記述されています。一般的に Web のサービスで利用される TLS は、接続においてクライアントから見てサービスを提供する接続先の真正性を確立するためサーバーから証明書を送りクライアントで認証する一方向の認証が行われます。このため TLS のネゴシエーションはサーバーが CertificateRequest を送ったり、Client から Certificate が送られる事はありません。

full handshake

Server Key Exchange は Client Hello/Server Hello によって選択される Cipher Suit によって特に Perfect Forward Secrecy(PFS)に対応している場合に送られる場合があります。TLS Session の確立は下図によって再開されます。

abbreviated handshake

Session の再開は以前の接続情報を引き継ぐので、再接続に比較し Session 確立の Handshake を一部省略する事ができます。Session 再開時はクライアントは証明書の検証を行わず、また Server/Client における鍵共有も行われません。このため、Session 再開は新規のSession 接続に比較しクライアント/サーバーともに負荷が下がり、パケット交換も少ないのでパケット到達遅延の影響も小さくなります。

Session id の生成と Session 情報の保存

Session 確立時に交換される Client Hello と Server Hello は Session id と呼ばれるデータを含みます。Session id は 32バイト以下のバイト列であり Session の識別子(ID)となります。Client が Session を確立する時、新規の場合再開する対象の Session は存在しないためサーバーに送られる Client Hello の Session id は空(Session id Length = 0)で送られます。逆に Client は、接続に対し再開する Session id を持ち、そして Session の再開を望む場合、その Session id を Client Hello に入れて Server に送ります。空の Session id を受け取った場合、Client Hello に含まれる Session id に対する Session 情報を持たない、もしくは対応する Session 情報を持っていても Server が Session の再開を望まない場合 Server は新規の Session id を生成し Server Hello に載せて返します。Server と Client は交換した Session id を Key に確立した Session の情報を保存します。

そこで実際に Session resumption を OpenSSL を用いて試してみます。ごぞんじのように OpenSSL は多くの HTTP Server の実装において SSL/TLS の機能や暗号アルゴリズムの実装に利用されています。

試験用サーバー実装

まず、簡単に TLS の Server を記述します。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>

#include <openssl/evp.h>
#include <openssl/ssl.h>

#define SERVER_PORT "8000"
#define SERVER_CERT_FILE "server.crt"
#define SERVER_PRIVATE_KEY_FILE "server.key"

int main()
{
  SSL_library_init();
  SSL_CTX *ctx = SSL_CTX_new(SSLv23_server_method());

  SSL_CTX_use_certificate_file(ctx, SERVER_CERT_FILE, SSL_FILETYPE_PEM);
  SSL_CTX_use_PrivateKey_file(ctx, SERVER_PRIVATE_KEY_FILE, SSL_FILETYPE_PEM);

  struct addrinfo hints;
  struct addrinfo *ai = NULL;

  memset(&hints, sizeof(hints), 0);
  hints.ai_family = AF_INET6;
  hints.ai_socktype = SOCK_STREAM;
  hints.ai_flags = AI_PASSIVE;
  getaddrinfo(NULL, SERVER_PORT, &hints, &ai);

  int server_fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
  bind(server_fd, ai->ai_addr, ai->ai_addrlen);
  freeaddrinfo(ai);
  listen(server_fd, 1);

  while (1) {
    int session_fd = accept(server_fd, NULL, NULL);

    SSL *ssl = SSL_new(ctx);
    SSL_set_fd(ssl, session_fd);
    SSL_accept(ssl);

    SSL_shutdown(ssl);
    SSL_free(ssl);

    close(session_fd);
  }

  close(server_fd);
  SSL_CTX_free(ctx);

  return 0;
}

このコードは OpenSSL を利用して TLS の Server を提供します。この Server の実装は Session resumption の動作の確認のみを目的とし同時に複数の接続をハンドリングするといった機能を有さず、エラーハンドリングすらせず Session の確立後即時切断する最小のコードとしています(OpenSSL を用いた通信の実装は BIO を用いる方法もありますが、TCP Session のハンドリングから OpenSSL に支配され個人的に好みではないので利用していません)。コードの内容は非常に単純です。まず OpenSSL を利用を行うための初期化を行い SSL/TLS を利用するためのコンテキストを作成します。

  SSL_library_init();
  SSL_CTX *ctx = SSL_CTX_new(SSLv23_server_method());

SSL_library_init は SSL/TLS で利用する暗号アルゴリズムを登録します。 SSLv23_server_method は SSLv3, TLSv1, TLSv1.1 および TLSv1.2 に対応する Server の機能を提供する SSL_METHOD を返します。 SSL_CTX_new は提供する機能となる SSL_METHOD を引数とし、SSL_CTX を返します。SSL_CTX は機能を持つ SSL_METHOD とその機能を用いるためのメモリー空間の資源や設定等の情報を持ちます。次に、証明書と秘密鍵を SSL_CTX に設定します。

  SSL_CTX_use_certificate_file(ctx, SERVER_CERT_FILE, SSL_FILETYPE_PEM);
  SSL_CTX_use_PrivateKey_file(ctx, SERVER_PRIVATE_KEY_FILE, SSL_FILETYPE_PEM);

前述したように、一般的な Web のサービスはサーバーがクライアントに証明書を送信します。このため SSL/TLS のコンテキストである SSL_CTX に証明書とそれに対応する秘密鍵を設定し SSL/TLS の Server を提供する SSL_CTX の作成が完了します。以降は TCP Server の実装が続き、TCP Session をリニアに受け付ける無限ループに入ります。この無限ループ内は、TCP Session である accept した socket を SSL_CTX と結びつけ、SSL/TLS の個々の Session に対する処理実体を生成し、Session の確立を行います。

    SSL *ssl = SSL_new(ctx);
    SSL_set_fd(ssl, session_fd);
    SSL_accept(ssl);

Session 確立直後、その Session は切断され Session の処理実体を解放します。

    SSL_shutdown(ssl);
    SSL_free(ssl);

SSL/TLS の Session の切断後、その下位となる TCP Session をクローズします。

試験用クライアントの実装

Server と同様に試験用の Client の実装を行います。まず簡単に SSL/TLS 接続を行うコードを示します。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>

#include <openssl/evp.h>
#include <openssl/ssl.h>

#define SERVER_PORT "8000"
#define SERVER_HOST "localhost"

int main()
{

    SSL_library_init();
    SSL_CTX *ctx = SSL_CTX_new(SSLv23_client_method());

    struct addrinfo hints;
    struct addrinfo *res, *ai;

    memset(&hints, sizeof(hints), 0);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    getaddrinfo(SERVER_HOST, SERVER_PORT, &hints, &res);

    int sockfd;
    for(ai=res; ai; ai=ai->ai_next) {
        sockfd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
        if(!connect(sockfd, ai->ai_addr, ai->ai_addrlen))
            break;
        close(sockfd);
    }
    freeaddrinfo(res);

    SSL *ssl = SSL_new(ctx);
    SSL_set_options(ssl, SSL_OP_NO_TICKET);
    SSL_set_fd(ssl, sockfd);

    SSL_connect(ssl);
    SSL_shutdown(ssl);
    SSL_free(ssl);

    close(sockfd);
    SSL_CTX_free(ctx);

    return 0;
}

このコードは、設定された HOST と PORT に対して SSL/TLS の接続を行い直後に切断するだけの動作を行うコードです。SSL_CTX の作成は SSLv23_client_method を利用します。その後 TCP Socket のハンドリングを行い Client ですので connect によって Server に接続します。接続された TCP Socket は Server と同様に SSL_set_fd で SSL/TLS セッション実体と紐づけられ、 SSL_connect によって Server に対して SSL/TLS のセッション接続が行われます。接続が行われた後は SSL/TLS 接続を切断し TCP Session を close して終了します(SSL_set_options に関しては後ほど解説します)。今回作成したコードは証明書の検証を実装していません。これは、このコードがあくまでも試験用であるためです。実装は必要に応じて証明書の検証を実装しなければなりません。証明書検証の実装は SSL_CTX_set_verify などを用いて実装する事ができます。

クライアントのセッション再開の実装

さて、このままだと接続後切断し終了してしまうので Session の再開の試験はできません。ですので、このコードに対し Session の再開の動作を加えます。まず Session を再開するための Session の情報を取得します。Session の情報は SSL_get0_session もしくは SSL_get1_session を用いて Session の情報である SSL_SESSION のポインタを SSL/TLS の処理実体から取得する事ができます。SSL_get0_session と SSL_get1_session 違いは SSL_get0_session で取得されたポインタは取得元であるSSL/TLS の処理実体を SSL_free で解放した時同時に解放され内容が保証されなくなりますが、SSL_get1_session で取得されたポインタは解放されません。このため SSL_get1_session で取得されたポインタは SSL_SESSION_free を用いて解放する必要があります。接続の再開は、この取得したセッション情報を SSL_set_session を用いて設定します。以上をふまえて、前述のコードを書き換えます。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>

#include <openssl/evp.h>
#include <openssl/ssl.h>

#define RECONNECT_COUNT 1
#define SERVER_PORT "8000"
#define SERVER_HOST "localhost"

int main()
{

    SSL_library_init();
    SSL_CTX *ctx = SSL_CTX_new(SSLv23_client_method());

    struct addrinfo hints;
    struct addrinfo *res, *ai;

    memset(&hints, sizeof(hints), 0);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    SSL_SESSION *session = NULL;
    int count = 0;

    do {
        getaddrinfo(SERVER_HOST, SERVER_PORT, &hints, &res);

        int sockfd;
        for(ai=res; ai; ai=ai->ai_next) {
            sockfd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
            if(!connect(sockfd, ai->ai_addr, ai->ai_addrlen))
                break;
            close(sockfd);
        }
        freeaddrinfo(res);

        SSL *ssl = SSL_new(ctx);
        SSL_set_options(ssl, SSL_OP_NO_TICKET);
        if(session) SSL_set_session(ssl, session);
            SSL_set_fd(ssl, sockfd);

        SSL_connect(ssl);
        if(!session) session = SSL_get1_session(ssl);
        SSL_shutdown(ssl);

        close(sockfd);
        count++;
    } while(count <= RECONNECT_COUNT);

    SSL_SESSION_free(session);
    SSL_CTX_free(ctx);

    return 0;
}

これで Server と Client の試験用の実装がそろいました。これらをコンパイルし試験してみたいと思います。

試してみる

コンパイルはそれぞれの環境に応じて行いますが、それぞれのコードを server.c, client.c としたとき

 $ cc server.c -lssl -lcrypto -o server
 $ cc client.c -lssl -lcrypto -o client

とすれば Windows を除く大抵の環境でコンパイルできるかと思います。実行は

 $ ./server &
 $ ./client

とすれば実行されますが、何も表示されません。ですので packet を dump して確認したいと思います。packet の dump は wireshark などを用いますが、今回は wireshark の text 版である tshark を用います。

$ sudo tshark -i lo0 -d tcp.port==8000,ssl -Y ssl 
Capturing on 'Loopback'
  5   0.000186          ::1 -> ::1          SSL 380 Client Hello
  7   0.000291          ::1 -> ::1          TLSv1.2 1009 Server Hello, Certificate, Server Hello Done
  9   0.000976          ::1 -> ::1          TLSv1.2 403 Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message
 11   0.002651          ::1 -> ::1          TLSv1.2 136 Change Cipher Spec, Encrypted Handshake Message
 12   0.002680          ::1 -> ::1          TLSv1.2 113 Encrypted Alert
 18   0.002929          ::1 -> ::1          TLSv1.2 113 Encrypted Alert
 26   0.003251          ::1 -> ::1          SSL 412 Client Hello
 28   0.003372          ::1 -> ::1          TLSv1.2 226 Server Hello, Change Cipher Spec, Encrypted Handshake Message
 30   0.003754          ::1 -> ::1          TLSv1.2 136 Change Cipher Spec, Encrypted Handshake Message
 32   0.003770          ::1 -> ::1          TLSv1.2 113 Encrypted Alert
 37   0.003879          ::1 -> ::1          TLSv1.2 113 Encrypted Alert
^C11 packets captured
$

出力は左から frame number, frame time となっており、src/dst address, packet size, packet の内容と続きます。出力を見ると frame number の 5 から 18 が最初の接続、26 から 37 までが Session の再開となっています。Session の再開では Client Hello の大きさが Session id の 32 バイト増え Certificate や Client Key Exchange が交換されません。また最初の Session は Client Hello の送出からの接続開始から切断まで約 2.5ms かかっています。本来最初の Session は Client において Server から送出される証明書の検証を行うので、より長い時間がかかる事になります。ですが、再開は 0.5ms 程度しかかかっていません。

OpenSSL の SSL/TLS Server における session caching 実装

試験実装では OpenSSL の Server と Client を試験実装し Session resumption の動作を確認しました。実は OpenSSL は Server の実装に対し OpenSSL 自体で Server の Session caching の機能が実装されています。このため、OpenSSL を利用した Server の実装は特に Session cache の動作を意識せずに実装を行っても Session id を用いた Session の再開が行われます。さてせっかくの OSS なのですからここで少し OpenSSL の中をのぞいてみましょう。OpenSSL を用いた Server の SSL/TLS 接続は SSL_accept の実体である s3_accept で行われます。s3_accept は SSL/TLS 接続における handshake の動作がリニアに実装されており非常に長いものになっています。s3_accept の中は Session の状態に対して処理を移す実装になっており、Client Hello の受け取りは get_client_hello によって行われます。get_client_hello は Client Hello を受け取り、受け取った Client Hello に対する処理を行います。この中で、新規 Session であれば Session id を生成し、そうでなければ cache されたセッションの情報を検索します。

1012         if ((s->new_session && (s->options & SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION)))
1013                 {
1014                 if (!ssl_get_new_session(s,1))
1015                         goto err;
1016                 }
1017         else
1018                 {
1019                 i=ssl_get_prev_session(s, p, j, d + n);
1020                 if (i == 1)
1021                         { /* previous session */
1022                         s->hit=1;
1023                         }
1024                 else if (i == -1)
1025                         goto err;
1026                 else /* i == 0 */
1027                         {
1028                         if (!ssl_get_new_session(s,1))
1029                                 goto err;
1030                         }
1031                 }

これは、新規 Session であれば ssl_get_new_session を呼び出し、そうでなければ ssl_get_prev_session を呼び出して cache を検索し、存在しなければ ssl_get_new_session を呼び出す実装になっています。ssl_get_new_session は Server で用いる SSL_SESSION を初期化し、この時新たな Session id が生成されます。Session id の生成はデフォルトで def_generate_session_id で生成されるように設定されており、衝突を確認しながら乱数で生成しています。ssl_get_prev_session は cache を検索します。cache の実体は OpenSSL が持つ Dynamic Hash にあり、lh_SSL_SESSION_retrieve によって検索します。

さて OpenSSL の実装を読むと少し面白い事がわかります。OpenSSL は Session id の生成に対して SSL_CTX_set_generate_session_id などを用いてコールバック関数を設定し、Session id 生成ロジックを変更する事ができます。これを変更しないと Session id の生成は組み込まれた def_generate_session_id が用いられます。def_generate_session_id は単純に与えられた長さの乱数のバイト列を生成します。この時、Session id 生成ロジックは cache として記憶している既存の Session id との衝突を検証します。衝突の検証は ssl/ssl_lib.c に実装されている SSL_has_matching_session_id で行われます。
既存の Session id の検索に cache mode のフラグと関係なく lh_SSL_SESSION_retrieve のみを用いています。lh_SSL_SESSION_retrieve は OpenSSL の cache 機能による検索の実装なので、Session id の生成時の衝突検査は OpenSSL に組み込まれている cache 機能を利用しているかどうかに関係なく OpenSSL が管理する cache に対して行われると言う事になります。これは、OpenSSL の cache 機能を利用しない場合、OpenSSL の Session id 生成は衝突を確認しないという意味になります。この問題は、OpenSSL のコードのコメントにも現れています。

 499 
 500         if (try_session_cache &&
 501             ret == NULL &&
 502             !(s->session_ctx->session_cache_mode & SSL_SESS_CACHE_NO_INTERNAL_LOOKUP))
 503                 {
 504                 SSL_SESSION data;
 505                 data.ssl_version=s->version;
 506                 data.session_id_length=len;
 507                 if (len == 0)
 508                         return 0;
 509                 memcpy(data.session_id,session_id,len);
 510                 CRYPTO_r_lock(CRYPTO_LOCK_SSL_CTX);
 511                 ret=lh_SSL_SESSION_retrieve(s->session_ctx->sessions,&data);
 512                 if (ret != NULL)
 513                         {
 514                         /* don't allow other threads to steal it: */
 515                         CRYPTO_add(&ret->references,1,CRYPTO_LOCK_SSL_SESSION);
 516                         }
 517                 CRYPTO_r_unlock(CRYPTO_LOCK_SSL_CTX);
 518                 if (ret == NULL)
 519                         s->session_ctx->stats.sess_miss++;
 520                 }

その後、検索によって取得された Session 情報は timeout がチェックされ、timeout であった場合その情報を cache から削除します。

 595         if (ret->timeout < (long)(time(NULL) - ret->time)) /* timeout */
 596                 {
 597                 s->session_ctx->stats.sess_timeout++;
 598                 if (try_session_cache)
 599                         {
 600                         /* session was from the cache, so remove it */
 601                         SSL_CTX_remove_session(s->session_ctx,ret);
 602                         }
 603                 goto err;
 604                 }

cache からの削除は SSL_CTX_remove_session の呼び出しによって行われます。SSL_CTX_remove_session の実体は remove_session_lock であり、その中で lh_SSL_SESSION_delete が呼び出され削除されます。Session 情報の保存は s3_accept において SSL/TLS の接続が正常に確立した時に ssl_update_cache が呼び出される事によって実行されます。ssl_update_cache は SSL_CTX_add_session を呼び出し lh_SSL_SESSION_insert によって cache に挿入されます。

以上のように OpenSSL は OpenSSL か管理するメモリ空間内に Session の情報を cache し、Session resumption の機能が実装されています。試験実装での動作でも確認された通り、この機能は特にそれを利用する指定を行わなくても動作します。いくつかの HTTP Server の実装においても OpenSSL の実装を用いて Session resumption を実現しています。ですが、OpenSSL の Session resumption の実装はその仕様上、実際の利用において動作しないシチュエーションがあります。

分散された Server の cache の共有

OpenSSL の session 情報の cache は OpenSSL の実装が管理するメモリー空間内にあります。つまり cache の情報は OpenSSL を利用しているそのプロセス空間内のみで共有され、プロセスを跨いで共有する事ができません。まして異なるホストで共有する事は OpenSSL の実装は想定していない構造に成っています。トラフィックの集中するサービスは、複数のホストでサービスを提供し負荷分散を行います。複数のホストで cache を共有できないという事は最初の接続と再開が異なるホストに接続された時、再開は失敗し再接続が行われるという事になります。

Cache Sharing

これを解決するには cache の共有を複数のプロセスやホストで可能とするようにしなければなりません。

Cache Sharing

OpenSSL 利用時の Server における cache 機能の変更

実は OpenSSL はあらかじめ組み込まれている Session cache の機能を変更する事ができます。Session cache の機能の変更は、

といった手順で行います。では実際に先ほどの試験実装に追加してみます。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>

#include <openssl/evp.h>
#include <openssl/ssl.h>
#include <openssl/err.h>

#include <libmemcached/memcached.h>

#define SERVER_PORT "8000"
#define SERVER_CERT_FILE "server.crt"
#define SERVER_PRIVATE_KEY_FILE "server.key"

void sess_cache_init();
void sess_cache_terminate();
int sess_cache_new(SSL *ssl, SSL_SESSION *sess);
SSL_SESSION *sess_cache_get(SSL *ssl, unsigned char *key, int key_len, int *copy);
void sess_cache_remove(SSL_CTX *ctx, SSL_SESSION *sess);

int main()
{
    SSL_library_init();
    SSL_CTX *ctx = SSL_CTX_new(SSLv23_server_method());
    sess_cache_init();

    SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_SERVER|SSL_SESS_CACHE_NO_INTERNAL|SSL_SESS_CACHE_NO_AUTO_CLEAR);

    SSL_CTX_sess_set_new_cb(ctx, sess_cache_new);
    SSL_CTX_sess_set_get_cb(ctx, sess_cache_get);
    SSL_CTX_sess_set_remove_cb(ctx, sess_cache_remove);

    SSL_CTX_use_certificate_file(ctx, SERVER_CERT_FILE, SSL_FILETYPE_PEM);
    SSL_CTX_use_PrivateKey_file(ctx, SERVER_PRIVATE_KEY_FILE, SSL_FILETYPE_PEM);

    struct addrinfo hints;
    struct addrinfo *ai = NULL;

    memset(&hints, sizeof(hints), 0);
    hints.ai_family = AF_INET6;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;
    getaddrinfo(NULL, SERVER_PORT, &hints, &ai);

    int server_fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
    bind(server_fd, ai->ai_addr, ai->ai_addrlen);
    freeaddrinfo(ai);
    listen(server_fd, 1);

    while (1) {
        int session_fd = accept(server_fd, NULL, NULL);
        SSL *ssl = SSL_new(ctx);
        SSL_set_fd(ssl, session_fd);

        SSL_accept(ssl);

        SSL_shutdown(ssl);
        SSL_free(ssl);
        close(session_fd);
    }

    sess_cache_terminate();
    close(server_fd);
    SSL_CTX_free(ctx);

    return 0;
}

static struct memcached_st *mmc = NULL;
const static char hex_table[] = "0123456789ABCDEF";

#define SESS_MEMCACHE_SRV "--SERVER=localhost:11211 --BINARY-PROTOCOL"
#define MAX_SESSION_ID_HEX_STRING_LEN 65

static size_t bin2hex(const unsigned char* data, size_t data_len, char *str, size_t str_len)
{
    size_t len = data_len * 2;
    if(str_len < len + 1) return 0;

    int i;
    for(i = 0; i < data_len; i++) {
        *(str + i * 2) = hex_table[*(data + i) >> 4];
        *(str + i * 2 + 1) = hex_table[*(data + i) & 0x0F];
    }
    *(str + len) = '\0';

    return len;
}

void sess_cache_init()
{
    mmc = memcached(SESS_MEMCACHE_SRV, strlen(SESS_MEMCACHE_SRV));

    return;
}

void sess_cache_terminate()
{
     memcached_free(mmc);

    return;
}

int sess_cache_new(SSL *ssl, SSL_SESSION *sess)
{
    memcached_return r;

    char keystr[MAX_SESSION_ID_HEX_STRING_LEN];
    size_t len = bin2hex(sess->session_id, sess->session_id_length, keystr, MAX_SESSION_ID_HEX_STRING_LEN);

    size_t data_len = i2d_SSL_SESSION(sess, NULL);
    const char *data = (const char *)alloca(data_len);
    unsigned char *_data = (unsigned char *)data;
    i2d_SSL_SESSION(sess, &_data);

    r = memcached_set(mmc, (const char *)keystr, len, data, data_len, 
              SSL_SESSION_get_time(sess) + SSL_SESSION_get_timeout(sess), 0);

    if(r == MEMCACHED_SUCCESS) return 1;
    return 0;
}

SSL_SESSION *sess_cache_get(SSL *ssl, unsigned char *key, int key_len, int *copy)
{
    memcached_return r;
    size_t data_len;
    SSL_SESSION *sess = NULL;

    char keystr[MAX_SESSION_ID_HEX_STRING_LEN];
    size_t len = bin2hex(key, key_len, keystr, MAX_SESSION_ID_HEX_STRING_LEN);

    char *data = memcached_get(mmc, (const char *)keystr, len, &data_len, 0, &r);

    if(data) {
        const unsigned char *_data = (const unsigned char *)data;
        sess = d2i_SSL_SESSION(NULL, &_data, data_len);
        free((void *)data);
    } 
    *copy = 0;

    return sess;
}

void sess_cache_remove(SSL_CTX *ctx, SSL_SESSION *sess)
{
    char keystr[MAX_SESSION_ID_HEX_STRING_LEN];
    size_t len = bin2hex(sess->session_id, sess->session_id_length, keystr, MAX_SESSION_ID_HEX_STRING_LEN);

    memcached_delete(mmc, (const char *)keystr, len, 0);
    return;
}

実装は cache の共有を memcached に格納し libmemcached を用いてアクセスを行います。これによって複数のプロセスやホストでの共有が可能とします。cache の登録/更新/削除はそれぞれ sess_cache_new,sess_cache_get,sess_cache_remove として実装しています。実装した関数は

  SSL_CTX_sess_set_new_cb(ctx, sess_cache_new);
  SSL_CTX_sess_set_get_cb(ctx, sess_cache_get);
  SSL_CTX_sess_set_remove_cb(ctx, sess_cache_remove);

とし、それぞれのコールバックに登録します。また、OpenSSL に実装されている既存の cache 機能は

  SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_SERVER|SSL_SESS_CACHE_NO_INTERNAL|SSL_SESS_CACHE_NO_AUTO_CLEAR);

によって無効としています。

ここまでで OpenSSL を用いた SSL/TLS のセッション再開に対する対応とその対応を分散された環境で実現する手段が確認できました。では実際に HTTP Server ではどのように実装されているかを調べてみます。

Apache httpd における実装

Apache httpd の modules/ssl/ssl_engine_init.c には下記の記述があります。

static void ssl_init_ctx_session_cache(server_rec *s,
                                       apr_pool_t *p,
                                       apr_pool_t *ptemp,
                                       modssl_ctx_t *mctx)
{
    SSL_CTX *ctx = mctx->ssl_ctx;
    SSLModConfigRec *mc = myModConfig(s);

    SSL_CTX_set_session_cache_mode(ctx, mc->sesscache_mode);

    if (mc->sesscache) {
        SSL_CTX_sess_set_new_cb(ctx,    ssl_callback_NewSessionCacheEntry);
        SSL_CTX_sess_set_get_cb(ctx,    ssl_callback_GetSessionCacheEntry);
        SSL_CTX_sess_set_remove_cb(ctx, ssl_callback_DelSessionCacheEntry);
    }
}

すでにご存知のようにこの実装は cache の登録/更新/削除に対するコールバックの登録になります。ここを手掛かりに読み進めていきたいと思います。

ssl_init_ctx_session_cache の内容は module の設定を読み込み SSL_CTX_set_session_cache_mode を用いて SSLModConfigRec.sesscache_mode に Session cache のモードを設定し、SSLModConfigRec.sesscache が存在する(非NULL)場合 session cache のコールバックを設定します。つまり、設定情報の SSLModConfigRec.sesscache や SSLModConfigRec.sesscache_mode が Apache httpd における Session cache の動作を決定しています。
SSLModConfigRec.sesscache や SSLModConfigRec.sesscache_mode の内容の決定は modules/ssl/ssl_engine_config.c ssl_cmd_SSLSessionCache で実装されています ssl_cmd_SSLSessionCache は引数となる文字列が “none” であれば何もせず初期値のままになります。“nonenotnull” であれば sesscache_mode のみを SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_INTERNAL とします。その他の場合 sesscache_mode を SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_INTERNAL とし、’:’ をセパレータとした左側を socache の provider 名として socache provider を検索し SSLModConfigRec.sesscache に格納し、続く右側の文字列を用いて socache を作成/初期化します。

SSLModConfigRec の初期値は同じく ssl_engine_config.c にある ssl_config_global_create で設定されており

    mc->sesscache_mode         = SSL_SESS_CACHE_OFF;
    mc->sesscache              = NULL;

となっています。SSL_SESS_CACHE_OFF は OpenSSL の SSL_CTX_set_session_cache_mode を用いた Session cache の設定において何のフラグも設定しないという意味になります。OpenSSL は SSL_SESS_CACHE_SERVER を設定しないと Session id を生成しません。つまり、この設定は Session cache を利用せず Session id も生成しないという意味になります。

ssl_cmd_SSLSessionCache は modules/ssl/mod_ssl.c で mod_ssl module の directive を決定する command_rec 構造体に設定されています。

    SSL_CMD_SRV(SessionCache, TAKE1,
                "SSL Session Cache storage "
                "('none', 'nonenotnull', 'dbm:/path/to/file')")

SSL_CMD_SRV がマクロになっていて分かりづらいですが、この意味は “SSLSessionCache” directive は一つの引数をとり ssl_cmd_SSLSessionCache によって設定されるという意味です。

さて次に、Session cache に設定される各コールバック関数の実装を確認したいと思います。Session の cache に対する 登録/更新/削除の各コールバック関数は ssl_callback_NewSessionCacheEntry, ssl_callback_GetSessionCacheEntry, ssl_callback_DelSessionCacheEntry となっており、実装は modules/ssl/ssl_engine_kenel.c にあります。それぞれを見ると、cache の操作はそれぞれ ssl_scache_store/ssl_scache_retrieve/ssl_scache_remove という関数でラップされており、これらの実装は modules/ssl/ssl_scache.c にあります。さらに確認すると

ssl_scache_store
    rv = mc->sesscache->store(mc->sesscache_context, s, id, idlen, expiry, encoded, len, p);

ssl_scache_retrieve
    rv = mc->sesscache->retrieve(mc->sesscache_context, s, id, idlen, dest, &destlen, p);

ssl_scache_remove
    mc->sesscache->remove(mc->sesscache_context, s, id, idlen, p);

となっており、socache の provider を用いて cache を実現していることが分かります。

以上が Apache httpd における Session cache の実装です。Apache httpd では socache を利用して Session の cache を実現します。Session cache の利用は “SSLSessionCache”directive を用いて設定を行い、設定は “socache provider 名:socache provider に対する設定” で行い、他に “none” と “nonenotnull” があります。“none” は OpenSSL の cache mode に対して SSL_SESS_CACHE_OFF が設定され Session cache を行わず Session id も生成しません。“nonenotnull” は OpenSSL の cache mode に対して SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_INTERNAL が設定され Session cache を行いませんが Session id の生成は行われます。

socache は memcached を用いる socache providor が存在します。これを利用して複数のホストで cache の共有を実現する事ができます。しかし、これはあくまでも技術的に可能であると言うだけの話であり、安易に利用する事はお勧めできません

nginx における実装

Apache httpd の実装は分かりました。さて Apache httpd だけでは芸が無いので nginx についても調べてみたいと思います。nginx の src/event/ngx_event_openssl.c ngx_ssl_session_cache に Apache httpd と良く似た以下の実装があります。

ngx_int_t
ngx_ssl_session_cache(ngx_ssl_t *ssl, ngx_str_t *sess_ctx,
    ssize_t builtin_session_cache, ngx_shm_zone_t *shm_zone, time_t timeout)
{
...
    if (builtin_session_cache == NGX_SSL_NO_SCACHE) {
        SSL_CTX_set_session_cache_mode(ssl->ctx, SSL_SESS_CACHE_OFF);
        return NGX_OK;
    }

    if (builtin_session_cache == NGX_SSL_NONE_SCACHE) {

        /*
         * If the server explicitly says that it does not support
         * session reuse (see SSL_SESS_CACHE_OFF above), then
         * Outlook Express fails to upload a sent email to
         * the Sent Items folder on the IMAP server via a separate IMAP
         * connection in the background. Therefore we have a special
         * mode (SSL_SESS_CACHE_SERVER|SSL_SESS_CACHE_NO_INTERNAL_STORE)
         * where the server pretends that it supports session reuse,
         * but it does not actually store any session.
         */

        SSL_CTX_set_session_cache_mode(ssl->ctx,
                                       SSL_SESS_CACHE_SERVER
                                       |SSL_SESS_CACHE_NO_AUTO_CLEAR
                                       |SSL_SESS_CACHE_NO_INTERNAL_STORE);

        SSL_CTX_sess_set_cache_size(ssl->ctx, 1);x

        return NGX_OK;
    }

    cache_mode = SSL_SESS_CACHE_SERVER;

    if (shm_zone && builtin_session_cache == NGX_SSL_NO_BUILTIN_SCACHE) {
        cache_mode |= SSL_SESS_CACHE_NO_INTERNAL;
    }

    SSL_CTX_set_session_cache_mode(ssl->ctx, cache_mode);

    if (builtin_session_cache != NGX_SSL_NO_BUILTIN_SCACHE) {

        if (builtin_session_cache != NGX_SSL_DFLT_BUILTIN_SCACHE) {
            SSL_CTX_sess_set_cache_size(ssl->ctx, builtin_session_cache);
        }
    }

    if (shm_zone) {
        SSL_CTX_sess_set_new_cb(ssl->ctx, ngx_ssl_new_session);
        SSL_CTX_sess_set_get_cb(ssl->ctx, ngx_ssl_get_cached_session);
        SSL_CTX_sess_set_remove_cb(ssl->ctx, ngx_ssl_remove_session);

        if (SSL_CTX_set_ex_data(ssl->ctx, ngx_ssl_session_cache_index, shm_zone)
            == 0)
        {
            ngx_ssl_error(NGX_LOG_EMERG, ssl->log, 0,
                          "SSL_CTX_set_ex_data() failed");
            return NGX_ERROR;
        }
    }
...

ngx_ssl_session_cache の引数であるbuiltin_session_cache は NGX_SSL_NO_SCACHE , NGX_SSL_NONE_SCACHE , NGX_SSL_NO_BUILTIN_SCACHE および NGX_SSL_DFT_BUILTIN_SCACHE のどれか一つ、もしくは 0 以上の値をとり、cache mode を決定します。

  1. NGX_SSL_NO_SCACHE である場合 cache mode は SSL_SESS_CACHE_OFF となり Session cache を行わず Session id の生成を行いません。
  2. NGX_SSL_NONE_SCACHE である場合 SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_AUTO_CLEAR | SSL_CACHE_NO_INTERNAL_STORE となっており、これは動作としては Session id を生成するが OpenSSL に組み込まれている Session cache に対し格納しない(ただし検索は行われる)といった動作になります。さらにコールバック関数を設定せずに return しているので、結果的に Session id を生成するが Session cache を行わないといった動作になります。
  3. NGX_SSL_NO_BUILTIN_SCACHE であり shm_zone が 非NULL である場合 cache mode は SSL_SESS_CACHE_SERVER|SSL_SESS_CACHE_NO_INTERNAL が設定され、shm_zone が 非NULL なのでコールバックが設定されます。
  4. NGX_SSL_DFT_BUILTIN_SCACHE である場合 cache mode は SSL_SESS_CACHE_SERVER のみが設定されます
  5. 以上のどれでもない場合、つまり 0 以上の値が渡された場合 cache mode は SSL_SESS_CACHE_SERVER のみが設定され、値を SSL_CTX_sess_set_cache_size を用いて OpenSSL が持つ cache のサイズに設定します。

コールバック関数は shm_zone が非NULLであればが設定されます。つまり、shm_zone が非NULLであり NGX_SSL_NO_BUILTIN_SCACHE である場合は OpenSSL に組み込まれている session cache を利用せず nginx が持っている cache の機能のみを使い、NGX_SSL_DFT_BUILTIN_SCACHE は OpenSSL と nginx の両方が持つ cache の機能が有効となります。両方の cache の機能が有効である場合 OpenSSL の cache の検索の実装は、OpenSSL, 設定されたコールバックの順序で cache の検索が行われ、OpenSSL の cache にヒットすると設定されたコールバックを実行しません。ですので、優先される情報は OpenSSL の cache となります。0 以上の値が与えられた場合、NGX_SSL_DFT_BUILTIN_SCACHE と同様に SSL_SESS_CACHE_SERVER となり OpenSSL とコールバックの両方が有効となりますが、加えて builtin_session_cache で与えられた値を SSL_CTX_sess_set_cache_size を用いて OpenSSL の cache で利用される容量として設定します。コールバックの設定は shm_zone が存在する(非NULL)場合に行われますが、同時に shm_zone を SSL_CTX_set_ex_data を用いて記憶しています。key となる ngx_ssl_session_cache_indexsrc/event/ngx_event_openssl.c の global static 変数であり、nginx の起動の初期に main から呼び出される ngx_ssl_init設定されます。
このように nginx における Session cache の挙動は ngx_ssl_session_cache が呼び出される際に引数として与えられる builtin_session_cache と shm_zone で決定します。

ではこれらの値はどこで決定するのでしょう。

ngx_ssl_session_cache src/http/modules/ngx_http_ssl_module.c にある ngx_http_ssl_merge_srv_conf から呼び出されます。ngx_http_ssl_merge_srv_conf は nginx の HTTP SSL Server の機能を担う ngx_http_ssl_module において設定値をマージする関数となっています。ここで引数として与えられる conf->builtin_session_cache と conf->shm_zone は ngx_http_ssl_session_cache で設定されます。設定値の処理の情報は ngx_http_ssl_commands にまとめられており、ngx_http_ssl_session_cache は一つもしくは二つの引数を取る “ssl_session_cache” という設定をパースする処理である事がわかります。ngx_http_ssl_session_cache は与えられた設定文字列から builtin_session_cache と shm_zone を設定します。obuiltin_session_cache は与えられた文字列が

  • “off” の場合 NGX_SSL_NO_SCACHE
  • “none” の場合 NGX_SSL_NONE_SCACHE
  • “builtin” の場合 NGX_SSL_DFLT_BUILTIN_SCACHE
  • “builtin:数値” の場合その数値

が設定されます。
また、与えられた文字列が“shared:文字列:サイズ値”である場合、与えられた文字列を用いて nginx の shared memory を作成し shm_zone に設定します。

ssl_session_cache の設定は一つもしくは二つ記述する事が可能であり、設定が衝突する場合は後者が優先されます。“設定が二つ書ける” という意味は、実装から見て nginx の cache 機能に対する設定である shared と OpenSSL cache 機能に対する builtin の組み合わせを書く事を意図した物と考えられます。”shared:文字列:サイズ値” のみ一つが与えられた場合 builtin_session_cache は NGX_SSL_NO_BUILTIN_SCACHE となります。

次に、コールバックで設定される各関数を確認します。Session の cache に対する 登録/更新/削除の各コールバック関数は ngx_ssl_new_session , ngx_ssl_get_cached_session , ngx_ssl_remove_session となっています。
nginx の session cache は shm_zone に格納されます。shm_zone は nginx の worker process で共有可能なメモリー領域になります。cache の構造は ngx_session_cache_t となっており、cache の index を持つ rbtree(赤黒木)と cache されたデータの寿命を管理する queue を持ちます。各コールバック関数は、これらの構造にデータを登録/更新/削除を行う実装になっています。

ngx_ssl_new_session は expire の queue と cache に Session 情報を登録します。

    ngx_queue_insert_head(&cache->expire_queue, &sess_id->queue);

    ngx_rbtree_insert(&cache->session_rbtree, &sess_id->node);

ngx_ssl_get_cached_session は cache の rbtree から検索しデータを返します。

    while (node != sentinel) {

        if (hash < node->key) {
            node = node->left;
            continue;
        }

        if (hash > node->key) {
            node = node->right;
            continue;
        }

        /* hash == node->key */

        sess_id = (ngx_ssl_sess_id_t *) node;

        rc = ngx_memn2cmp(id, sess_id->id, (size_t) len, (size_t) node->data);

        if (rc == 0) {

            if (sess_id->expire > ngx_time()) {
                ngx_memcpy(buf, sess_id->session, sess_id->len);

                ngx_shmtx_unlock(&shpool->mutex);

                p = buf;
                sess = d2i_SSL_SESSION(NULL, &p, sess_id->len);

                return sess;
            }

ngx_ssl_remove_session は cache の rbtree から削除します。

    while (node != sentinel) {

        if (hash < node->key) {
            node = node->left;
            continue;
        }

        if (hash > node->key) {
            node = node->right;
            continue;
        }

        /* hash == node->key */

        sess_id = (ngx_ssl_sess_id_t *) node;

        rc = ngx_memn2cmp(id, sess_id->id, len, (size_t) node->data);

        if (rc == 0) {

            ngx_queue_remove(&sess_id->queue);

            ngx_rbtree_delete(&cache->session_rbtree, node);

            ngx_slab_free_locked(shpool, sess_id->session);
#if (NGX_PTR_SIZE == 4)
            ngx_slab_free_locked(shpool, sess_id->id);
#endif
            ngx_slab_free_locked(shpool, sess_id);

            goto done;
        }

nginx は worker process で共有するメモリー空間である shm_zone を用いる事がわかりました。と言う事は残念ながら nginx はそれ自体の実装ではホストを跨いだ cache の共有はできないと言う事になります。

nginx ではホストを跨いだ共有は本当にできないのでしょうか ?

TLS Session Ticket 拡張(Transport Layer Security(TLS)Session Resumption without Server-Side State)

これまでの解説は Server において Session id を key にその Server 自身が Session 情報を保存するという構造でした。実は TLS は RFC5077 で定義されている Session Resumption without Server-Side State という拡張があります。これは、Server が保存すべき Session 情報を暗号化し Client に送って Client が Session 情報を cache するという仕組みです。Client は Session を再開する場合、送られて来た暗号化された Session 情報を Server に送り Server はその情報を復号し検証して問題がなければ送られて来た Session 情報を元に Session を再開します。もちろん、Client も暗号化されていない Session の情報を Client 側の cache として保存しています。Server から送られて来た暗号化された Session 情報は Client は復号できないため単なるバイトデータに過ぎません。“暗号化された Session 情報” は Session Ticket と呼ばれます。この仕組みが有効に機能する場合、Session の再開は Server が Session 情報を保存しなくても実現する事ができます。

この機能は二つのパケット構造の拡張が RFC5077 で定義されています。

  1. SessionTicket TLS Extension
    SessionTicket TLS Extension は TLS の Handshake における Hello Message の拡張であり、Hello Message に含まれる構造です。Hello Message は Client から送出される Client Hello と Server から送られる Server Hello があります。Client はこれから接続を始めようとする接続先に対して適当な Session Ticket を持ち、かつ Session の再開を望む場合、その Session Ticket を送出する Client Hello の中に SessionTicket TLS Extension の構造で挿入します。逆にこれから接続を始めようとする接続先に対して適当な Session Ticket を持たない、もしくは Session の再開を望まない場合、空(長さ0)の Session Ticket を送出する Client Hello の中に SessionTicket TLS Extension の構造で挿入します。
    Server は SessionTicket TLS Extension を持つ Client Hello を受け取り、かつ Session Ticket を Client に返す場合、その Session Ticket を更新するのであれば送出する Server Hello に空の SessionTicket TLS Extension を挿入します。

  2. NewSessionTicket Handshake Message
    NewSessionTicket Handshake Message は TLS の Handshake の一つとして定義され、Server が Client に対して次回の再開において利用する新たな Session Ticket を送出する構造です。Server は SessionTicket TLS Extension を含む Client Hello を受け取り、かつ以降の接続で Session Ticket を用いた接続の再開を望む場合 NewSessionTicket Handshake Message を用いて Session Ticket を Client に返します。

Session Ticket を用いた新規の接続は Client は空の SessionTicket TLS Extension を含んだ Client Hello を Server に送ります。空の SessionTicket TLS Extension を含んだ Client Hello を受け取った Server は Session Ticket を生成するのであれば空の SessionTicket TLS Extension を ServerHello に含めて Client に返します。以降の接続ネゴシエーションは接続を確立する Chenge Cipher Spec, Finished の交換の手前まで変わりません。これは NewSessionTicket で返すべき Session の情報は Session が確立するまで明確にならないためです。Server は証明書や premaster secret の交換の後 Session が確立する直前の Change Cipher Spec, Finished を送出する前に NewSessionTicket にその Session の情報から生成した Session Ticket を含めて Client に返します。

full handshake

Session Ticket を用いた接続の再開は、Client は再開に用いる Session Ticket を SessionTicket TLS Extension に入れ、それを含んだ Client Hello を Server に送ります。Session Ticket を受け取った Server は受け取った Session Ticket を復号して検証し検証に成功した場合接続の再開を行います。この時、 Session Ticket を更新するのであれば Server は空の SessionTicket TLS Extension を含む Server Hello を Client に返した後、新たな Session Ticket を NewSessionTicket Handshake Message を用いて Client に返します。以降 Server と Client は Change Cipher Spec, Finished を交換し、Server は復号した Session Ticket から得た Session の情報を用いて Session を再開します。Session Ticket を更新しないのであれば Server は Session TLS Extension を含まない Server Hello を Client に返し、Change Cipher Suite と Finished を交換して Session を再開します。

abbreviated handshake

OpenSSL の SSL/TLS Server における session ticket 実装

では早速 OpenSSL で Session Ticket がどのように実装されているかを確認してみましょう。

実は OpenSSL は Server での cache 同様に特に意識せずに Session Ticket が利用できます。このため、前述の試験コードは Client の実装で SSL_set_options(ssl, SSL_OP_NO_TICKET)を入れる事により OpenSSL の Session Ticket の機能を無効にしています。ですので、前述の試験コードで Session Ticket を試験する場合、この一文を削除してください。

さて、既に session caching の説明で OpenSSL は Session の再開において Client Hello を受け取った後に動作する ssl_get_prev_session によって Session id から cache を検索する事がわかっています。ssl_get_prev_session の中を見ると Session id による cache の検索の前に下記の記述があります。

    r = tls1_process_ticket(s, session_id, len, limit, &ret); /* sets s->tlsext_ticket_expected */
    switch (r)
        {
    case -1: /* Error during processing */
        fatal = 1;
        goto err;
    case 0: /* No ticket found */
    case 1: /* Zero length ticket found */
        break; /* Ok to carry on processing session id. */
    case 2: /* Ticket found but not decrypted. */
    case 3: /* Ticket decrypted, *ret has been set. */
        try_session_cache = 0;
        break;
    default:
        abort();
        }

どうやら、tls1_process_ticket によって Session Ticket の動作を決定させているようです。

tls1_process_ticket ssl/t1_lib.c に実装があり、Client Hello の extension から SessionTicket TLS Extension を探します。tls1_process_ticket は SessionTicketExtension が見つかれば長さをチェックし 0 であれば 1 を返し、tls_session_secret_cb が設定されていれば 2 を返し、どちらも成立しなければ tls_decrypt_ticket によって復号と検証を試みます。tls_decrypt_ticket は同様に ssl/t1_lib.c に実装があり、tlsext_ticket_key_cb が設定されていればそれを用いて key を取得し、設定されてなければあらかじめ設定されている key を用いて検証し復号し結果を返します。tls_decrypt_ticket の返り値

 * Returns:
 *   -1: fatal error, either from parsing or decrypting the ticket.
 *    2: the ticket couldn't be decrypted.
 *    3: a ticket was successfully decrypted and *psess was set.
 *    4: same as 3, but the ticket needs to be renewed.

となっています。この返り値から tls1_process_ticket は 3 の場合はそのまま 3 を返し、2,4 の場合は新規の Session Ticket の更新のフラグである tlsext_ticket_expected を 1 として、返り値にそれぞれ 2,4 を返します。
ssl_get_prev_session は tls1_process_ticket の返り値に対し、3 の場合のみ try_session_cache を 0 とします。try_session_cache は Session id による検索に対するフラグとなっており、Session Ticket での Session 情報の取得に成功するとssl_get_prev_session は Session id を用いた検索を実行せずに検索が成功した返り値である 1 を返します。

tls_decrypt_ticket は ticket の検証と復号を行います。この実装は

....
        /* Check key name matches */
        if (memcmp(etick, tctx->tlsext_tick_key_name, 16))
        HMAC_Init_ex(&hctx, tctx->tlsext_tick_hmac_key, 16,
                    tlsext_tick_md(), NULL);
        EVP_DecryptInit_ex(&ctx, EVP_aes_128_cbc(), NULL,
                tctx->tlsext_tick_aes_key, etick + 16);
....

    /* Check HMAC of encrypted ticket */
    HMAC_Update(&hctx, etick, eticklen);
    HMAC_Final(&hctx, tick_hmac, NULL);
    HMAC_CTX_cleanup(&hctx);
    if (CRYPTO_memcmp(tick_hmac, etick + eticklen, mlen))
        {
        EVP_CIPHER_CTX_cleanup(&ctx);
        return 2;
        }
    /* Attempt to decrypt session data */
    /* Move p after IV to start of encrypted ticket, update length */
    p = etick + 16 + EVP_CIPHER_CTX_iv_length(&ctx);
    eticklen -= 16 + EVP_CIPHER_CTX_iv_length(&ctx);
    sdec = OPENSSL_malloc(eticklen);
    if (!sdec)
        {
        EVP_CIPHER_CTX_cleanup(&ctx);
        return -1;
        }
    EVP_DecryptUpdate(&ctx, sdec, &slen, p, eticklen);
    if (EVP_DecryptFinal(&ctx, sdec + slen, &mlen) <= 0)
        {
        EVP_CIPHER_CTX_cleanup(&ctx);
        OPENSSL_free(sdec);
        return 2;
        }
    slen += mlen;
    EVP_CIPHER_CTX_cleanup(&ctx);
    p = sdec;

    sess = d2i_SSL_SESSION(NULL, &p, slen);

となっています。つまり、

  1. Session Ticket のはじめの 16 バイトを tlsext_tick_key_name と比較する
  2. Session Ticket の最後からハッシュ長をMAC値とし、先頭から MAC値手前までを HMAC でチェックする
  3. Session Ticket の 17 バイト目から IV 長(デフォルトで AES128CBC の16)を IV とし、以降を MAC値手前まで復号する
  4. 復号して得られたデータをセッション情報とする

といった処理になっています。ですので Session Ticket の構造は下図のようになります。

Session Ticket Structure

この復号と検証の処理と Ticket の構造は RFC5077 Section 4 で推奨されている形式になります。

これで Client Hello の受け取りから Session Ticket を検証するまでがわかりました。
次に Server Hello を返します。Server Hello は Session Ticket の更新が発生する場合、空の SessionTicket TLS Extension を送出しなければなりません。これは s3_accept から Server Hello の送出で呼び出される ssl3_send_server_hello から ssl/t1_lib.c に実装されている TLS 拡張を組み立てる ssl_add_serverhello_tlsext によって挿入されます。Server は Server Hello を返した後 Session の再開をする場合 Session Ticket の更新の必要があれば NewSessionTicket の送出に移行し、更新がないのであれば Change Cipher Suite の送出に移行します。この実装は s3_accept 実装されています。

        case SSL3_ST_SW_SRVR_HELLO_A:
        case SSL3_ST_SW_SRVR_HELLO_B:
            ret=ssl3_send_server_hello(s);
            if (ret <= 0) goto end;
#ifndef OPENSSL_NO_TLSEXT
            if (s->hit)
                {
                if (s->tlsext_ticket_expected)
                    s->state=SSL3_ST_SW_SESSION_TICKET_A;
                else
                    s->state=SSL3_ST_SW_CHANGE_A;
                }
#else
            if (s->hit)
                    s->state=SSL3_ST_SW_CHANGE_A;
#endif
            else
                    s->state = SSL3_ST_SW_CERT_A;
            s->init_num = 0;
            break;

Session を再開しないのであれば Server は Certificate の送出に移行し、Client からの Finished の読み込みの後 NewSessionTicket の送出に移行します

        case SSL3_ST_SR_FINISHED_A:
        case SSL3_ST_SR_FINISHED_B:
            /*
             * Enable CCS for resumed handshakes without NPN.
             * In a full handshake, we end up here through
             * SSL3_ST_SR_CERT_VRFY_B, where SSL3_FLAGS_CCS_OK was
             * already set. Receiving a CCS clears the flag, so make
             * sure not to re-enable it to ban duplicates.
             * s->s3->change_cipher_spec is set when a CCS is
             * processed in s3_pkt.c, and remains set until
             * the client's Finished message is read.
             */
            if (!s->s3->change_cipher_spec)
                s->s3->flags |= SSL3_FLAGS_CCS_OK;
            ret=ssl3_get_finished(s,SSL3_ST_SR_FINISHED_A,
                SSL3_ST_SR_FINISHED_B);
            if (ret <= 0) goto end;
            if (s->hit)
                s->state=SSL_ST_OK;
#ifndef OPENSSL_NO_TLSEXT
            else if (s->tlsext_ticket_expected)
                s->state=SSL3_ST_SW_SESSION_TICKET_A;
#endif
            else
                s->state=SSL3_ST_SW_CHANGE_A;
            s->init_num=0;
            break;

NewSessionTicket の送出に移行すると Server は ssl3_send_newsession_ticket を呼び出し、NewSessionTicket を送出します。

#ifndef OPENSSL_NO_TLSEXT
        case SSL3_ST_SW_SESSION_TICKET_A:
        case SSL3_ST_SW_SESSION_TICKET_B:
            ret=ssl3_send_newsession_ticket(s);
            if (ret <= 0) goto end;
            s->state=SSL3_ST_SW_CHANGE_A;
            s->init_num=0;
            break;

ssl3_send_newsession_ticket は Session 情報を抽出し Session Ticket の暗号化とMACを生成し Session Ticket の構造に収めます

            RAND_pseudo_bytes(iv, 16);
            EVP_EncryptInit_ex(&ctx, EVP_aes_128_cbc(), NULL,
                    tctx->tlsext_tick_aes_key, iv);
            HMAC_Init_ex(&hctx, tctx->tlsext_tick_hmac_key, 16,
                    tlsext_tick_md(), NULL);
            memcpy(key_name, tctx->tlsext_tick_key_name, 16);
            }

        /* Ticket lifetime hint (advisory only):
         * We leave this unspecified for resumed session (for simplicity),
         * and guess that tickets for new sessions will live as long
         * as their sessions. */
        l2n(s->hit ? 0 : s->session->timeout, p);

        /* Skip ticket length for now */
        p += 2;
        /* Output key name */
        macstart = p;
        memcpy(p, key_name, 16);
        p += 16;
        /* output IV */
        memcpy(p, iv, EVP_CIPHER_CTX_iv_length(&ctx));
        p += EVP_CIPHER_CTX_iv_length(&ctx);
        /* Encrypt session data */
        EVP_EncryptUpdate(&ctx, p, &len, senc, slen);
        p += len;
        EVP_EncryptFinal(&ctx, p, &len);
        p += len;
        EVP_CIPHER_CTX_cleanup(&ctx);

        HMAC_Update(&hctx, macstart, p - macstart);
        HMAC_Final(&hctx, p, &hlen);
        HMAC_CTX_cleanup(&hctx);

Session Ticket は Session 情報を暗号化し、暗号化したデータに加え鍵名となる 16 バイトのバイト列を含めて HMAC で算出した MAC値で構成されています。鍵名、暗号化鍵、HMAC のシークレットはどこで設定されているのでしょうか。
OpenSSL は SSL_CTX を型とするコンテキストを用い、その作成に SSL_CTX_new を用います。鍵名、暗号鍵、シークレットの生成は ssl/ssl_lib.c に実装されている SSL_CTX_new の中で行われています。

    /* Setup RFC4507 ticket keys */
    if ((RAND_pseudo_bytes(ret->tlsext_tick_key_name, 16) <= 0)
        || (RAND_bytes(ret->tlsext_tick_hmac_key, 16) <= 0)
        || (RAND_bytes(ret->tlsext_tick_aes_key, 16) <= 0))
        ret->options |= SSL_OP_NO_TICKET;

このように鍵名、暗号鍵、シークレットは単純に乱数で生成されており、その内容は SSL_CTX が共有可能な範囲でしか共有できず、生成した SSL_CTX を解放すると消えてしまいます。

これで OpenSSL におけるひととおりの Session Ticket の処理の内容がわかりました。さて、Session id の cache で問題となった複数の Server で分散された環境を考えてみます。

Session Ticket は Client が Cache し Server が利用する暗号化されたセッションの情報です。Server は送られて来た Session Ticket を自身が持つ鍵を利用して復号し検証してセッション情報を取得します。また Session Ticket を生成する時 Server は同様に自身が持つ鍵を利用して暗号化します。しかし、そのままでは鍵名、暗号鍵、シークレットはプロセスの起動ごとに再生成され、なおかつ複数の Server で共有する事ができません。では、複数の Server で各々が生成する Session Ticket を相互に利用するためには、その鍵や Session Ticket の生成ロジックを共有できれば良いという事になります。
OpenSSL は SSL_CTX_set_tlsext_ticket_key_cb という関数があります。これは Session Ticket に対する暗号/復号および検証のロジックを置き換える事ができます。

では、早速試験実装の Server に組み込んでみます。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>

#include <openssl/evp.h>
#include <openssl/ssl.h>
#include <openssl/rand.h>

#define SERVER_PORT "8000"
#define SERVER_CERT_FILE "server.crt"
#define SERVER_PRIVATE_KEY_FILE "server.key"

#define TICKET_KEY_FILE "ticket.key"
#define TICKET_KEY_SIZE 48

static void ticket_key_init(const char *filename);
static void ticket_key_close();
int ticket_key_cb(SSL *ssl, unsigned char *keyname, unsigned char *iv, EVP_CIPHER_CTX *cctx, HMAC_CTX *hctx, int mode);

int main()
{
    SSL_library_init();
    SSL_CTX *ctx = SSL_CTX_new(SSLv23_server_method());

    SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_SERVER|SSL_SESS_CACHE_NO_INTERNAL);
    SSL_CTX_set_tlsext_ticket_key_cb(ctx, ticket_key_cb);

    SSL_CTX_use_certificate_file(ctx, SERVER_CERT_FILE, SSL_FILETYPE_PEM);
    SSL_CTX_use_PrivateKey_file(ctx, SERVER_PRIVATE_KEY_FILE, SSL_FILETYPE_PEM);

    ticket_key_init(TICKET_KEY_FILE);

    struct addrinfo hints;
    struct addrinfo *ai = NULL;

    memset(&hints, sizeof(hints), 0);
    hints.ai_family = AF_INET6;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE; 
    getaddrinfo(NULL, SERVER_PORT, &hints, &ai);

    int server_fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
    bind(server_fd, ai->ai_addr, ai->ai_addrlen);
    freeaddrinfo(ai);
    listen(server_fd, 1);

    while (1) {
      int session_fd = accept(server_fd, NULL, NULL);

      SSL *ssl = SSL_new(ctx);
      SSL_set_fd(ssl, session_fd);
      SSL_accept(ssl);

      SSL_shutdown(ssl);
      SSL_free(ssl);

      close(session_fd);
    }

    ticket_key_close();
    close(server_fd);
    SSL_CTX_free(ctx);

    return 0;
}

static struct {
    unsigned char *key_name;
    unsigned char *aes_key;
    unsigned char *hmac_secret;
} ticket_key;

static void ticket_key_init(const char *filename)
{
    unsigned char *data = (unsigned char *)malloc(TICKET_KEY_SIZE);

    FILE *fd = fopen(filename, "r");
    fread(data, TICKET_KEY_SIZE, 1, fd);
    fclose(fd);

    ticket_key.key_name = data;
    ticket_key.aes_key = data + 16;
    ticket_key.hmac_secret = data + 32;

    return;
}

static void ticket_key_close()
{
    free(ticket_key.key_name);

    return;
}

int ticket_key_cb(SSL *ssl, unsigned char *keyname, unsigned char *iv, EVP_CIPHER_CTX *cctx, HMAC_CTX *hctx, int mode)
{

    if (mode == 1) {

        memcpy(keyname, ticket_key.key_name, 16);
        RAND_pseudo_bytes(iv, EVP_MAX_IV_LENGTH);
        EVP_EncryptInit_ex(cctx, EVP_aes_128_cbc(), NULL, ticket_key.aes_key, iv);
        HMAC_Init_ex(hctx, ticket_key.hmac_secret, 16, EVP_sha256(), NULL);

        return 0;
    } else if (mode == 0) {

        if (memcmp(keyname, ticket_key.key_name, 16)) return 0;
        EVP_DecryptInit_ex(cctx, EVP_aes_128_cbc(), NULL, ticket_key.aes_key, iv);
        HMAC_Init_ex(hctx, ticket_key.hmac_secret, 16, EVP_sha256(), NULL);

        return 1;
    }

    return -1;
}

この試験実装のコードは、鍵名、暗号鍵、メッセージ検証のシークレットを ticket.key というファイルから読み込み、その File を共有する事で複数の Server やプロセスで Session Ticket の暗号/復号/検証の処理を統一する事ができます。SSL_CTX_set_tlsext_ticket_key_cb で設定されるコールバック関数は 6 つの引数を取ります。第6引数は数値であり、コールバックが ticket の生成で利用されているか、もしくは復号/検証で利用されているかを示す mode になります。mode は 0 もしくは 1 を取り、0 であれば ticket の生成での呼び出し、1 であれば ticket の復号/検証での呼び出しです。mode が 1 つまり生成の場合コールバックは鍵名、IV、暗号鍵を設定した暗号アルゴリズム、シークレットを設定したMACアルゴリズムをそれぞれ引数によって指定されたポインタに格納します。mode が 0 つまり復号/証であればコールバックは、鍵名を比較し一致すれば暗号鍵を設定した復号アルゴリズム、シークレットを設定したMACアルゴリズムをそれぞれ引数によって指定されたポインタに格納します。OpenSSL はコールバックの返り値を見て動作を決定します。この返り値は OpenSSL のマニュアルには

The return value of the cb function is used by OpenSSL to determine what further processing will occur. The following return values have meaning:

 1. This indicates that the ctx and hctx have been set and the session can continue on those parameters. Additionally it indicates that the session ticket is in a renewal period and should be replaced. The OpenSSL library will call cb again with an enc argument of 1 to set the new ticket (see RFC5077 3.3 paragraph 2).
 2. This indicates that the ctx and hctx have been set and the session can continue on those parameters.
 3. This indicates that it was not possible to set/retrieve a session ticket and the SSL/TLS session will continue by by negiotationing a set of cryptographic parameters or using the alternate SSL/TLS resumption mechanism, session ids.

If called with enc equal to 0 the library will call the cb again to get a new set of parameters.

less than 0
This indicates an error.

と記載されていますが、 tls_decrypt_ticket にある OpenSSL がコールバックを呼び出す実際の処理を確認すると

    if (tctx->tlsext_ticket_key_cb)
        {
        unsigned char *nctick = (unsigned char *)etick;
        int rv = tctx->tlsext_ticket_key_cb(s, nctick, nctick + 16,
                            &ctx, &hctx, 0);
        if (rv < 0)
            return -1;
        if (rv == 0)
            return 2;
        if (rv == 2)
            renew_ticket = 1;
        }

となっており、返り値が 2 の時に renew_ticket を設定しているのがわかります。つまりマニュアルの記載は返り値の 1 と 2 の説明が逆になっています。

さてこれで Client から送られて来る Session Ticket を複数の Server で共用できるようになりました。では Apache httpd や nginx ではどのように実装されているのでしょうか。

Apache httpd における実装

Apache httpd の Session Ticket に対する実装は modules/ssl/ssl_engine_init.c ssl_init_ticket_key にあります。ssl_init_ticket_key は Apache httpd が起動し設定を読み込んだ後に実行される post_config という処理タイミングで Hook され実行されます。ssl_init_ticket_key の内容は、ticket_key->file_path に設定された文字列のパスをなおし、そのパスが示すファイルを開いて TLSEXT_TICKET_KEY_LEN に指定される長さ(48 バイト)読み込み、16 バイトずつをそれぞれ鍵名、MACシークレット、暗号鍵として保存し、SSL_CTX_set_tlsext_ticket_key_cb を用いて ssl_callback_SessionTicket をコールバック関数に設定します。

    rv = apr_file_open(&fp, path, APR_READ|APR_BINARY,
                       APR_OS_DEFAULT, ptemp);

    if (rv != APR_SUCCESS) {
        ap_log_error(APLOG_MARK, APLOG_EMERG, 0, s, APLOGNO(02286)
                     "Failed to open ticket key file %s: (%d) %pm",
                     path, rv, &rv);
        return ssl_die(s);
    }

    rv = apr_file_read_full(fp, &buf[0], TLSEXT_TICKET_KEY_LEN, &len);

    if (rv != APR_SUCCESS) {
        ap_log_error(APLOG_MARK, APLOG_EMERG, 0, s, APLOGNO(02287)
                     "Failed to read %d bytes from %s: (%d) %pm",
                     TLSEXT_TICKET_KEY_LEN, path, rv, &rv);
        return ssl_die(s);
    }

    memcpy(ticket_key->key_name, buf, 16);
    memcpy(ticket_key->hmac_secret, buf + 16, 16);
    memcpy(ticket_key->aes_key, buf + 32, 16);

    if (!SSL_CTX_set_tlsext_ticket_key_cb(mctx->ssl_ctx,
                                          ssl_callback_SessionTicket)) {

ticket_key->file_path は“SSLSessionTicketKeyFile”という directive で設定されます。SessionTicketKeyFile directive は一つの引数のみが設定可能となるように modules/ssl/mod_ssl.c 実装されています。

    SSL_CMD_SRV(SessionTicketKeyFile, TAKE1,
                "TLS session ticket encryption/decryption key file (RFC 5077) "
                "('/path/to/file' - file with 48 bytes of random data)")

コールバック関数である ssl_callback_SessionTicket modules/ssl/ssl_engine_kernel.c で実装されており、前述の試験実装のコールバック関数と内容はほとんど同じになっています。

以上が Apache httpd の Session Ticket に対する実装になっています。さて次に nginx の実装を見てみます。

nginx における実装

nginx の実装は Apache httpd と異なる点があります。それは、nginx の実装は Session Ticket に対する鍵名/シークレット/暗号鍵が複数設定可能でこれらを入れ替える運用が意識されていると言う点です。
ではコードを確認してみましょう。nginx の Session Ticket の実装は src/event/ngx_event_openssl.c ngx_ssl_session_ticket_keys にあります。

#ifdef SSL_OP_NO_TICKET
    if (!conf->session_tickets) {
        SSL_CTX_set_options(conf->ssl.ctx, SSL_OP_NO_TICKET);
    }
#endif

    path = paths->elts;
    for (i = 0; i < paths->nelts; i++) {

...
        file.fd = ngx_open_file(file.name.data, NGX_FILE_RDONLY, 0, 0);

...
        n = ngx_read_file(&file, buf, 48, 0);

...
        key = ngx_array_push(keys);

...
        ngx_memcpy(key->name, buf, 16);
        ngx_memcpy(key->aes_key, buf + 16, 16);
        ngx_memcpy(key->hmac_key, buf + 32, 16);

    }

...
    if (SSL_CTX_set_tlsext_ticket_key_cb(ssl->ctx,
                                         ngx_ssl_session_ticket_key_callback)
        == 0)

ngx_ssl_session_ticket_keys は Session id を用いた Server での cache でも出て来た src/http/modules/ngx_http_ssl_module.c ngx_http_ssl_merge_srv_conf から呼び出され、Session Ticket の鍵情報を初期化し、コールバックを設定します。
この実装は Apache httpd の ssl_init_ticket_key にある実装と良く似ていますが、与えられるファイルが配列になっており複数指定する事が可能です。指定された複数のファイルはループの中で読み込まれ、鍵名/シークレット/暗号鍵の情報を格納する配列に順番に push します。その後 ngx_ssl_session_ticket_key_callback をコールバック関数に登録します。

コールバック関数である ngx_ssl_session_ticket_key_callback は鍵情報の配列の最初の一つを用いて、Session Ticket を生成します。ですが、鍵の復号/検証の処理は鍵名から鍵情報の配列を線形に探索します。この時、最初の一つではない鍵情報が用いられると返り値は 2 となり Session Ticket の再生成を促します。

    if (enc == 1) {
        /* encrypt session ticket */

        ngx_log_debug3(NGX_LOG_DEBUG_EVENT, c->log, 0,
                       "ssl session ticket encrypt, key: \"%*s\" (%s session)",
                       ngx_hex_dump(buf, key[0].name, 16) - buf, buf,
                       SSL_session_reused(ssl_conn) ? "reused" : "new");

        RAND_pseudo_bytes(iv, 16);
        EVP_EncryptInit_ex(ectx, EVP_aes_128_cbc(), NULL, key[0].aes_key, iv);
        HMAC_Init_ex(hctx, key[0].hmac_key, 16,
                     ngx_ssl_session_ticket_md(), NULL);
        ngx_memcpy(name, key[0].name, 16);

        return 0;

    } else {
        /* decrypt session ticket */

        for (i = 0; i < keys->nelts; i++) {
            if (ngx_memcmp(name, key[i].name, 16) == 0) {
                goto found;
            }
        }

        ngx_log_debug2(NGX_LOG_DEBUG_EVENT, c->log, 0,
                       "ssl session ticket decrypt, key: \"%*s\" not found",
                       ngx_hex_dump(buf, name, 16) - buf, buf);

        return 0;

    found:

        ngx_log_debug3(NGX_LOG_DEBUG_EVENT, c->log, 0,
                       "ssl session ticket decrypt, key: \"%*s\"%s",
                       ngx_hex_dump(buf, key[i].name, 16) - buf, buf,
                       (i == 0) ? " (default)" : "");

        HMAC_Init_ex(hctx, key[i].hmac_key, 16,
                     ngx_ssl_session_ticket_md(), NULL);
        EVP_DecryptInit_ex(ectx, EVP_aes_128_cbc(), NULL, key[i].aes_key, iv);

        return (i == 0) ? 1 : 2 /* renew */;
    }

つまり、nginx は Session Ticket の生成に対して特定の一つの鍵情報、復号/検証に対して前者を含む複数の鍵情報を用いる事ができるという事になります。これは、運用時に鍵情報を入れ替える場合に過去に生成した Session Ticket を無駄にしないための実装になります。

nginx の実装を Apache httpd で真似る

さて、nginx の実装は Apache httpd に比べて運用に配慮したものになっているようです。これをもって “nginx は Apache httpd に比べてできが良い” という批評は非技術者の批評家の方々にお任せするとして、Apache httpd もまたせっかくの OSS なのですから nginx の機能を組み込んでみましょう。

まず“SSLSessionTicketKeyFile”directive を複数指定できるように改修します。

modssl_ctx_t にある設定を保存する変数の型を modssl_ticket_key_t から配列を格納する apr_array_header_t に変更します。同時に後に変更する関数プロトタイプを変更します。これらは modules/ssl/ssl_private.h にあります。

#ifdef HAVE_TLS_SESSION_TICKETS
    apr_array_header_t *ticket_key;
#endif

次に SSLSessionTicketKeyFile directive を複数指定とします。これは modules/ssl/mod_ssl.c にあります。複数設定への指定は nginx と同様に複数を一行に空白区切りとします。このため設定のパースは TAKE_ARGV とします。

#ifdef HAVE_TLS_SESSION_TICKETS
    SSL_CMD_SRV(SessionTicketKeyFile, TAKE_ARGV,
                "TLS session ticket encryption/decryption key file (RFC 5077) "
                "('/path/to/file' - file with 48 bytes of random data)")
#endif

次に directive を複数指定とし、設定の初期化、設定の読み込みとマージの処理を改修します。既にご存知のようにこれらは modules/ssl/ssl_engine_config.c にあります。

  • 設定の初期化
static void modssl_ctx_init_server(SSLSrvConfigRec *sc,
                                   apr_pool_t *p)
{
    modssl_ctx_t *mctx;

...

#ifdef HAVE_TLS_SESSION_TICKETS
    mctx->ticket_key = apr_array_make(p, 0, sizeof(modssl_ticket_key_t *));
#endif
}
  • 設定の読み込み
#ifdef HAVE_TLS_SESSION_TICKETS
const char *ssl_cmd_SSLSessionTicketKeyFile(cmd_parms *cmd,
                                            void *dcfg,
                                            int argc,
                                            const char **argv)
{
    SSLSrvConfigRec *sc = mySrvConfig(cmd->server);
    const char *err;
    int i;

    for(i = 0; i < argc; i++) {
        if ((err = ssl_cmd_check_file(cmd, &(argv[i])))) {
            return err;
        }

        modssl_ticket_key_t *ticket_key =
            (modssl_ticket_key_t *)apr_palloc(cmd->pool, sizeof(modssl_ticket_key_t));
        ticket_key->file_path = apr_pstrdup(cmd->pool, argv[i]);

        *(modssl_ticket_key_t **)apr_array_push(sc->server->ticket_key) = ticket_key;
    }

    return NULL;
}
#endif

ここで ssl_cmd_SSLSessionTicketKey の引数が TAKE_ARGV に対応した形に変更されるのでプロトタイプも変更します。これは modules/ssl/ssl_private.h にあります。

#ifdef HAVE_TLS_SESSION_TICKETS
const char *ssl_cmd_SSLSessionTicketKeyFile(cmd_parms *cmd, void *dcfg, int argc, const char **argv);
#endif
  • 設定のマージ
static void modssl_ctx_cfg_merge_server(apr_pool_t *p,
                                        modssl_ctx_t *base,
                                        modssl_ctx_t *add,
                                        modssl_ctx_t *mrg)
{
    modssl_ctx_cfg_merge(p, base, add, mrg);

...

#ifdef HAVE_TLS_SESSION_TICKETS
    cfgMergeArray(ticket_key);
#endif
}

次に読み込まれた設定を持って鍵情報を読み込みます。これは modules/ssl/ssl_engine_init.c にあります。

#ifdef HAVE_TLS_SESSION_TICKETS
static apr_status_t ssl_init_ticket_key(server_rec *s,
                                        apr_pool_t *p,
                                        apr_pool_t *ptemp,
                                        modssl_ctx_t *mctx)
{
    apr_status_t rv;
    apr_file_t *fp;
    apr_size_t len;
    char buf[TLSEXT_TICKET_KEY_LEN];
    char *path;
    modssl_ticket_key_t *ticket_key;
    int i;

    for(i = 0; i < mctx->ticket_key->nelts; i++) {

        ticket_key = ((modssl_ticket_key_t**)mctx->ticket_key->elts)[i];

        if (!ticket_key->file_path) {
            return APR_SUCCESS;
        }

        path = ap_server_root_relative(p, ticket_key->file_path);

        rv = apr_file_open(&fp, path, APR_READ|APR_BINARY,
                           APR_OS_DEFAULT, ptemp);

        if (rv != APR_SUCCESS) {
            ap_log_error(APLOG_MARK, APLOG_EMERG, 0, s, APLOGNO(02286)
                         "Failed to open ticket key file %s: (%d) %pm",
                         path, rv, &rv);
            return ssl_die(s);
        }

        rv = apr_file_read_full(fp, &buf[0], TLSEXT_TICKET_KEY_LEN, &len);

        if (rv != APR_SUCCESS) {
            ap_log_error(APLOG_MARK, APLOG_EMERG, 0, s, APLOGNO(02287)
                         "Failed to read %d bytes from %s: (%d) %pm",
                         TLSEXT_TICKET_KEY_LEN, path, rv, &rv);
            return ssl_die(s);
        }

        memcpy(ticket_key->key_name, buf, 16);
        memcpy(ticket_key->hmac_secret, buf + 16, 16);
        memcpy(ticket_key->aes_key, buf + 32, 16);
    }

最後にコールバック関数を複数の鍵情報に対応するように改修します。これは modules/ssl/ssl_engine_kernel.c にあります。

nt ssl_callback_SessionTicket(SSL *ssl,
                               unsigned char *keyname,
                               unsigned char *iv,
                               EVP_CIPHER_CTX *cipher_ctx,
                               HMAC_CTX *hctx,
                               int mode)
{
    conn_rec *c = (conn_rec *)SSL_get_app_data(ssl);
    server_rec *s = mySrvFromConn(c);
    SSLSrvConfigRec *sc = mySrvConfig(s);
    SSLConnRec *sslconn = myConnConfig(c);
    modssl_ctx_t *mctx = myCtxConfig(sslconn, sc);
    modssl_ticket_key_t *ticket_key;
    int i;

    if (mode == 1) {
        /*
         * OpenSSL is asking for a key for encrypting a ticket,
         * see s3_srvr.c:ssl3_send_newsession_ticket()
         */
        ticket_key = ((modssl_ticket_key_t**)mctx->ticket_key->elts)[0];

        if (ticket_key == NULL) {
            /* should never happen, but better safe than sorry */
            return -1;
        }

        memcpy(keyname, ticket_key->key_name, 16);
        RAND_pseudo_bytes(iv, EVP_MAX_IV_LENGTH);
        EVP_EncryptInit_ex(cipher_ctx, EVP_aes_128_cbc(), NULL,
                           ticket_key->aes_key, iv);
        HMAC_Init_ex(hctx, ticket_key->hmac_secret, 16, tlsext_tick_md(), NULL);

        ap_log_cerror(APLOG_MARK, APLOG_DEBUG, 0, c, APLOGNO(02289)
                      "TLS session ticket key for %s successfully set, "
                      "creating new session ticket", sc->vhost_id);

        return 0;
    }
    else if (mode == 0) {
        /*
         * OpenSSL is asking for the decryption key,
         * see t1_lib.c:tls_decrypt_ticket()
         */
        for(i = 0; i < mctx->ticket_key->nelts; i++) {

            ticket_key = ((modssl_ticket_key_t**)mctx->ticket_key->elts)[i];

            /* check key name */
            if (memcmp(keyname, ticket_key->key_name, 16) == 0) {

                EVP_DecryptInit_ex(cipher_ctx, EVP_aes_128_cbc(), NULL,
                                   ticket_key->aes_key, iv);
                HMAC_Init_ex(hctx, ticket_key->hmac_secret, 16, tlsext_tick_md(), NULL);

                ap_log_cerror(APLOG_MARK, APLOG_DEBUG, 0, c, APLOGNO(02290)
                              "TLS session ticket key %s for %s successfully set, "
                              "decrypting existing session ticket",
                              ticket_key->file_path, sc->vhost_id);

                return (i == 0) ? 1 : 2;
            }
        }

        return 0;
    }

    /* OpenSSL is not expected to call us with modes other than 1 or 0 */
    return -1;
}

以上で Apache httpd の Session Ticket の対応が nginx と同様に複数の鍵情報に対応できます。

Session Ticket は Session id に対する Server での cache に比べ、Server 側の運用負荷が低く、特に分散された環境へ対応が楽になります。しかしながら Session Ticket は TLS そのものにある仕様ではなく拡張されたものであり、Client での対応が十分に行われている状態ではありません。

セキュリティ

これまで Session の再開に対する SSL/TLS の session resumption の機能や実装がわかりました。ではこれらを用いた時、どういったセキュリティの問題が起こるのでしょうか。

session resumption は保存されたセッションの情報を用いて SSL/TLS の接続を再開します。では “セッションの情報” とは具体的に何でしょうか?

試験実装や Apache httpd, nginx の Server における Session 情報のキャッシュのコールバック、OpenSSL における Session Ticket の生成の実装は SSL_SESSION という型のセッションの情報を i2d_SSL_SESSION を用いて DER 形式に変換して保存しています。SSL_SESSION は OpenSSL の ssl/ssl.h 定義されています。

typedef struct ssl_session_st SSL_SESSION;
...
/*-
 * Lets make this into an ASN.1 type structure as follows
 * SSL_SESSION_ID ::= SEQUENCE {
 *    version         INTEGER,    -- structure version number
 *    SSLversion         INTEGER,    -- SSL version number
 *    Cipher             OCTET STRING,    -- the 3 byte cipher ID
 *    Session_ID         OCTET STRING,    -- the Session ID
 *    Master_key         OCTET STRING,    -- the master key
 *    KRB5_principal        OCTET STRING    -- optional Kerberos principal
 *    Key_Arg [ 0 ] IMPLICIT    OCTET STRING,    -- the optional Key argument
 *    Time [ 1 ] EXPLICIT    INTEGER,    -- optional Start Time
 *    Timeout [ 2 ] EXPLICIT    INTEGER,    -- optional Timeout ins seconds
 *    Peer [ 3 ] EXPLICIT    X509,        -- optional Peer Certificate
 *    Session_ID_context [ 4 ] EXPLICIT OCTET STRING,   -- the Session ID context
 *    Verify_result [ 5 ] EXPLICIT INTEGER,   -- X509_V_... code for `Peer'
 *    HostName [ 6 ] EXPLICIT OCTET STRING,   -- optional HostName from servername TLS extension 
 *    PSK_identity_hint [ 7 ] EXPLICIT OCTET STRING, -- optional PSK identity hint
 *    PSK_identity [ 8 ] EXPLICIT OCTET STRING,  -- optional PSK identity
 *    Ticket_lifetime_hint [9] EXPLICIT INTEGER, -- server's lifetime hint for session ticket
 *    Ticket [10]             EXPLICIT OCTET STRING, -- session ticket (clients only)
 *    Compression_meth [11]   EXPLICIT OCTET STRING, -- optional compression method
 *    SRP_username [ 12 ] EXPLICIT OCTET STRING -- optional SRP username
 *    }
 * Look in ssl/ssl_asn1.c for more details
 * I'm using EXPLICIT tags so I can read the damn things using asn1parse :-).
 */
struct ssl_session_st
    {

となっており Master Key が入っているのがわかります。
Master Key はセッションで用いられる秘密鍵で、これを用いて暗号化された通信を復号しています。つまり、このセッション情報が第三者に漏れると、漏洩したセッション情報を用いて再開された SSL/TLS セッションはその第三者によって復号する事ができます。

TLS1.2 の規定である RFC5246 は Session の再開において Appendix F.1.4 に下記の記述があります。

F.1.4.  Resuming Sessions

   When a connection is established by resuming a session, new
   ClientHello.random and ServerHello.random values are hashed with the
   session's master_secret.  Provided that the master_secret has not
   been compromised and that the secure hash operations used to produce
   the encryption keys and MAC keys are secure, the connection should be
   secure and effectively independent from previous connections.
   Attackers cannot use known encryption keys or MAC secrets to
   compromise the master_secret without breaking the secure hash
   operations.

   Sessions cannot be resumed unless both the client and server agree.
   If either party suspects that the session may have been compromised,
   or that certificates may have expired or been revoked, it should
   force a full handshake.  An upper limit of 24 hours is suggested for
   session ID lifetimes, since an attacker who obtains a master_secret
   may be able to impersonate the compromised party until the
   corresponding session ID is retired.  Applications that may be run in
   relatively insecure environments should not write session IDs to
   stable storage.

Session id を用いたキャッシュ機能はその保存において十分に機密性が保たれる事が述べられています。と言う事は、外部の記憶領域を用いてキャッシュを共有するような構造、例えば Apache httpd の socache において memcached provider を用いて共有を行う設計は、その利用環境において十分な機密性を確保する事が必要になります。また、Session 情報の有効期限は最大でも 24 時間を上限とする事が提案されています。

Session Ticket の仕組みはセッション情報を安全性の保証されない通信路を用いて Client に送ります。Session Ticket を用いた Session の再開では最初の Client Hello に含まれる SessionTicket TLS Extension に Session Ticket が平文で送られます。つまり、Session Ticket は通信路で盗む事ができると言う事です。Session Ticket が盗まれる事に対すしては Session Ticket の RFC である RFC5077 Section 5.2 で解説されています。

5.2.  Stolen Tickets

   An eavesdropper or man-in-the-middle may obtain the ticket and
   attempt to use it to establish a session with the server; however,
   since the ticket is encrypted and the attacker does not know the
   secret key, a stolen ticket does not help an attacker resume a
   session.  A TLS server MUST use strong encryption and integrity
   protection for the ticket to prevent an attacker from using a brute
   force mechanism to obtain the ticket's contents.

単純に Session Ticket のみが盗まれたとしても、それは暗号化されており復号できなければ Session 情報そのものが取得されたわけではなく、盗んだ Session Ticket を持って Session が再開できるわけではありません。ただし、Session Ticket は復号した情報、つまり暗号化された Session Ticket に対する平文を Client も持ちます。このため平文と暗号文、それに暗号アルゴリズムがそろうためを復号するための暗号鍵を総当たり攻撃(brute force attack)などを用いる事が可能となります。

このため用いる暗号鍵の運用に対する推奨が RFC5077 Section 5.5 に記述されています。

5.5.  Ticket Protection Key Management

   A full description of the management of the keys used to protect the
   ticket is beyond the scope of this document.  A list of RECOMMENDED
   practices is given below.

   o  The keys should be generated securely following the randomness
      recommendations in [RFC4086].

   o  The keys and cryptographic protection algorithms should be at
      least 128 bits in strength.  Some ciphersuites and applications
      may require cryptographic protection greater than 128 bits in
      strength.

   o  The keys should not be used for any purpose other than generating
      and verifying tickets.

   o  The keys should be changed regularly.

   o  The keys should be changed if the ticket format or cryptographic
      protection algorithms change.

鍵情報は定期的に入れ替えられる事が推奨されています。これが nginx での鍵の入れ替えに対する実装の配慮につながっています。鍵の入れ替えは用いる暗号アルゴリズムに対する有効な攻撃に対して十分に安全である期間で入れ替えを行わなければなりません。

Session Ticket はそれが盗まれる事を前提としています。と言う事は、Session Ticket は Session の情報を cache するにあたり、機密性が保証されない経路やストレージに対してどのような形で保存すべきかと言う事を提案している内容でもあります。つまり、Session Ticket が十分安全に運用可能であるのであれば、Session Ticket と同一の形式で Server に保存する事によって機密性の保証されない Storage に対する cache も Session Ticket と同等に安全であると言えます。

終わりに

Session の再開を用いると Server や Client の負荷を下げ接続における遅延を下げる事ができます。特に最近利用が増えている Web API 等は、転送するデータ量が小さく client が接続を意識した実装をしていない場合、頻度の高い再接続が行われる場合があります。このような場合、接続のオーバヘッドを下げる Session の再開への対応が効果を生む場合があります。しかし、その仕組みにおいて cache される情報はセキュリティ上非常に危険な情報となっており、安易な cache 構造や、その cache データの保存の運用を行うと脆弱性を生み脅威につながる危険性があります。
このような仕組みの理解やその仕組みの実装への理解はセキュリティの脅威を把握し安全に配慮した設計や運用に非常に効果的で根本的な対応策となり得ます。

例えば今回 Apache httpd における memcached を用いた cache の危険性を述べていますが、Session Ticket の仕組みが機密性の低いストレージに対してどのように cache をすれば安全と考えられているかも述べています。同時に、cache の仕組みがコールバック関数で実装可能であり Apache httpd においてどのように実装されているかを解説しました。と言う事は、Session Ticket が十分に安全であると考えるのであれば Server での cache においても Session Ticket と同等のアルゴリズムをコールバック関数に適用し鍵情報等に対し Session Ticket と同等の運用を行う事で対応可能となるかもしれません。Session id による Server での cache が安全に運用できるのであれば、Session Ticket に対応していない Client に対しても Session の再開が可能となります。

今回はコールバック関数に Session 情報の暗号化を施す機能の実装を示しません。ぜひ、いろいろな実装を検討し試験して提案を行っていただければと思います。

Appendix

他実装に対するポインタ

Apache httpd 2.4.11 で入る予定の Session Ticket の新規 directive

TLS 1.3 Internet Draft

こちらの記事のご感想を聞かせください。

  • 学びがある
  • わかりやすい
  • 新しい視点

ご感想ありがとうございました

このページの先頭へ