Luke a Pro

Luke Sun

Developer & Marketer

🇺🇦
EN||

TLS 深入分析:HTTPS 實際上是如何運作的

| , 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_2

7. 前向保密

為什麼重要

沒有前向保密(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-RTT

9. 常見 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.com

11. 程式碼中的 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. 本章小結

三點要記住:

  1. TLS 1.3 更簡單更快。 一個往返握手、強制前向保密、更少的密碼套件選擇(都是好的)。

  2. 握手結合了 ECDH + 簽章 + 憑證。 ECDH 用於金鑰交換,簽章證明伺服器身份,憑證證明誰擁有簽章金鑰。

  3. 前向保密保護過去的會話。 即使伺服器之後被入侵,記錄的流量也無法被解密,因為臨時金鑰已被刪除。

13. 下一步

TLS 保護傳輸中的資料。但靜態資料呢,比如使用者密碼?你不能加密密碼(你需要驗證它們),那怎麼安全地儲存它們?

在下一篇文章中:密碼儲存——為什麼你永遠不應該加密密碼,以及 bcrypt、Argon2 和 scrypt 如何保護使用者憑證。