HTTPS 用 AES 加密、雙方拿同一把 key,那這把 key 是怎麼送過去的?答案是根本沒送
先講結論:「HTTPS 就是 AES」這句話沒錯,但只講了後半場
常聽到一種說法:HTTPS 就是用 AES 加密,瀏覽器跟伺服器手上有同一把 key,一邊鎖、一邊開。
這句話是對的。我拆開自己部落格的連線來看:
$ echo | openssl s_client -connect bobochen.dev:443 -servername bobochen.dev 2>&1 \
| grep -E "^Protocol|Cipher is|group|signature type"
Peer signature type: ecdsa_secp256r1_sha256
Negotiated TLS1.3 group: X25519MLKEM768
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
第三行中間那段 AES_256_GCM,就是真正拿來加密網頁內容的東西:AES,key 長度 256 bits。
可是這句話跳過了一個問題:瀏覽器跟伺服器素昧平生,這把「同一把 key」是怎麼同時出現在兩邊手上的?
直接寄過去不行,路上偷聽的人也會拿到一份,加密就白做了。上面另外兩行 X25519MLKEM768 跟 ecdsa_secp256r1_sha256,處理的就是這件事。它們都是非對稱加密,只在連線一開頭出場。
先把重點濃縮成一張表:
| 你可能以為 | 其實是 |
|---|---|
| HTTPS 全程都在做很重的運算 | 重的非對稱運算只在開頭做一次,之後每個 byte 都交給 AES。我的 M2 MacBook Pro 跑 AES-256-GCM,一秒可以加密約 7 GB |
| key 是用伺服器的公鑰加密以後寄過去的 | 那是 TLS 1.2 以前的其中一種做法,TLS 1.3 整個拿掉了。現在雙方各自「算」出同一把 key,最後那把 key 從沒在網路上出現過 |
| 有加密就安全 | 沒確認對方身分的加密,可能是在跟壞人加密聊天。憑證跟簽章那一步,是在確認跟你算 key 的是本人 |
| 雙方用同一把 key | 是同一組,但細看有兩把:瀏覽器寄出去的用一把,伺服器寄回來的用另一把 |
| 非對稱比較厲害,乾脆全程都用非對稱 | 太慢了。同樣 1 MB,我用 RSA-2048 硬解密花了 3 到 5 秒,AES 不到 1 毫秒 |
拆開我部落格的連線:三個零件,只有一個是 AES
openssl s_client 會假裝自己是瀏覽器,跟伺服器走一次完整的握手,再把談出來的結果印出來。上面那幾行對照起來是這樣:
| openssl 印的 | 是什麼 | 對稱還是非對稱 | 負責的事 |
|---|---|---|---|
X25519MLKEM768 | 金鑰交換 | 非對稱 | 跟伺服器約定出一把 key |
ecdsa_secp256r1_sha256 | 簽章 | 非對稱 | 證明對方真的是 bobochen.dev |
TLS_AES_256_GCM_SHA384 | 加密套件 | AES 是對稱 | 之後每一個 byte 的加密跟防竄改 |
加密套件最後的 SHA384 是雜湊函數,推導 key 的時候會用到,後面會講。
所以一條 HTTPS 連線其實分成兩個階段:
- 前半場:握手。用非對稱加密做兩件事:約定出一把 key、確認對方是本人。每條連線只做一次。
- 後半場:傳資料。拿剛剛約定好的 key,用 AES 加密每一個 request 跟 response。
「HTTPS 就是 AES」講的是後半場。這篇要補的是前半場。
冷知識:輸出寫的是 TLS,為什麼大家還在講「SSL 憑證」?
因為 TLS 本來就是 SSL 改名來的。
SSL 是 Netscape 在 90 年代做的協定。後來要交給 IETF 變成公開標準,名字就換掉了。負責編寫 TLS 1.0 規格書 RFC 2246 的 Tim Dierks,2014 年在自己的部落格講了當年的內幕:
As a part of the horsetrading, we had to make some changes to SSL 3.0 (so it wouldn’t look the IETF was just rubberstamping Netscape’s protocol), and we had to rename the protocol (for the same reason). And thus was born TLS 1.0 (which was really SSL 3.1).
— Tim Dierks,Security Standards and Name Changes in the Browser Wars
翻成白話:那時 Netscape 跟微軟正在打瀏覽器大戰,微軟還自己搞了一個從 SSL 2 改出來的 PCT 協定。雙方談好讓 IETF 接手,但為了不讓人覺得 IETF 只是在替 Netscape 蓋章,內容得改一點、名字也得換。所以 TLS 1.0 其實就是 SSL 3.1。
證據還留在規格書裡。RFC 2246 寫明 TLS 1.0 在協定裡的版本號是 { 3, 1 },並且解釋:「3.1 這個值是歷史遺留:TLS 1.0 是 SSL 3.0 的小幅修改版」。
後半場:AES,同一把鑰匙鎖、同一把鑰匙開
對稱加密就像保險箱:鎖跟開用的是同一把鑰匙。你跟朋友各拿一把一模一樣的鑰匙,誰要寄東西就鎖好寄出,對方用自己那把打開。
HTTPS 的後半場就是這樣。它選 AES 的理由很實際:快。
我用 openssl speed 在自己的 M2 MacBook Pro 上量了一下,TLS 1.3 能選的兩種對稱加密:
| 演算法 | 一秒可以加密 |
|---|---|
| AES-256-GCM | 約 7.0 GB |
| ChaCha20-Poly1305 | 約 1.8 GB |
AES 快了將近 4 倍,因為現在的 CPU 大多內建 AES 專用指令。Apple 的 M 系列也有,sysctl hw.optional.arm.FEAT_AES 在我這台回的是 1。
那 ChaCha20 是給誰的?給沒有 AES 硬體指令的裝置。Cloudflare 當年開始支援它的時候,是這樣說的:
For recent Intel processors, we use the standard AES-GCM algorithm. For browsers on machines that do not have a hardware AES chip, we prefer the ChaCha20-Poly1305.
— Cloudflare,Do the ChaCha: better mobile performance with cryptography
還有一件事:AES-256-GCM 最後那個 GCM,代表它不只加密,還會替每一段密文附一張封條(authentication tag)。路上有人改了一個 bit,封條就對不上,整段直接被拒收。TLS 1.3 能選的加密套件全部都是這種「加密+封條」的組合,規格書 RFC 8446 規定每個實作至少要支援 TLS_AES_128_GCM_SHA256,我的部落格用的是更長 key 的 256 版本。
冷知識:AES 原本叫 Rijndael
AES 的全名是 Advanced Encryption Standard,這是一個「標準」的名字。被選進這個標準的演算法,原名叫 Rijndael。
1997 年,美國國家標準與技術研究院(NIST)公開徵求新的加密標準,各國的密碼學團隊都送了演算法來比。2000 年 10 月 2 日,NIST 宣布由兩位比利時密碼學家 Joan Daemen 跟 Vincent Rijmen 設計的 Rijndael 勝出。一年後的 2001 年 11 月 26 日,它正式成為 FIPS 197。
名字看得出來是兩位設計者的姓 Rijmen 跟 Daemen 拼起來的。這點他們自己沒特別解釋,倒是很認真回答了怎麼念。Rijmen 舊網站的 FAQ 是這樣寫的:
How is that pronounced ? If you’re Dutch, Flemish, Indonesian, Surinamer or South-African, it’s pronounced like you think it should be. Otherwise, you could pronounce it like “Reign Dahl”, “Rain Doll”, “Rhine Dahl”. We’re not picky.
— Vincent Rijmen 的 Rijndael 頁面(Internet Archive 存檔)
意思是:荷蘭、法蘭德斯、印尼、蘇利南、南非的人,照你直覺念就對了。其他人可以念「Reign Dahl」「Rain Doll」或「Rhine Dahl」,他們不挑。NIST 的新聞稿則是直接寫「pronounced Rhine-doll」。
問題來了:要安全地寄一把 key,得先有一把 key
AES 很快、很安全,前提是雙方手上有同一把 key。這把 key 怎麼來,是一個雞生蛋的問題:
- 用明文寄過去:路上偷聽的人也拿到一份。
- 先加密再寄:那要用哪一把 key 加密?那一把又要怎麼寄?
無限迴圈。
非對稱加密打破這個迴圈的方法,是把 key 拆成一對:公鑰跟私鑰。用公鑰鎖上的東西,只有對應的私鑰打得開。公鑰可以大方公開給全世界,私鑰從頭到尾不離開主人身上。
拿它來約定 AES 的 key,有兩種做法。第一種是 TLS 1.2 以前常見的,第二種是 TLS 1.3 的做法。
第一代做法:伺服器先寄給你一個打開的掛鎖
想像伺服器準備了一大堆打開的掛鎖(公鑰),誰來要都給一個,鑰匙(私鑰)只有它自己有:
- 瀏覽器連上來,伺服器把憑證給它,憑證裡就放著那個打開的掛鎖,也就是 RSA 公鑰。
- 瀏覽器產生一個隨機秘密,放進盒子,用掛鎖扣上,寄回去。
- 路上的人看得到盒子、看得到鎖,但打不開。
- 伺服器用自己的鑰匙打開,雙方手上就有同一個秘密,再從它推導出 AES key。
TLS 1.2 的規格書 RFC 5246 寫得很直白:
If RSA is being used for key agreement and authentication, the client generates a 48-byte premaster secret, encrypts it using the public key from the server’s certificate, and sends the result in an encrypted premaster secret message.
瀏覽器產生 48 bytes 的秘密,用憑證裡的公鑰加密,寄給伺服器。這正是「用非對稱加密把 key 寄過去」。
它有一個大洞:每一條連線的秘密,都是用同一把私鑰打開的。
sequenceDiagram
participant B as 瀏覽器
participant E as 路上錄音的人
participant S as 伺服器
S->>B: 憑證(裡面是 RSA 公鑰)
Note over B: 產生 48 bytes 隨機秘密<br/>用公鑰鎖起來
B->>S: 鎖好的秘密
Note over E: 全程錄下來<br/>現在打不開
Note over S: 用私鑰打開<br/>雙方推導出 AES key
Note over E: 幾年後伺服器私鑰外洩<br/>拿它打開當年錄的秘密<br/>整段對話全部解開
有人現在把你的加密流量全部錄下來存著,他當下確實解不開。但只要哪天伺服器的私鑰外洩,不管是被駭、備份外流還是有人帶走,他就能回頭把當年錄下的秘密一個一個打開,過去幾年的對話全部攤在陽光下。
這種做法缺的性質叫做前向保密(forward secrecy):今天的鑰匙丟了,不該連累昨天的對話。
TLS 1.3 的規格書 RFC 8446,在列出跟 1.2 的主要差異時,直接寫了這句:
Static RSA and Diffie-Hellman cipher suites have been removed; all public-key based key exchange mechanisms now provide forward secrecy.
用固定 RSA 金鑰傳輸的套件整個移除,剩下的金鑰交換方式全部都有前向保密。
我的部落格其實還收這種舊做法
拆連線的時候,我順手叫 openssl 只用 TLS 1.2,而且只提這一種舊套件:
$ echo | openssl s_client -connect bobochen.dev:443 -servername bobochen.dev \
-tls1_2 -cipher AES128-GCM-SHA256 2>&1 \
| grep -E "^depth|Cipher is|^Protocol|public key"
depth=2 C=US, O=Google Trust Services LLC, CN=GTS Root R1
depth=1 C=US, O=Google Trust Services, CN=WR1
depth=0 CN=bobochen.dev
New, TLSv1.2, Cipher is AES128-GCM-SHA256
Protocol: TLSv1.2
Server public key is 2048 bit
AES128-GCM-SHA256 這個名字前面沒有 ECDHE,代表 key 就是用「RSA 公鑰鎖起來寄過去」的方式送的。它連上了。
更有意思的是,Cloudflare 為了這條連線,換了一張憑證給我:平常給的是 ECDSA 憑證(簽發者 WE1),這次換成 2048 bits 的 RSA 憑證(簽發者 WR1)。因為這種做法得有 RSA 公鑰才鎖得起來。
這條路是留給舊客戶端的。現代瀏覽器跟我的部落格都會談成 TLS 1.3,而 TLS 1.3 根本沒有這個選項,所以正常情況下走不到這裡。
冷知識:Alice 跟 Bob 就是從 RSA 論文來的
RSA 這個名字,是三位發明者 Rivest、Shamir、Adleman 的姓氏字首。他們 1978 年發表的論文裡,有這麼一句:
For our scenarios we suppose that A and B (also known as Alice and Bob) are two users of a public-key cryptosystem.
— Rivest、Shamir、Adleman,A Method for Obtaining Digital Signatures and Public-Key Cryptosystems
原本只是用 A 跟 B 代表兩個使用者,順手替他們取了名字。後來整個密碼學界講到「兩個人要傳祕密訊息」,主角就一直是 Alice 跟 Bob。
現代做法:key 不用寄,雙方各自算出同一個
TLS 1.3 約定 key 的核心是 Diffie-Hellman 金鑰交換,實際跑的是橢圓曲線的版本,X25519 就是其中一種。
它最神奇的地方是:雙方從頭到尾都沒把秘密寄出去,卻能各自算出同一個秘密。
最常見的比喻是調顏料:
- 雙方先公開約好一個底色,例如黃色。誰都看得到。
- 瀏覽器偷偷挑一個秘密色:紅。加進黃色,調出橘色,寄出去。
- 伺服器偷偷挑一個秘密色:藍。加進黃色,調出綠色,寄出去。
- 瀏覽器拿到綠色,加進自己的紅;伺服器拿到橘色,加進自己的藍。
- 兩邊調出來的都是「黃+紅+藍」,同一個顏色。
flowchart TD
PUB["公開約好的底色:黃<br/>誰都看得到"]
PUB --> SA["伺服器加入秘密色:藍<br/>調出綠色"]
PUB --> BA["瀏覽器加入秘密色:紅<br/>調出橘色"]
SA -- "綠色公開寄出" --> BB["瀏覽器:<br/>綠色+自己的紅"]
BA -- "橘色公開寄出" --> SB["伺服器:<br/>橘色+自己的藍"]
BB --> SAME["兩邊都調出:黃+紅+藍<br/>同一個顏色=共享秘密"]
SB --> SAME
SAME ~~~ EVE["路上的人只看到黃、橘、綠<br/>顏料混了分不回去<br/>調不出最後那個顏色"]
路上偷聽的人看到了黃、橘、綠三種顏色,但顏料一混就分不回去,他拿不到紅也拿不到藍,就調不出最後那一個。換成數學,「混色」是橢圓曲線上的運算:往前算很容易,要從結果倒推回秘密數字,實際上做不到。
對照回真的東西:
| 顏料比喻 | TLS 1.3 裡的東西 |
|---|---|
| 秘密色 | 私鑰,一個隨機數字 |
| 調好寄出去的顏色 | 公鑰,TLS 裡叫 key_share |
| 最後調出來的顏色 | 共享秘密,之後用來推導 AES key |
還有一個關鍵差異:這組金鑰每條連線都重新產生、用完就丟。ECDHE 最後那個 E 就是 ephemeral,臨時的意思。
所以就算幾年後伺服器的憑證私鑰外洩,也解不開今天這條連線,因為憑證私鑰根本沒有參與算 key。
那憑證私鑰在 TLS 1.3 裡做什麼?只剩一件事:簽名。這是下一節的主題。
冷知識:Hellman 本人說,應該叫 Diffie-Hellman-Merkle
Diffie-Hellman 這個名字,來自 Whitfield Diffie 跟 Martin Hellman 1976 年的論文〈New Directions in Cryptography〉。
但 Hellman 自己一直覺得少了一個人。他在 2002 年的一篇回顧文章裡寫:
While that system was first described in a paper by Diffie and me, it is a public key distribution system, a concept developed by Merkle, and hence should be called “Diffie-Hellman-Merkle key exchange” if names are to be associated with it. I hope this small pulpit might help in that endeavor to recognize Merkle’s equal contribution to the invention of public key cryptography.
— Martin E. Hellman,An Overview of Public Key Cryptography,IEEE Communications Magazine,2002
翻成白話:這個系統雖然是他跟 Diffie 先寫進論文,但「公開金鑰分配」這個概念是 Ralph Merkle 想出來的。如果要掛名字,就應該叫 Diffie-Hellman-Merkle,他希望借這個小講台,讓大家承認 Merkle 的貢獻跟他們一樣多。
二十幾年過去,大家還是叫它 Diffie-Hellman。
光會算 key 還不夠:怎麼確定跟你算 key 的是本人?
Diffie-Hellman 解決了「偷聽」,但沒解決「冒充」。
如果有人不只偷聽,而是直接擋在中間呢?他跟瀏覽器算一把 key,再跟伺服器算另一把。瀏覽器以為在跟伺服器說話,伺服器以為在跟瀏覽器說話,兩段都有加密,中間人卻可以解開、偷看、改掉,再加密轉送出去。
flowchart TD
B["瀏覽器"] -- "以為對面是<br/>bobochen.dev" --> M["中間人<br/>跟兩邊各算一把 key"]
M -- "假裝自己是瀏覽器" --> S["bobochen.dev"]
M --> Q{"中間人拿得出<br/>bobochen.dev 的簽章嗎?"}
Q -- "沒有驗身分的話" --> R["兩段加密都成立<br/>中間人解開、偷看<br/>再加密轉送"]
Q -- "有驗憑證跟簽章" --> X["簽不出來<br/>瀏覽器當場中斷連線"]
擋住中間人的,是握手裡的另一個非對稱步驟:簽章。伺服器除了寄出自己的 key_share,還會多寄兩樣東西:
- 憑證:上面寫著「bobochen.dev 的公鑰是這個」,由憑證機構(CA)簽名背書。
- 簽章:伺服器用憑證對應的私鑰,對這次握手到目前為止的內容簽名。TLS 1.3 裡,這個訊息叫 CertificateVerify。
RFC 8446 對 CertificateVerify 的描述是:
This message is used to provide explicit proof that an endpoint possesses the private key corresponding to its certificate.
瀏覽器用憑證裡的公鑰驗章。驗得過,就代表剛剛跟它交換 key_share 的,是握有 bobochen.dev 私鑰的那一方。
中間人沒有這把私鑰,簽不出來。他如果拿自己的憑證頂替,瀏覽器又會發現那張憑證不是可信任的 CA 簽給 bobochen.dev 的,也就是你看過的「您的連線不是私人連線」。
憑證本身也要驗:它是誰簽的?簽它的那張又是誰簽的?一路往上追,直到一張作業系統或瀏覽器內建信任的根憑證。我的部落格這條鏈是三層:
$ echo | openssl s_client -connect bobochen.dev:443 -servername bobochen.dev 2>&1 \
| grep -E "^depth"
depth=2 C=US, O=Google Trust Services LLC, CN=GTS Root R4
depth=1 C=US, O=Google Trust Services, CN=WE1
depth=0 CN=bobochen.dev
根憑證 GTS Root R4 替中繼的 WE1 背書,WE1 再替 bobochen.dev 背書。
到這裡可以發現,非對稱加密在 HTTPS 裡做了兩件事,而且兩件都不是在加密你的資料:
- 約定 key:X25519,加上後面會講的 ML-KEM。
- 驗明正身:ECDSA 簽章加上憑證鏈。
你輸入的密碼、你看的網頁,從頭到尾都是 AES 在加密。
把整個握手串起來:TLS 1.3 只要來回一趟
sequenceDiagram
participant B as 瀏覽器
participant S as bobochen.dev
B->>S: ClientHello<br/>支援的加密套件+瀏覽器的 key_share
Note over S: 自己的私鑰+瀏覽器的 key_share<br/>算出共享秘密、推導 key
S->>B: ServerHello<br/>伺服器的 key_share
S->>B: (已加密)憑證+簽章+Finished
Note over B: 算出同一個共享秘密<br/>驗憑證鏈、驗簽章
B->>S: (已加密)Finished
B->>S: (AES-256-GCM)GET /blog/
S->>B: (AES-256-GCM)200 OK
注意一個細節:伺服器寄出 ServerHello 的時候,雙方已經有足夠的材料算出 key 了。所以 TLS 1.3 從 ServerHello 之後的握手訊息全部加密,連憑證都是加密後才寄。RFC 8446 寫的是「All handshake messages after the ServerHello are now encrypted.」
整個握手只要一趟來回:瀏覽器送出 ClientHello、收到伺服器的回應,就能開始送加密的 request。但一趟還是一趟。我在 CDN 那篇量過,從中華電信連到聖荷西的機房,光是加密握手就多花了約 135 到 160 毫秒。
「同一把 key」,其實是兩把
約定出來的共享秘密,不會直接拿來當 AES key。雙方會把它丟進一個叫 HKDF 的函數,加密套件最後那個 SHA384 就是在這裡出場。你可以把它想成用同一顆種子,照固定規則長出好幾把 key。
RFC 8446 §7.3 的算式長這樣:
[sender]_write_key = HKDF-Expand-Label(Secret, "key", "", key_length)
[sender]_write_iv = HKDF-Expand-Label(Secret, "iv", "", iv_length)
[sender] 指的是寄件方。也就是說:
- 瀏覽器寄給伺服器的資料,用 client 那把 key 加密。
- 伺服器寄給瀏覽器的資料,用 server 那把 key 加密。
兩邊手上同時都有這兩把,所以它還是對稱加密:瀏覽器用 client 那把鎖,伺服器就用同一把 client 那把開。說「雙方拿同一組 key」最精確。握手階段跟正式傳資料的階段,又各有自己的一組。
為什麼不乾脆全程用非對稱?我讓 RSA 加密 1 MB 試試
非對稱加密聽起來比較厲害,為什麼只用在開頭?
我寫了一小段 Node.js,拿同樣 1 MB 的隨機資料,一邊用 RSA-2048 硬加密,一邊用 AES-256-GCM:
$ node rsa-vs-aes.mjs
RSA 加密:切成 5519 塊,花 110.2 ms,密文 1.35 MB
RSA 解密:花 4004.3 ms
AES 加密:花 1.5 ms,密文 1.00 MB
AES 解密:花 0.4 ms
連跑三次,RSA 解密花了 3.3 到 4.9 秒,AES 解密每次都在 0.5 毫秒以內,差了好幾千倍。
RSA 在這裡有兩個硬傷:
- 一次只能鎖一小塊。2048 bits 的 RSA 配上 OAEP-SHA256 填充,一次最多塞 190 bytes。1 MB(1,048,576 bytes)得切成 5519 塊,每塊加密完變成 256 bytes,密文膨脹成 1.35 MB。
- 私鑰那一側特別慢。加密用公鑰、解密用私鑰,而私鑰運算是 RSA 最慢的部分:同一台機器上,加密 110 毫秒,解密要 4 秒。
那握手用的非對稱運算,又是什麼速度?一樣用 openssl speed 量:
| 運算 | 我的 M2 一秒能做幾次 |
|---|---|
| X25519 金鑰交換 | 約 3.2 萬次 |
| ML-KEM-768 封裝 | 約 4.3 萬次 |
| ECDSA P-256 簽章 | 約 5.5 萬次 |
| RSA-2048 私鑰運算 | 約 1,700 次 |
每條連線的握手,只需要做少少幾次這種運算。一秒幾萬次,應付握手綽綽有餘;拿來加密整個網頁,就完全不是對手。
所以分工很自然:非對稱只負責開頭那一小段,把 key 約定好就退場,剩下的交給 AES。
最近多了一個新零件:後量子的 ML-KEM
回頭看我部落格談出來的 X25519MLKEM768,它其實是兩種金鑰交換綁在一起:
- X25519:前面講的橢圓曲線 Diffie-Hellman。
- ML-KEM-768:一種「後量子」演算法,前身叫 Kyber。
為什麼要多綁一個?RSA 跟橢圓曲線的安全性,都建立在某些數學題很難倒推。夠大的量子電腦可以用 Shor 演算法把這些題目解開。
量子電腦現在還沒那麼大,但前面那個「先錄下來,以後再解」的劇本在這裡又出現了:今天錄下的加密流量,等哪天量子電腦夠大,就能回頭解開約定 key 的那一步。
所以要先升級的,是約定 key 的那一步。AES 那半場沒換,我的連線談出來的還是 TLS_AES_256_GCM_SHA384。
ML-KEM 的做法,其實有點像第一代的掛鎖:瀏覽器臨時產生一組 ML-KEM 公鑰寄過去,伺服器產生一個隨機秘密,用它鎖起來寄回來。差別在於這組公鑰只用這一次、用完就丟,所以前向保密還在。
最後 X25519 跟 ML-KEM 兩邊各自得到的秘密,會混在一起再推導 key。攻擊者要兩邊都破解才拿得到,只破一邊沒用。
Chrome 從 131 版開始改用這個組合,Google 的安全部落格寫的是:
the codepoint in TLS for hybrid post-quantum key exchange is changing from 0x6399 for Kyber768+X25519, to 0x11EC for ML-KEM768+X25519.
— Google Online Security Blog,A new path for Kyber on the web
連我筆電上的 openssl 不加任何參數,跟我的部落格談出來的也是它。
3 分鐘自己動手
一行指令,看任何網站的 HTTPS 用了什麼
echo | openssl s_client -connect github.com:443 -servername github.com 2>&1 \
| grep -E "^Protocol|Cipher is|group|signature type"
把 github.com 換成你想看的網站。要看憑證鏈就改 grep ^depth。
我用的是 Homebrew 裝的 OpenSSL 3.6.3。macOS 內建的 openssl 其實是 LibreSSL 3.3.6,同一條指令,它跟我的部落格談出來的是 X25519 加 ChaCha20,沒有 ML-KEM,輸出格式也不一樣。
50 行 Node.js,重演一次握手
下面這段只用 Node.js 內建的 crypto,把前面講的步驟照順序跑一遍:臨時金鑰、簽章、驗章、各自算出共享秘密、推導 key、AES-256-GCM 加解密,最後再偷改一個 bit。
// 用 Node.js 內建的 crypto,重演一次 HTTPS 握手的骨架(簡化版)
import crypto from 'node:crypto';
const show = (buf) => buf.toString('hex').slice(0, 16) + '…';
// 0. 伺服器的身分:一把長期的簽章金鑰。真實世界裡,它的公鑰放在 CA 簽過的憑證裡
const identity = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
// 1. 雙方各自臨時產生一組 X25519 金鑰,私鑰留在自己手上,只把公鑰丟到網路上
const browser = crypto.generateKeyPairSync('x25519');
const server = crypto.generateKeyPairSync('x25519');
const browserPub = browser.publicKey.export({ type: 'spki', format: 'der' });
const serverPub = server.publicKey.export({ type: 'spki', format: 'der' });
// 2. 伺服器用身分金鑰,替「這次握手雙方丟出來的公鑰」簽名
const transcript = Buffer.concat([browserPub, serverPub]);
const signature = crypto.sign('sha256', transcript, identity.privateKey);
// 3. 瀏覽器拿憑證裡的公鑰驗章:跟我交換金鑰的,確實是握有憑證私鑰的本人
console.log('驗章結果 ', crypto.verify('sha256', transcript, identity.publicKey, signature));
// 4. 各自用「自己的私鑰+對方的公鑰」算出共享秘密。這個值從來沒在網路上傳過
const secretB = crypto.diffieHellman({ privateKey: browser.privateKey, publicKey: server.publicKey });
const secretS = crypto.diffieHellman({ privateKey: server.privateKey, publicKey: browser.publicKey });
console.log('瀏覽器算出 ', show(secretB));
console.log('伺服器算出 ', show(secretS));
// 5. 從共享秘密導出 AES-256 的 key。一個方向一把,這裡只示範瀏覽器寄出去的那把
const derive = (secret, label) =>
Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.alloc(0), label, 32));
const browserSendKey = derive(secretB, 'browser to server');
const serverRecvKey = derive(secretS, 'browser to server');
console.log('兩邊的 key 一樣嗎', browserSendKey.equals(serverRecvKey));
// 6. 從這裡開始,全部是 AES-256-GCM 對稱加密
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', browserSendKey, iv);
const sealed = Buffer.concat([cipher.update('GET /blog/ HTTP/1.1'), cipher.final()]);
const tag = cipher.getAuthTag();
console.log('網路上的樣子', show(sealed));
const open = (data) => {
const d = crypto.createDecipheriv('aes-256-gcm', serverRecvKey, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(data), d.final()]).toString();
};
console.log('伺服器解開 ', open(sealed));
// 7. 有人在路上偷改了 1 個 bit
sealed[0] ^= 1;
try { open(sealed); } catch (e) { console.log('被改過的封包', e.message); }
存成 https-in-50-lines.mjs,用 node https-in-50-lines.mjs 跑。我用 Node.js 24 跑出來是這樣:
驗章結果 true
瀏覽器算出 baa7a52af023e7d3…
伺服器算出 baa7a52af023e7d3…
兩邊的 key 一樣嗎 true
網路上的樣子 c56ef9735f71a82f…
伺服器解開 GET /blog/ HTTP/1.1
被改過的封包 Unsupported state or unable to authenticate data
每次跑,數字都不一樣,因為金鑰每次都重新產生。但「瀏覽器算出」跟「伺服器算出」那兩行永遠相同,而且這個值從來沒有被寄出去過。
最後一行是 GCM 的封條在做事:改了 1 個 bit,封條對不上,整個封包直接被拒收。
這是簡化版。真正的 TLS 1.3 還多做了這些事:
- 簽的是整段握手紀錄的雜湊,不只是雙方的公鑰。
- 伺服器的身分公鑰要從憑證拿,還要一路驗到根憑證;這裡直接拿來用。
- HKDF 分成好幾層,握手跟傳資料各有一組 key,最後還有 Finished 訊息確認雙方算出來的一樣。
- 每個封包都用序號推出不同的 nonce,這裡只送了一個封包。
- 金鑰交換用的是 X25519MLKEM768,這裡只有 X25519。
反思:非對稱負責「開始」,AES 負責「一直」
技術面
把整篇拆過的零件照順序排一次:
| 階段 | 用什麼 | 類型 | 擋掉什麼 | 我的部落格實際用的 |
|---|---|---|---|---|
| 約定 key | ECDHE+ML-KEM | 非對稱 | 偷聽、先錄以後再解 | X25519MLKEM768 |
| 驗明正身 | 憑證鏈+簽章 | 非對稱 | 冒充、中間人 | ECDSA P-256,GTS Root R4 → WE1 |
| 推導 key | HKDF | 雜湊 | 一個秘密變出多把 key | SHA-384 |
| 傳資料 | AEAD | 對稱 | 偷看、竄改 | AES-256-GCM |
如果只能帶走一句話:HTTPS 用 AES 加密你的資料,用非對稱加密確保那把 AES key 只有你跟真正的伺服器知道。
心態面
「HTTPS 就是 AES」這句話會流傳,是因為它描述的是幾乎每一個 byte 的遭遇。一個網頁幾 MB 的內容,全是 AES 在處理,非對稱加密只佔開頭那一趟來回。
但安不安全,是開頭那一趟決定的。AES-256 再強,key 要是被中間人一起算走,強度就沒有任何意義。
有趣發現
- Cloudflare 手上有兩張 bobochen.dev 的憑證:平常給 ECDSA 那張,由 WE1 簽發、根是 GTS Root R4;遇到只提 RSA 金鑰傳輸的客戶端,就換成 RSA-2048 那張,由 WR1 簽發、根是 GTS Root R1。
- 在我這台 M2 上,AES-256-GCM 比 ChaCha20-Poly1305 快將近 4 倍,因為晶片有 AES 專用指令。
- 同樣是 RSA-2048,用公鑰加密 1 MB 花 110 毫秒,用私鑰解密要 4 秒。「非對稱很慢」其實主要慢在私鑰那一側。
說到底,HTTPS 裡最重的活是 AES 在扛,但讓 AES 有意義的,是開頭那一趟來回。