14.4.5. 验证公共服务器(摄像头作为客户端)

上一页中关于客户端"已经拥有根证书"的所有内容对浏览器、手机和 PC 都成立——但对摄像头并不成立。MicroPython 的 ssl 不附带任何内置信任库:一台刚刷过固件的摄像头根本不信任任何 CA,而默认设置(ssl.CERT_NONE)不验证任何内容,对中间人攻击完全敞开。因此当摄像头作为客户端向外连接到公共 TLS 服务器(HTTPS API、MQTT broker 等)并且你希望它真正验证该服务器时,你必须自己提供信任锚。

其机制与 自签名证书 上的自签名客户端示例相同;唯一的区别在于你加载的文件是一份真实的 CA 证书,而不是对端自己的证书:

  1. 获取作为服务器证书链锚点的 CA 证书。"锚定"是指服务器证书链顶端(或接近顶端)那张你选作信任起点的证书。TLS 服务器会发送其叶证书,通常还有中间证书;但它从不发送自己的根证书。你必须自己获取该信任锚,而且要独立于服务器——单纯信任服务器递交给你的任何东西会使整个验证失去意义。

    首先查清究竟是哪个 CA 实际签发了服务器的证书。例如,针对 openmv.io:

    openssl s_client -connect openmv.io:443 -showcerts < /dev/null
    

    Certificate chain 块列出了每张证书及其主题(s:)和签发者(i:);较新版本的 OpenSSL 还会打印 a:(密钥类型)和 v:(有效期)行,这里可以忽略:

    Certificate chain
     0 s:CN=openmv.io
       i:C=US, O=Let's Encrypt, CN=E8
     1 s:C=US, O=Let's Encrypt, CN=E8
       i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
    

    条目 0 是叶证书(openmv.io),由中间证书 E8 签发。条目 1 是该中间证书,由根证书 ISRG Root X1 签发。最顶端条目的签发者(i:)即指明了根证书——这里是 ISRG Root X1。(中间证书是 E8 而不是你在别处可能见过的 R10 / R11,是因为 openmv.io 使用 ECDSA 证书;Let's Encrypt 用其 E 系列中间证书签发 ECDSA 叶证书,用其 R 系列中间证书签发 RSA 叶证书。两者都链接到 ISRG Root X1。)

    OpenSSL 还会打印 depth= 行,并且可能以 Verification: OK 报告根证书。这只是因为你的 PC已经信任 ISRG Root X1——服务器没有发送它(服务器从不发送自己的根证书),而摄像头由于没有信任库,同样不会拥有它。这正是你必须自己提供它的原因。

    从 CA 自己发布的根证书处下载那张根证书。Let's Encrypt 在 Let's Encrypt 证书页面 上编录了它们所有的证书;ISRG Root X1 的直接文件是 isrgrootx1.pem(它们也提供预先编码好的 isrgrootx1.der)。其他 CA 在类似的"根证书"/"仓库"页面上发布它们的证书;权威的公共集合是 Mozilla CA 计划(CCADB)。通过将其指纹与 CA 发布的值进行比对,确认你获取的是正确的文件(如果你下载的是 .der,请添加 -inform DER):

    openssl x509 -in isrgrootx1.pem -noout -subject -fingerprint -sha256
    

    如果你不愿意去跟踪根证书,可以改为直接从 -showcerts 输出中复制中间证书(第二个 -----BEGIN CERTIFICATE----- 块),信任它,并接受每当 CA 轮换中间证书时你都必须刷新它——这比根证书频繁得多(参见下面的权衡)。

  2. 将其转换为 DER,与之前完全相同:

    openssl x509 -in isrgrootx1.pem -outform DER -out ca.der
    
  3. ca.der 复制到摄像头(文件系统或 ROMFS)并将其作为信任锚加载:

    import socket
    import ssl
    import ntptime
    
    ntptime.settime()                  # validity check needs the clock
    
    addr = socket.getaddrinfo("api.example.com", 443)[0][-1]
    sock = socket.socket()
    sock.connect(addr)
    
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.verify_mode = ssl.CERT_REQUIRED
    ctx.load_verify_locations(cafile="ca.der")
    ssock = ctx.wrap_socket(sock, server_hostname="api.example.com")
    

    这里需要 server_hostname:它驱动 SNI,并且是用来与服务器证书的 subjectAltName 进行核对的名称。

小技巧

常见情形的捷径。Let's Encrypt 是使用最广泛的公共 CA,它的 RSA 和 ECDSA 证书目前都链接到 ISRG Root X1(如上面 openmv.io 示例所示)。如果你的摄像头要通信的服务器使用 Let's Encrypt,你可以完全跳过检查:只需将 isrgrootx1.der 放到摄像头上并对其执行 load_verify_locations 即可。

并不能让 TLS 对每个站点都正常工作。证书来自不同 CA(DigiCert、Google Trust Services、Amazon、Sectigo 等)的服务器仍会验证失败,并且由于每个 ssl.SSLContext 摄像头只信任一份 DER 证书,你无法像浏览器那样捆绑每一个根证书。如有疑问,请按上面所示找出服务器实际的 CA 并信任那张根证书。

信任哪张证书是一种权衡:

  • 根证书(推荐)。寿命长——往往长达数十年——因此 ca.der 很少变化。它要求服务器发送其中间证书,以便 mbedTLS 能够构建路径:叶证书 → 中间证书 → 你信任的根证书;几乎每台正确配置的公共服务器都会这么做。

  • 中间证书。也能工作,并且即使服务器省略了中间证书它仍能继续工作,但中间证书的轮换比根证书频繁得多,因此你将不得不更频繁地刷新 ca.der

  • 叶证书本身(证书固定)。最严格,但叶证书在每次续期时都会改变——对 Let's Encrypt 而言大约每 90 天一次——因此只有当你同时控制着服务器、并且能够同步地将新的固定值推送到每台摄像头时,这才有意义。这正是自签名客户端示例所做的。

备注

ssl.SSLContext.load_verify_locations() 接受一份 DER 编码的 CA 证书,因此摄像头一次恰好信任一个锚。要访问不同 CA 下的服务器,请为每个锚使用一个单独的 ssl.SSLContext。而且由于该证书本身最终也会过期或被 CA 轮换,请像对待设备上的任何其他证书一样对待它。