Pythonで学ぶ:mTLS(相互TLS)の仕組みと実装

Security

このドキュメントでは、ローカルのPC上でPythonのWebフレームワークであるFlaskと、各種クライアントツールを使用して、mTLS(相互TLS認証)の仕組みを実験する手順を解説します。

この記事の内容
  • PythonとFlaskを使ったmTLSの実装方法がわかります。
  • OpenSSLによるmTLSのための証明書発行方法がわかります。
開発・動作環境

この記事のプログラムは、以下の環境で開発および動作確認を行っています
・OS: Windows11
・言語: Python 3.13
・Webフレームワーク: Flask 3.1.1

1. mTLSの仕組みと全体像

通常のTLS(HTTPS通信で使われるもの)では、クライアントがサーバーの身元をサーバー証明書で確認します。これに対し、mTLS(相互TLS) では、クライアントがサーバーを認証するだけでなく、サーバーもクライアントの身元をクライアント証明書で確認します。これにより、許可されたクライアントだけがサーバーに接続できる、よりセキュアな通信が実現できます。

今回の実験では、この認証の仕組みを自分たちで構築します。

  1. 認証局(CA) を自分たちで作成します。
  2. そのCAに、サーバーとクライアントそれぞれの「身分証明書」を発行してもらいます。
  3. Flaskサーバーを「クライアントの身分証明書を確認する」設定で起動します。
  4. クライアントから「自分の身分証明書を提示して」サーバーにアクセスします。

2. 各証明書の役割

mTLSを理解する上で、各ファイルがどのような役割を持つのかを知ることが非常に重要です。

認証局 (Certificate Authority – CA)

信頼の基点となる、証明書を発行する機関です。今回はこれを自分で作成します。

  • ca.key(CAの秘密鍵)
    • 目的: サーバー証明書やクライアント証明書にデジタル署名を行うための、CAだけが持つ秘密の鍵です。いわば、証明書を発行するための「実印」そのものです。
    • 重要性: 絶対に外部に漏らしてはいけません。 もしこれが漏洩すると、誰でも正規の証明書を偽造できてしまい、認証の仕組み全体が崩壊します。
  • ca.crt(CAの証明書)
    • 目的: CA自身の公開鍵を含む証明書です。サーバーやクライアントは、相手から提示された証明書が「本当にこのCAによって署名されたものか?」を確認するために使います。いわば「印鑑証明書」のようなものです。
    • 配布: サーバーとクライアントの両方に配布し、信頼する認証局として登録させる必要があります。

サーバー

クライアントからの接続を待ち受けるアプリケーションです。(今回のFlaskサーバー)

  • server.key(サーバーの秘密鍵)
    • 目的: サーバーだけが持つ秘密の鍵です。クライアントとの通信を暗号化したり、クライアントに対して「私はserver.crtに記載された本物のサーバーです」と証明したりするために使われます。
    • 重要性: サーバーのコンポーネントであり、外部に漏らしてはいけません。
  • server.crt(サーバーの証明書)
    • 目的: サーバーの「身分証明書」です。ca.crtによって署名されており、サーバーの公開鍵やlocalhostといった情報(Common Name)が含まれています。
    • 役割: 接続してきたクライアントに対して提示し、クライアントにサーバーが正規のものであることを信頼させます。

クライアント

サーバーに接続しにいくアプリケーションです。(今回のPythonスクリプトやcurl)

  • client.key(クライアントの秘密鍵)
    • 目的: クライアントだけが持つ秘密の鍵です。サーバーに対して「私はclient.crtに記載された本物のクライアントです」と証明するために使われます。
    • 重要性: クライアントのコンポーネントであり、外部に漏らしてはいけません。
  • client.crt(クライアントの証明書)
    • 目的: クライアントの「身分証明書」です。ca.crtによって署名されており、クライアントの識別情報が含まれています。
    • 役割: サーバーに接続する際に提示し、サーバーに自分がアクセスを許可された正規のクライアントであることを信頼させます。

3. 手順1: 証明書の作成 (OpenSSL)

まず、opensslコマンドで証明書と秘密鍵を作成します。Pythonのrequestsライブラリなど、現代的なクライアントが行う厳格な検証に対応するため、証明書の役割やホスト名を正確に定義した設定ファイルを使用します。

1. OpenSSLを用意

OpenSSLは、暗号化通信や証明書を作成するためのオープンソースのソフトウェアライブラリです。

