
Python ใช้ CA store ไหน เช็กให้ชัดก่อนแก้ TLS error
สคริปต์ส่งยอดชำระเงินรันผ่านบนเครื่องเรา แต่พอขึ้นเซิร์ฟเวอร์กลับเจอข้อผิดพลาดตรวจใบรับรอง งานที่ควรจบก่อนปิดบัญชีเลยต้องรอแก้ 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 ของไลบรารีที่แอปใช้จริงก่อนสรุปผล