创见博客
HTTPS 的 TLS 到底做了什么:从握手到证书,一次讲清
七崽爱吃小饼干2026/10/30阅读 1

先看一段 curl -v 的输出:

text
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=example.com
*  start date: ...
*  expire date: ...

你敲下 https:// 开头的地址后,浏览器和服务器之间先谈了一轮 TLS,谈完才轮到 HTTP 说话。上面这几行,就是这轮谈判的结果:用哪个版本的 TLS、用什么算法加密、证书是谁的、后面走 HTTP/2 还是 HTTP/1.1。

很多人把 HTTPS 理解成“加密过的 HTTP”,这只说对了一半。TLS 真正解决的是三个问题,而且缺一不可。

一、HTTPS 到底多了什么

HTTP 是明文协议。你在公共 Wi-Fi 下访问 http:// 网站,中间任何一个节点都能:

  • 偷看:请求头、Cookie、密码、返回的 HTML,全都是明文。
  • 篡改:往响应里塞广告、改掉下载的脚本。
  • 冒充:伪装成你要访问的网站,把你引到钓鱼页。

TLS 对应给出三个保证:

保证靠什么实现
机密性握手协商出的对称密钥,加密后续所有数据
完整性AEAD 算法或 MAC,改一个字节就会校验失败
身份认证X.509 证书 + 证书链,确认对面确实是它声称的域名

所以更准确的说法是:

text
HTTPS = HTTP + TLS

TLS 夹在应用层和传输层之间。传统 HTTPS 跑在 TCP 上,默认端口 443;HTTP/3 则把 TLS 1.3 直接集成进了 QUIC,跑在 UDP 上。协议换了,但“先握手、再加密传数据”的思路没变。

还有一个容易被忽略的点:TLS 不只服务 HTTP。邮件、数据库、MQTT 都能套 TLS。HTTPS 只是它最出名的用户。

二、握手:在不安全的通道上谈出安全的密钥

TLS 要解决一个看似矛盾的问题:

  • 对称加密快,但双方得先有同一把密钥,怎么把密钥安全地送过去?
  • 非对称加密能解决密钥分发,但速度慢,不适合加密大量数据。

TLS 的答案是混合加密:

text
用非对称加密(或密钥交换)安全地谈出一把临时对称密钥,
之后所有数据都用这把对称密钥加密。

具体怎么谈,TLS 1.2 和 1.3 的流程不一样。先看 TLS 1.2 的经典版本(以最常用的 ECDHE 密钥交换为例):

text
Client                                              Server
  |--- ClientHello ----------------------------------->|
  |    版本 / 客户端随机数 / 密码套件 / SNI / ALPN      |
  |<-- ServerHello ------------------------------------|
  |    选定版本 / 服务端随机数 / 选定密码套件            |
  |<-- Certificate ------------------------------------|
  |    服务端证书链                                     |
  |<-- ServerKeyExchange ------------------------------|
  |    ECDHE 参数 + 用证书私钥对参数的签名               |
  |<-- ServerHelloDone --------------------------------|
  |                                                    |
  |  客户端验证证书链、域名、有效期                      |
  |  生成自己的 ECDHE 私钥                              |
  |--- ClientKeyExchange ----------------------------->|
  |    客户端 ECDHE 公钥                                |
  |--- ChangeCipherSpec + Finished ------------------->|
  |<-- ChangeCipherSpec + Finished --------------------|
  |                                                    |
  |<========= 之后全部走对称加密的 HTTP 数据 ==========>|

几个关键动作:

  1. 双方各出一个随机数,后面一起参与密钥推导。随机数保证每次握手产生的密钥都不同。
  2. 服务端用证书私钥签名握手参数,客户端用证书公钥验签。这证明对面真的持有这张证书对应的私钥,不是随便抄了张证书。
  3. ECDHE 交换让双方各自算出同一个共享密钥,但这个密钥从没在网络上明文传输过。
  4. Finished 消息包含整轮握手的摘要,任何一条握手消息被中间人改过,校验都会失败。

TLS 1.3 把这套流程压缩得更狠:

text
Client                                              Server
  |--- ClientHello ----------------------------------->|
  |    支持的版本 / 密码套件 / SNI / key_share          |
  |<-- ServerHello ------------------------------------|
  |    选定参数 / 服务端 key_share                      |
  |    (之后双方已经能算出握手密钥)                    |
  |<== EncryptedExtensions ============================|
  |<== Certificate ====================================|
  |<== CertificateVerify ==============================|
  |<== Finished =======================================|
  |=== Finished ======================================>|
  |                                                    |
  |<========= 应用数据(1-RTT 建连完成)================|

