🔐

Erlang/ElixirでTLSクライアント認証する

2021/03/28に公開

TLSクライアント認証は使われる場面が少なく、これをErlang/Elixirで行っている日本語の解説記事となるともっと少ないので少し難儀した。やる機会があったのでここに書き残す。

TLSはまだしも、そこで行われるクライアント認証の仕組みのほうは普段あまり把握していないことが多いと思うので、自己完結した記事になるよう、その説明も合わせて書いておく。

この記事では、各種証明書を対象主体に対して発行・管理するための枠組みであるPKIやCAについては扱わない。

【前提】HTTPS(HTTP over TLS)

サーバ証明書だけを使う片方向のTLSは大体わかっているなら、この節をスキップして次へ。

HTTPS(HTTP over TLS)でクライアントがあるサーバのエンドポイントにアクセスするとき、だいたい以下のようなことが行われる

  1. ClientHello/ServerHello】先に従来のTCP handshakeで接続を確立したら、TLSによる通信暗号化とサーバ認証を開始する(TLS Handshake
  2. Certificate】サーバはまず自分のサーバ証明書を提供する
    • (このとき、「クライアント認証」を使わないなら、そのための「クライアント証明書」をサーバは要求しない)
  3. ServerKeyExchange, ServerHelloDone】更に続けてサーバはサーバの秘密鍵を使って「鍵交換」手続きを開始する
  4. クライアントは自分が事前に知っているルート証明書を起点としてサーバ証明書の正当性を検証する
    • ルート証明書はOSにインストールされていたり、あるいはブラウザに同梱されていたりする
    • (自分が勝手に作ったルート認証局で発行した、いわゆる「オレオレ証明書」を使ってテストしている場合などにスキップされるのはこの作業)
  5. ClientKeyExchange】クライアントはサーバ証明書に含まれる(=サーバ証明書がその正当性を「証明」した)公開鍵を取り出し、それを使って「鍵交換」に応じる
  6. 「鍵交換」は大変良くできた仕組みなので、ここまで成功するとクライアントとサーバは「それぞれ独立して」「同じ共通鍵を生成」することができる
    • この共通鍵は従って通信経路に暴露されない!すごい
  7. ChangeCipherSpec, Finished】最後にクライアントとサーバは、以降の通信内容はすべて共通鍵を使って暗号化することを了解しあい、暗号通信を1往復試してTLS Handshakeを完了する

これで暗号化された接続が成立したので、以降二者間で秘密裏にHTTPのリクエストとレスポンスをやり取りできる。めでたし。

余談)非対称鍵暗号・電子署名も良くできているが、鍵交換(DH鍵交換など)はそれにも増して天才的な仕組みなので一度自分でやってみると楽しい。小さな数字を使えば紙とペンでできる。

TLSクライアント認証(Mutual TLS)

「片方向」のTLSはHTTPSで広く使われるようになった。そのためにサーバ証明書を用意するとか、Let's Encryptを使うとか、AWS Certificate Managerが大変便利とか、知っている人も多いと思われる。

一方で「逆方向」つまりクライアント証明書を使ってサーバがクライアントを認証するという仕組みも提供されていて、両方向同時に使うとMutual TLSとなる。

リクエストを送信したクライアントの認証はHTTPレイヤーでBasic認証ないしBearer認証などを用いることが多いが、TLSクライアント認証を使えば「TLS確立の時点で」クライアントを認証することができる。
タイミングとしてはHTTPペイロードの交換が始まるより前の段階になるし、クライアント証明書の窃取はたいていパスワードなどよりは難しいことが多そうだ。もちろん、HTTPレイヤーでの認証と組み合わせることもできる。
が、準備や管理がより複雑なのは間違いないので使われる場面は片方向TLSより少ない。

仕組みとしては、

  • CertificateRequest】前節2.に加えて、サーバは「クライアント証明書」をクライアントに要求する
  • Certificate】クライアントはクライアント証明書をサーバに提供する
  • サーバは(クライアントがサーバ証明書に対してするのと同様に)自分が事前に知っているルート証明書を起点としてクライアント証明書の正当性を検証する
  • CertificateVerify】前節5.で鍵交換の応答を返したあと、クライアントは「そこまでの通信内容全て」に対し、クライアントの秘密鍵で署名を生成してサーバに提供する
  • サーバはクライアント証明書に含まれる(=クライアント証明書がその正当性を「証明」した)公開鍵を取り出し、署名を検証してクライアントが対となる秘密鍵を持っている(=クライアント証明書の持ち主である)ことを確かめる

