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 如何保护用户凭证。