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