9.17. 加密套接字与 TLS

到目前为止介绍的所有内容都是以明文方式传输字节。摄像头与服务器之间路径上的任何设备——家用路由器、互联网服务提供商、咖啡馆里的恶意接入点——原则上都可以读取或修改经过的数据。对于大多数互联网流量来说,这是不可接受的。标准的解决办法是用一层加密包裹连接:TLS,即传输层安全协议。浏览器中的 "HTTPS" 锁形图标就是运行在 TCP 之上的 TLS,而正是这同一层包裹使其他任何互联网协议变得 "安全"。摄像头的 ssl 模块就是用来将 socket 包裹进 TLS 的。

9.17.1. TLS 增加了什么,以及摄像头自带什么

TLS 位于 TCP 与应用之间——应用向经过 TLS 包裹的套接字写入字节,TLS 将其加密后把结果交给 TCP,而在另一端这个过程则反向进行。在其完整形态下,TLS 在普通 TCP 之上提供三项保证:

  • 机密性。 路径上的窃听者无法读取两个端点之间交换的内容。

  • 完整性。 传输过程中对流量的任何修改都会被检测到;连接会断开,而不是传递被篡改的数据。

  • 身份验证。 服务器证明自己确实是所声称的那个服务器,而非冒充者(并且,客户端也可以选择性地证明 自己 的身份)。

前两项来自加密本身。第三项则需要至少一方持有 证书,外加某个预先受信任的东西来据以验证这些证书。OpenMV 摄像头出厂时 完全没有内置的证书存储:一台刚刷写好的摄像头不信任任何证书颁发机构,也没有自己的服务器证书,而默认的验证模式(ssl.CERT_NONE)不会将对端的证书与任何东西进行比对。因此,开箱即用时,摄像头上的 TLS 只能为你提供前两项保证——针对被动观察者的窃听和篡改的加密——但 不包括 第三项。

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"。靠近顶部有一条虚线水平线, 标注为 "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 流量,方法是选择服务器协议并向 ssl.wrap_socket() 传入 server_side=True。上面的警告仍然适用:没有自己的证书,摄像头就无法向客户端证明自己的身份,而好奇的客户端在大多数 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(数据报 TLS),摄像头的 ssl 模块以同样的方式支持它。TLS 将一条 TCP 连接变成一条加密的字节流,而 DTLS 则将一个 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 图相同;区别在于每条握手消息本身都是一个 UDP 数据报,并且任一方都会在丢包时重试。

备注

丢包会破坏加密吗?不会。每个 DTLS 包都携带一个序列号,加密会使用该序号为每个包产生不同的输出——因此相同的输入永远不会两次加密成相同的字节,而且任何一个包都可以独立解密,无需前一个包已经到达。丢失或乱序的包不会使双方失去同步。(握手本身是唯一必须可靠送达的部分,而 DTLS 用它自己的重传机制来处理这一点。)

上面提到的同一条 "无证书即仅加密" 警告同样适用:针对一个 CERT_NONE 对端的 DTLS 握手会加密流量,但不会验证另一方的身份。完整的 DTLS 工作流——证书、服务器侧的反欺骗 cookie,以及除协议常量外它与 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.send / s.recv 都会立即返回(或抛出 OSError),而 await asyncio.sleep_ms 让程序的其余部分继续运行。

9.17.5. 更进一步

超出仅加密 TLS 的一切内容——验证公共 HTTPS 服务器的证书、将摄像头作为经过身份验证的 TLS 服务器运行、摄像头与后端之间的双向 TLS、选择密钥和密钥类型、处理证书过期——都在 使用 TLS 证书 中。该部分涵盖了如何为本地测试生成自签名证书、如何为生产环境获取 CA 签名的证书、如何以正确的格式(DER)将它们放到摄像头上、当摄像头作为客户端时如何验证一个公共服务器、如何看待在攻击者可能拆解的设备上的密钥保护,以及如何为证书过期那一天做规划。

有关完整的 ssl API 参考——支持的 TLS 版本、密码套件以及上下文选项——请参阅 ssl --- SSL/TLS 模块