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

  • 葉憑證本身(憑證固定,certificate pinning)。最嚴格,但葉憑證每次續約都會變更——Let's Encrypt 大約每 90 天一次——所以只有在你同時掌控伺服器、能夠把新的固定值同步推送到每一台相機時,這才有意義。這正是自簽用戶端範例所做的事。

備註

ssl.SSLContext.load_verify_locations() 只接受單一張 DER 編碼的 CA 憑證,所以相機一次只信任剛好一個錨點。若要連線到不同 CA 底下的伺服器,請為每個錨點使用獨立的 ssl.SSLContext。並且由於該憑證本身最終也會過期或被 CA 輪替,請把它當作裝置上的任何其他憑證一樣對待。