となる。「逆方向」という通りほぼ逆向きに同じようなことをやるだけなのだが、サーバの秘密鍵・公開鍵ペアは鍵交換に使われる(=鍵交換が成功することで、対象のサーバが秘密鍵を持っていることを確かめられる)のに対し、クライアントの秘密鍵・公開鍵ペアは鍵交換に使われないので、追加で署名提供と検証のステップを加えることで秘密鍵の所在確認を行っている。

クライアント証明書の形式

TLS自体を自分で実装することはまずないし、避けられるなら避けるべき(十分テストされた既存のライブラリを使うべき)だが、ライブラリを使うとしても実務的に知る必要があるのが証明書のファイル形式の話だ。

参考にした記事:

鍵ペアの発行を受けたときにはパスワード保護されたPKCS#12形式(.p12)にパッケージされていることが多い。これを、使おうとしているライブラリが読み込める形式(DERないしPEM)に変換するという準備が必要になる。PKCS#12形式のまま鍵ペアを読み込めるライブラリもあるだろう。

PKCS#12およびDERはバイナリ形式、PEMはDERをBase64エンコードしたテキスト形式となっている。PKCS#12は鍵ペアがセットで格納されているが、DERやPEMは同じようにペアを格納することもできるし、秘密鍵と公開鍵証明書を別ファイルとして格納することもある。

どの形式で利用するかはプログラムの実行環境によって決める。

  • ファイルを直接扱える環境で、パスワードは別経路からプログラム実行時の秘密情報として注入でき、ライブラリが対応しているならばPKCS#12のまま利用
  • ファイルを直接扱える環境で、パスワードの注入が困難ないし煩雑である場合、またはPKCS#12にライブラリが対応していない場合はDER形式に変換
  • ファイルを直接扱えない環境で、環境変数などを経由してテキスト形式で鍵を注入する必要があるならPEM形式に変換

という下準備を行う。

Erlangのpublic_keyモジュールでPEM形式のクライアント認証用鍵ペアを扱う

ここからようやく具体的なErlang/Elixirでの実装の話ができる。

Erlangはpublic_key, crypto, ssl, sshなどセキュアな通信や暗号に関わる標準ライブラリが整っている。ただし、Elixirで開発している場合はErlang側モジュールの関数を使うのに慣れが必要なのは確か。というか、Erlangで開発していてもこのあたりの関数は複雑なものが多いので解説はあったほうがいいだろう。

前節の分類に従うと、public_keyPKCS#12形式を直接扱えないので(筆者が理解している限り)、DERかPEMのデータをまずファイルや環境変数などから注入する。前述の記事でopensslを利用した形式間の変換については複数解説されている。

DERやPEMはそもそも内部的にASN.1という汎用的なデータ表現形式を使っていて、「秘密鍵」「公開鍵」「公開鍵証明書」といったタイプのASN.1エントリが、それぞれのファイルフォーマットに則った形でエンコードされて1つ以上格納された代物(よって「コンテナ形式」と呼ばれる)。Erlangのpublic_keyでも鍵データをデコードしてASN.1エントリごとに操作する。

以下コード例はElixir、証明書や鍵のファイルはPEM形式だとして説明する。

# 秘密鍵→公開鍵証明書の順番で、セットで格納されているPEMファイルだとする
pem = File.read!("path/to/key_and_cert.pem")

# デコードされても格納順序は保持されているので、
# 先頭には秘密鍵のエントリ、それ以降には公開鍵証明書チェーンのエントリが入っている。
# パターンマッチして順番通りに受け取る
[priv_key | pub_key_certs] = :public_key.pem_decode(pem)

# PEMのエントリはこんな感じのtupleである(PEMはBase64エンコードしたDERなので、ここでDERが得られる)
# DERが更に暗号化されている場合もサポートされているが、ここでは暗号化されていない場合
{:PrivateKeyInfo, <<_::binary>> = der, :not_encrypted} = priv_key

# ここからさらにPEMエントリをデコードする関数と、DERをデコードする関数、どちらも提供されているが、結果は同じとなる
# デコードすると最終的に生の秘密鍵のASN.1エントリが手に入る
{:RSAPrivateKey, :"two-prime", ... 素数など ...} = :public_key.pem_entry_decode(priv_key)
{:RSAPrivateKey, :"two-prime", ... 素数など ...} = :public_key.der_decode(:PrivateKeyInfo, der)

これでクライアント認証する準備は整った。

Hackney/HTTPoisonを経由して、Erlangのsslモジュールでクライアント認証する

HackenyはErlangのHTTPクライアントライブラリ、HTTPoisonはそのElixirラッパー。デファクト・スタンダードというわけではないがどちらも比較的よく使われる。

Erlang/ElixirでHTTPクライアントに何を使うとしても、TLSの処理は内部的にErlangのsslモジュールが使われている(例外は原理的にはありうるが、見たことはない)。サーバ証明書だけを使う片方向TLSについてはユーザが特に意識せずに処理してもらえるデフォルト挙動となっているだろう。クライアント認証などの追加の処理を行うときだけ、オプションから必要な情報を注入する。