TLS 1.3 的变化可以总结成三句话:

  • 1-RTT 建连:客户端在第一个 ClientHello 里就把密钥交换材料 key_share 带上,省掉一个来回。
  • 握手本身也加密:ServerHello 之后的消息全部加密,中间人连证书都看不到。
  • 砍掉老算法:RSA 密钥交换、静态 DH、CBC、RC4 这些都被移除,只留下更安全的 AEAD 套件。

还有一个 0-RTT:客户端用之前会话留下的 PSK,在第一个包里就带上加密的应用数据。代价是这些早期数据可能被重放,所以只适合幂等请求,比如 GET,不适合转账。

三、证书:TLS 如何确认“对面真是它”

密钥谈成了,但还有个前提:你谈的那个公钥,真的是 example.com 的吗? 如果中间人把自己的公钥塞给你,加密再完美也只是加密给你和中间人。

证书解决的就是这件事。一张 X.509 证书本质上是这样一句话:

text
“我(CA)证明,这个公钥属于 example.com。”
—— 后面跟着 CA 的签名

这句话拆开看,一张证书里至少要装下这些字段:

字段作用
Subject(主体)证书属于谁,通常是域名或组织信息
Subject Public Key被背书的公钥,也就是服务端私钥对应的那一半
Issuer(签发者)是哪个 CA 签的
Validity生效时间和过期时间
SAN(Subject Alternative Name)这张证书对哪些域名有效,现代浏览器只看这个字段
Key Usage / Extended Key Usage这把钥匙能干什么,比如只能做 TLS 服务端认证
SignatureCA 用自己私钥对以上内容的签名

浏览器验证时,真正关心的不是“证书长得像不像”,而是这些字段有没有被一个自己信任的 CA 签过、签名之后有没有被改过。

CA 是什么:信任为什么要“外包”

CA(Certificate Authority,证书颁发机构)就是专门做“公钥背书”的机构。它解决的问题很朴素:

  • 你访问一个陌生网站,凭什么相信握手时拿到的公钥是它的?
  • 如果每个网站都让你手动核对公钥,互联网就没法用了。

CA 的解法是把信任集中化、可传递:操作系统和浏览器出厂时预装一批根证书(root store),你默认信任这些根 CA;根 CA 再给中间 CA 签发证书,中间 CA 给具体网站签发证书。于是信任沿着一条链传下去。

text
叶子证书(example.com)
    └── 中间 CA 证书
            └── 根 CA 证书(预装在系统/浏览器信任库里)

这里有个关键设计:根 CA 不直接签发网站证书。根证书的私钥通常离线保管,中间 CA 才是在线签发的那一层。因为一旦根私钥泄露,整个信任体系都会塌,而中间 CA 出了问题可以被单独吊销。

常见的根 CA 有 DigiCert、GlobalSign、Sectigo、Let's Encrypt 的 ISRG Root X1,以及各大云厂商合作的 CA。你可以在系统或浏览器的“证书管理”里看到完整列表。

验证一条证书链,大致是这几步:

  1. 用中间 CA 的公钥验叶子证书的签名;
  2. 用根 CA 的公钥验中间 CA 的签名;
  3. 根证书必须在自己信任的根列表里;
  4. 检查域名是否匹配证书里的 SAN;
  5. 检查当前时间是否在有效期里;
  6. 查吊销状态(OCSP / CRL,很多场景用 OCSP Stapling);
  7. 对公开证书,还会查 CT(Certificate Transparency)日志,确认这张证书的签发被公开记录过。

任何一步失败,浏览器都会给出那几种熟悉的报错:证书过期、域名不匹配、证书链不完整、证书不受信任。

证书到底解决了什么问题

问题证书的作用
公钥是不是对方的把公钥和域名绑定,CA 签名背书
会不会被中间人冒充没有对应私钥就过不了 CertificateVerify,冒充不了
用户怎么知道该信谁信任根预置,用户不需要逐个网站确认
出事了能不能止损证书可吊销、可过期,CA 能撤销错误签发
责任能不能追溯CT 日志和 CA 审计留下签发记录

所以证书不是“加密用的”,加密是密钥交换干的,证书负责的是身份。两者配合,才让“和不认识的人安全通信”成立。

证书的类型怎么选

按验证强度分:

类型验证内容适用场景
DV(Domain Validation)只验证域名控制权绝大多数网站,Let's Encrypt 就是 DV
OV(Organization Validation)额外验证企业身份企业官网、对主体有要求的场景
EV(Extended Validation)更严格的企业审核金融等强合规场景,浏览器已不再特殊显示

