14.4.5. אימות שרת ציבורי (המצלמה כלקוח)

כל מה שנאמר בעמוד הקודם על כך שלקוח ”כבר מחזיק בשורש“ נכון לגבי דפדפנים, טלפונים ומחשבים – אבל הוא אינו נכון לגבי המצלמה. ה-ssl של MicroPython מגיע ללא מאגר אמון מובנה: מצלמה שזה עתה הוטענה בקושחה אינה סומכת על אף רשות אישורים (CA) כלל, וברירת המחדל (ssl.CERT_NONE) אינה מאמתת דבר וחשופה לחלוטין להתקפת man-in-the-middle. לכן כשהמצלמה היא הלקוח שמתחבר החוצה אל שרת TLS ציבורי (API מסוג HTTPS, ברוקר MQTT, …) ואתה רוצה שהיא תאמת באמת את אותו שרת, עליך לספק בעצמך את עוגן האמון.

המנגנון זהה לדוגמת הלקוח עם אישור עצמי-חתום ב-תעודות חתומות-עצמית; ההבדל היחיד הוא שהקובץ שאתה טוען הוא אישור 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 חותם על עלי ECDSA עם אישורי הביניים מסדרת E שלו, ועל עלי RSA עם אלה מסדרת R. שניהם משתרשרים אל ISRG Root X1.)

    OpenSSL מדפיס גם שורות depth= ועשוי לדווח על השורש עם Verification: OK. זה קורה רק מכיוון שהמחשב שלך כבר סומך על ISRG Root X1 – השרת לא שלח אותו (שרת לעולם אינו שולח את השורש שלו), ולמצלמה, שאין לה מאגר אמון, גם לא יהיה אותו. זו בדיוק הסיבה שעליך לספק אותו.

    הורד את אותו שורש ממאגר השורשים שהרשות עצמה מפרסמת. Let’s Encrypt מקטלגת את כל שלה בעמוד האישורים של Let’s Encrypt; הקובץ הישיר ל-ISRG Root X1 הוא isrgrootx1.pem (היא מציעה אותו גם מקודד מראש כ-isrgrootx1.der). רשויות CA אחרות מפרסמות את שלהן בעמוד ”אישורי שורש“ / ”מאגר“ דומה; הקבוצה הציבורית הקנונית היא תוכנית ה-CA של Mozilla (CCADB). ודא שהורדת את הקובץ הנכון על ידי השוואת טביעת האצבע שלו לערך שהרשות מפרסמת (הוסף -inform DER אם הורדת את ה-.der):

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

    אם אתה מעדיף לא לעקוב אחר שורש, אתה יכול במקום זאת להעתיק את הביניים ישירות מתוך פלט ה--showcerts (בלוק ה------BEGIN CERTIFICATE----- השני), לסמוך עליו, ולקבל שתצטרך לרענן אותו בכל פעם שהרשות מסובבת את הביניים – לעיתים קרובות הרבה יותר מאשר את השורש (ראה את שיקול הדעת למטה).

  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, …) עדיין ייכשל באימות, ומכיוון שהמצלמה סומכת על אישור DER יחיד לכל ssl.SSLContext אינך יכול לצרף כל שורש כפי שדפדפן עושה. כשמתעורר ספק, זהה את ה-CA האמיתית של השרת כפי שמוצג למעלה וסמוך על אותו שורש.

על איזה אישור אתה סומך הוא שיקול דעת:

  • השורש (מומלץ). ארוך-טווח – לעיתים קרובות עשרות שנים – כך ש-ca.der ישתנה לעיתים נדירות. הוא מחייב שהשרת ישלח את הביניים שלו כדי ש-mbedTLS יוכל לבנות את המסלול עלה ← ביניים ← השורש המהימן שלך; כמעט כל שרת ציבורי מוגדר נכון עושה זאת.

  • הביניים. עובד גם כן, וממשיך לעבוד אפילו אם שרת משמיט את הביניים, אך אישורי ביניים מסובבים לעיתים קרובות הרבה יותר משורשים, כך שתצטרך לרענן את ca.der בתדירות גבוהה יותר.

  • העלה עצמו (certificate pinning). ההדוק ביותר, אך העלה משתנה בכל חידוש – בערך כל 90 יום עבור Let’s Encrypt – כך שזה הגיוני רק כשאתה גם שולט בשרת ויכול לדחוף את ה-pin החדש לכל מצלמה בו-זמנית. זה בדיוק מה שעושה דוגמת הלקוח עם אישור עצמי-חתום.

הערה

ssl.SSLContext.load_verify_locations() מקבל אישור CA יחיד מקודד-DER, כך שהמצלמה סומכת בדיוק על עוגן אחד בכל פעם. כדי להגיע לשרתים תחת CA שונות, השתמש ב-ssl.SSLContext נפרד לכל עוגן. ומכיוון שאותו אישור עצמו יפוג בסופו של דבר או יסובב על ידי ה-CA, התייחס אליו כמו לכל אישור אחר במכשיר.