まだOpneSSLをインストールしていない方は、次のリンクからOpenSSLをインストールしてください。

GitHub - openssl/openssl: TLS/SSL and crypto library
TLS/SSL and crypto library. Contribute to openssl/openssl development by creating an account on GitHub.

2. OpenSSLの設定ファイルを作成します 以下の内容で openssl.cnf という名前のファイルを作成してください。このファイルは、他の証明書と同じ mtls-test ディレクトリ内に作成することが重要です。

[ v3_ca ]
basicConstraints = critical,CA:TRUE
keyUsage = critical, keyCertSign, cRLSign

[ v3_server ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names

[ v3_client ]
basicConstraints = CA:FALSE
keyUsage = digitalSignature
extendedKeyUsage = clientAuth

[ alt_names ]
DNS.1 = localhost
IP.1 = 127.0.0.1
  • [v3_ca]keyUsage: CAが他の証明書に署名する権限があることを示します。
  • [v3_server]subjectAltName: サーバー証明書がlocalhost127.0.0.1で有効であることを示す、現代のTLS通信で必須の項目です。

3. 鍵と証明書を生成します 以下のコマンドを順に実行してください。

# CAの秘密鍵(ca.key)と証明書(ca.crt)を作成します
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 365 -key ca.key -subj "/CN=My Test CA" -out ca.crt -config openssl.cnf -extensions v3_ca

# サーバーの秘密鍵(server.key)とCSRを作成します
openssl genrsa -out server.key 2048
openssl req -new -key server.key -subj "/CN=localhost" -out server.csr

# CAの鍵を使ってサーバー証明書(server.crt)に署名します
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -extfile openssl.cnf -extensions v3_server

# クライアントの秘密鍵(client.key)とCSRを作成します
openssl genrsa -out client.key 2048
openssl req -new -key client.key -subj "/CN=Test Client" -out client.csr

# CAの鍵を使ってクライアント証明書(client.crt)に署名します
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -extfile openssl.cnf -extensions v3_client

補足: 本番環境での証明書作成場所

今回の実験では、学習の便宜上、認証局(CA)、サーバー、クライアントのすべての秘密鍵と証明書を1台のPCで作成しました。しかし、実際の本番環境では、これは非常に危険な行為です。

セキュリティの最も重要な原則は**「秘密鍵は、それを使用するサーバーやクライアントから絶対に移動させない」**ということです。

本来あるべき作成場所:

  • 認証局 (CA): ca.key(CAの秘密鍵)は、最も厳重に管理されるべきものです。通常、インターネットから隔離された専用のサーバー(オフライン環境)や、HSM(ハードウェア・セキュリティ・モジュール)と呼ばれる専用の耐タンパー性デバイスで保管・使用されます。
  • サーバー:
    1. サーバー自身の上で server.key(秘密鍵)とserver.csr(証明書署名要求)を作成します。
    2. 作成したserver.csrのみをCAに送付します。
    3. CAは受け取ったCSRに署名し、server.crt(サーバー証明書)を発行して返します。
    4. サーバー管理者はserver.crtをサーバーに配置します。この一連の流れにおいて、server.keyがサーバーの外に出ることは一切ありません。
  • クライアント: サーバーと同様に、クライアントとなるデバイスやマシンの上でclient.keyclient.csrを作成します。client.csrのみをCAに送付し、署名されたclient.crtを受け取ります。

この手順を守ることで、万が一csrファイルが途中で漏洩しても、秘密鍵が含まれていないためセキュリティ上のリスクを最小限に抑えることができます。

4. 手順2: mTLS対応Flaskサーバーの構築

クライアント証明書を要求し、それがca.crtによって署名されているかを検証するFlaskサーバーを作成します。

server.py

import ssl
from flask import Flask, request
from cryptography import x509

app = Flask(__name__)

@app.route("/")
def hello():
    """
    クライアント証明書が提示されていれば、その情報を表示するエンドポイント
    """
    # クライアント証明書は、環境によって様々なキーで渡される可能性がある
    client_cert_pem = request.environ.get('SSL_CLIENT_CERT')
    
    if client_cert_pem:
        try:
            # PEM形式の証明書文字列をパースする
            cert_obj = x509.load_pem_x509_certificate(client_cert_pem.encode('utf-8'))
            # 証明書のsubject(主題)から共通名(commonName)を取得
            cn = cert_obj.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value
            return f"<h1>Hello, {cn}!</h1><p>mTLS connection successful.</p>"
        except Exception as e:
            return f"<h1>Hello!</h1><p>Client certificate detected but parsing failed: {str(e)}</p>"
    
    # werkzeugのgetpeercertも試す (環境によっては辞書形式で渡される)
    peer_cert_dict = request.environ.get('werkzeug.socket.getpeercert')
    if peer_cert_dict:
        subject = dict(x[0] for x in peer_cert_dict['subject'])
        cn = subject.get('commonName', 'N/A')
        return f"<h1>Hello, {cn}!</h1><p>mTLS connection successful.</p>"

    return "<h1>Hello!</h1><p>Connected, but no client certificate provided.</p>"

if __name__ == "__main__":
    # サーバーサイドのSSL/TLS設定を作成
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    
    # サーバー自身の証明書と秘密鍵をロード
    context.load_cert_chain('server.crt', 'server.key')
    
    # --- mTLSのための重要な設定 ---
    # 1. クライアント証明書の提示を「必須」に設定
    context.verify_mode = ssl.CERT_REQUIRED
    
    # 2. クライアント証明書を検証するためのCA証明書をロード
    context.load_verify_locations('ca.crt')
    # ---------------------------

    print("Starting Flask server with mTLS on https://localhost:5000")
    # SSL/TLS設定を有効にしてサーバーを起動
    app.run(host='0.0.0.0', port=5000, ssl_context=context)

サーバーの起動 サーバーコードは証明書のパースにcryptographyライブラリを使用するため、Flaskと共にインストールします。

# 必要なライブラリをインストール
pip install Flask cryptography

# サーバーを起動
python server.py

5. 手順3: 接続テスト

作成したmTLSサーバーに、curlコマンドやPythonスクリプトを使って接続できるかテストします。

5.1 curlでのテスト (Windows Git版)

Windows標準のcurlは、TLSバックエンドにSChannelというライブラリを使用しており、OpenSSLで作成したクライアント証明書をうまく扱えないことがあります。(今回の例では、扱えませんでした。多分、Windowsのcurlの場合は、クライアント証明書を証明書ストアにインストールする必要があるのでしょう・・・)

しかし、Git for Windowsに同梱されているcurl.exeOpenSSLをバックエンドとしており、クライアント証明書を正しく扱うことができます。

成功ケース: Git版curlでクライアント証明書を提示 Git for Windowsをデフォルトのパスにインストールした場合、以下のコマンドで接続できます。

"C:\Program Files\Git\mingw64\bin\curl.exe" https://localhost:5000 --cert client.crt --key client.key --cacert ca.crt

注意: Gitのインストール場所が異なる場合は、curl.exeへのパスを適宜変更してください。

応答:

<h1>Hello, Test Client!</h1><p>mTLS connection successful.</p>

5.2 Pythonクライアントでのテスト

Pythonのrequestsライブラリを使った接続は、環境による差異が少なく、より安定したテスト方法です。

client.pyの作成 以下の内容でclient.pyファイルを作成します。

import requests

SERVER_URL = "https://localhost:5000"

# 各種証明書ファイルのパス
CA_CERT = 'ca.crt'
CLIENT_CERT = 'client.crt'
CLIENT_KEY = 'client.key'

print("--- mTLS接続テスト (成功ケース) ---")
try:
    # cert引数にクライアント証明書と鍵を、verify引数にCA証明書を指定
    response = requests.get(
        SERVER_URL,
        cert=(CLIENT_CERT, CLIENT_KEY),
        verify=CA_CERT
    )
    print("Status Code:", response.status_code)
    print("Response Body:", response.text)

except requests.exceptions.SSLError as e:
    print(f"SSLエラーが発生しました: {e}")
except requests.exceptions.RequestException as e:
    print(f"リクエストエラーが発生しました: {e}")

print("\n--- mTLS接続テスト (失敗ケース: クライアント証明書なし) ---")
try:
    # cert引数を指定せずにリクエスト
    response = requests.get(
        SERVER_URL,
        verify=CA_CERT
    )
    # ここに到達することは期待されない
    print("Status Code:", response.status_code)
    print("Response Body:", response.text)

except requests.exceptions.SSLError as e:
    print("クライアント証明書がないため、期待通りSSLエラーが発生しました。")
    # print(f"エラー詳細: {e}") # 詳細を見たい場合はコメントアウトを外す
except requests.exceptions.RequestException as e:
    print(f"リクエストエラーが発生しました: {e}")

スクリプトの実行 Flaskサーバーが起動している状態で、このPythonスクリプトを実行します。

出力:

--- mTLS接続テスト (成功ケース) ---
Status Code: 200
Response Body: <h1>Hello, Test Client!</h1><p>mTLS connection successful.</p>

--- mTLS接続テスト (失敗ケース: クライアント証明書なし) ---
リクエストエラーが発生しました: ('Connection aborted.', ConnectionResetError(10054, '既存の接続はリモート ホストに強制的に切断されました。', None, 10054, None))

Pythonクライアントのrequests.get はどの層で動いているか

  • requests.get() はアプリケーション層の関数です。
  • ただし内部では urllib3 → ssl → socket という下位モジュールに処理を委譲しており、そこでトランスポート層の TLS ハンドシェイクが実行されます。
  • つまり 「アプリ層のコードが、下層に設定を渡すことで TLS の証明書交換を起動している」 という関係になります。

6. 【発展】本番環境でのmTLSアーキテクチャ

ここまでの実験では、Flaskアプリケーション自体がクライアント証明書の検証を行いました。これはmTLSの原理を理解する上で非常に有効ですが、実際の本番環境では異なるアプローチを取るのが一般的です。

本番環境では、mTLSのクライアント認証(TLS終端)を、ロードバランサーAPIゲートウェイといった専用のコンポーネントに任せます。

なぜ専用コンポーネントを使うのか?

  • 責務の分離: アプリケーションはビジネスロジックに集中できます。TLSハンドシェイクのような複雑で負荷の高い処理をアプリケーションから切り離すことで、コードがシンプルになり、メンテナンス性も向上します。
  • パフォーマンスとスケーラビリティ: ロードバランサーなどのコンポーネントは、大量のTLS接続を効率的に処理するよう最適化されています。アプリケーションサーバーで直接処理するよりも遥かに高性能です。
  • 一元的なセキュリティ管理: どのCAを信頼するか、どのクライアントを許可するかといったセキュリティポリシーを、クラウドの管理画面などで一元的に設定・管理できます。

AWSでの具体例

AWSでは、主に以下のコンポーネントを組み合わせてmTLSを実現します。

  • Application Load Balancer (ALB): インターネットからのトラフィックを受け付ける窓口です。
  • AWS Certificate Manager (ACM): サーバー証明書や、信頼するCA証明書を管理するサービスです。

処理フロー:

  1. クライアントはクライアント証明書を提示して、ALBに接続します。
  2. ALBは、リスナールールでmTLSが有効になるように設定されています。
  3. ALBは、ACMに登録されたCA証明書(トラストストアと呼ばれます)を使って、クライアントから提示された証明書が信頼できるものか検証します。
  4. 認証に成功すると、ALBはクライアント証明書の情報(CN、発行者、有効期限など)をHTTPヘッダーに付加して、リクエストをバックエンドのFlaskアプリケーションに転送します。
  5. Flaskアプリケーションは、sslモジュールのような複雑な処理を行う代わりに、リクエストヘッダー(例: X-Amzn-Mtls-Client-Cert-Subject-Cn)を見るだけで「どのクライアントから来たか」を安全に識別できます。

このように、ローカルでの実験は「認証のロジック」そのものを学ぶ体験であり、本番環境ではそのロジックをより堅牢で高性能なマネージドサービスに**「移譲」**するのが、モダンなシステム設計の考え方です。

7. まとめ

このガイドでは、ローカル環境でmTLSの完全な認証フローを構築する手順を、トラブルシューティングを含めて実践的に学びました。

この実験のポイント:

  • mTLSの核心: サーバーがクライアントを認証し、クライアントもサーバーを認証する「相互認証」の仕組みを理解しました。
  • 証明書の役割: 自ら認証局(CA)を立て、サーバー証明書とクライアント証明書を発行することで、それぞれの鍵と証明書がどのような役割を担っているかを明確にしました。
  • サーバーサイドの実装: Flaskサーバーで ssl.CERT_REQUIRED を設定することで、有効なクライアント証明書を持たない接続を拒否する方法を学びました。
  • クライアントサイドの実装: PythonのrequestsやGit版curlを使い、クライアント証明書を提示してmTLSで保護されたエンドポイントに接続する方法を実践しました。
  • ローカルと本番の違い: ローカルでの実験はあくまで原理を学ぶためのものであり、本番環境ではロードバランサーやAPIゲートウェイがTLS終端を担うのが一般的であることも理解しました。
タイトルとURLをコピーしました