9.17. 暗号化ソケットとTLS

ここまでで取り上げた内容はすべて、バイトを平文のままやり取りするものです。カメラとサーバーの間の経路上にあるあらゆる機器、すなわち家庭用ルーター、インターネットサービスプロバイダー、あるいはコーヒーショップにある悪意あるアクセスポイントなどが、原理的には通過するデータを読み取ったり改ざんしたりできます。ほとんどのインターネット通信において、これは受け入れられません。標準的な解決策は、接続を暗号化の層で包み込むことです。それが TLS(Transport Layer Security プロトコル)です。ブラウザに表示される「HTTPS」の鍵アイコンは、TCP上で動作するTLSであり、同じ仕組みによって他のあらゆるインターネットプロトコルも「セキュア」になります。カメラの ssl モジュールは、socket をTLSで包み込むためのものです。

9.17.1. TLSが追加するもの、そしてカメラに同梱されているもの

TLSはTCPとアプリケーションの間に位置します。アプリケーションがTLSで包まれたソケットにバイトを書き込むと、TLSがそれを暗号化して結果をTCPに渡し、反対側ではその逆の処理が行われます。完全な形態では、TLSは素のTCPに加えて次の3つの保証を提供します。

  • 機密性。 経路上の盗聴者は、2つのエンドポイントがやり取りしている内容を読み取れません。

  • 完全性。 転送中のトラフィックに対するあらゆる改変は検出され、改ざんされたデータを配信する代わりに接続が切断されます。

  • 認証。 サーバーは、なりすましではなく自身が名乗ったとおりのサーバーであることを証明します(さらに任意で、クライアント側も 自身 が何者であるかを証明できます)。

最初の2つは暗号化そのものから得られます。3つ目には、少なくとも一方の側に 証明書 が必要で、加えてそれらの証明書を検証するための、あらかじめ信頼されたものが必要です。OpenMVカメラには 証明書ストアがまったく組み込まれていません。フラッシュしたばかりのカメラは、いかなる認証局も信頼せず、自身のサーバー証明書も持たず、デフォルトの検証モード(ssl.CERT_NONE)は相手の証明書を何とも照合しません。したがって、初期状態ではカメラ上のTLSは最初の2つの保証、すなわち受動的な観察者による盗聴と改ざんに対する暗号化を提供しますが、3つ目は 提供しません

9.17.2. 外向き接続を暗号化する

最も単純な使い方は、外向きのTCP接続を包み込むことです。手順は次のとおりです。通常のTCPソケットを開き、それを ssl.wrap_socket() に渡し、その後は素のソケットとまったく同じように、包まれたソケットを通して読み書きします:

import socket
import ssl

addr = socket.getaddrinfo("example.com", 443)[0][-1]
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(addr)

s = ssl.wrap_socket(sock)