按覆盖范围分:

  • 单域名:只对 example.com 有效;
  • 多域名(SAN):一张证书覆盖多个不同域名;
  • 泛域名:*.example.com 覆盖一级子域名,但不覆盖 example.com 本身,通常要一起申请。

对普通站点来说,DV + 泛域名/多域名已经足够。EV 证书现在不会让地址栏变绿,性价比不高。

怎么申请一张证书

不管走哪家 CA,流程都是四步:

text
生成私钥 + CSR  →  证明域名归你  →  CA 签发  →  部署并续期

第一步:生成私钥和 CSR。 CSR(Certificate Signing Request)里包含公钥和域名信息,私钥必须自己保管好,不能上传给任何人。

bash
openssl req -new -newkey rsa:2048 -nodes \
  -keyout example.com.key \
  -out example.com.csr \
  -subj "/CN=example.com" \
  -addext "subjectAltName=DNS:example.com,DNS:www.example.com"

第二步:证明域名控制权。 CA 要确认你确实控制这个域名,常见三种方式:

挑战方式做法特点
HTTP-01在 /.well-known/acme-challenge/ 放指定文件简单,但需要 80 端口可访问
DNS-01添加一条指定的 TXT 记录支持泛域名,不依赖 Web 服务
TLS-ALPN-01在 443 端口完成特殊 TLS 握手不占 80 端口

第三步:CA 签发。 验证通过后,CA 返回证书(有时还带中间证书)。

第四步:部署并续期。 免费证书通常 90 天,必须自动化续期,否则某天凌晨站点会突然报证书过期。

实际最省事的是用 Let's Encrypt + Certbot:

bash
# Nginx 场景,自动改配置并申请
sudo certbot --nginx -d example.com -d www.example.com

# 只申请证书,不碰 Web 服务器配置
sudo certbot certonly --webroot -w /var/www/html -d example.com

# 泛域名必须走 DNS-01
sudo certbot certonly --manual --preferred-challenges dns \
  -d "*.example.com" -d example.com

证书会落在:

text
/etc/letsencrypt/live/example.com/fullchain.pem   # 证书 + 中间证书
/etc/letsencrypt/live/example.com/privkey.pem     # 私钥

续期用 Certbot 自带的定时任务即可,先演练一次:

bash
sudo certbot renew --dry-run

不想自己维护 Certbot,也可以用云厂商的免费 DV 证书:在控制台申请、做 DNS 验证、下载证书,再手动或通过 CDN/负载均衡部署。缺点是续期往往要人工介入,域名一多就容易漏。

部署时:证书链要发全

这一点踩坑的人特别多。服务端在握手时应该发送叶子证书 + 中间证书,也就是 fullchain.pem,而不是只发叶子证书。根证书一般不用发,客户端本地就有。

以 Nginx 为例:

nginx
ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

只配叶子证书时,浏览器可能因为缓存或 AIA 补链能蒙混过关,但 curl、Java、部分移动端会直接报 unable to get local issuer certificate。用 openssl s_client -showcerts 可以确认服务端到底发了几张证书。

为什么“自签证书”浏览器不认

自签证书是自己给自己签名,相当于“我自己就是 CA”,而这张根证书不在系统信任库里。浏览器没法确认它到底代表谁,所以默认拒绝。

本地开发可以用自签证书,但必须手动信任,或者用 mkcert 这类工具在本地生成一个受信任的根 CA。生产环境不要用自签证书,也不要在客户端关掉证书校验。

SNI:一个 IP 怎么承载多个 HTTPS 站点

服务器在握手前并不知道你要访问哪个域名,因为 HTTP 的 Host 头还在 TLS 之后。解决办法是 SNI(Server Name Indication):客户端在 ClientHello 里先报上域名,服务器据此选择对应证书。

SNI 在传统 TLS 里是明文的,中间人能看到你访问了哪个域名。ECH(Encrypted Client Hello) 正在解决这个问题,把 SNI 也加密,但目前还没有全面普及。

四、握手之后,数据到底怎么加密

TLS 1.3 的密码套件长这样:

text
TLS_AES_256_GCM_SHA384

拆开看:

部分含义
AES_256_GCM用 AES-256-GCM 做认证加密,同时保证机密性和完整性
SHA384握手和密钥推导用的哈希算法
TLS_AES_256_GCM_SHA384整个套件名,TLS 1.3 里不再单独写密钥交换和签名算法

