メインコンテンツまでスキップ

gRPC TLS 自己署名証明書

· 約11分

マイクロサービス間の通信では、内部ネットワーク通信の安全を確保するためにデータの暗号化が必要です。そこで、自己署名証明書の生成手順を記録しておきます。

まず背景から説明します。gRPC はデフォルトで平文の HTTP/2 を使うため、サービス間のリクエストボディは内部ネットワーク上でパケットキャプチャすればそのまま読めてしまいます。本番環境では、たとえ内部ネットワークであってもネットワークが絶対に信頼できるとは想定できません。一台のマシンが侵害されれば、横方向にトラフィックを盗聴するコストは非常に低いのです。gRPC に TLS を加えることが、最も直接的な防御手段です。そして内部サービスのためにパブリック CA へ証明書を申請する必要はなく(そもそも申請できないことも多く)、自前でルート証明書を構築して各サービスに証明書を発行するのが、より一般的なやり方です。この一連の流れは OpenSSL だけで完結しますが、細かい点が多く、特に Go 側の証明書フィールド検証ルールについては、一通り記録しておく価値があります。

以下は Linux または MacOS で OpenSSL コマンドラインツールを使って自己署名証明書を生成する手順です。この過程では、まず新しいルート証明書(CA)を作成し、次にそのルート証明書で新しいサーバー証明書に署名します。

ここでの証明書の体系は 2 層構造です。ルート証明書は自分自身に署名し、信頼のアンカーとして機能します。サービス証明書はルート証明書によって発行され、クライアントはルート証明書をあらかじめ信頼しておくだけで、それが発行したどのサービス証明書も検証できます。クライアント側に ca.crt だけを配布すればよく、各サービスの証明書を個別に配布する必要がないのはこのためです。

まず全体の流れを簡潔に示します。

ルート証明書:

1、ルート秘密鍵を生成する

2、ルート秘密鍵からルート証明書を生成する

サービス証明書

1、サービス秘密鍵を生成する

2、サービス秘密鍵からサービスの CSR を生成する

3、サービス .csr ファイル + ルート証明書 + ルート証明書の key でサービス証明書 .crt ファイルに署名する

ルート証明書の秘密鍵を生成する

# 2048 ビットの RSA 秘密鍵を生成し、ルート CA の秘密鍵とする
openssl genrsa -out ca.key 2048

このコマンドは、長さ 2048 ビットの新しい RSA 秘密鍵を生成します。この秘密鍵は ca.key という名前のファイルに保存されます。

この秘密鍵は信頼チェーン全体の根であり、手に入れた者は誰でも、すべてのクライアントから信頼される証明書を発行できてしまいます。厳重に保管し、コードリポジトリにはコミットしないでください。

ルート証明書を生成する

# ルート秘密鍵で X.509 ルート証明書を自己署名する。有効期間は 365 日
openssl req -new -x509 -days 365 -key ca.key -out ca.crt

このコマンドは、SHA-256 アルゴリズムで署名された新しい X.509 ルート証明書を生成します。有効期間は 365 日です(日数は自由に調整できます)。この証明書は ca.crt という名前のファイルに保存されます。

-x509 オプションは、署名リクエストではなく自己署名証明書を直接出力することを意味します。これこそが「ルート証明書が自分自身に署名する」ということです。

このコマンドを実行すると、OpenSSL はいくつかの情報の入力を求めてきます。これらの情報は証明書に含まれます。ほとんどの場合、そのまま Enter キーを押してデフォルト値を受け入れて構いません。

ただし Subject Name または Common Name にはマイクロサービス側の名前を記入する必要があります。

ルート証明書の準備ができたら、次は個々のサービス用の証明書を生成します。各サービスごとに以下の 3 ステップを繰り返すだけです。

サーバー証明書の秘密鍵を生成する

# サービス用に 2048 ビットの RSA 秘密鍵を生成する
openssl genrsa -out [service-name].key 2048

このコマンドは最初のコマンドと同様で、生成された秘密鍵は [service-name].key という名前のファイルに保存されます。[service-name] はあなた自身のサービス名です。

サーバー証明書の証明書署名リクエスト(CSR)を生成する

# サービス秘密鍵をもとに証明書署名リクエスト(CSR)を生成する
openssl req -new -key [service-name].key -out [service-name].csr

このコマンドは、新しい証明書署名リクエスト(CSR)を生成します。このリクエストにはサーバー証明書の公開鍵といくつかの付加情報が含まれます。このリクエストは server.csr という名前のファイルに保存されます。

CSR 自体は証明書ではありません。「私は誰で、私の公開鍵は何か」を記した申請書類にすぎず、CA に署名してもらって初めて使える証明書になります。

同様に、このコマンドを実行すると OpenSSL はいくつかの情報の入力を求めてきます。これらの情報は CSR に含まれます。

ルート証明書でサーバー証明書に署名する

# ルート証明書 + ルート秘密鍵で CSR に署名し、サービス証明書を得る
openssl x509 -req -in [service-name].csr -CA ca.crt -CAkey ca.key -CAcreateserial -out [service-name].crt -days 365

このコマンドはルート証明書でサーバー証明書に署名し、生成された証明書は server.crt という名前のファイルに保存されます。

-CAcreateserial.srl というシリアル番号ファイルを生成します。これはこの CA が発行した証明書の連番を記録するもので、以降の発行時には自動的にインクリメントされるため、手動で扱う必要はありません。

