งูการ์ตูนสีเขียวส่องไฟฉายเข้าไปในลิ้นชักตู้เก็บบัตรใบรับรอง เทียบกับตู้สีเหลืองอีกใบฝั่งตรงข้าม มีเส้นประพาซองจดหมายลอยไปยังตู้ที่จะใช้ตรวจ

Python ใช้ CA store ไหน เช็กให้ชัดก่อนแก้ TLS error

น้องกายEngineering

สคริปต์ส่งยอดชำระเงินรันผ่านบนเครื่องเรา แต่พอขึ้นเซิร์ฟเวอร์กลับเจอข้อผิดพลาดตรวจใบรับรอง งานที่ควรจบก่อนปิดบัญชีเลยต้องรอแก้ TLS ซึ่งเป็นระบบป้องกันข้อมูลระหว่างการเชื่อมต่อ เวลามีเหตุขัดข้องเกี่ยวกับ CA ประเด็นแรกที่ควรเช็กคือ Python ตัวที่รันงานกำลังเชื่อถือใบรับรองจากที่ไหน บทความนี้จะพาเก็บหลักฐานจากเครื่องจริง โดยไม่ต้องเชื่อมต่อบริการปลายทางครับ

WHY: รู้แหล่งความเชื่อถือก่อนเปลี่ยนใบรับรอง

CA ย่อจาก Certificate Authority คือผู้ออกใบรับรองที่ใช้ประกอบการตรวจตัวตน ส่วน CA store คือชุดใบรับรองที่โปรแกรมใช้เป็นฐานความเชื่อถือ การมีไฟล์ใบรับรองอยู่บนเครื่องจึงยังไม่ตอบว่าโปรแกรมโหลดไฟล์นั้นจริงหรือไม่

  • ลดการแก้ผิดที่ เช่น อัปเดตชุดใบรับรองคนละชุดกับที่โปรเซสใช้งาน
  • เปรียบเทียบเครื่องพัฒนา เซิร์ฟเวอร์ และคอนเทนเนอร์ด้วยข้อมูลชุดเดียวกัน
  • แยกปัญหาแหล่งใบรับรองออกจากการตั้งค่าตรวจตัวตน ก่อนวิเคราะห์การเชื่อมต่อจริง

เป้าหมายของเราคือดูทั้งพาธเริ่มต้นและสถานะของ context หรือออบเจ็กต์เก็บการตั้งค่า TLS ตาม เอกสาร ssl ฟังก์ชัน create_default_context() เตรียมค่าเริ่มต้นด้านความปลอดภัยให้ และเมื่อใช้ตรวจเซิร์ฟเวอร์จะตั้ง verify_mode เป็น CERT_REQUIRED เพื่อบังคับตรวจใบรับรอง

ทำงานยังไง

นึกถึงสมุดรายชื่อผู้มีสิทธิ์รับพัสดุที่นิติบุคคลเก็บไว้ พาธคือที่ตั้งตู้เอกสาร ส่วนใบรับรองที่โหลดแล้วคือรายชื่อที่เจ้าหน้าที่หยิบมาใช้ การรู้ว่าตู้อยู่ตรงไหนยังไม่ได้แปลว่าเปิดดูครบทุกแฟ้มแล้วครับ

ssl.get_default_verify_paths() คืน DefaultVerifyPaths ซึ่งเป็น named tuple หรือ tuple ที่อ่านสมาชิกผ่านชื่อได้ มีหกช่องตาม เอกสารทางการ:

ช่องอ่านว่าอะไร
cafileพาธไฟล์ที่หาได้ หรือ None เมื่อไฟล์ไม่มีอยู่
capathพาธไดเรกทอรีที่หาได้ หรือ None เมื่อไดเรกทอรีไม่มีอยู่
openssl_cafile_envชื่อตัวแปรสภาพแวดล้อมสำหรับกำหนดไฟล์
openssl_cafileพาธไฟล์เริ่มต้นที่กำหนดไว้ใน OpenSSL
openssl_capath_envชื่อตัวแปรสภาพแวดล้อมสำหรับกำหนดไดเรกทอรี
openssl_capathพาธไดเรกทอรีเริ่มต้นที่กำหนดไว้ใน OpenSSL

ตัวแปรสภาพแวดล้อมเป็นค่าที่ส่งให้โปรเซสตอนทำงาน ในตัวอย่างจะทดลอง SSL_CERT_FILE และแสดง SSL_CERT_DIR ควบคู่กับชื่อที่ OpenSSL รายงานผ่านช่อง *_env ส่วน ssl.OPENSSL_VERSION บอกเวอร์ชันไลบรารี OpenSSL ที่ interpreter หรือโปรแกรม Python ตัวนี้โหลดอยู่

