Bốn phần trước, client luôn là bên kiểm tra và server luôn là bên bị kiểm tra. mTLS lật thêm một chiều: server đòi client xuất trình chứng chỉ, và từ chối nếu chuỗi không dẫn về một CA server tin. Đây là cách phổ biến nhất để các dịch vụ nội bộ xác thực lẫn nhau mà không cần mật khẩu hay token, và là nền của mọi service mesh. Câu hỏi vận hành của nó cũng khác: bắt tay tốn thêm bao nhiêu khi client phải ký; khi client sai, hai bên thấy lỗi gì và thấy ở đâu; và quan trọng nhất, làm sao xoay chứng chỉ server, xoay cả CA cấp chứng chỉ client, mà 2.000 kết nối đang mở không rơi cái nào. Bài này đo cả ba với Go 1.23, rồi cho một CA sống đúng 40 giây để xem kết nối đã mở có chết theo CA hay không.
Thêm gì vào bắt tay khi server đòi chứng chỉ

Hình 1: Ba thông điệp mTLS thêm vào bắt tay, thời điểm server kiểm chứng chỉ client khác nhau giữa TLS 1.3 và 1.2, bộ chứng chỉ, và bốn phép đo.
Khi ClientAuth là RequireAndVerifyClientCert, server chèn một thông điệp CertificateRequest vào flight của mình, kèm phần mở rộng certificate_authorities liệt kê tên các CA trong ClientCAs. Client trả lời bằng Certificate và CertificateVerify, trong đó nó ký một bản tóm tắt bắt tay bằng khoá riêng, rồi mới tới Finished. Server verify chuỗi của client về ClientCAs, kiểm Extended Key Usage phải có clientAuth, và gọi VerifyPeerCertificate nếu được cấu hình, đây là chỗ để làm phân quyền kiểu "chỉ nhận CN svc-a". Có một khác biệt về thứ tự mà bài này sẽ đo được hậu quả: trong TLS 1.3, server chỉ kiểm chứng chỉ client sau khi đã gửi Finished của mình, nên tại thời điểm Handshake() của client trả về, server chưa nói gì về chứng chỉ cả. TLS 1.2 kiểm trước khi server gửi Finished.
Bộ chứng chỉ sinh bằng crypto/x509: một CA server cấp hai chứng chỉ srv-v1 và srv-v2 cùng tên tls-lab nhưng khoá khác nhau, để xoay; CA client số 1 cấp c1-svca, c1-svcb, một bản hết hạn, một bản khoá RSA-2048, và một bản có EKU serverAuth thay vì clientAuth; CA client số 2 cấp c2-svca cho bước chuyển CA; một CA ngoài cấp other-svca; một CA sống 40 giây cấp cshort; và một file gom 50 CA giả cộng CA 1 để đo CertificateRequest phình theo số CA. Server không cầm chứng chỉ trong Certificates mà trả qua GetCertificate, đọc từ một atomic.Pointer; ClientCAs được trả qua GetConfigForClient theo cùng cách; một goroutine poll mtime của file mỗi 300 ms và nạp lại khi đổi. Client đo giống các phần trước, với một thay đổi: sau Handshake() nó gửi một byte và chờ echo rồi mới tính xong, vì với TLS 1.3 đó là lúc duy nhất biết chắc server đã chấp nhận chứng chỉ.
Đo thật: +100 µs, lỗi ở Read, và 0 kết nối rơi khi xoay

