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ỉ

Ảnh chụp đoạn mã nền tối mTLS server đòi chứng chỉ client rồi xoay mọi thứ với Go 1.23 crypto tls server dist-lab cổng 9490 tới 9494 và 9500 client go-lab CA và chứng chỉ sinh bằng crypto x509, thêm gì vào bắt tay khi ClientAuth bằng RequireAndVerifyClientCert gồm server gửi client CertificateRequest kèm danh sách CA chấp nhận certificate_authorities to theo số CA, client gửi server Certificate cộng CertificateVerify client ký bằng khoá riêng cộng Finished, server verify chuỗi client về ClientCAs kiểm EKU clientAuth gọi VerifyPeerCertificate nếu có, TLS 1.3 server kiểm cert client sau khi client đã Finished nên client thấy Handshake OK lỗi rơi vào Read, TLS 1.2 kiểm trước Finished client thấy lỗi ngay ở Handshake, bộ chứng chỉ server-ca cấp srv-v1 srv-v2 CN tls-lab hai khoá khác nhau client tin server-ca, client-ca1 cấp c1-svca c1-svcb c1-expired c1-rsa RSA-2048 c1-serverauth EKU sai, client-ca2 cấp c2-svca other cấp other-svca short-ca sống 40 giây cấp cshort, client-ca50 bằng 50 CA giả cộng client-ca1 client-ca12 bằng client-ca1 cộng client-ca2, bốn phép đo A chi phí 1000 bắt tay không đòi cert client ECDSA client RSA cộng VerifyPeerCertificate 51 CA byte gửi nhận p50 CPU server và client mỗi bắt tay client gửi p chờ echo mới tính xong, B lỗi client thiếu cert hết hạn CA khác EKU serverAuth CN không được phép TLS 1.3 rồi 1.2 ghi cả hai phía client nhận alert gì server log gì, C xoay server dùng GetCertificate cộng GetConfigForClient đọc atomic.Pointer poll mtime file 300 ms 2000 kết nối c1-svca mở sẵn t bằng 10 đổi srv-v1 sang v2 t bằng 15 ClientCAs bằng CA1 cộng CA2 t bằng 20 chỉ CA2 đếm kết nối còn sống mỗi 2 giây kết nối mới nhìn serial nào, D CA chết CA client sống 40 giây 500 kết nối mở lúc t bằng 0 t bằng 47 CA hết hạn kết nối cũ còn sống nối mới

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

Ảnh chụp output thật nền tối mTLS cộng 100 micro giây server xoay cert 0 kết nối rơi với go-lab tới dist-lab 1000 bắt tay mỗi dòng ở A 2000 kết nối giữ ở C 500 ở D Go 1.23, A chi phí bắt tay gồm không đòi client cert gửi 351 byte nhận 800 p50 0,41 ms CPU server 270 micro giây client 270, mTLS client ECDSA P-256 884 918 0,49 370 270 thêm 533 byte và 100 micro giây server, mTLS client RSA-2048 1272 917 1,21 ms 350 1040 client ký RSA thêm 770 micro giây, mTLS cộng VerifyPeerCertificate CN 885 917 0,49 370 270 callback miễn phí, mTLS ClientCAs có 51 CA 885 2917 byte 0,50 380 290 CertificateRequest thêm 2 KB, TLS 1.2 client ECDSA 841 796 0,56 370 320, B lỗi phía client TLS 1.3 so với TLS 1.2 gồm không gửi cert Handshake OK Read certificate required so với Handshake bad certificate, cert hết hạn Handshake OK Read expired certificate so với Handshake expired certificate, cert do CA khác ký Handshake OK Read certificate required so với Handshake bad certificate, EKU serverAuth không clientAuth Handshake OK Read bad certificate so với Handshake bad certificate, CN svc-b allowlist chỉ svc-a Handshake OK Read bad certificate so với Handshake bad certificate, server log client didn't provide a certificate x509 certificate has expired incompatible key usage CN svc-b không có trong danh sách CA khác ký cũng client didn't provide a certificate vì client Go đọc certificate_authorities trong CertificateRequest issuer không khớp nên không gửi cert nào, C xoay server cert và CA client với 2000 kết nối đang mở t 8 giây nối mới serial a9a86e srv-v1 hold t 2 tới 36 giây sống 2000 hỏng 0 ở mọi mốc, t 10 giây cp srv-v2.key rồi srv-v2.pem server nạp lại chứng chỉ serial b02913 sau tối đa 300 ms nối mới serial b02913 c2-svca certificate required CA2 chưa tin, t 15 giây cas.pem bằng CA1 cộng CA2 nạp lại ClientCAs 2 CA c1-svca OK c2-svca OK, t 20 giây cas.pem bằng CA2 nạp lại ClientCAs 1 CA c1-svca certificate required c2-svca OK 2000 kết nối c1-svca mở từ t 0 vẫn sống 2000 tới t 36 giây CA1 nghỉ không đụng tới chúng, D short-ca hết hạn 13:13:45 t 5 giây nối mới OK t 47 giây nối mới expired certificate 500 kết nối mở lúc t 0 sống 500 hỏng 0 suốt 60 giây qua mốc hết hạn chỉ kiểm cert lúc bắt tay, nói thẳng mTLS với ECDSA tốn server thêm 100 micro giây và 533 byte mỗi bắt tay gần như không đáng kể đáng kể là TLS 1.3 báo lỗi cert client ở Read chứ không ở Handshake và cert sai CA hiện ra như không có cert

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ề

  1. 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ữ ClientCAs ngắn vì mỗi CA là vài chục byte trong mọi CertificateRequest.
  2. 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.
  3. Xoay bằng GetCertificate và 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

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.