以上の手順で生成した rootCA.crtserver.crtserver.key を対応するサーバー側とクライアント側に設定すれば、gRPC の暗号化通信に使えます。サーバー側は自身の証明書と秘密鍵をロードし、クライアント側はサーバーの身元検証のためにルート証明書をロードします。

一部のカスタム項目

Go のより新しいバージョンでは、証明書内の一部フィールドの値の検証と取得に変更がありました。(Go 1.15 以降は証明書の alt_names フィールドの情報を取得してサービス名の検証を行います。)そのため、証明書生成時に一部のカスタムフィールド情報を指定する必要があります。

つまり、Common Name にサービス名を書くだけではもう不十分だということです。Go の TLS クライアントはハンドシェイク時に、接続先の名前を証明書の SAN(Subject Alternative Name)フィールドと照合し、一致しなければハンドシェイクは即座に失敗します。したがって証明書には SAN 拡張を明示的に含める必要があります。こうした拡張フィールドはコマンドラインの対話入力では設定しにくいので、設定ファイル方式に切り替えます。

san.cnf というファイルを作成し、内容は以下のとおりにします。

[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn

# 証明書のサブジェクト情報。対話式入力の代わり
[dn]
C = CN
ST = Beijing
L = Beijing
O = 'lynx'
OU = 'lynx'
emailAddress = 'lynx'
CN = [service-name]

# CSR 段階で使う拡張:SAN を宣言する
[req_ext]
subjectAltName = @alt_names

# SAN のリスト。Go クライアントが検証するのはここの DNS 名
[alt_names]
DNS.1 = [service-name]

# 証明書発行段階で使う拡張
[v3_ext]
authorityKeyIdentifier=keyid,issuer:always
basicConstraints=CA:FALSE
keyUsage=keyEncipherment,dataEncipherment,digitalSignature
extendedKeyUsage=serverAuth,clientAuth
subjectAltName=@alt_names

設定内の prompt = no により、OpenSSL は対話式の質問をやめて [dn] セクションの内容を直接読み込みます。extendedKeyUsage には serverAuthclientAuth の両方が宣言されているので、この証明書はサーバー側で使えるだけでなく、相互 TLS(mTLS)のシーンではクライアント証明書としても使えます。

その上で、前述の証明書生成の手順に以下を加えます。

サービスの csr ファイル生成時に -extensions req_ext -config san.cnf を追加します。

# CSR 生成時に設定ファイルを参照し、req_ext セクションで宣言した SAN を含める
openssl req -new -key [service-name].key -out [service-name].csr -extensions req_ext -config san.cnf

サービス証明書の生成時にも、-extensions v3_ext -extfile san.cnf を追加する必要があります。

# 発行時にも v3_ext 拡張を付けること。付けないと SAN が最終的な証明書に書き込まれない
openssl x509 -req -in [service-name].csr -CA ca.crt -CAkey ca.key -CAcreateserial -out [service-name].crt -days 365 -extensions v3_ext -extfile san.cnf

こうして生成された証明書ファイルには、指定したフィールドの内容と、いくつかの証明書記述情報が含まれます。

警告

発行ステップの -extensions v3_ext -extfile san.cnf は見落としやすいポイントです。openssl x509 -req はデフォルトでは CSR 内の拡張フィールドを最終的な証明書に持ち込みません。CSR 段階でしか SAN を宣言していないと、発行された証明書には SAN が含まれないままになります。

証明書内容の検証

コマンドで、生成された証明書に本当にフィールド情報が含まれているか確認できます。

# 証明書の内容をテキスト形式で出力し、SAN などの拡張フィールドを確認する
openssl x509 -in [service-name].crt -text -noout

出力では、X509v3 Subject Alternative Name のセクションに期待どおりの DNS 名が含まれているか、そして有効期間と発行者が正しいかを重点的に確認します。このステップは定型フローに組み込むことをおすすめします。サービスをリリースしてからハンドシェイクエラーで調査するより、はるかに手間が省けます。

ハマりどころと注意点

1)クライアントが接続時に使う宛先の名前は、証明書の SAN 内の DNS 名と一致していなければなりません。IP 直結で接続する場合は、SAN に IP.1 = ... の形式で IP エントリを宣言する必要があります。DNS エントリでは一致しません。

2)証明書の有効期限が切れると gRPC のハンドシェイクは即座に失敗しますが、この種の障害は期限当日になって初めて表面化しがちです。内部の自己署名証明書は誰も更新をリマインドしてくれないので、有効期限を記録して前もって差し替えるのが得策です。

3)ca.key と各サービスの .key ファイルはいずれも機密情報です。ファイル権限に注意し、イメージやリポジトリと一緒に配布しないこと。クライアントに必要なのは ca.crt だけです。

4)san.cnf を変更したら、CSR と発行の 2 ステップをやり直す必要があります。CSR を再生成せずに証明書だけ再署名すると、古いサブジェクト情報を引きずる可能性があります。

まとめ

全体の流れは一本の信頼チェーンに凝縮できます。ルート秘密鍵からルート証明書を生成し、サービス秘密鍵から CSR を生成し、ルート証明書が CSR に署名してサービス証明書が得られる。Go の gRPC のシーンでのポイントは、san.cnf で SAN フィールドを証明書に書き込むこと、CSR と発行の両方の段階で拡張を明示的に指定すること、そして最後に openssl x509 -text で検証することです。これらのステップをスクリプトに固めてしまえば、新しいサービスへの証明書発行は 1 分で済む作業になります。

COMMENTS