TLS 深入分析:HTTPS 實際上是如何運作的
Published: Sat Feb 01 2025 | Modified: Sat Feb 07 2026 , 11 minutes reading.
1. 為什麼要關心這個問題?
每天,你發起數百個 HTTPS 請求。但從輸入 URL 到看到鎖標誌的那一瞬間發生了什麼?
理解 TLS 能幫助你:
- 除錯連線問題和憑證錯誤
- 安全地配置伺服器
- 理解關於協定漏洞的安全公告
- 對密碼套件和版本做出明智的決定
2. TLS 提供什麼
TLS(傳輸層安全)提供三個安全屬性:
┌─────────────────────────────────────────────────────────────┐
│ 1. 機密性 │
│ 資料被加密,竊聽者只能看到密文 │
│ → 透過以下實現:AES-GCM、ChaCha20-Poly1305 │
├─────────────────────────────────────────────────────────────┤
│ 2. 完整性 │
│ 任何修改都會被偵測到 │
│ → 透過以下實現:AEAD(內建於 AES-GCM) │
├─────────────────────────────────────────────────────────────┤
│ 3. 身份驗證 │
│ 伺服器確實是它宣稱的那個 │
│ → 透過以下實現:憑證、數位簽章 │
└─────────────────────────────────────────────────────────────┘3. TLS 1.3 vs 早期版本
TLS 1.3(2018)相比 TLS 1.2 的改進:
移除了:
✗ RSA 金鑰交換(沒有前向保密)
✗ CBC 模式密碼(填充預言攻擊)
✗ MD5、SHA-1 用於簽章
✗ 壓縮(CRIME 攻擊)
✗ 重新協商
新增了:
✓ 強制前向保密(僅 ECDHE)
✓ 1-RTT 握手(更快)
✓ 0-RTT 恢復(可選,更快)
✓ 加密的握手訊息
✓ 簡化的密碼套件
效能:
TLS 1.2:資料前需要 2 個往返
TLS 1.3:資料前需要 1 個往返4. TLS 1.3 握手
概覽
客戶端 伺服器
│ │
│──────────── ClientHello ──────────────────────>│
│ - 支援的版本 │
│ - 密碼套件 │
│ - 金鑰共享(ECDH 公鑰) │
│ - 隨機數 │
│ │
│<─────────── ServerHello ───────────────────────│
│ - 選擇的版本 │
│ - 選擇的密碼套件 │
│ - 金鑰共享(ECDH 公鑰) │
│ - 隨機數 │
│ │
│ [雙方計算共享金鑰] │
│ [衍生握手金鑰] │
│ │
│<─────────── {EncryptedExtensions} ─────────────│
│<─────────── {Certificate} ─────────────────────│
│<─────────── {CertificateVerify} ───────────────│
│<─────────── {Finished} ────────────────────────│
│ │
│──────────── {Finished} ───────────────────────>│
│ │
│<════════════ 應用資料 ════════════════════════>│
│ │
{} = 用握手金鑰加密步驟 1:ClientHello
# 客戶端發送的內容(概念性)
client_hello = {
'legacy_version': 0x0303, # TLS 1.2 用於相容
'random': os.urandom(32),
'session_id': os.urandom(32), # 用於相容
'cipher_suites': [
'TLS_AES_256_GCM_SHA384',
'TLS_CHACHA20_POLY1305_SHA256',
'TLS_AES_128_GCM_SHA256',
],
'extensions': {
'supported_versions': ['TLS 1.3', 'TLS 1.2'],
'supported_groups': ['x25519', 'secp256r1'],
'signature_algorithms': ['ecdsa_secp256r1_sha256', 'rsa_pss_rsae_sha256'],
'key_share': {
'x25519': client_x25519_public_key
},
'server_name': 'example.com', # SNI
}
}步驟 2:ServerHello
# 伺服器回應的內容(概念性)
server_hello = {
'legacy_version': 0x0303,
'random': os.urandom(32),
'session_id': client_session_id, # 回傳
'cipher_suite': 'TLS_AES_256_GCM_SHA384',
'extensions': {
'supported_versions': 'TLS 1.3',
'key_share': {
'x25519': server_x25519_public_key
}
}
}步驟 3:金鑰衍生
雙方現在都有:
- 客戶端的 ECDH 公鑰
- 伺服器的 ECDH 公鑰
- 他們自己的 ECDH 私鑰
他們計算:
shared_secret = ECDH(my_private, peer_public)
TLS 1.3 使用 HKDF 衍生多個金鑰:
shared_secret
│
▼
┌───────────────────────┐
│ HKDF-Extract │
│ (使用零) │
└───────────────────────┘
│
▼
Early Secret
│
┌───────────────────────┐
│ HKDF-Expand │
└───────────────────────┘
│
▼
┌───────────────────────┐
│ HKDF-Extract │
│ (使用 shared_secret)│
└───────────────────────┘
│
▼
Handshake Secret
│ │
▼ ▼
client_handshake server_handshake
_traffic_secret _traffic_secret
│
▼
┌───────────────────────┐
│ HKDF-Extract │
│ (使用零) │
└───────────────────────┘
│
▼
Master Secret
│ │
▼ ▼
client_application server_application
_traffic_secret _traffic_secret步驟 4:伺服器認證
伺服器發送(用握手金鑰加密):
1. EncryptedExtensions
- 金鑰交換不需要的額外參數
2. Certificate
- 伺服器的 X.509 憑證鏈
- 現在加密了(相比 TLS 1.2 的隱私改進)
3. CertificateVerify
- 對握手記錄的簽章
- 證明伺服器擁有憑證的私鑰
- signature = Sign(private_key, Hash(handshake_messages))
4. Finished
- 握手記錄的 HMAC
- 證明握手沒有被竄改
- finished = HMAC(finished_key, Hash(handshake_messages))步驟 5:客戶端 Finished
客戶端驗證:
1. 憑證鏈有效
2. 伺服器名稱與憑證匹配
3. CertificateVerify 簽章有效
4. Finished MAC 正確
然後發送自己的 Finished 訊息:
- 證明客戶端也看到了完整的握手
- 現在雙方切換到應用流量金鑰5. 程式碼:檢查 TLS 連線
import ssl
import socket
import pprint
def inspect_tls_connection(hostname, port=443):
"""檢查 TLS 連線詳情"""
context = ssl.create_default_context()
with socket.create_connection((hostname, port)) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
print(f"TLS 版本: {ssock.version()}")
print(f"密碼套件: {ssock.cipher()}")
print(f"壓縮: {ssock.compression()}")
cert = ssock.getpeercert()
print(f"\n憑證主題: {cert['subject']}")
print(f"發行者: {cert['issuer']}")
print(f"有效期從: {cert['notBefore']}")
print(f"有效期到: {cert['notAfter']}")
print(f"SANs: {cert.get('subjectAltName', [])}")
# 範例
inspect_tls_connection("www.google.com")
# 輸出:
# TLS 版本: TLSv1.3
# 密碼套件: ('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256)
# 壓縮: None
# ...6. TLS 1.3 中的密碼套件
可用的套件
TLS 1.3 只有 5 個密碼套件:
TLS_AES_128_GCM_SHA256 # 快速、安全
TLS_AES_256_GCM_SHA384 # 更高的安全邊際
TLS_CHACHA20_POLY1305_SHA256 # 沒有 AES-NI 時快速
TLS_AES_128_CCM_SHA256 # CCM 模式(少見)
TLS_AES_128_CCM_8_SHA256 # 短標籤(物聯網)
格式:TLS_<AEAD>_<HASH>
- 沒有金鑰交換演算法(始終是 ECDHE)
- 沒有簽章演算法(單獨協商)選擇密碼套件
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
# TLS 1.3 密碼套件(透過 ciphersuites 設定,不是 set_ciphers)
context.set_ciphersuites('TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256')
# TLS 1.2 後備密碼(如果需要)
context.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!MD5:!DSS')
# 最低版本
context.minimum_version = ssl.TLSVersion.TLSv1_27. 前向保密
為什麼重要
沒有前向保密(RSA 金鑰交換):
1. 攻擊者記錄加密流量
2. 多年後,竊取伺服器的私鑰
3. 解密所有歷史流量
有前向保密(ECDHE):
1. 每個會話使用臨時金鑰
2. 私鑰在會話後刪除
3. 即使長期金鑰被盜:
- 無法解密過去的會話
- 每個會話的金鑰都已消失ECDHE 如何提供前向保密
會話 1:
客戶端:產生臨時金鑰對 (a₁, A₁)
伺服器:產生臨時金鑰對 (b₁, B₁)
共享:K₁ = a₁×B₁ = b₁×A₁
會話後:a₁, b₁ 永久刪除
會話 2:
客戶端:產生臨時金鑰對 (a₂, A₂)
伺服器:產生臨時金鑰對 (b₂, B₂)
共享:K₂ = a₂×B₂ = b₂×A₂
會話後:a₂, b₂ 永久刪除
之後入侵伺服器的攻擊者:
- 獲得長期簽章金鑰
- 但 K₁、K₂ 從未被儲存
- 無法恢復會話金鑰8. 會話恢復
TLS 1.3 會話票據
首次連線:
客戶端 伺服器
│ │
│──── 完整握手 ─────────────────────────>│
│<─── 完整握手 ──────────────────────────│
│<─── NewSessionTicket ──────────────────│
│ (加密的會話狀態) │
│ │
恢復連線:
客戶端 伺服器
│ │
│──── ClientHello + pre_shared_key ────>│
│ (包含會話票據) │
│<─── ServerHello(選擇的 PSK)──────────│
│ │
│ [簡化的握手] │
│<════ 應用資料 ════════════════════════>│
好處:
- 1-RTT 恢復握手
- 伺服器不儲存會話狀態
- 票據用伺服器的金鑰加密0-RTT(早期資料)
客戶端可以立即發送資料:
客戶端 伺服器
│ │
│──── ClientHello + early_data ────────>│
│ (用 PSK 加密) │
│<─── ServerHello ──────────────────────│
│ │
風險:
- 可能受到重放攻擊!
- 只用於冪等請求
- 伺服器可以拒絕 0-RTT9. 常見 TLS 問題
憑證問題
import ssl
import socket
def diagnose_tls_issues(hostname, port=443):
"""診斷常見 TLS 問題"""
# 嘗試帶驗證的連線
try:
context = ssl.create_default_context()
with socket.create_connection((hostname, port), timeout=5) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
print(f"✓ 連線成功: {ssock.version()}")
return True
except ssl.SSLCertVerificationError as e:
print(f"✗ 憑證驗證失敗: {e}")
# 嘗試不驗證以獲取更多資訊
context_no_verify = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context_no_verify.check_hostname = False
context_no_verify.verify_mode = ssl.CERT_NONE
with socket.create_connection((hostname, port)) as sock:
with context_no_verify.wrap_socket(sock) as ssock:
cert = ssock.getpeercert(binary_form=True)
# 分析憑證...
except ssl.SSLError as e:
print(f"✗ SSL 錯誤: {e}")
except socket.timeout:
print(f"✗ 連線超時")
except Exception as e:
print(f"✗ 錯誤: {e}")
return False版本和密碼不匹配
# 檢查伺服器支援哪些版本/密碼
openssl s_client -connect example.com:443 -tls1_3
openssl s_client -connect example.com:443 -tls1_2
# 列出支援的密碼
openssl ciphers -v 'HIGH:!aNULL:!MD5'
# 測試特定密碼
openssl s_client -connect example.com:443 -cipher 'ECDHE-RSA-AES256-GCM-SHA384'10. 伺服器配置最佳實踐
現代 TLS 配置
# Nginx 配置
server {
listen 443 ssl http2;
# 憑證
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
# 協定版本
ssl_protocols TLSv1.2 TLSv1.3;
# 密碼套件(TLS 1.2)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off; # TLS 1.3 讓客戶端選擇
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
# 會話票據
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off; # 為了前向保密停用
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}測試你的配置
# Mozilla SSL 配置產生器
# https://ssl-config.mozilla.org/
# SSL Labs 測試
# https://www.ssllabs.com/ssltest/
# testssl.sh
./testssl.sh https://example.com11. 程式碼中的 TLS
Python HTTPS 客戶端
import ssl
import urllib.request
# 預設(安全)設定
response = urllib.request.urlopen('https://example.com')
# 自訂上下文
context = ssl.create_default_context()
context.minimum_version = ssl.TLSVersion.TLSv1_3 # 要求 TLS 1.3
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
response = urllib.request.urlopen('https://example.com', context=context)Python HTTPS 伺服器
import ssl
from http.server import HTTPServer, SimpleHTTPRequestHandler
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain('server.crt', 'server.key')
context.minimum_version = ssl.TLSVersion.TLSv1_2
server = HTTPServer(('0.0.0.0', 443), SimpleHTTPRequestHandler)
server.socket = context.wrap_socket(server.socket, server_side=True)
server.serve_forever()雙向 TLS(mTLS)
import ssl
# 需要客戶端憑證的伺服器
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain('server.crt', 'server.key')
server_context.load_verify_locations('client_ca.crt')
server_context.verify_mode = ssl.CERT_REQUIRED # 要求客戶端憑證
# 帶憑證的客戶端
client_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
client_context.load_cert_chain('client.crt', 'client.key')
client_context.load_verify_locations('server_ca.crt')12. 本章小結
三點要記住:
TLS 1.3 更簡單更快。 一個往返握手、強制前向保密、更少的密碼套件選擇(都是好的)。
握手結合了 ECDH + 簽章 + 憑證。 ECDH 用於金鑰交換,簽章證明伺服器身份,憑證證明誰擁有簽章金鑰。
前向保密保護過去的會話。 即使伺服器之後被入侵,記錄的流量也無法被解密,因為臨時金鑰已被刪除。
13. 下一步
TLS 保護傳輸中的資料。但靜態資料呢,比如使用者密碼?你不能加密密碼(你需要驗證它們),那怎麼安全地儲存它們?
在下一篇文章中:密碼儲存——為什麼你永遠不應該加密密碼,以及 bcrypt、Argon2 和 scrypt 如何保護使用者憑證。
