先看一段 curl -v 的输出:
* 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 证书 + 证书链,确认对面确实是它声称的域名 |
所以更准确的说法是:
HTTPS = HTTP + TLS
TLS 夹在应用层和传输层之间。传统 HTTPS 跑在 TCP 上,默认端口 443;HTTP/3 则把 TLS 1.3 直接集成进了 QUIC,跑在 UDP 上。协议换了,但“先握手、再加密传数据”的思路没变。
还有一个容易被忽略的点:TLS 不只服务 HTTP。邮件、数据库、MQTT 都能套 TLS。HTTPS 只是它最出名的用户。
二、握手:在不安全的通道上谈出安全的密钥
TLS 要解决一个看似矛盾的问题:
- 对称加密快,但双方得先有同一把密钥,怎么把密钥安全地送过去?
- 非对称加密能解决密钥分发,但速度慢,不适合加密大量数据。
TLS 的答案是混合加密:
用非对称加密(或密钥交换)安全地谈出一把临时对称密钥,
之后所有数据都用这把对称密钥加密。
具体怎么谈,TLS 1.2 和 1.3 的流程不一样。先看 TLS 1.2 的经典版本(以最常用的 ECDHE 密钥交换为例):
Client Server
|--- ClientHello ----------------------------------->|
| 版本 / 客户端随机数 / 密码套件 / SNI / ALPN |
|<-- ServerHello ------------------------------------|
| 选定版本 / 服务端随机数 / 选定密码套件 |
|<-- Certificate ------------------------------------|
| 服务端证书链 |
|<-- ServerKeyExchange ------------------------------|
| ECDHE 参数 + 用证书私钥对参数的签名 |
|<-- ServerHelloDone --------------------------------|
| |
| 客户端验证证书链、域名、有效期 |
| 生成自己的 ECDHE 私钥 |
|--- ClientKeyExchange ----------------------------->|
| 客户端 ECDHE 公钥 |
|--- ChangeCipherSpec + Finished ------------------->|
|<-- ChangeCipherSpec + Finished --------------------|
| |
|<========= 之后全部走对称加密的 HTTP 数据 ==========>|
几个关键动作:
- 双方各出一个随机数,后面一起参与密钥推导。随机数保证每次握手产生的密钥都不同。
- 服务端用证书私钥签名握手参数,客户端用证书公钥验签。这证明对面真的持有这张证书对应的私钥,不是随便抄了张证书。
- ECDHE 交换让双方各自算出同一个共享密钥,但这个密钥从没在网络上明文传输过。
- Finished 消息包含整轮握手的摘要,任何一条握手消息被中间人改过,校验都会失败。
TLS 1.3 把这套流程压缩得更狠:
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 证书本质上是这样一句话:
“我(CA)证明,这个公钥属于 example.com。”
—— 后面跟着 CA 的签名
这句话拆开看,一张证书里至少要装下这些字段:
| 字段 | 作用 |
|---|---|
| Subject(主体) | 证书属于谁,通常是域名或组织信息 |
| Subject Public Key | 被背书的公钥,也就是服务端私钥对应的那一半 |
| Issuer(签发者) | 是哪个 CA 签的 |
| Validity | 生效时间和过期时间 |
| SAN(Subject Alternative Name) | 这张证书对哪些域名有效,现代浏览器只看这个字段 |
| Key Usage / Extended Key Usage | 这把钥匙能干什么,比如只能做 TLS 服务端认证 |
| Signature | CA 用自己私钥对以上内容的签名 |
浏览器验证时,真正关心的不是“证书长得像不像”,而是这些字段有没有被一个自己信任的 CA 签过、签名之后有没有被改过。
CA 是什么:信任为什么要“外包”
CA(Certificate Authority,证书颁发机构)就是专门做“公钥背书”的机构。它解决的问题很朴素:
- 你访问一个陌生网站,凭什么相信握手时拿到的公钥是它的?
- 如果每个网站都让你手动核对公钥,互联网就没法用了。
CA 的解法是把信任集中化、可传递:操作系统和浏览器出厂时预装一批根证书(root store),你默认信任这些根 CA;根 CA 再给中间 CA 签发证书,中间 CA 给具体网站签发证书。于是信任沿着一条链传下去。
叶子证书(example.com)
└── 中间 CA 证书
└── 根 CA 证书(预装在系统/浏览器信任库里)
这里有个关键设计:根 CA 不直接签发网站证书。根证书的私钥通常离线保管,中间 CA 才是在线签发的那一层。因为一旦根私钥泄露,整个信任体系都会塌,而中间 CA 出了问题可以被单独吊销。
常见的根 CA 有 DigiCert、GlobalSign、Sectigo、Let's Encrypt 的 ISRG Root X1,以及各大云厂商合作的 CA。你可以在系统或浏览器的“证书管理”里看到完整列表。
验证一条证书链,大致是这几步:
- 用中间 CA 的公钥验叶子证书的签名;
- 用根 CA 的公钥验中间 CA 的签名;
- 根证书必须在自己信任的根列表里;
- 检查域名是否匹配证书里的 SAN;
- 检查当前时间是否在有效期里;
- 查吊销状态(OCSP / CRL,很多场景用 OCSP Stapling);
- 对公开证书,还会查 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,流程都是四步:
生成私钥 + CSR → 证明域名归你 → CA 签发 → 部署并续期
第一步:生成私钥和 CSR。 CSR(Certificate Signing Request)里包含公钥和域名信息,私钥必须自己保管好,不能上传给任何人。
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:
# 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
证书会落在:
/etc/letsencrypt/live/example.com/fullchain.pem # 证书 + 中间证书
/etc/letsencrypt/live/example.com/privkey.pem # 私钥
续期用 Certbot 自带的定时任务即可,先演练一次:
sudo certbot renew --dry-run
不想自己维护 Certbot,也可以用云厂商的免费 DV 证书:在控制台申请、做 DNS 验证、下载证书,再手动或通过 CDN/负载均衡部署。缺点是续期往往要人工介入,域名一多就容易漏。
部署时:证书链要发全
这一点踩坑的人特别多。服务端在握手时应该发送叶子证书 + 中间证书,也就是 fullchain.pem,而不是只发叶子证书。根证书一般不用发,客户端本地就有。
以 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 的密码套件长这样:
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.2 | TLS 1.3 |
|---|---|---|
| 建连往返 | 2-RTT | 1-RTT(支持 0-RTT 恢复) |
| 握手加密 | 大部分明文 | ServerHello 之后全加密 |
| 密钥交换 | RSA / DHE / ECDHE | 只允许 (EC)DHE |
| 对称算法 | 包含 CBC 等 | 只允许 AEAD |
| 前向保密 | 取决于套件 | 默认具备 |
| 配置复杂度 | 高,套件多 | 低,套件少 |

五、动手看一次真实握手
用 OpenSSL 连一个站点,把证书链和握手信息打出来:
openssl s_client \
-connect example.com:443 \
-servername example.com \
-showcerts \
-tls1_3 </dev/null
输出里重点关注这几段:
SSL-Session:
Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
Protocol 是最终协商的版本,Cipher 是选中的套件。如果这里出现 TLSv1.2,说明服务端或中间代理还没开 1.3。
再看证书有效期:
openssl s_client -connect example.com:443 -servername example.com </dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
curl 也能看到握手和 ALPN 结果:
curl -vI https://example.com
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN: server accepted h2
本地起一个 HTTPS 服务
先生成一张自签证书(需要 OpenSSL 1.1.1+):
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 起服务:
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)
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 关进保险箱。