พาธเหล่านี้อธิบายค่าเริ่มต้นของ OpenSSL ไม่ใช่รายการแหล่งใบรับรองทั้งหมดของทุกไลบรารี และไม่ใช่ประวัติว่าใบรับรองแต่ละใบมาจากไฟล์ใด create_default_context() อาจโหลดชุดเริ่มต้นของระบบ ขณะที่โปรแกรมอาจระบุ cafile เอง จึงควรตรวจ context ที่แอปใช้จริงด้วย

context.cert_store_stats() ให้จำนวน x509 คือใบรับรอง X.509, x509_ca คือใบที่มีธง CA และ crl คือรายการเพิกถอนใบรับรอง ส่วน get_ca_certs() คืนรายการ CA ที่โหลดแล้ว ข้อควรจำจาก เอกสาร get_ca_certs คือใบรับรองใน capath จะยังไม่โหลดจนถูกใช้ในการเชื่อมต่อ ดังนั้นรายการว่างไม่ได้พิสูจน์ว่าไดเรกทอรีนั้นไม่มี CA

โค้ดตัวอย่างพร้อมเทสต์

บันทึกเป็น check_ca.py แล้วรัน python3 -I -B check_ca.py ตัวอย่างจำลองงานส่งยอดชำระของคุณมาลี 1,250 บาท มีเทสต์สี่ข้อและรายงานค่าจากเครื่องที่รัน ไม่มีการเปิดการเชื่อมต่อเครือข่าย ไฟล์ว่างในเทสต์ใช้ตรวจพฤติกรรมการโหลดเท่านั้น ไม่ใช่ชุด CA สำหรับใช้งานจริง

import os
import platform
from pathlib import Path
import ssl
import tempfile
import unittest
from unittest.mock import patch


FIELDS = (
    "cafile", "capath", "openssl_cafile_env", "openssl_cafile",
    "openssl_capath_env", "openssl_capath",
)


def print_report():
    print("งาน: ส่งยอดชำระของมาลี 1,250 บาท วันที่ 2026-10-21")
    print("Python:", platform.python_version())
    print("OpenSSL:", ssl.OPENSSL_VERSION)
    paths = ssl.get_default_verify_paths()
    for name in FIELDS:
        print(f"{name}: {getattr(paths, name)!r}")
    for name in ("SSL_CERT_FILE", "SSL_CERT_DIR"):
        print(f"env {name}: {os.environ.get(name)!r}")
    context = ssl.create_default_context()
    print("verify_mode:", context.verify_mode.name)
    print("check_hostname:", context.check_hostname)
    print("loaded store:", context.cert_store_stats())
    print("loaded CA count:", len(context.get_ca_certs()))
    print("หมายเหตุ: CA ใน capath อาจยังไม่ถูกโหลด")


class CAStoreTests(unittest.TestCase):
    def test_default_context_checks_certificate_and_hostname(self):
        context = ssl.create_default_context()
        self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
        self.assertTrue(context.check_hostname)

    def test_default_verify_paths_namedtuple_fields(self):
        paths = ssl.get_default_verify_paths()
        self.assertIsInstance(paths, tuple)
        self.assertEqual(paths._fields, FIELDS)
        self.assertEqual(tuple(paths._asdict()), FIELDS)

    def test_empty_cafile_rejected_or_loads_no_certificates(self):
        with tempfile.TemporaryDirectory() as folder:
            cafile = Path(folder) / "empty-ca.pem"
            cafile.write_bytes(b"")
            try:
                context = ssl.create_default_context(cafile=str(cafile))
            except ssl.SSLError as error:
                print("Empty cafile result:", type(error).__name__)
            else:
                self.assertEqual(context.cert_store_stats()["x509"], 0)
                self.assertEqual(context.get_ca_certs(), [])
                print("Empty cafile result: loaded 0 certificates")

    def test_ssl_cert_file_changes_resolved_path(self):
        before = ssl.get_default_verify_paths()
        with tempfile.TemporaryDirectory() as folder:
            cafile = Path(folder) / "probe-ca.pem"
            cafile.write_bytes(b"")
            with patch.dict(os.environ, {"SSL_CERT_FILE": str(cafile)}):
                paths = ssl.get_default_verify_paths()
                self.assertEqual(paths.openssl_cafile_env, "SSL_CERT_FILE")
                self.assertEqual(paths.cafile, str(cafile))
                self.assertNotEqual(paths.cafile, before.cafile)
            self.assertEqual(ssl.get_default_verify_paths(), before)


if __name__ == "__main__":
    print_report()
    unittest.main()

ผมรันด้วย Python 3.14.7 แล้ว เทสต์ทั้งสี่ข้อผ่านครับ

บนเครื่องที่ทดสอบ การส่งไฟล์ว่างให้ cafile เกิด SSLError ขณะที่การกำหนด SSL_CERT_FILE ไปยังไฟล์ว่างที่มีอยู่ทำให้ get_default_verify_paths().cafile เปลี่ยนตามได้ สองผลนี้แยกกันชัดเจน: การพบพาธไม่ได้ยืนยันว่าเนื้อหาเป็นใบรับรองที่โหลดได้ เทสต์ยอมรับทั้งกรณีปฏิเสธไฟล์ว่างและกรณีโหลดได้ศูนย์ใบ แต่ผลที่สังเกตได้ครั้งนี้คือกรณีแรก