同様にsslモジュールを使っている他のHTTPクライアントでも同じことができる。オプションの注入経路をまとめると、

  • Hackneyから使う場合、ssl_options
  • HTTPoisonから使う場合、:ssl
  • gunから使う場合、tls_opts
  • Mintから使う場合、:transport_opts

なんと全部違う。それぞれ思想や時代背景が出ていて面白い。

で、肝心の指定できるオプションはssl:tls_client_optionに列挙されている。
ここでやりたいクライアント認証のための主なオプションと型は以下:

  • {cert, cert() | [cert()]}
    -type cert() :: public_key:der_encoded().
    
    • クライアントの公開鍵証明書。末端の証明書だけ渡すこともできるし、証明書チェーンをリストとして渡すこともできる。末端の証明書を渡した場合、証明書チェーンはその他のプションから組み立てられる
    • 型定義から、DER形式で渡す必要があると読める
  • {key, key()}
    -type key() :: { 'RSAPrivateKey' | 'DSAPrivateKey' | 'ECPrivateKey' | 'PrivateKeyInfo',
                    public_key:der_encoded()}
                |  #{algorithm := rsa | dss | ecdsa,
                     engine := crypto:engine_ref(),
                     key_id := crypto:key_id(),
                     password => crypto:password()}.
    
    • クライアントの秘密鍵。鍵ファイル由来のデータを使う場合はASN.1のタイプと、DER形式の鍵のtupleを渡すように読める
  • その他、PEMファイルをそのまま指定する場合のcertfilekeyfile、ルート証明書を具体指定するcacertscacertfile、接続開始時にクライアントが利用可能なTLSバージョン候補を提示するversions、暗号スイートをさらに調整するciphersなど
    • versionsに何も明示しない場合、sslアプリケーションがデフォルトでサポートするバージョン候補が指定される。これは:ssl.versions[:supported]で確認でき、例えば手元のOTP23.2.7では[:"tlsv1.3", :"tlsv1.2"]。クライアントにとって好ましい順に並んでいる
    • ciphersに何も指定しない場合、サーバとの了解によって決まったTLSバージョンで利用可能な暗号スイートのうち、最も強力なものが自動で選択される。調整する場合、利用可能な候補リストは:ssl.cipher_suites/2で確認できる

我々はすでに前節でPEMファイルを読み込んで、DERにデコードした状態でメモリ内に保持することができている。ということで、以下のように使用する。

# 再掲
[priv_key | pub_key_certs] = :public_key.pem_decode(pem)
{:PrivateKeyInfo, <<_::binary>> = der_pkey, :not_encrypted} = priv_key

# pub_key_certsは[{:Certificate, <<_::binary>>, :not_encrypted}, ...]という形式
# なのでmapしてtupleの真ん中のDERだけ取り出す
der_certs = Enum.map(pub_key_certs, fn {_type, der, :not_encrypted} -> der end)

# ここではkeyとcertの指定以外は全てデフォルト値を利用
ssl_opts = [key: {:PrivateKeyInfo, der_pkey}, cert: der_certs]
HTTPoison.post("https://client-auth.example.com/api",
               ~S/{"key":"value"}/,
               [{"content-type","application/json"}],
               ssl: ssl_opts)

これにてめでたくクライアント認証が成功する。

鍵ペアのファイルを直接指定するか、予め読み込んでおいたものを指定するかはプログラムの用途と実行環境による。一般的な話だが、

  • サーバプログラムで、ユーザのリクエストなどを契機として比較的頻繁にクライアント認証が必要になるなら、鍵ペアの内容を予めメモリに読み込んでおいたほうがいい
    • 特に鍵ファイルの置き場所がNFSなどをマウントしたパスだったりすると、頻繁な読み出しはボトルネックになるし、外部ストレージ側に急な障害が起きた場合に例外発生するタイミングがユーザリクエスト処理のタイミングとなってしまう
    • 起動時処理の一環として予め鍵ペアファイルを読み込み、デコードし、ETSなどに保持しておけば、ストレージI/Oはその後発生せず、障害も起動時に気づける
  • ユーザの手元に配布して実行するスクリプトなどの場合は都度ファイル読み出しでも構わない

ただ、HackneyもGunもMintもTCP接続を可能な限りプールしておいて再利用するという効率化が行われているので、実際そんなに毎回TCP/TLS Handshakeは必要なく、心配するのはずっと先でいいだろう。

あとがき

sslモジュールのAPIはdoc読んだら意外とそのまま使えた。

むしろ各種ファイル形式間の関係性をちゃんと知らなかったので、そこを調べて理解する作業が必要なのだった。

Discussion