HTTPS trên ESP32: nhúng chứng chỉ gốc đúng cách và vì sao setInsecure() nguy hiểm
HTTPS bảo vệ được gì, chuỗi tin cậy của chứng chỉ, nhúng chứng chỉ gốc ISRG Root X1 vào ESP32, vì sao setInsecure() mở đường cho tấn công xen giữa, chương trình thử ba trường hợp.
ESP32 gửi dữ liệu lên Google Sheets, Telegram, Firebase hay máy chủ riêng đều phải dùng HTTPS. Nhiều hướng dẫn trên mạng "cho chạy được" bằng một dòng client.setInsecure(), nhưng dòng đó tắt luôn phần quan trọng nhất của HTTPS: kiểm tra xem máy chủ có đúng là máy chủ thật không. Bài này giải thích HTTPS bảo vệ được gì, chuỗi chứng chỉ hoạt động thế nào, cách nhúng chứng chỉ gốc vào ESP32, vì sao setInsecure() nguy hiểm, và một chương trình thử ba trường hợp để thấy tận mắt.
1. HTTPS bảo vệ được gì
HTTPS là HTTP chạy bên trong TLS. TLS làm hai việc:
- Mã hóa: người đứng giữa đường truyền (cùng mạng Wi-Fi, nhà mạng, router) không đọc được nội dung, kể cả mật khẩu, token API.
- Xác thực máy chủ: ESP32 kiểm tra chứng chỉ của máy chủ để biết chắc mình đang nói chuyện với đúng máy chủ đó, không phải một máy giả mạo.
Mã hóa mà không xác thực thì gần như vô nghĩa: kẻ giả mạo chỉ cần đứng giữa, nhận kết nối mã hóa của ESP32 bằng chứng chỉ tự tạo, đọc hết rồi chuyển tiếp tới máy chủ thật. setInsecure() chính là bỏ phần xác thực.
2. Chuỗi tin cậy của chứng chỉ

Chứng chỉ của máy chủ được một tổ chức cấp chứng chỉ (CA) ký, qua một chứng chỉ trung gian. Chứng chỉ trên cùng là chứng chỉ gốc: nó tự ký và được hệ điều hành, trình duyệt tin sẵn. Trên ESP32 không có kho chứng chỉ như máy tính, nên chương trình phải tự đưa chứng chỉ gốc vào bằng setCACert().
Nên nhúng chứng chỉ gốc, không nhúng chứng chỉ máy chủ: chứng chỉ máy chủ của Let's Encrypt chỉ có hạn khoảng 90 ngày và đổi liên tục, còn ISRG Root X1 có hạn tới tháng 6/2035. Cách so "vân tay" (fingerprint) của chứng chỉ máy chủ bằng verify() cũng gặp đúng vấn đề này: mỗi lần máy chủ đổi chứng chỉ là ESP32 ngừng kết nối được.
Lấy chứng chỉ gốc của một trang web: mở trang bằng trình duyệt máy tính, bấm biểu tượng ổ khóa, xem chứng chỉ, chọn chứng chỉ trên cùng của chuỗi và xuất ra dạng Base64 (PEM), rồi chép vào chương trình như trong bài. Chứng chỉ gốc là dữ liệu công khai, không phải bí mật.
3. Vì sao setInsecure() nguy hiểm