s.send(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
print(s.recv(4096))
s.close()

このラップ処理がTLSハンドシェイクを実行します。その後、s.send を通るすべてのバイトは送出時に暗号化され、s.recv から得られるすべてのバイトは通信路上で暗号化されていたものです。証明書は何も設定されておらず、トラストアンカーも一切提供されていません。TLSは応答したサーバーが何であれ、それとの間で一時的なセッション鍵をネゴシエートし、それを使用するだけです。

「client」と「server」とラベル付けされた2つの列を持つ図。上部近くにある破線の水平線には「TCP connection already open」とラベルが付いています。その下では、3つの矢印がTLSハンドシェイクを示しています。クライアントからサーバーへの「ClientHello」、戻りの「ServerHello + certificate + key share」、そして再び前方への「Finished」です。その下にある2本目の破線の水平線には「TLS session open -- everything after this is encrypted」とラベルが付いています。その下にある2本の太い双方向の矢印が「encrypted data」を運びます。

ssl.wrap_socket() が実行するTLSハンドシェイク。これは前の図にある、すでに開いているTCP接続の上に位置します。両側が Finished を送信すると、それ以降の通信は双方向とも暗号化されます。

警告

これは暗号化のみであり、認証された TLSでは ありません。カメラは、TCP接続の相手側で応答したものが 何であれ それと安全に通信します。中間者攻撃を行う者が接続を自身の制御下にあるサーバーへリダイレクトし、そのサーバーが 任意の 証明書を提示した場合でも、ハンドシェイクは依然として成功し、カメラは結果として攻撃者と安全に通信することになります。このモードは、中間者攻撃が脅威モデルに含まれない場合、すなわち閉じたローカルネットワーク、開発環境、同じハードウェア上で動作するサービスとカメラが通信する場合などにのみ使用してください。公共のインターネットへ接続する際には使用 しないでください

本物の認証、すなわちカメラが公共のサーバーを検証する、カメラがTLSサーバーとして動作する、あるいは相互TLSを行うには、証明書をデバイスに取り込む必要があります。詳細はすべて TLS証明書の取り扱い にあります。

同じラップ処理は、サーバープロトコルを選択して ssl.wrap_socket()server_side=True を渡すことで、受信するTCPトラフィックにも機能します。上記の警告は依然として当てはまります。自身の証明書がなければ、カメラはクライアントに対して自身が何者であるかを証明できず、好奇心旺盛なクライアントはほとんどのTLSスタックで「証明書なし」のハンドシェイク失敗を目にすることになります。本番環境向けの証明書ワークフローこそが、カメラを有用な形でTLSサーバーとして動作させることを可能にするものです。

9.17.3. asyncioを使う場合

asyncioの章 では、素のTCPクライアント向けに asyncio.open_connection() を紹介しました。同じ呼び出しは ssl=True キーワードを受け付け、これによって接続をTLSで包み込みます。やはり証明書の設定は一切不要です:

import asyncio

async def main():
    reader, writer = await asyncio.open_connection(
        "example.com", 443, ssl=True,
    )
    writer.write(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
    await writer.drain()
    print(await reader.read(4096))
    writer.close()
    await writer.wait_closed()

asyncio.run(main())

TLS接続の背後にあるリーダー/ライターのペアは、素のTCP接続の場合と同じ形をしています。異なるのはセットアップだけです。認証に関する同じ注意点が当てはまります。ssl=True だけでは暗号化のみが得られ、検証は得られません。

9.17.4. DTLS -- UDP上のTLS

ここまで議論してきたTLSはTCPの上で動作します。UDP向けの対応プロトコルが DTLS(Datagram TLS)であり、カメラの ssl モジュールは同じ方法でこれをサポートしています。TLSが1つのTCP接続を1つの暗号化されたバイトストリームに変えるのに対し、DTLSは1つのUDPソケットを、暗号化され個別に配信されるデータグラムのストリームに変えます。そのため、UDP -- パケットを送って最善を祈る で説明したUDPの損失/順序の乱れ/フロー制御なしという性質がすべて引き継がれ、各データグラム内のバイトが暗号化されるようになります。

ラップ処理はTLSの場合と同じに見えますが、SOCK_DGRAM ソケットとDTLSのプロトコル定数を使う点だけが異なります:

import socket
import ssl

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(socket.getaddrinfo("example.com", 4433)[0][-1])

ctx = ssl.SSLContext(ssl.PROTOCOL_DTLS_CLIENT)
s = ctx.wrap_socket(sock)

s.send(b"ping")
print(s.recv(64))
s.close()

(UDPソケットに対して connect() を呼び出しても接続は開きません。これは単にデフォルトの宛先を記憶し、以降の send()recv() の呼び出しで宛先を繰り返し指定しなくて済むようにするだけです。DTLSはハンドシェイクを実行するために、その固定された宛先を必要とします。)

ハンドシェイクは上記のTLSの図と同じ形をしています。違いは、各ハンドシェイクメッセージそのものが1つのUDPデータグラムであり、損失時にはどちらの側も再送する点です。

注釈

パケットの損失は暗号化を壊すのでしょうか。いいえ。各DTLSパケットはシーケンス番号を持ち、暗号化はその番号を使ってパケットごとに異なる出力を生成します。そのため、同じ入力が2度と同じバイト列に暗号化されることはなく、どのパケットも前のパケットが届いていなくても単独で復号できます。失われたパケットや順序の乱れたパケットが両側の同期を崩すことはありません。(ハンドシェイクそのものは、確実に届かなければならない唯一の部分であり、DTLSはこれを独自の再送によって処理します。)

上記と同じ、証明書なしでは暗号化のみという警告が当てはまります。CERT_NONE の相手に対するDTLSハンドシェイクはトラフィックを暗号化しますが、相手側が何者であるかは検証しません。完全なDTLSワークフロー、すなわち証明書、サーバー側のなりすまし防止クッキー、そしてプロトコル定数を除けばこれがTLSとまったく同じ面であることについては、TLSの解説と並んで TLS証明書の取り扱い で取り上げています。

asyncio版は、asyncioを使ったソケット で示したのと同じノンブロッキングUDPのパターンを使用します。最初にハンドシェイクを同期的に実行し、ソケットをノンブロッキングに切り替え、その後コルーチン内でポーリングします:

import asyncio
import socket
import ssl

async def dtls_ping(target_addr, period_ms):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.connect(target_addr)

    # Handshake while still blocking, then switch to async polling.
    ctx = ssl.SSLContext(ssl.PROTOCOL_DTLS_CLIENT)
    s = ctx.wrap_socket(sock)
    s.setblocking(False)

    while True:
        try:
            s.send(b"ping")
        except OSError:
            pass
        await asyncio.sleep_ms(period_ms)

ハンドシェイクは、このコルーチンがイベントループをブロックする唯一の箇所です。その後は、すべての s.sends.recv が即座に戻り(または OSError を送出し)、await asyncio.sleep_ms がプログラムの残りの部分を動作させ続けます。

9.17.5. さらに進む

暗号化のみのTLSを超える内容、すなわち公共のHTTPSサーバーの証明書の検証、カメラを認証されたTLSサーバーとして動作させること、カメラとバックエンドの間の相互TLS、鍵と鍵の種類の選択、証明書の有効期限への対処などはすべて TLS証明書の取り扱い にあります。そのセクションでは、ローカルテスト用の自己署名証明書を生成する方法、本番用にCA署名された証明書を取得する方法、それらを正しい形式(DER)でカメラに取り込む方法、カメラがクライアントである場合に公共のサーバーを検証する方法、攻撃者が分解する可能性のあるデバイス上での鍵の保護の考え方、そして証明書が期限切れになる日に備える計画の立て方を取り上げています。

ssl API の完全なリファレンス、すなわちサポートされているTLSのバージョン、暗号スイート、コンテキストオプションについては、ssl --- SSL/TLS モジュール を参照してください。