Hình 2: Chi phí bắt tay theo loại chứng chỉ client, lỗi ở hai phía với TLS 1.3 và 1.2, dòng thời gian xoay chứng chỉ với 2.000 kết nối mở, và thử nghiệm CA hết hạn.
| phép đo | kết quả |
|---|---|
| không đòi cert → mTLS client ECDSA, CPU server | 270 → 370 µs, client gửi 351 → 884 B |
| mTLS client RSA-2048, CPU client | 1.040 µs, p50 1,21 ms thay vì 0,49 |
| ClientCAs 1 → 51 CA, byte client nhận | 918 → 2.917 B |
| VerifyPeerCertificate kiểm CN | không đo được chênh lệch |
| client sai, TLS 1.3 | Handshake() OK, lỗi ở Read đầu tiên |
| cert do CA khác ký | client không gửi cert, server báo "didn't provide a certificate" |
| xoay cert server, rồi CA1+CA2, rồi chỉ CA2 | 2.000 kết nối mở: sống 2.000, hỏng 0 ở mọi mốc |
| CA client hết hạn sau 40 s | 500 kết nối cũ sống hết 60 s, kết nối mới bị từ chối |
mTLS với chứng chỉ ECDSA tốn server thêm 100 µs và client thêm 533 byte, không tốn thêm gì ở client. Không đòi chứng chỉ: 270 µs CPU server, 270 µs client, p50 0,41 ms. Đòi và nhận chứng chỉ ECDSA P-256: server 370 µs vì phải verify thêm một chuỗi và một chữ ký CertificateVerify, client vẫn 270 µs vì một chữ ký ECDSA chỉ vài chục µs và nằm trong nhiễu, byte client gửi tăng từ 351 lên 884 cho Certificate cộng CertificateVerify, p50 0,49 ms. Thêm VerifyPeerCertificate kiểm CN: 370 µs, 885 byte, không đo được khác biệt; callback này chạy trên chuỗi đã verify nên gần như miễn phí, và là chỗ đúng để đặt luật phân quyền thay vì một tầng khác. TLS 1.2 với cùng chứng chỉ: 370 µs server, 320 client, ít byte hơn một chút vì không có EncryptedExtensions, nhưng chậm hơn, 0,56 ms, vì thêm một vòng đi về.
Chứng chỉ client RSA-2048 đẩy chi phí sang phía client: 1.040 µs. Đây là ảnh ngược của phần 2: ở đó chữ ký RSA nằm ở server và server trả 730 µs; ở đây client ký CertificateVerify bằng khoá RSA và client trả thêm 770 µs, server chỉ verify, 350 µs, rẻ như verify ECDSA. p50 lên 1,21 ms. Với dịch vụ nội bộ, "client" thường là một server khác, nên chi phí này không biến mất mà đổi chỗ; với thiết bị IoT dùng chứng chỉ RSA, nó là thời gian và pin trên thiết bị. Chọn ECDSA cho chứng chỉ client cũng vì lý do như chứng chỉ server.
ClientCAs có 51 CA làm CertificateRequest phình 2 KB. Client nhận 2.917 byte thay vì 918, vì server liệt kê tên của cả 51 CA trong certificate_authorities. CPU không đổi, 380 µs. Nhưng 2 KB thêm vào flight của server là một đến hai gói TCP, và có một hệ quả tinh tế hơn: client Go dùng danh sách này để quyết định có gửi chứng chỉ hay không. Chứng chỉ other-svca do một CA ngoài ký, gửi tới server chỉ tin CA 1: client Go đọc danh sách CA server chấp nhận, thấy issuer của mình không có trong đó, và không gửi chứng chỉ nào cả. Server vì thế ghi tls: client didn't provide a certificate, cùng một dòng với trường hợp client thật sự không có chứng chỉ, và client nhận cùng alert certificate required. Hai sự cố khác nhau, một là cấu hình client sai file, một là client chưa được cấp chứng chỉ, cho ra cùng một dấu vết ở cả hai phía. Khi gặp dòng đó trong log, câu hỏi đầu tiên không phải "client có cert không" mà là "cert của client do CA nào ký, và server có tin CA đó không".
TLS 1.3 báo lỗi chứng chỉ client ở Read, không ở Handshake. Cả năm cách sai, thiếu chứng chỉ, hết hạn, CA khác, EKU serverAuth, CN không được phép, đều cho cùng một hình dạng với TLS 1.3: Handshake() trả về nil, rồi Read đầu tiên trả remote error: tls: certificate required, expired certificate hay bad certificate. Lý do nằm ở thứ tự thông điệp: server gửi Finished trước khi nhận Certificate của client, nên client đã có đủ khoá để coi bắt tay là xong, và alert từ chối chỉ đến sau. Với TLS 1.2, cả năm trường hợp lỗi ngay ở Handshake(). Hệ quả thực tế cho mã client: kiểm tra lỗi ở Handshake() là không đủ với TLS 1.3; một client ghi log "kết nối thành công" rồi gửi request đầu tiên sẽ nhận lỗi ở chỗ nó không ngờ tới, và một health check chỉ bắt tay rồi đóng sẽ báo xanh với chứng chỉ client sai. Server thì thấy đúng nguyên nhân trong log: x509: certificate has expired, x509: certificate specifies an incompatible key usage, hay thông điệp tự viết trong VerifyPeerCertificate; client chỉ nhận mã alert ba loại. Debug mTLS nên bắt đầu từ log server.
Xoay chứng chỉ server và CA client: 2.000 kết nối mở, 0 rơi. Dòng thời gian: 2.000 kết nối với c1-svca mở từ giây 0 và được ping mỗi 2 giây. Giây 10, thay srv-v1 bằng srv-v2 trên đĩa, khoá trước rồi chứng chỉ; server nạp lại trong vòng 300 ms và kết nối mới thấy serial mới, kết nối cũ không biết gì vì chứng chỉ chỉ dùng lúc bắt tay. Giây 15, ClientCAs thành CA 1 cộng CA 2: cả c1-svca lẫn c2-svca đều được nhận, đây là giai đoạn chuyển tiếp để các client lần lượt đổi sang chứng chỉ do CA 2 cấp. Giây 20, chỉ còn CA 2: c1-svca mới nối bị từ chối, c2-svca vào bình thường, và 2.000 kết nối c1-svca đã mở vẫn sống 2.000 tới hết 36 giây. Toàn bộ nhờ hai callback: GetCertificate đọc atomic.Pointer[tls.Certificate], và GetConfigForClient trả tls.Config mới với ClientCAs hiện tại cho mỗi kết nối; không restart, không listener mới, không socket nào bị đóng. Thứ tự copy khoá trước, chứng chỉ sau là có chủ ý: trình nạp kích hoạt theo mtime của file chứng chỉ, nếu chứng chỉ mới đến trước khoá mới thì LoadX509KeyPair thất bại vì không khớp và server giữ cặp cũ tới vòng poll sau, không bao giờ phục vụ nửa cặp.
CA client hết hạn không chạm tới kết nối đang mở. CA sống 40 giây cấp cshort; 500 kết nối mở ở giây 0, ping mỗi 2 giây suốt 60 giây: sống 500, hỏng 0, kể cả sau mốc hết hạn ở giây 40. Kết nối mới ở giây 47 bị từ chối với expired certificate, server log in rõ current time 13:13:53Z is after 13:13:45Z. TLS kiểm chứng chỉ đúng một lần lúc bắt tay; sau đó phiên sống bằng khoá đối xứng đã dẫn xuất và không ai nhìn lại chứng chỉ nữa. Hai mặt của chuyện này: một CA hay chứng chỉ hết hạn lúc 3 giờ sáng không làm sập các kết nối dài đang chạy, sự cố chỉ hiện khi có kết nối mới, thường là lúc deploy hoặc lúc client nào đó mất kết nối và nối lại, vì vậy nó trông như "ngẫu nhiên" và "chỉ một vài client". Mặt kia là thu hồi hay xoay CA không ngắt được kẻ đã kết nối; muốn ép một client ra khỏi hệ thống sau khi đổi CA thì phải đóng kết nối của nó một cách chủ động, ví dụ server đặt tuổi thọ tối đa cho mỗi kết nối. Cả hai đều là lý do để đặt tuổi thọ kết nối hữu hạn, chủ đề phần 7 sẽ quay lại.
Đánh đổi cần cân nhắc
mTLS rẻ về CPU, đắt về vận hành. Thêm 100 µs mỗi bắt tay là không đáng bàn so với 270 µs sẵn có; thứ phải trả là vòng đời chứng chỉ cho mọi client, một CA nội bộ, và quy trình xoay mà bài này vừa diễn tập. Nếu đã có service mesh thì nó làm việc đó hộ; nếu tự làm, hãy làm đúng từ đầu hai callback GetCertificate và GetConfigForClient, vì thêm chúng vào sau nghĩa là một lần restart.
Xoay CA luôn qua ba bước, và bước giữa phải đủ dài. Thêm CA mới vào ClientCAs trước, đổi chứng chỉ client dần, rồi mới bỏ CA cũ. Bước giữa phải dài hơn thời gian mọi client nối lại ít nhất một lần, vì như mục D cho thấy, client đang mở kết nối không hề biết CA đã đổi, và chỉ lộ ra khi nó nối lại với chứng chỉ cũ mà CA cũ đã bị bỏ.
Phạm vi đo. Chưa có thu hồi: CRL và OCSP cho chứng chỉ client là chỗ Go không làm sẵn gì, phải tự viết trong VerifyPeerCertificate. Chưa đo chứng chỉ client có intermediate, thứ làm Certificate của client to thêm và verify đắt thêm một chữ ký. Và certificate_authorities 51 CA ở đây chỉ 2 KB; một số triển khai liệt kê cả trăm CA công cộng và làm flight của server vượt cửa sổ TCP ban đầu, cộng thêm RTT, hiệu ứng phần 4 đã đo với HRR.
Ba ý mang về
- mTLS với ECDSA: +100 µs server, +533 byte client, 0 µs client. Chứng chỉ client RSA đổi chỗ chi phí sang client, +770 µs. Chọn ECDSA cho cả hai đầu, và giữ
ClientCAsngắn vì mỗi CA là vài chục byte trong mọi CertificateRequest. - Với TLS 1.3, lỗi chứng chỉ client đến ở Read, không ở Handshake, và cert sai CA trông như không có cert. Health check phải gửi một byte và đọc lại; debug thì đọc log server, nơi duy nhất có nguyên nhân thật.
- Xoay bằng
GetCertificatevàGetConfigForClient, không bằng restart: 2.000 kết nối, 0 rơi. Nhưng TLS chỉ kiểm chứng chỉ lúc bắt tay, nên CA hết hạn hay bị thay không đụng tới kết nối đang mở; muốn ép client ra thì phải tự đóng kết nối.
Nguồn
- Go
crypto/tls—Config.ClientAuth,ClientCAs,GetCertificate,GetConfigForClient,VerifyPeerCertificate: https://pkg.go.dev/crypto/tls#Config - RFC 8446 — TLS 1.3, mục 4.3.2 Certificate Request và 4.4.3 Certificate Verify: https://www.rfc-editor.org/rfc/rfc8446#section-4.3.2
- RFC 8446 — mục 4.2.4 Certificate Authorities (phần mở rộng
certificate_authorities): https://www.rfc-editor.org/rfc/rfc8446#section-4.2.4 - RFC 5280 — X.509 PKI, mục 4.2.1.12 Extended Key Usage: https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
- Go issue #37906 — thảo luận về việc client chọn chứng chỉ theo
certificate_authorities: https://github.com/golang/go/issues/37906 - SPIFFE — X.509-SVID và mô hình xoay chứng chỉ ngắn hạn cho workload: https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md
Phần sau hỏi một câu kiến trúc: TLS nên kết thúc ở đâu. Đo cùng một ứng dụng Go khi tự nói TLS, khi đứng sau một reverse proxy Go làm TLS termination rồi nói plaintext nội bộ, và khi proxy nói TLS cả hai chiều; so độ trễ, CPU, và chi phí của hop thêm dưới netem, kèm ALPN chọn HTTP/2.