Với setInsecure(), ESP32 chấp nhận bất kỳ chứng chỉ nào, kể cả chứng chỉ kẻ giả mạo vừa tự tạo. Kẻ đó không cần phá mã gì cả: chỉ cần ESP32 kết nối qua một điểm phát Wi-Fi giả cùng tên, một router đã bị chiếm, hoặc một máy chủ DNS trả địa chỉ sai. Token Telegram, khóa API, mật khẩu trong yêu cầu gửi đi đều lộ, và kẻ đó còn trả về dữ liệu giả cho ESP32.
setInsecure() chỉ nên dùng lúc thử nghiệm trên bàn, với dữ liệu không có gì quan trọng, và phải bỏ trước khi mang thiết bị đi dùng thật.
4. Chương trình thử ba trường hợp
Chương trình nhúng chứng chỉ gốc ISRG Root X1 (của Let's Encrypt) rồi thử:
- Kết nối
valid.x1.test-certs.letsencrypt.org, trang thử nghiệm chính thức của Let's Encrypt có chứng chỉ dẫn tới ISRG Root X1: phải thành công. - Kết nối
example.com(chứng chỉ của một CA khác) với cùng chứng chỉ gốc đó: phải bị từ chối. Trường hợp này giống hệt khi gặp kẻ giả mạo. - Kết nối
example.comvớisetInsecure(): thành công, nhưng ESP32 không hề biết đầu bên kia là ai.
// PYWORLD.VN - HTTPS trên ESP32: kiểm tra máy chủ bằng chứng chỉ gốc, thấy setInsecure() bỏ qua kiểm tra nguy hiểm thế nào
// Ba lần thử: máy chủ dùng chứng chỉ Let's Encrypt (khớp chứng chỉ gốc ISRG Root X1), máy chủ dùng chứng chỉ của hãng khác, và setInsecure()
// Thay TEN_WIFI, MAT_KHAU_WIFI. Chỉ dùng thư viện có sẵn trong gói esp32 (WiFi, WiFiClientSecure)
#include <WiFi.h>
#include <WiFiClientSecure.h>
const char *TEN_WIFI = "TEN_WIFI";
const char *MAT_KHAU_WIFI = "MAT_KHAU_WIFI";
// Chứng chỉ gốc ISRG Root X1 (của Let's Encrypt), hết hạn ngày 04/06/2035. Đây là dữ liệu công khai, không phải bí mật.
const char *ISRG_ROOT_X1 =
"-----BEGIN CERTIFICATE-----\n"
"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n"
"TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n"
"cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n"
"WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n"
"ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n"
"MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n"
"h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n"
"0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n"
"A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n"
"T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n"
"B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n"
"B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n"
"KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n"
"OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n"
"jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n"
"qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n"
"rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n"
"HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n"
"hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n"
"ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n"
"3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n"
"NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n"
"ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n"
"TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n"
"jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n"
"oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n"
"4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n"
"mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n"
"emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n"
"-----END CERTIFICATE-----\n";
// Mở kết nối HTTPS, gửi GET và in dòng trạng thái. caGoc = nullptr nghĩa là dùng setInsecure().
void thu(const char *tieuDe, const char *mayChu, const char *caGoc) {
WiFiClientSecure ketNoi;
if (caGoc) ketNoi.setCACert(caGoc);
else ketNoi.setInsecure(); // KHÔNG kiểm tra máy chủ là ai: chỉ để thử nghiệm
ketNoi.setHandshakeTimeout(15);
Serial.printf("\n[%s] %s\n", tieuDe, mayChu);
uint32_t batDau = millis();
if (!ketNoi.connect(mayChu, 443)) {
char loi[128];
ketNoi.lastError(loi, sizeof(loi));
Serial.printf(" Khong ket noi duoc: %s\n", loi);
return;
}
Serial.printf(" Bat tay TLS xong sau %lu ms\n", (unsigned long)(millis() - batDau));
ketNoi.printf("GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", mayChu);
String dong = ketNoi.readStringUntil('\n'); // dòng đầu: HTTP/1.1 200 OK
Serial.printf(" May chu tra loi: %s\n", dong.c_str());
ketNoi.stop();
}
void setup() {
Serial.begin(115200);
WiFi.begin(TEN_WIFI, MAT_KHAU_WIFI);
Serial.print("Dang ket noi Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nDia chi IP: %s, heap trong: %lu byte\n", WiFi.localIP().toString().c_str(), (unsigned long)ESP.getFreeHeap());
thu("1. Dung chung chi goc", "valid.x1.test-certs.letsencrypt.org", ISRG_ROOT_X1);
thu("2. Chung chi goc khong khop", "example.com", ISRG_ROOT_X1);
thu("3. setInsecure", "example.com", nullptr);
}
void loop() {}
Mỗi dòng của chứng chỉ PEM được viết thành một chuỗi C kết thúc bằng \n; trình dịch tự nối các chuỗi đứng liền nhau thành một. Sai một ký tự là chứng chỉ hỏng, nên hãy chép nguyên khối, không gõ lại.
5. Kết quả mong đợi

Bắt tay TLS mất khoảng 1–2 giây và cần khoảng 40–50 KB RAM cho mỗi kết nối, vì vậy nên giữ kết nối dùng lại nhiều lần thay vì mở mới cho mỗi yêu cầu, và kiểm tra ESP.getFreeHeap() khi chương trình lớn. Câu lỗi "X509 - Certificate verification failed" là dấu hiệu chứng chỉ gốc không khớp: hoặc nhúng nhầm chứng chỉ, hoặc máy chủ đã đổi sang CA khác, hoặc đúng là có kẻ giả mạo.
6. Những lưu ý khi dùng lâu dài
- Máy chủ có thể đổi CA. Thiết bị dùng nhiều năm nên có cách cập nhật chứng chỉ (cập nhật chương trình qua mạng, hoặc lưu chứng chỉ trong LittleFS để thay được), hoặc nhúng sẵn hai chứng chỉ gốc.
- Bộ chứng chỉ gốc có sẵn: gói esp32 3.x được dựng kèm bộ chứng chỉ gốc phổ biến (bundle) như trình duyệt; dùng qua
setCACertBundle()thì kết nối được hầu hết các trang mà không cần chọn chứng chỉ, đổi lại tốn thêm bộ nhớ flash. - Ngày hết hạn: với cấu hình mbedTLS của gói esp32 3.3.10 (không bật
MBEDTLS_HAVE_TIME_DATE), ESP32 kiểm tra chữ ký và tên miền nhưng không kiểm tra ngày hết hạn của chứng chỉ. Đây thêm một lý do để chọn chứng chỉ gốc còn hạn lâu và theo dõi khi CA thông báo thay đổi. - Không để token trong mã nguồn chia sẻ: chứng chỉ gốc công khai được, còn mật khẩu Wi-Fi, token API thì không.
7. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| "X509 - Certificate verification failed" | Chứng chỉ gốc không khớp chuỗi của máy chủ | Xem chuỗi chứng chỉ trên trình duyệt, nhúng đúng chứng chỉ gốc |
| "X509 - The CRT/CRL/CSR format is invalid" | PEM chép thiếu dòng, thiếu \n, sai ký tự | Chép lại nguyên khối từ dòng BEGIN tới END |
| Kết nối được vài tuần rồi hỏng | Nhúng chứng chỉ máy chủ thay vì chứng chỉ gốc; máy chủ đổi CA | Nhúng chứng chỉ gốc; có cách cập nhật từ xa |
| Kết nối chậm, đôi khi treo | Mạng yếu, bắt tay TLS lâu | setHandshakeTimeout(); giữ kết nối dùng lại |
| Khởi động lại khi mở kết nối HTTPS | Thiếu RAM (nhiều kết nối, chuỗi String lớn) | Mỗi lúc một kết nối; giải phóng bộ nhớ; theo dõi heap |
| "Khong ket noi duoc" với mọi trang | Chưa có Wi-Fi, sai DNS, tường lửa chặn cổng 443 | Kiểm tra Wi-Fi, thử trang khác, thử mạng khác |
8. Xem thêm
- Dùng MQTT với ESP32: MQTT cũng có bản chạy trên TLS (cổng 8883) với cùng cách nhúng chứng chỉ gốc.
- ESP32 làm web server: chiều ngược lại, ESP32 là máy chủ trong mạng nội bộ.
- Các bài ESP32 với dịch vụ đám mây (Telegram, Google Sheets, Firebase) và bài LittleFS trong loạt ESP32 nâng cao: nơi dùng tới HTTPS và chỗ lưu chứng chỉ.
Xem thêm