- 1 TLS/SSL 的原理
- 2 對應用透明的加密通道的方案
- 3 mTLS
- 4 解決方案
最近在跨機房做一個部署,因為機房之間暫時沒有專線,所以流量需要經過公網。對于經過公網的流量,我們一般需要做以下的安全措施:
- 只能允許已知的 IP 來訪問;
- 流量需要加密。
第一項很簡單,一般的防火墻,或者 Iptables 都可以做到。
對于加密的部分,最近做了一些實驗和學習,這篇文章總結加密的實現方案,假設讀者沒有 TLS 方面的背景知識,會簡單介紹原理和所有的代碼解釋。
1 TLS/SSL 的原理
TLS 是加密傳輸數據,保證數據在傳輸的過程中中間的人無法解密,無法修改。(本文中將 TLS 與 SSL 作為同義詞。所以提到 SSL 的時候,您可以認為和 TLS 沒有區別。)
傳輸的加密并不是很困難,比如雙方用密碼加密就可以。但是這樣一來,問題就到了該怎么協商這個密碼。顯然使用固定的密碼是不行的,比如每個人都要訪問一個網站,如果網站使用固定的密碼,那么和沒有密碼也沒有什么區別了,每個人都可以使用這個密碼去偽造網站。
TLS 要解決的問題就是,能證明你,是你。現在使用的是非對稱加密的技術。非對稱加密會有兩個秘鑰,一個是公鑰,一個是私鑰。公鑰會放在互聯網上公開,私鑰不公開,只有自己知道。只有你有私鑰,我才相信你是你。非對稱加密的兩個秘鑰提供了一下功能(本文不會詳細介紹這部分原理,只簡單提到理解后續內容需要的知識):
- 公鑰加密的數據,只有用私鑰可以解密;
- 私鑰可以對數據進行簽名,公鑰拿到數據之后可以驗證數據是否由私鑰的所有者簽名的。
有了這兩點,網站就可以和訪問者構建一個加密的數據通道。
首選,網站將公鑰公開(即我們經常說的“證書”),訪客連接到網站的服務器第一件事就是下載網站的證書。因為證書是公開的,每個人都能下載到此網站的證書,那么怎么確定對方就是此證書的所有者呢?客戶端會生成一個隨機數,并使用公鑰進行加密,發送給服務器:請解密這段密文。
這就是上文提到的功能1,即公鑰加密的數據,只有私鑰才能解密。服務器解密之后發回來(當然,并不是明文發回來的,詳細的 TLS 握手過程,見這里[1]),客戶端就相信對方的確是這個證書的所有者。后續就可以通過非對稱加密協商一個密碼,然后使用此密碼進行對稱加密傳輸(性能快)。
但是這樣就足夠驗證對方身份了嗎?假設這樣一種情況,我并不是 google.com 這個域名的所有者,但是我生成了一對證書,然后自己部署,將用戶訪問 google.com 的流量劫持到自己這里來,是不是也能使用自己的證書和用戶進行加密傳輸呢?
所以就有了另一個問題:訪客不僅要驗證對方是證書的真實所有者,還要驗證對方的證書的合法性。即 google.com 的證書只有 Google 公司可以擁有,我的博客的證書只有我的博客可以擁有。私自簽發的證書不合法。
為了解決這個問題,就需要有一個權威的機構,做如下的保證:只有網站的所有者,才能擁有網站的證書。然后訪客只要信任這個“權威的機構”就可以了。
CA 扮演的角色
CA 的全稱是 Certification Authority,是一個第三方機構,在上述加密的流程中,扮演的角色同時被訪客和網站所信任。
網站需要去 CA 申請證書,而 CA 要對自己頒發(簽名)的證書負責,即確保證書頒發給了對方,頒發證書之前要驗證你是你。申請證書的時候,CA 一般會要求你完成一個 Challenge 來證明身份,比如,要求你將某個 URL 返回特定內容,或者要求你將 DNS 的某個 text record 返回特定內容來證明你的確擁有此域名(詳見 validation standards[2])。只有你證明了你是你,CA 才會簽證書給你。
訪客是怎么驗證證書的呢?這就用到了上文提到的功能2:“私鑰可以對數據進行簽名,公鑰拿到數據之后可以驗證數據是否由私鑰的所有者簽名的。” CA 也有自己的一套私鑰公鑰,CA 使用私鑰對網站的證書進行簽名(擔保),訪客拿到網站的證書之后,使用 CA 的公鑰校驗簽名即可驗證這個“擔保”的有效性。
那么 CA 的公鑰是怎么來的呢?答案是直接存儲在客戶端的。linux 一般存儲在 /etc/ssl/certs。由此可見,CA 列表更新通常意味著要升級系統,一個新的 CA 被廣泛接受是一個漫長的過程。新 CA 簽發的證書可能有一些老舊的系統依然不信任。
比如 letsencrypt 的 CA[3],之前就是使用交叉簽名的方式工作,即已有的 CA 為我做擔保,我可以給其他的網站簽發證書。這也是中級證書的工作方式。每天有這么多網站要申請證書,CA 怎么簽發的過來呢?于是 CA 就給很多中級證書簽名,中級證書給網站簽名。這就是“信任鏈”。訪客既然信任 CA,也就信任 CA 簽發的中級,也就信任中級簽發的證書。
被信任很漫長,被不信任很簡單。
CA (以及中級證書機構)有著非常大的權利。舉例,CA 假如給圖謀不軌的人簽發了 Google 的證書,那么攻擊者就可以冒充 Google。即使 Google 和這個 CA 并沒有任何業務往來,但是自己的用戶還是被這個 CA 傷害了。所以 CA 必須做好自己的義務:
- 保護自己的私鑰不被泄漏;
- 做好驗證證書申請者身份的義務;
- 如果(2)有了疏忽,對于錯誤簽發的證書要及時吊銷。
案例:賽門鐵克證書占了活躍證書的 30% – 45%(當時[4]),但是被 Google 發現其錯誤頒發了 3 萬個證書,發現后卻不作為。因此逐步在后續的 Chrome 版本中吊銷了賽門鐵克的證書。
案例2:let’sencrypt 今年 1 月份發現自己的 TLS-ALPN-01 chanllege 有問題,于是按照規定,在 5 天后吊銷了這期間通過 TLS-ALPN-01 頒發的所有證書。
說道這里我想繼續跑一個題。我以前給博客部署證書的時候(2017年[5])就想:CA 給我發一個證書居然要收我的錢?這個不是零成本的東西嗎?他們想發多少就發多少。看到現在讀者應該明白了,這并不是一個零成本的事情:簽發證書的驗證服務需要花錢,而 CA Root key 的保護要花更多的錢。整個 CA 公司(組織)的核心資產就是一個 key,如果這個 key 暴露了,后果不堪設想。
所以,一個無比重要卻要一直使用的 key 在一個上千萬人的組織里怎么被使用而不暴露給任何一個人呢?這是要花很多錢的。Root key 的生成會有一個儀式(Key ceremony),全程錄像,有 20 多個不同組織的代表會現場參加并監督,會有 3000 多個人觀看實時錄像,確保 key 的生成是標準流程。
在 Root key 的保存和使用上,Root key 只會簽中級 CA,以減少使用次數以及 Root key 需要被 revoke(代價太大)的風險。Root Key 保存在一個特殊的硬件中(HSM,Hardware security module),完全離線保存,HSM 也放在特殊的機房中,7×24 有人看守,并離線錄像,機房有 Class 5 Alarm System,有多把鎖,沒有一個人可以單獨進入。
使用這個 Root Key 必須物理上進入這個機房,使用過程全程錄像,并且記錄使用過程,如果有問題可以很快地將 Root Key 簽的內容 revoke。這里有一個視頻介紹 Key Signing Ceremony[6],非常有趣。所以說 CA 機構并不是一個搖錢樹,Let’s Encrypt 這種組織簡直就是慈善機構。
以上就是 TLS,證書,CA 大致的工作原理,稍稍有些跑題,有了這些知識我們就可以利用 TLS 來建立一個加密的數據通道了。后續幾乎都是實際的操作。筆者對這部分也不是精通,如果有錯誤,歡迎指出。
2 對應用透明的加密通道的方案
上文是通過網站部署 HTTPS 來講的 TLS 的工作原理。其實網站部署 HTTPS 還算是比較簡單:你只需要找一個 CA,申請證書,完成 CA 的驗證,部署證書,就可以了。
現在要解決的問題更加復雜一些:我們的兩個組件之間是通過自己研發的協議通訊(基于 TCP),現在要分別部署在兩個機房,通過公網進行通訊。
我們的方案要對通訊的兩邊做好安全防護:
- 數據要進行加密傳輸;
- 要對兩邊做身份驗證,比如 A 向 B 發起連接,A 要驗證 B 的身份,B 也要驗證 A 的身份;
- 最好對于應用來說透明,即應用完全不修改代碼,依然按照原來的方式工作,但是我們將中間的流量進行加密。
3 mTLS
mTLS 的全稱是 Mutual TLS,即雙向的 TLS 驗證。HTTPS 只是訪客驗證了網站的身份,網站并沒有驗證訪客的身份。其實要驗證也是可以的,網站發送證書之后可以跟訪客說:“現在該輪到你出示你的證書了”。如果訪客不能提供有效的證書,網站可以拒絕服務。
其實,ssh 方式就是一個雙向驗證的過程。我們都知道通過 ssh key 登錄 server 的時候,需要讓 server 信任你的 key(即將你的 pubkey 放到 server 上去)。但是還有一個過程容易被忽略掉,在第一次通過 ssh 連接服務器的時候,ssh 客戶端會給你展示 server 的 pubkey,問你是否信任。如果之后這個 key 變了,說明有可能你連接到的并不是目的服務器。
第一次連接到服務器的提示
如果之后這個 key 變了,ssh 客戶端就會拒絕連接。
Git 也是通過走 ssh 協議的,所以也是一個雙向認證。你在使用 Github 的時候要互相信任對方:
- Github 信任你的方式是:你將自己的 pubkey 上傳到 GitHub (設置,profile,keys)
- 你信任 GitHub 的方式是:GitHub 將自己的 pubkey 公布在網上[7]。
4 解決方案
為了實現對應用透明的加密通訊,我們在兩個機房各搭建一個 Nginx,這里兩個 Nginx 之間通過 mTLS 相互認證對方。應用將請求明文發給同機房的 Nginx,然后 Nginx 負責加密發給對方。對于應用來說,對方機房的組件就如同和自己工作在相同機房一樣。最終搭建起來如下圖所示。
搭建過程
因為用 HTTP 流量來搭建,相關的工具和日志會更友好一些。所以我們會先用 HTTP 將這個通道搭建起來,然后換成 tcp steam。
準備證書
我們一共需要兩套證書,一套給 Client,一套給 Server。
因為我們這里主要要解決的問題內部互相信任的問題,不需要開給外面的用戶,所以這里我們采用 self signed certificate,即,我們自己做 CA,給自己簽發證書。自簽發證書的好處是很靈活,方便,壞處是有一些安全隱患(畢竟不像權威機構那樣專業)。所以我把這個過程寫在博客上,請大家幫忙看看流程有沒有問題。
首先我們創建一個 CA 的 key,即私鑰。CA 的 key 最好給一個密碼保護,每次使用這個 CA 簽發證書的時候,都需要輸入密碼。
生成 key 的命令:
openssl genrsa -des3 -out ca.key 4096
輸出(其中按照提示輸入密碼):
Generating RSA private key, 4096 bit long modulus (2 primes)
.............................................................++++
....................................................................................................................................................................................++++
e is 65537 (0x010001)
Enter pass phrase for ca.key:<passphrase>
Verifying - Enter pass phrase for ca.key:<passphrase>
命令的解釋:
- openssl:cert 和 key 相關的操作我們都用 openssl 來完成
- genrsa:生成 RSA 私鑰
- -des3:生成的 key,使用 des3 進行加密,如果不加這個參數,就不會提示讓你輸入密碼
- 4096:生成 key 的長度
這里我們假設所使用的密碼是 hello。
然后我們來生成 CA 的公鑰部分,即證書。
openssl req -new -x509 -days 365 -key ca.key -out ca.crt
這時會詢問你一些信息,比如地區,組織名字之類的。其中,Organization Name 和 Common Name 需要留意。CA 的這一步填什么都可以。Common Name 又簡稱 CN,就是證書簽發給哪一個域名(也可以是 IP)的意思。
輸出會是如下所示:
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:CertAuth
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:
命令的解釋:
- req:創建證書請求;
- -new:產生新的證書;
- -x509:直接使用 x509 產生新的自簽名證書,如果不加這個參數,會產生一個“證書簽名請求”而不是一個證書。
- -days 365:證書1年之后過期,也可以省略這個參數,設置為永不過期;
- key:創建公共證書的私鑰,會被提示輸入私鑰的密碼;
- -out:生成的證書。
到這里,我們有了一對 CA 證書,ca.key 和 ca.crt 兩個文件。接下來申請 server 端的證書。
Server 端證書依然是先生成一個 key,這里就不需要密碼保護了:
openssl genrsa -out server.key 4096
然后這里下一步不是直接生成證書,而是生成一個證書請求。但是那些問題依然是要回答一遍的。
openssl req -new -key server.key -out server.csr
回答問題的時候要注意兩個地方:
- Organization Name: 不能和 CA 的一樣;
- Common Name: 必須要寫一個,可以寫一個不存在的域名,比如 proxy.example.com。否則,會有錯誤:“* SSL: unable to obtain common name from peer certificate”。
否則證書無法使用。
到這里其實也可以看出,CA 的證書和其他的證書沒有什么不同,也是一個普通的證書而已。
這個 .csr 文件是 Ceritifcate Signing Request,即請求簽名。接下來我們使用我們的 CA 給這個 Server 證書簽名(作擔保!)。
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -set_serial 01 -out server.crt
這個命令需要輸入 CA key 的密碼,就是剛剛說的 hello。
命令的解釋:
- x509:公有證書的標準格式;
- -CA:使用 CA 對其簽名;
- -CAkey:CA key(沒有這個豈不是人人可以用 CA 證書簽名了?);
- -set_serial 01:簽發的序列號,如果證書有過期時間的話,過期之后,可以直接用這個 .csr 修改序列號重新簽一個,不需要重新生成 .csr 文件;
如此,就得到了 server.crt 文件。
我們可以使用這條命令驗證生成的證書是 ok 的:
# openssl verify -verbose -CAfile ca.crt server.crt
server.crt: OK
重復此流程再簽發一個 client 端的證書。
結束后,我們有以下內容:
- ca.key
- ca.crt
- CA 的密碼,需要保存
- server.key
- server.crt
- server.csr:部署不需要用到,可以只保存在安全的地方即可;
- Server 證書簽發序列:只保存即可;
- client.key
- client.crt
- client.csr:部署不需要用到,可以只保存在安全的地方即可;
- Client 證書簽發序列:只保存即可;
然后接下來就可以部署起來了。
搭建遠程 Server 端的 Nginx
為了模擬轉發到后端應用的場景,這里的 Nginx 不使用靜態文件,而是用一個 fastapi 寫的樣例程序來做后端:
from typing import Optionalfrom fastapi import FastAPIApp = FastAPI()@app.get("/")def read_root(): return {"Hello": "World"}
啟動的命令是:
uvicorn app:app
程序默認會運行在 8000 端口。
然后修改 Nginx 的配置,nginx.conf 不變,我們只修改 default 的配置,將 default rename 成 remote_server,然后修改成成如下配置:
server { listen 443 default_server ssl; listen [::]:443 default_server ssl; server_name _; ssl_certificate /home/vagrant/cert/server.crt; ssl_certificate_key /home/vagrant/cert/server.key; location / { proxy_pass http://127.0.0.1:8000; }
這就是一個很簡單的 Nginx HTTPS 配置,證書配置上了我們剛剛自己簽發的證書:
- ssl_certificate:告訴 Nginx 使用哪一個公有證書;
- ssl_certificate_key:此證書對用的私鑰是什么,服務器需要有私鑰才能工作。
證書已經配置好了。這時候我們去 cURL 443 端口會出現錯誤:“curl: (60) SSL: unable to obtain common name from peer certificate”,cURL 不信任這個服務器的證書。這是當然了,因為這個證書是我們自己作為 CA 簽的。
要正常訪問,必須使用 cURL --ca ./ca.cert 來告訴 cURL 我們信任這個 CA (所簽發的所有證書)。
另外還要注意的是,記得我們之前的 Server 證書是簽發給 proxy.example.com 的嗎?我們這里必須要訪問這個域名才行。需要這樣使用:
curl -v https://proxy.example.com --cacert ./ca.crt --connect-to proxy.example.com:443:127.0.0.1:443
--connect-to 的意思是,所有發往這個域名的請求,都直接發給這個 IP。
Client 對 Server 的驗證就配置好了,接下來再配置 Server 對 Client 的驗證。
我們只需要將上面的配置文件改成如下即可:
server { listen 443 default_server ssl; listen [::]:443 default_server ssl; server_name _; ssl_certificate /home/vagrant/cert/server.crt; ssl_certificate_key /home/vagrant/cert/server.key; ssl_verify_client on; ssl_client_certificate /home/vagrant/cert/ca.crt; location / { proxy_pass http://127.0.0.1:8000; }}
添加的內容的含義:
- ssl_verify_client:需要驗證客戶端的證書;
- ssl_client_certificate:我們信任這個 CA 所簽發的所有證書。
這里有一個小插曲:Nginx 的文檔上說,ssl_trusted_certificate 和 ssl_client_certificate 這兩個配置效果都是一樣的,唯一的區別是 ssl_client_certificate 會將信任的 CA 列表發送給客戶端,但是 ssl_trusted_certificate 不會發。
發送是合理的,因為客戶端如果有很多證書,讓客戶端一個一個去嘗試哪一個能建連是沒有意義并且很浪費的。ssl_trusted_certificate 的作用是驗證 OCSP Response。但是我嘗試了 ssl_trusted_certificate,Nginx 會直接 fail 掉語法檢查:
The server fails to start with error: nginx: [emerg] no ssl_client_certificate for ssl_verify_clientb
這里發現一個 ticket 詢問和我一樣的問題:https://trac.nginx.org/nginx/ticket/1902,不過至今沒有回復。我以為是 Nginx 版本的 Bug,然后嘗試了最新的版本依然是一樣的結果。如果讀者知道可以指點一下,謝謝。
這樣配置之后 reload Nginx,就開啟了對客戶端的證書驗證了。這時候我們繼續使用上面那個 cURL,就無法得到響應。
<head><title>400 No required SSL certificate was sent</title></head>
Nginx 會要求你提供證書。
如下的 cURL,帶上證書,就可以正常拿到響應。
curl -v https://proxy.example.com --cacert ./ca.crt --connect-to proxy.example.com:443:127.0.0.1:443 --cert client.crt --key client.key
這樣,遠端的 Nginx 就配置好了,它會提供證書證明自己的身份,也會要求客戶端提供證書進行驗證。
接下來搭建本地的 Nginx,將明文請求加密對接到遠端的 Nginx。
搭建本地 Client 端的 Nginx
本地機房開啟一個 Nginx,監聽 80 端口,轉發到遠程的 443 端口。
配置如下:
upstream remote{ server 127.0.0.1:443;}server { listen 80 default_server; listen [::]:80 default_server; server_name _; location / { proxy_pass https://remote; proxy_ssl_trusted_certificate /home/vagrant/cert/ca.crt; proxy_ssl_verify on; proxy_ssl_server_name on; proxy_ssl_name proxy.example.com; proxy_ssl_certificate /home/vagrant/crt/client.crt; proxy_ssl_certificate_key /home/vagrant/cert/client.key; }}
這個配置可以分成兩部分看,第一部分,是要驗證對方的證書:
- proxy_ssl_verify:需要對方提供證書;
- proxy_ssl_trusted_certificate:我們只信任這個 CA 簽發的所有證書;
- proxy_ssl_server_name:不像 cURL 的 --connect-to 選項,這里我們直接指定目標 IP 轉發,但是我們使用 SNI 功能來告訴對方我們要連接哪一個 domain,來驗證相關 domain 的證書;
- proxy_ssl_name:我們需要哪一個 domain 的證書。
然后第二部分是提供自己的證書:
- proxy_ssl_certificate:我的證書;
- proxy_ssl_certificate_key:我的私鑰,不會發送給對方,只是本地 Nginx 自己使用。
然后就可以 cURL 本地的 80 端口了:
curl http://127.0.0.1 -v * Trying 127.0.0.1:80...* TCP_NODELAY set* Connected to 127.0.0.1 (127.0.0.1) port 80 (#0)> GET / HTTP/1.1> Host: 127.0.0.1> User-Agent: curl/7.68.0> Accept: */*> * Mark bundle as not supporting multiuse< HTTP/1.1 200 OK< Server: nginx/1.18.0 (Ubuntu)< Date: Wed, 16 Mar 2022 03:49:05 GMT< Content-Type: application/json< Content-Length: 17< Connection: keep-alive< * Connection #0 to host 127.0.0.1 left intact{"Hello":"World"}
可以看到我們從客戶端(cURL)發出明文 HTTP 請求,到服務端(fastapi)收到明文 HTTP 請求,兩邊都不知道中間流量加密過程,但是走公網的部分已經被加密了。就實現了本文開頭的需求。
代理 TCP steam
以上是 HTTP 的配置,將其換成 TCP Steam 的代理也很簡單,相應的配置修改一下就可以。這里我們以 redis 服務為例來展示一下配置。
/etc/nginx/nginx.conf
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 768;
}
stream {
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
ssl_prefer_server_ciphers on;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
Remote Server 的配置:/etc/nginx/sites-enabled/remote_server
server {
listen 443 ssl;
proxy_pass 127.0.0.1:6379;
ssl_certificate /home/vagrant/cert/server.crt;
ssl_certificate_key /home/vagrant/cert/server.key;
ssl_verify_client on;
ssl_client_certificate /home/vagrant/cert/ca.crt;
}
local_client 的配置:/etc/nginx/sites-enabled/client_server
upstream remote{
server 127.0.0.1:443;
}
server {
listen 80;
listen [::]:80;
proxy_pass remote;
proxy_ssl_trusted_certificate /home/vagrant/cert/ca.crt;
proxy_ssl_verify on;
proxy_ssl_server_name on;
proxy_ssl_name config.example.com;
proxy_ssl on;
proxy_ssl_certificate /home/vagrant/cert/client.crt;
proxy_ssl_certificate_key /home/vagrant/cert/client.key;
}
基本上就是把 HTTP 代理換成了 TCP 代理指令。
這樣配置好之后,我們就可以用 redis-cli 去連接本地的 80 端口了。
redis-cli -p 80
127.0.0.1:80> get foo
"bar"