gRPCの通信ををSSL/TLSの暗号化で保護するとともに、クライアント認証をX.509証明書で行えるように、Tkrzw-RPCのサーバとクライアントライブラリを改良した。gRPCではSSL/TLSだけでなく、ALTSやToken-based認証も使えるのだが、まずは手軽かつ歴史のあるなSSL/TLSの対応から取り組む。SSL化したことによる各言語のスループットの影響は以下のようになる。EchoのスループットはC++で75%ほどに低下するが、それが許容範囲であれば実用できる。
データベースサービスであるTkrzw-RPCを運用する場合、普通はデータセンター内で起動して、信頼できるクライアントからしかアクセスされないようにネットワーク層でセキュリティを確保する。信頼できないクライアントが一つでもいれば、そいつがClearメソッドを発行するだけで、データベースの全データは一瞬で消失してしまう。イテレータを回して秘匿情報を抜き取る攻撃もありうるだろう。一旦侵入を許せば、やられ放題だ。そこで、特定のクライアントマシンからしかデータを操作できないようにしたくなる。ネットワークに侵入された時点で負け戦ではあるのだが、それでも、いわゆる水際対策を施したくもなるだろう。また、内部の人間が気軽にサボタージュ行為を起こせないようにすることも重要だ。同一ネットワークに攻撃者がいたとしても、データベースに認証されるクライアントのマシンの操作権限がなければ、データベースを攻撃することはできない。
クライアント認証と暗号通信のおかげで、データベースサービスをインターネット経由で利用することも可能になる。デスクトップやモバイル機器のアプリケーションからデータベースを参照したり、データセンタ間でデータを共有したりもできる。とはいえ、データベースサービスをそのままインターネットに公開することは稀で、前段にビジネスロジックを司るサービスを置き、そいつがクライアント認証も行うのが普通だ。
gRPCでは、チャンネルを作成する際にSSLを有効化すると、サーバとクライアント間の通信がPKI(公開鍵基盤)を使って暗号化される。クライアントとサーバは互いに公開鍵入りの電子証明書を交換し、互いに相手の公開鍵を使って暗号化したデータを送信する。公開鍵で暗号化したデータの復号には秘密鍵が必要なので、盗聴はこれで防げる。また、電子証明書を交換した際にその署名が信頼された認証機関によるものかどうかを確認することで、相手が本物かが認証できる。
認証機構の利用法は様々で、全てのニーズに対応しようとすると非常に複雑なインターフェイスになってしまう。よって、現時点ではかなり大胆な単純化を行う。クライアントの証明書が、サーバ起動時に指定したルート証明書のCA(認証局)の署名として検証できさえすれば、そのクライアントは認証する。つまり、証明書の内容(CN=common name=共通名など)は調べない。よって、信頼すべきクライアントには、自分で立てたCAで署名した証明書を配備する必要がある。秘密鍵と証明書のペアはクライアント毎に作ってもよいし、全てのクライアントで同じものを使ってもよい。
SSLを使った認証機構を試すには、結構な手数を要する準備が必要だ。OpenSSLのコマンドをバシバシ叩くことになる。スクリプトを書けばコマンド一発で済む作業なのだが、個々の手順を把握しておくことは重要だ。まずは自前のCAを立てる。外部のCAに署名してもらってもよいが、面倒なのでここでは自己署名のCAを作る。以下のコマンドを実行する。
# 空のディレクトリを作る rm -rf root-ca mkdir -p root-ca # OpenSSLの設定ファイルを作って、デフォルトの設定に依存しないようにする cat > root-ca/openssl.conf <<__EOF [ ca ] default_ca = ca_default [ ca_default ] dir = root-ca certs = root-ca new_certs_dir = root-ca database = root-ca/index.txt serial = root-ca/serial.txt RANDFILE = root-ca/rand certificate = root-ca/root-cert.pem private_key = root-ca/root-key.pem default_days = 10000 default_crl_days = 10000 default_md = md5 preserve = no policy = generic_policy [ generic_policy ] countryName = optional stateOrProvinceName = optional localityName = optional organizationName = optional organizationalUnitName = optional commonName = supplied emailAddress = optional __EOF # 自己署名のルートCA証明書を作るCN=xxxの部分は適当に変えること openssl req -x509 -newkey rsa:2048 -nodes -keyout root-key.pem \ -sha256 -days 10000 -subj "/CN=root.dbmx.net" -out root-cert.pem # 以後の署名作業用のインテックスファイルと連番ファイルを作る touch root-ca/index.txt echo 01 > root-ca/serial.txt
サーバやクライアントの秘密鍵と署名のペアを作るべく、以下のコマンドをシェルスクリプトとして用意する。実行する際には、第1引数にエージェント(サーバまたはクライアント)のCN(共通名)を指定する。サーバの場合、FQDNとCNは一致する必要がある。つまり、localhost とか、www.example.com とかだ。クライアントの場合、mikio でも client001 でも何でも良い。
#! /bin/bash name="$1" # RSAの秘密鍵をPKCS#1形式で作る openssl genrsa -out "${name}-key-pkcs1.pem" 2048 # 秘密鍵をPKCS#8形式に変換する openssl pkcs8 -topk8 -nocrypt -in "${name}-key-pkcs1.pem" -out "${name}-key.pem" # 秘密鍵から共通鍵を抽出する openssl pkey -pubout -in "${name}-key.pem" > "${name}-pubkey.pem" # CAに提出する署名要求を生成する openssl req -new -key "${name}-key.pem" -subj "/CN=${name}" -out "${name}-csr.pem" # CAの秘密鍵を使って署名要求に署名して証明書を作る openssl ca --batch -in "${name}-csr.pem" -out "${name}-cert.pem" \ -keyfile root-key.pem -cert root-cert.pem -md sha256 -days 10000 \ -policy generic_policy -config root-ca/openssl.conf # 不要なデータを削除する rm -f "${name}-key-pkcs1.pem" "${name}-csr.pem"
上記をlocalhostを引数にして実行すると、秘密鍵ファイル localhost-key.pemと、公開鍵ファイルlocalhost-pubkey.pemと、証明書ファイルlocalhost-cert.pemが作られる。PEMというのはPKI関係のデータをBase64でエンコードしてテキストとして扱えるようにした形式だ。拡張子をpemでなく、localhost.key、localhost.pub、localhost.crtなどにする流儀もあるが、名前はどうでもいい。秘密鍵と公開鍵と証明書の区別さえついていればよい。公開鍵や証明書は第三者に見せてもよいが、秘密鍵だけは見られないように厳重に管理せねばならない。
前置きが非常に長くなったが、これを知らないとPKIの管理ができないのだからしょうがない。テスト用に、CAとサーバとクライアントの秘密鍵と証明書はテスト用の秘密鍵と証明書をリポジトリに上げてあるし、ソースパッケージにも含めているので、以後の手順はそれらを使って進める。
実際にTkrzw-RPCのサーバを立ててみよう。0.9.6からは--authというオプションが追加されていて、それでSSLを有効化する旨と、サーバの秘密鍵、サーバの証明書、ルートCA証明書の各ファイルを指定する。CNがlocalhostなので、FQDNもlocalhostにすること。
$ tkrzw_server \ --auth "ssl:key=localhost-key.pem,cert=localhost-cert.pem,root=root-cert.pem" \ --address localhost:1978 --async
ちゃんとSSLで通信できているかどうかは、以下のコマンドで確認できる。-alpn h2を指定することで、gRPCが用いるTLSのALPN拡張がHTTP/2越しで使えるか確認できる。出力に「ALPN protocol: h2」と書いてあれば大丈夫だ。
$ openssl s_client -alpn h2 -connect localhost:1978 ONNECTED(00000003) --- Certificate chain 0 s:CN = localhost i:CN = root.dbmx.net 1 s:CN = root.dbmx.net i:CN = root.dbmx.net --- ... New, TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384 Server public key is 2048 bit Secure Renegotiation IS supported Compression: NONE Expansion: NONE ALPN protocol: h2 SSL-Session: Protocol : TLSv1.2 Cipher : ECDHE-RSA-AES256-GCM-SHA384 ...
クライアントがサーバに接続する際にも、--authオプションを使う。SSLを有効化する旨と、クライアントの秘密鍵、クライアントの証明書、ルートCAの証明書の各ファイルを指定する。
$ tkrzw_dbm_remote_util inspect \ --auth "ssl:key=client-key.pem,cert=client-cert.pem,root=root-cert.pem" class=TinyDBM num_records=0 num_buckets=1048583
C++のサーバ側の実装を見てみよう。SSL対応前には、サーバを立てる際のコードは以下のようになっていた。
std::shared_ptr<grpc::ServerCredentials> credentials = grpc::InsecureServerCredentials(); grpc::ServerBuilder builder; builder.AddListeningPort(address, credentials);
現状の仕様では、チャンネルにクライアントが接続した時にのみ認証を確認する。よって、チャンネルを作成する際の処理のみを置き換えればよい。具体的には以下のようなコードを書くことになる。
grpc::SslServerCredentialsOptions ssl_opts; grpc::SslServerCredentialsOptions::PemKeyCertPair pkcp; // サーバの秘密鍵と証明書のペアを登録する pkcp.private_key = ReadFileSimple(key_path); pkcp.cert_chain = ReadFileSimple(cert_path); ssl_opts.pem_key_cert_pairs.emplace_back(pkcp); // ルート証明書を登録する ssl_opts.pem_root_certs = ReadFileSimple(root_path); // 認証時に、クライアント証明書をルート証明書で検証することを指示する ssl_opts.client_certificate_request = GRPC_SSL_REQUEST_AND_REQUIRE_CLIENT_CERTIFICATE_AND_VERIFY; // SSLを有効化したチャンネルを作る std::shared_ptr<grpc::ServerCredentials> credentials = SslServerCredentials(ssl_opts); grpc::ServerBuilder builder; builder.AddListeningPort(address, credentials);
クライアント側の実装も見てみよう。SSL対応前はこんなコードだった。
auto credentials = grpc::InsecureChannelCredentials(); auto channel = grpc::CreateChannel(address, credentials);
SSL対応はこんな感じになる。サーバと同様に、クライアント秘密鍵とクライアント証明書とルート証明書をcredentialオブジェクトに登録して、それを渡してチャンネルを作るのだ。
grpc::SslCredentialsOptions ssl_opts; ssl_opts.pem_private_key = ReadFileSimple(key_path); ssl_opts.pem_cert_chain = ReadFileSimple(cert_path); ssl_opts.pem_root_certs = ReadFileSimple(root_path); auto credentials = grpc::SslCredentials(ssl_opts); auto channel = grpc::CreateChannel(address, credentials);
その他のコードは、何も変えなくて良い。認証も暗号化もgRPCのライブラリが適当にやってくれる。セキュリティ周りのコードを自分で書かなくてよいというのは、gRPCの非常に重要な利点だ。生半可に自分でやろうとすると、後悔する可能性が高い。私ごときがJPCERT様の手を煩わせるのは忍びない。
PythonとRubyのクライアントでもSSLに対応した。それぞれの接続部分を抜粋する。C++と同様に、接続処理のコードだけを書き換えれば対応が完了する。
with open(key_path, "rb") as f: key_data = f.read() with open(cert_path, "rb") as f: cert_data = f.read() with open(root_path, "rb") as f: root_data = f.read() credentials = grpc.ssl_channel_credentials(root_data, key_data, cert_data) self.channel = grpc.secure_channel(address, credentials)
key_data = File.read(key_path) cert_data = File.read(cert_path) root_data = File.read(root_path) credentials = GRPC::Core::ChannelCredentials.new(root_data, key_data, cert_data) channel = GRPC::ClientStub.setup_channel(nil, address, credentials)
JavaとGoでもSSL機能を実装したのだが、現状だとなぜか動かない。Javaでは、SSLの処理系がTLSのALPN拡張をサポートしている必要があって、それはJava9から有効になっているはずなのだが、なぜか UnsupportedOperationException: JDK provider does not support ALPN protocol とか言うエラーが出てしまう。JDK17で動かしているが、なぜかSSLProvider.isAlpnSupported(SslProvider.JDK)が偽を返してくる。libnetty-java、libnetty-tcnative-javaをインストールしても事態は変わらない。
Goでは、他の言語と同様にクライアント秘密鍵とクライアント証明書とルート証明書を設定しているが、なぜか接続に失敗する。サーバ側のログには Handshake failed with fatal error SSL_ERROR_SSL: error:14094412:SSL routines:ssl3_read_bytes:sslv3 alert bad certificate. とか書いてあるので、証明書が壊れているらしい。しかし、他の言語で正常動作が確認できている証明書を使っているので、うまくデータが渡せていないというのが実際のところだろう。
二つの問題とも、調べたけど答えが全く出てこない。誰か原因をご存知なら教えてくだされ。実際のコードは、RemoteDBM.javaとremote_dbm.goである。
SSL化したことで、スループットにどのような影響があるかは知りたいところだ。C++とPythonとRubyにて、Echo操作のスループットを、デフォルト設定とSSL有効化設定で測定する。例によってサーバとクライアントは同じマシンで動かして、IPv4で接続する。結果の単位はQPSだ。
デフォルト | SSL有効化 | |
C++ | 20891 | 15989 |
Python | 9966 | 8402 |
Ruby | 11845 | 9804 |
SSL化すると、C++で75%、Pythonで84%、Rubyで82%にスループットが低下する。とはいえEchoでの影響なので、実際にサーバ側で処理を行う場合には、影響の割合は小さくなるだろう。この程度のオーバーヘッドでセキュリティが確保できるなら安いとも言える。
まとめ。Tkrzw-RPCのクライアントライブラリで、gRPCのSSL機能を使って暗号通信と認証が行えるようにした。スループットは多少低下するが、普通に実用できるレベルに収まっている。C++とPythonとJavaの対応は問題なくできたが。JavaとGoは未完了だ。嵌りどころが解消次第、それらもリリースしたい。