เวลาอ่านรายงาน ให้เริ่มที่ Python และ OpenSSL แล้วเทียบ cafile, capath กับค่า environment จากนั้นดูจำนวนที่โหลดจริง อย่ายึดจำนวน CA ของเครื่องผมเป็นค่ามาตรฐาน เพราะรายงานนี้มีไว้ตรวจสภาพแวดล้อมแต่ละเครื่อง และเทสต์ไม่ได้พิสูจน์ว่าเซิร์ฟเวอร์ปลายทางจะตรวจผ่าน

จุดที่คนพลาดบ่อย

  • แก้ error ด้วย CERT_NONE หรือ check_hostname=False: อย่าใช้เป็นทางแก้ ให้ตรวจแหล่ง CA และชื่อโฮสต์แทน CERT_REQUIRED อย่างเดียวไม่ตรวจชื่อโฮสต์ ต้องเปิด check_hostname ด้วยตาม เอกสารการตรวจใบรับรอง
  • เห็น cafile=None แล้วสรุปว่าไม่มีแหล่งความเชื่อถือ: ค่านี้บอกว่าไม่พบไฟล์ตามพาธนั้น ให้ดู capath และข้อมูลใน context ต่อ อย่าตัดสินทั้งระบบจากช่องเดียว
  • นับ get_ca_certs() แล้วคิดว่าได้ทุกใบใน capath: จำข้อจำกัดเรื่องโหลดเมื่อใช้งาน และเก็บทั้งพาธกับสถิติประกอบกัน
  • คิดว่าตั้ง environment แล้วแปลว่าไฟล์ใช้ได้: เทสต์พาธผ่านได้แม้ไฟล์ว่าง ต้องแยกการพบไฟล์ออกจากการโหลดใบรับรอง และตรวจการสร้าง context ด้วย
  • นำผลของ stdlib ไปสรุปแทน HTTP client ทุกตัว: โดยทั่วไปไลบรารีอย่าง requests ใช้ชุด CA จาก certifi ให้ตรวจการตั้งค่าของ client ที่แอปเรียกจริงเพิ่มเติม ผลจากตัวอย่างนี้อธิบายฝั่ง ssl ที่เราสร้างขึ้น

ใช้ตรงไหนในงานจริง

  • แนบรายงานตอนส่งต่องาน TLS error ให้ทีมระบบ พร้อมระบุ interpreter ที่ใช้รันงาน
  • รันในคอนเทนเนอร์เดียวกับแอป เพื่อเทียบพาธกับเครื่องพัฒนาก่อนปรับ image
  • ตรวจหลังเปลี่ยนชุด CA ขององค์กร โดยดูแหล่งโหลดและลองเชื่อมต่อปลายทางในขั้นทดสอบแยกต่างหาก
  • ใช้ช่วงรับมือเหตุ CA ขัดข้องเพื่อยืนยันว่าการแก้ไขไปถึงโปรเซสเป้าหมายแล้ว โดยไม่สรุปสาเหตุจากข้อความ error เพียงอย่างเดียว

เทียบกับทางเลือกอื่น

วิธีดีตรงไหนระวังอะไร
get_default_verify_paths()เห็นพาธเริ่มต้นและชื่อ environment ที่เกี่ยวข้องไม่ยืนยันเนื้อหาไฟล์หรือแหล่ง CA ของทุก client
สถิติและรายการ CA จาก contextเห็นสิ่งที่โหลดแล้วใน context นั้นใบใน capath อาจยังไม่โหลด
ระบุ cafile ให้ contextทดลองแหล่งใบรับรองที่ต้องการได้ชัดเจนต้องดูแลไฟล์และตรวจว่าโหลดได้จริง
ทดสอบการเชื่อมต่อปลายทางตรวจผลกับบริการที่ต้องใช้งานต้องมีเครือข่าย และผลผูกกับปลายทางขณะทดสอบ

Recap

  • เก็บเวอร์ชัน Python และ ssl.OPENSSL_VERSION จากโปรเซสที่มีปัญหา
  • อ่าน DefaultVerifyPaths ให้ครบทั้งหกช่อง
  • เทียบ SSL_CERT_FILE และ SSL_CERT_DIR กับชื่อที่ OpenSSL รายงาน
  • แยกพาธที่พบออกจากใบรับรองที่โหลดสำเร็จ
  • ดู cert_store_stats() และ get_ca_certs() โดยคำนึงถึง capath
  • รักษา CERT_REQUIRED และการตรวจชื่อโฮสต์ไว้
  • ตรวจการตั้งค่า CA ของไลบรารีที่แอปใช้จริงก่อนสรุปผล