9.17. 加密 socket 與 TLS

到目前為止介紹的一切都是以明文方式傳輸位元組。相機與伺服器之間路徑上的任何裝置——家用路由器、網際網路服務供應商、咖啡廳裡的惡意存取點——原則上都能讀取或修改通過的內容。對於大多數網際網路流量而言,這是無法接受的。標準的解決方法是將連線包裹在一層加密之中:TLS,即傳輸層安全協定(Transport Layer Security)。瀏覽器中「HTTPS」的鎖頭圖示就是執行在 TCP 之上的 TLS,而正是同樣的包裹方式讓其他任何網際網路協定變得「安全」。相機的 ssl 模組就是用來將 socket 包裹進 TLS 的工具。

9.17.1. TLS 帶來什麼,以及相機隨附了什麼

TLS 位於 TCP 與應用程式之間——應用程式將位元組寫入經 TLS 包裹的 socket,TLS 將其加密後把結果交給 TCP,另一端則反向執行此過程。在完整形式下,TLS 在純 TCP 之上提供三項保證:

  • 機密性。 路徑上的竊聽者無法讀取兩個端點之間交換的內容。

  • 完整性。 傳輸過程中對流量的任何修改都會被偵測到;連線會中斷,而不是傳遞被竄改的資料。

  • 身分驗證。 伺服器證明自己就是所宣稱的伺服器,而非冒名者(且用戶端也可以選擇性地證明它自己的身分)。

前兩項來自加密本身。第三項則至少需要其中一方持有憑證,再加上某個事先信任的東西來據以驗證這些憑證。OpenMV 相機完全沒有內建的憑證儲存區:剛燒錄好的相機不信任任何憑證授權單位、本身沒有伺服器憑證,而預設的驗證模式(ssl.CERT_NONE)不會根據任何東西檢查對端的憑證。因此在開箱即用的狀態下,相機上的 TLS 能提供前兩項保證——對抗被動觀察者的竊聽與竄改的加密——但無法提供第三項。

9.17.2. 加密對外連線

最簡單的用法是包裹一個對外的 TCP 連線。流程是:開啟一個一般的 TCP socket,將其交給 ssl.wrap_socket(),然後就像使用純 socket 一樣,透過包裹後的 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」。靠近頂端有一條虛線的水平 線,標示為「TCP connection already open」。在其下方,三個箭頭顯示 TLS 交握過程:從用戶端到伺服器的「ClientHello」、 回傳的「ServerHello + certificate + key share」,以及再次往前的「Finished」。下方 第二條虛線的水平線標示為「TLS session open -- everything after this is encrypted」。其下方 兩個粗的雙向箭頭承載著 「encrypted data」。

ssl.wrap_socket() 所執行的 TLS 交握。它位於前一張圖中已開啟的 TCP 連線之上;一旦雙方都送出 Finished,後續的對話便會在兩個方向上都被加密。

警告

這是僅加密、並非經過身分驗證的 TLS。相機會安全地與 TCP 連線另一端任何回應的對象通訊。如果中間人攻擊者將連線重新導向到它所控制的伺服器,而該伺服器提供任何憑證,交握仍然會成功,相機最終便會安全地與攻擊者通訊。只有在中間人不屬於威脅模型的情況下才使用此模式——封閉的本地網路、開發環境、相機與執行在同一硬體上的服務通訊——而不要在連往公開網際網路時使用。

若要進行真正的身分驗證——相機驗證公開伺服器、相機作為 TLS 伺服器,或雙向 TLS——你需要將憑證帶到裝置上。完整的說明請參閱 使用 TLS 憑證

同樣的包裹方式也適用於傳入的 TCP 流量,做法是選擇伺服器協定並將 server_side=True 傳給 ssl.wrap_socket()。上述警告仍然適用:相機若沒有自己的憑證,就無法向用戶端證明自己的身分,而好奇的用戶端在大多數 TLS 堆疊上都會看到「no certificate」的交握失敗。生產端的憑證工作流程正是讓相機能以有用的方式作為 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 連線背後的 reader/writer 配對與純 TCP 連線的形式相同——只有設定方式不同。關於身分驗證的同樣告誡仍然適用:單獨使用 ssl=True 只提供加密,不提供驗證。

9.17.4. DTLS——基於 UDP 的 TLS

到目前為止討論的 TLS 是執行在 TCP 之上的。對應於 UDP 的平行協定是 DTLS(Datagram TLS),相機的 ssl 模組以同樣的方式支援它。TLS 將一個 TCP 連線轉換成一個加密的位元組串流,DTLS 則將一個 UDP socket 轉換成一連串加密的、各自獨立傳遞的資料報——因此 UDP -- 送出封包,聽天由命 中 UDP 的遺失/亂序/無流量控制等特性全都會延續下來,只是每個資料報內的位元組現在都已加密。

包裹的方式看起來與 TLS 的情況相同,只是改用 SOCK_DGRAM socket 以及 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 socket 上呼叫 connect() 並不會開啟連線——它只是記住一個預設目的地,讓後續的 send()recv() 呼叫不必再重複指定。DTLS 需要這個固定的目的地來進行交握。)

交握的形式與上方的 TLS 圖相同;差別在於每則交握訊息本身都是一個 UDP 資料報,且任一方都會在遺失時重試。

備註

封包遺失會破壞加密嗎?不會。每個 DTLS 封包都帶有一個序號,加密會使用該序號為每個封包產生不同的輸出——因此相同的輸入永遠不會兩次加密成相同的位元組,而且任何封包都能在前一個封包尚未送達的情況下獨自被解密。遺失或亂序的封包不會使雙方失去同步。(交握本身是唯一必須可靠送達的部分,而 DTLS 透過自己的重傳機制來處理。)

上述同樣的「未使用憑證即僅加密」警告也適用:對 CERT_NONE 對端進行 DTLS 交握會將流量加密,但不會驗證另一方的身分。完整的 DTLS 工作流程——憑證、伺服器端的防偽 cookie,以及除了協定常數之外它如何與 TLS 是相同的範疇——都與 TLS 的相關內容一併涵蓋於 使用 TLS 憑證

asyncio 版本使用與 使用 asyncio 的 Socket 中相同的非阻塞 UDP 模式。先在前面同步完成交握,再將 socket 切換為非阻塞,然後在協程內進行輪詢:

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 版本、加密套件與 context 選項——請參閱 ssl --- SSL/TLS 模組