密钥交换和签名算法在 TLS 1.3 里被拆成了独立参数:

  • 密钥交换:X25519、secp256r1 等;
  • 签名:RSA-PSS、ECDSA、Ed25519 等。

常见的 AEAD 算法有两个:

  • AES-GCM:有 AES 硬件加速时性能很好,服务器和桌面浏览器常用。
  • ChaCha20-Poly1305:在没有 AES 加速的移动设备上更快。

TLS 1.3 还有一个重要性质:前向保密(Forward Secrecy)。因为密钥交换用的是临时 ECDHE 密钥,每次握手都重新生成。即使服务器的长期私钥以后泄露了,攻击者也没法解密之前抓到的流量。

TLS 1.2 和 1.3 的核心差异可以对照着看:

维度TLS 1.2TLS 1.3
建连往返2-RTT1-RTT(支持 0-RTT 恢复)
握手加密大部分明文ServerHello 之后全加密
密钥交换RSA / DHE / ECDHE只允许 (EC)DHE
对称算法包含 CBC 等只允许 AEAD
前向保密取决于套件默认具备
配置复杂度高,套件多低,套件少

五、动手看一次真实握手

用 OpenSSL 连一个站点,把证书链和握手信息打出来:

bash
openssl s_client \
  -connect example.com:443 \
  -servername example.com \
  -showcerts \
  -tls1_3 </dev/null

输出里重点关注这几段:

text
SSL-Session:
    Protocol  : TLSv1.3
    Cipher    : TLS_AES_256_GCM_SHA384

Protocol 是最终协商的版本,Cipher 是选中的套件。如果这里出现 TLSv1.2,说明服务端或中间代理还没开 1.3。

再看证书有效期:

bash
openssl s_client -connect example.com:443 -servername example.com </dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

curl 也能看到握手和 ALPN 结果:

bash
curl -vI https://example.com
text
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN: server accepted h2

本地起一个 HTTPS 服务

先生成一张自签证书(需要 OpenSSL 1.1.1+):

bash
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout key.pem -out cert.pem \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

然后用 Node 起服务:

javascript
const https = require('https')
const fs = require('fs')

https.createServer({
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem'),
}, (req, res) => {
  res.end('hello over tls')
}).listen(4433)
bash
curl -k https://localhost:4433

这里的 -k 是跳过证书校验,只用于本地自签测试。生产环境用 -k,等于把 TLS 的身份认证直接关掉。

六、常见坑与排查思路

现象可能原因怎么查
certificate has expired证书过期或系统时间不对openssl x509 -noout -dates
unable to get local issuer certificate服务端没发中间证书,证书链不完整openssl s_client -showcerts
hostname mismatch证书 SAN 里没有当前域名看 subjectAltName
wrong version number用 TLS 连了明文端口,或反代配置错确认端口和协议
handshake failure没有共同支持的协议版本或套件对比客户端和服务端配置
浏览器能开,Java/curl 报错信任库不同,或缺少中间证书检查对方信任库和证书链
页面部分资源加载失败混合内容,HTTPS 页面引了 HTTP 资源看控制台 Mixed Content 提示

几个实践建议:

  • 不要关证书校验。NODE_TLS_REJECT_UNAUTHORIZED=0、curl -k、verify=False 都只是把问题藏起来。
  • 部署时发完整证书链。只配叶子证书,很多客户端会报“找不到签发者”。
  • 开 HSTS 要谨慎。Strict-Transport-Security 一旦下发,浏览器会在 max-age 内强制走 HTTPS,回退很麻烦。
  • 注意 TLS 终止的位置。如果 TLS 在负载均衡器终止,LB 到后端可能是明文,内网也不能默认当成安全边界。
  • TLS 不隐藏一切。IP、连接时间、流量大小,以及未加密的 SNI,仍然可能暴露信息。

小结

  • HTTPS 不是新协议,而是 HTTP 套了一层 TLS,默认端口 443。
  • TLS 提供三件事:机密性、完整性、身份认证,缺一不可。
  • 握手用非对称手段安全地谈出一把临时对称密钥,之后全部走对称加密。
  • TLS 1.3 把建连压缩到 1-RTT,握手加密、只留 AEAD、默认前向保密;0-RTT 快但有重放风险。
  • 证书链解决“公钥属于谁”:叶子证书 → 中间 CA → 根 CA,根 CA 必须在信任库里。
  • 排查 TLS 问题,先看三样:协议版本、证书链、域名和有效期。
  • 一句话:TLS 先用证书确认“你是谁”,再用密钥交换谈出“只有我们俩知道的钥匙”,最后用这把钥匙把 HTTP 关进保险箱。
评论
0/100