聊网络传输加密这个话题,我一般不从公式开始讲,而是先问对方一个问题:你觉得浏览器地址栏里那个小锁,到底锁住了什么?很多人能答出"加密了",但再往下一层——加密的密钥是谁给的、凭什么是这个密钥、中间有没有人换过——就说不清楚了。这条链路上其实站着三个不同的角色:加密算法负责把明文变成密文,SSL/TLS负责把各种算法编排成一套可执行的握手流程,CA证书负责回答"对面那个公钥到底属于谁"。三者缺一环,整条链路就是纸糊的。这篇文章我打算把这三层拆开讲透,既讲每种算法为什么被选进来、参数怎么定,也讲证书链怎么验、线上出问题怎么查。适合刚接触后端和安全方向的同学打底子,也适合已经天天配 Nginx 但对握手细节一知半解的老手补课。
1. 先把地图摊开:三个概念到底怎么分工
1.1 加密算法、协议框架、身份凭证是三件事
很多人把这三者混为一谈,写简历的时候一句"熟悉 SSL 加密"就带过去了,真到排查问题的时候就露馅。我的理解是这样:加密算法是零件,SSL/TLS 是装配图纸,CA 证书是出厂合格证。零件决定单个动作的安全强度,图纸决定这些零件按什么顺序咬合,合格证决定你拿到的零件是不是原厂的。
具体拆开来看。加密算法这一层又分三类:对称加密(AES、ChaCha20 这类)、非对称加密(RSA、ECC 这类)、哈希与消息认证(SHA-256、HMAC 这类)。它们各自解决一个问题——保密性、密钥交换与身份证明、完整性。SSL/TLS 协议做的事情是把这三类算法按握手阶段和数据传输阶段编排好,规定客户端和服务端在什么时机、发什么消息、用什么算法、传什么参数。CA 证书解决的是信任的起点问题:非对称加密能保证"这个公钥加密的东西只有对应私钥能解开",但它保证不了"这个公钥真的是那家网站的"。证书就是这个绑定关系的书面证明,由双方都信任的第三方签字画押。
记住这个分层,后面所有的细节都能挂上去。你配 Nginx 的时候写的ssl_ciphers是零件清单,ssl_protocols是图纸版本,ssl_certificate是合格证。三样都得对,缺一样都不行。
1.2 为什么单靠一种算法撑不起一条链路
这个问题值得掰开讲,因为它是理解整套体系的钥匙。假设你只想用一种算法搞定所有事,会发现三条路都走不通。
只用对称加密。AES-256-GCM 强度足够,速度也快,现代 CPU 带 AES-NI 指令集的情况下吞吐能到几个 G。但有个死结:通信双方必须持有同一把密钥。你怎么把这把密钥交给对面?发邮件?发消息?只要密钥在网络上传过一回,它就有被截获的风险。而且 N 个客户端要和服务端通信,是每人一把密钥还是共用一把?共用一把,一个人泄露全线崩盘;每人一把,服务端要维护 N 把密钥,轮换的时候是场灾难。
只用非对称加密。RSA 看起来完美——公钥随便发,私钥自己留着。但性能上完全不可接受。RSA-2048 单次加密只能处理 245 字节的数据(还要扣掉填充),一个网页几 MB,你得切成上万块逐块加密,CPU 直接烧穿。实测下来 RSA 的加解密速度比 AES 慢三个数量级以上,差的是千倍量级,不是优化几下能追上的。
只用哈希。哈希是单向的,压根不能还原明文,它只能验证"内容有没有被改过",保密性一点都没有。
所以现实方案必须是混合加密:用非对称加密安全地协商出一把临时对称密钥,然后用这把对称密钥高速加密实际数据。非对称加密只干一次重活(握手阶段),对称加密干一万次轻活(数据传输阶段)。这个分工是整套体系的地基,SSL/TLS 的所有设计都围绕它展开。
1.3 没有 CA 会怎样:中间人是怎么插进来的
混合加密解决了性能问题,但留了个口子。客户端在握手时要拿到服务端的公钥,如果这个公钥是明文传过来的,中间人完全可以截下来,把自己的公钥塞给客户端,同时用真服务端的公钥去和真服务端建连。这样客户端以为在跟网站聊天,实际上两头都被中间人接着,所有明文对他来说一览无余。这就是中间人攻击。
证书就是为了堵这个口子。它的逻辑是:公钥不单独发,而是打包进一个结构化文件,里面写着"这把公钥属于 example.com,有效期到某某时候",然后由 CA 用自己的私钥对这个文件内容做数字签名。客户端内置了一批 CA 的公钥(根证书),拿这些公钥去验签名,签名通过就说明这份声明没被篡改、确实由该 CA 签发。因为攻击者拿不到 CA 的私钥,伪造不出合法签名,这条路就断了。
注意这里有个细节:攻击者理论上可以自己生成一对密钥、自己签一张"证书",但客户端不认——因为签发者的公钥不在客户端的信任列表里。信任是有锚点的,锚点就是浏览器和操作系统里预装的那批根证书。这也是为什么私搭的证书浏览器会报警,不是因为它技术上有问题,而是因为签发者不在信任链上。
1.4 落地时的几个现实约束
理论上最优的方案,工程上未必能用。我在实际项目里选型时主要看四条。
第一是客户端兼容性。老设备、老系统、嵌入式终端对协议版本和算法的支持差别很大。你全站上 TLS 1.3 + 纯 ECDHE + X25519,很爽,但一批跑着老系统的 POS 机、工业网关可能直接连不上。这种情况通常要保留一条 TLS 1.2 的兼容通道,同时把弱算法踢出去。
第二是性能开销。握手阶段非对称运算的 CPU 消耗是数据传输阶段的几十倍。QPS 高的服务如果不做会话复用,握手能把 CPU 打满。我在一个峰值几万连接的网关上实测过,开启会话票据复用后握手率从 60% 降到 5% 以下,CPU 占用直接腰斩。
第三是证书管理成本。证书有有效期,到期不续就是全站不可用。通配符证书便宜但覆盖面有限,多域名证书灵活但贵,证书数量一多,过期就成了必然事件。必须做自动化。
第四是合规与审计要求。有些业务场景对算法有硬性要求,比如必须使用国密算法套件,或者必须禁用某些哈希。这类要求往往来自行业规范而非技术优劣,选型时得先问清楚。
2. 对称加密:真正干重活的那一个
2.1 分组密码和流密码,为什么实际业务几乎都选分组
对称加密分两大流派。流密码是逐比特或逐字节生成密钥流,和明文异或得到密文,典型代表是 RC4(已经废了)和 ChaCha20。分组密码是把明文切成固定长度的块(AES 是 128 位一块),逐块加密,典型代表是 AES、DES(也废了)。
流密码的好处是不需要填充,密文长度等于明文长度,实现简单。但 RC4 因为密钥流前几个字节存在统计偏差,已经被全面禁用。ChaCha20 是现代的流密码,在纯软件实现、没有硬件加速的移动设备上比 AES 快,所以在移动端和某些协议里很受欢迎。
分组密码的好处是有成熟的硬件加速。AES-NI 指令集从 2010 年前后的 CPU 开始普及,一条指令就能完成一轮运算,速度极快。服务端场景基本都有 AES 硬件加速,所以 AES 是默认选择。ChaCha20-Poly1305 通常作为 AES-GCM 的备选,在客户端不支持 AES-NI 时(比如一些低端安卓机、老 ARM 设备)自动切换过去。
我自己的经验是:服务端对服务端、服务端对现代浏览器的场景,AES-256-GCM 或 AES-128-GCM 都能放心用;面向大量低端移动设备的场景,把 ChaCha20-Poly1305 放在密码套件列表靠前的位置,实际体验会好不少。
2.2 AES 的四个参数:密钥长度、模式、IV、填充
AES 用起来就是四个参数,但每一个都有讲究。
密钥长度有 128、192、256 三档。128 位已经足够安全(暴力破解需要 2^128 次运算,宇宙寿命算完都不够),256 位是为抗量子计算预留的余量。性能上 128 位比 256 位快一些,因为轮数少(10 轮 vs 14 轮)。我的建议:一般业务 AES-128-GCM 完全够用,有长周期保密需求(比如归档数据要存二十年)就上 256。
工作模式决定怎么把分组串起来。常见的有 ECB、CBC、CTR、GCM。这个后面单独讲,先说结论:新项目一律用 GCM,老系统迁移从 CBC 往 GCM 走,ECB 见到就改。
初始化向量 IV是让相同明文产生不同密文的关键。没有 IV 的话,同样的明文块加密出来永远一样,攻击者看一眼密文就知道你在传什么。IV 的要求是:CBC 模式下必须随机且不可预测,GCM 模式下必须是唯一的(同一个密钥下不能重复),可以随机也可以计数器递增。
这里有个最容易踩的坑:GCM 的 IV 重复使用是灾难性的。它不只是泄露明文,还会直接泄露认证密钥,让攻击者能够伪造任意消息。我见过一个项目为了省事,把 IV 固定写成全零,测试环境跑得好好的,上线后被安全扫描直接标成高危。所以 GCM 的 IV 一定要用随机数生成器或者严格递增的计数器。
填充只在 CBC 这类需要块对齐的模式下需要。PKCS#7 是标准做法,缺几个字节就补几个字节的值。GCM、CTR 是流式模式,不需要填充。CBC 的填充还有个经典攻击叫Padding Oracle,攻击者通过观察服务端对填充错误的响应差异,可以逐字节解出明文。防御方法是统一错误响应,填充校验失败和数据解密失败返回一模一样的错误,不给攻击者任何区分信号。
2.3 ECB 为什么必须淘汰,GCM 为什么成了默认答案
ECB 模式的问题是:相同的明文块加密结果完全相同,块与块之间没有任何关联。经典的演示是拿一张图片用 ECB 加密,加密后的图还能清晰看出原图轮廓——因为大面积的同色区域加密后还是同色。放在业务数据里,如果报文格式固定,攻击者能通过密文的重复模式推测出明文结构。
GCM 之所以成为默认,是因为它一次性解决了两件事:加密和完整性校验。它是 AEAD(带关联数据的认证加密)算法,加密的同时生成一个认证标签,接收方解密时校验这个标签,标签不对就拒绝。这意味着攻击者改了密文里任何一个比特,接收方都能发现,不会像 CBC 那样需要单独再套一层 HMAC。
我通常会强调 GCM 的附加认证数据(AAD)这个特性。它允许你把一些不需要保密但需要防篡改的信息(比如报文头、序列号、协议版本)一起纳入认证范围。这个在很多自定义协议里非常有用,能挡住重放和篡改。
2.4 密钥管理才是真难点
算法本身没什么可纠结的,难的是密钥怎么存、怎么换、怎么隔离。
存储上,密钥绝不能硬编码在代码里跟着代码仓库走。我见过把 AES 密钥写在常量里的项目,代码一泄露全线报废。合理做法是用配置中心或者密钥管理服务下发,代码只拿引用。更进一步的做法是用硬件安全模块或者云厂商的密钥托管服务,密钥从头到尾不出硬件,业务只调用加解密接口。
轮换上,密钥必须有生命周期。理想状态是定期自动轮换,同时保留旧密钥一段时间用于解密历史数据。做法一般是给密文加一个密钥版本号前缀,解密时按版本号找对应密钥。这样轮换对业务完全透明。
隔离上,不同用途的密钥必须分开。数据加密密钥、签名密钥、HMAC 密钥绝不能混用。原因是一旦某个场景下密钥泄露,混用会导致影响面扩大。这个原则叫密钥用途单一化,是最基础的安全工程实践。
3. 非对称加密:解决密钥分发的死结
3.1 RSA 的数学基础与密钥长度选择
RSA 的安全性建立在大整数分解难题上。生成密钥时选两个大素数 p 和 q,算出 n = p × q,公钥是 (n, e),私钥是 (n, d)。加密就是 c = m^e mod n,解密是 m = c^d mod n。想从公钥推出私钥,就得把 n 分解成 p 和 q,n 越大分解越难。
密钥长度的实际选择:1024 位已经不安全,理论上国家级力量可以破解;2048 位是目前的最低标准,业界普遍认为到 2030 年前仍然可用;3072 位对应 128 位对称安全强度;4096 位更保险但握手会明显变慢,因为密钥生成和运算的开销随位数增长很快。
我一般建议:证书用 2048 位起步,有长期规划的上 3072 或 4096。但要注意,4096 位的 RSA 握手在低配服务器上会明显拖慢首包时间,如果 QPS 很高,性能收益比得上这点强度提升。
3.2 ECC 为什么在移动端大行其道
椭圆曲线密码学(ECC)的安全性建立在椭圆曲线离散对数难题上。它的最大优势是单位安全强度高:256 位的 ECC 密钥提供的安全强度大约相当于 3072 位的 RSA。
这意味着什么?密钥更短、运算更快、握手更省电。对于移动端和 IoT 设备,这个差别是实打实的。一次 TLS 握手中,ECC 的密钥交换计算量大约是 RSA 的几分之一,手机上的电量消耗和延迟都能明显降低。
常用的曲线有 P-256(也叫 secp256r1)、P-384、X25519。X25519 是现在最推荐的,它设计上避免了很多实现陷阱(比如不检查点是否在曲线上导致的小子群攻击),实现简单,速度快,而且天然抗旁路攻击。
有一点要注意:ECC 的曲线参数必须用标准曲线,绝不能自己造。自定义曲线极容易出问题,历史上已经有好几起因为曲线参数选择不当导致私钥可被恢复的案例。
3.3 加密和签名是两回事,别搞混
非对称密钥有两把钥匙,公钥和私钥,于是有两个用法,很多人会混淆。
公钥加密、私钥解密,这是用来传数据的。发送方用接收方的公钥加密,只有接收方的私钥能解开。用途是密钥交换、传敏感信息。
私钥签名、公钥验签,这是用来证身份的。发送方用自己的私钥对消息的哈希值签名,任何人用发送方的公钥都能验证这个签名确实出自他手。用途是身份认证、防抵赖。
这两个方向的用途完全不同,绝对不能反着用。把私钥拿去加密、公钥拿去解密,看起来也能跑通,但毫无安全意义——因为公钥是公开的,谁都能解开,等于没加密。
具体的填充方案也有讲究。RSA 加密要用 OAEP 填充,早期的 PKCS#1 v1.5 填充存在 Bleichenbacher 攻击,现在应该淘汰。RSA 签名要用 PSS 填充,比 PKCS#1 v1.5 签名更安全。如果你的代码里还在用RSA_PKCS1_PADDING做加密,建议尽快改。
3.4 混合加密体系:一次握手到底做了哪些事
把前面几节串起来,一次完整的密钥协商是这样的:
客户端先发一个 ClientHello,里面带上自己支持的协议版本、密码套件列表、一个随机数(Client Random)。服务端回 ServerHello,选定协议版本和密码套件,回一个随机数(Server Random)。然后服务端把证书发过来,客户端验证证书链,确认公钥归属。接着双方做密钥交换——用 ECDHE 的话,双方各自生成临时密钥对,交换公钥参数,各自算出相同的共享密钥。最后双方用 Client Random、Server Random、预主密钥这三样东西,通过一个伪随机函数推导出主密钥,再派生出实际的会话密钥(客户端写密钥、服务端写密钥、客户端 MAC 密钥、服务端 MAC 密钥)。
关键点在于:非对称运算只发生在密钥交换这一步,而且 ECDHE 的临时密钥对用完就丢。这叫前向保密——即使服务端的长期私钥哪天被偷了,攻击者也没法解密之前录下来的流量,因为当时的会话密钥是用临时密钥协商的,私钥早就没了。这个特性非常重要,是我在选密码套件时的第一优先级。
4. 哈希与消息认证:完整性这一环
4.1 MD5 和 SHA-1 为什么必须淘汰
哈希函数的作用是把任意长度的输入压成固定长度的输出,且不可逆、抗碰撞。抗碰撞的意思是:找不出两个不同的输入产生相同的输出。
MD5 在 2004 年被王小云团队证明可以在很短时间内构造碰撞,现在普通笔记本电脑几秒钟就能生成两个 MD5 相同的不同文件。SHA-1 在 2017 年被谷歌用实际案例攻破(SHAttered 攻击),构造两个 SHA-1 相同的 PDF 文件,成本大约十万美金级别。这两个算法现在只能用于非安全场景(比如文件校验和、缓存键),绝对不能用于签名、证书、密码存储。
现在应该用的:SHA-256、SHA-384、SHA-512(属于 SHA-2 家族),或者 SHA-3 家族。SHA-256 是目前最通用的,性能和安全性平衡得好。
4.2 HMAC、加盐、慢哈希:密码存储的三条铁律
聊到哈希就绕不开密码存储,这块踩坑的人特别多。
第一条:绝不能用裸哈希存密码。直接md5(password)或者sha256(password)都是错的,因为彩虹表可以秒查。哪怕加个固定盐,只要盐是全局统一的,攻击者也能一次性生成表来撞。
第二条:每个用户用独立的随机盐。盐不需要保密,和哈希值一起存数据库就行。它的作用是让相同密码产生不同哈希,攻击者没法批量撞。
第三条:用慢哈希算法。bcrypt、scrypt、Argon2 这类算法故意设计得很慢,还能调参数。bcrypt 的 cost 参数每加 1,计算时间翻倍。一般设到单次验证耗时 100 毫秒左右,用户感知不到,但攻击者暴力破解的成本就上天了。Argon2 还额外消耗内存,能抵抗 GPU 和 ASIC 加速破解,是目前的推荐首选。
至于 HMAC,它是"带密钥的哈希",用途完全不同——用来验证消息完整性和来源真实性。HMAC-SHA256 是标准用法,密钥长度建议和哈希输出长度一致(32 字节)。注意 HMAC 的密钥不能和加密密钥共用,也不能是短口令。
4.3 数字签名:哈希和非对称的结合体
数字签名不是直接对原文做非对称运算,而是先哈希再签名。流程是:发送方计算消息的哈希,用私钥对哈希值签名;接收方收到消息后重新计算哈希,用公钥验签,比对哈希是否一致。
为什么要先哈希?一是效率,RSA 只能处理很短的数据,哈希把任意长度压成固定长度;二是安全性,直接对原文签名在某些填充方案下会有弱点。
TLS 里的签名算法标识形如rsa_pss_rsae_sha256、ecdsa_secp256r1_sha256,前半段是签名算法,后半段是哈希算法。这两个必须匹配,混用会握手失败。
5. SSL/TLS 握手全过程拆解
5.1 TLS 1.2 和 TLS 1.3 的差别有多大
TLS 1.2 的完整握手需要2-RTT,也就是客户端和服务端要来回两趟才能开始传数据。对于跨洲际的连接,RTT 可能 200 毫秒以上,两趟就是 400 毫秒,用户能明显感觉到慢。
TLS 1.3 把握手压缩到1-RTT,而且支持0-RTT恢复(会话复用时,客户端可以在第一个包里就带上应用数据)。它的做法是把密钥交换提前,ClientHello 里直接带上密钥共享参数,服务端回 ServerHello 时就能算出密钥,然后立刻发加密的证书和 Finished。
除了快,TLS 1.3 还砍掉了一堆不安全的算法:RSA 密钥交换(没有前向保密)、CBC 模式、RC4、SHA-1、压缩、重协商。留下来的密码套件只有五个左右,全部是 AEAD 加前向保密的组合。这让配置变得极其简单,也让误配置的空间大大缩小。
我现在的建议是:新项目直接只开 TLS 1.3 和 TLS 1.2,1.1 及以下全关。如果客户端兼容性允许,优先 TLS 1.3。
5.2 一次握手的字段级记录
拿 TLS 1.2 的 ECDHE-RSA-AES128-GCM-SHA256 套件举例,完整流程是这样的:
客户端发ClientHello,字段包括:version(支持的最高版本)、random(32 字节随机数,前 4 字节是时间戳)、session_id(用于会话复用)、cipher_suites(密码套件列表)、compression_methods、extensions(里面带 SNI 指明要访问的域名、supported_groups 列出支持的椭圆曲线、signature_algorithms 列出支持的签名算法、ALPN 列出应用层协议偏好)。
服务端回ServerHello,选定版本、套件、自己的random、session_id。然后发Certificate,把证书链(服务端证书 + 中间 CA 证书)一股脑发过来。接着发ServerKeyExchange,带上 ECDHE 的曲线参数和服务端临时公钥,并用服务端私钥对这个参数签名。再发ServerHelloDone表示这轮说完了。
客户端验证证书链、验证签名、生成自己的 ECDHE 临时密钥对,算出预主密钥。发ClientKeyExchange带上自己的临时公钥。发ChangeCipherSpec通知"接下来我要用协商好的密钥加密了"。发Finished,内容是之前所有握手消息的哈希,用会话密钥加密。
服务端同样回 ChangeCipherSpec 和 Finished。双方校验对方的 Finished 正确,握手完成,开始传应用数据。
TLS 1.3 的流程简化成:ClientHello(带 key_share)→ ServerHello(带 key_share)+ EncryptedExtensions + Certificate + CertificateVerify + Finished → 客户端 Finished。中间的证书和 Finished 全部是加密的,旁观者连你访问哪个域名对应的证书都看不到(SNI 除外,除非上了 ECH)。
5.3 会话复用:省下的不只是时间
握手很贵,尤其是非对称运算。会话复用让双方跳过密钥协商,直接复用之前的会话密钥或者快速派生新密钥。
有两种机制。Session ID是服务端缓存会话状态,客户端带 session_id 来,服务端查到了就直接恢复。缺点是服务端要存状态,集群环境下需要共享缓存。Session Ticket是把会话状态加密后打包成票据发给客户端,客户端下次带票据来,服务端解密就能恢复。优点是无状态,适合水平扩展。TLS 1.3 里对应的是 PSK 恢复机制。
票据的加密密钥需要在集群内共享,否则客户端连到另一台机器就解不开票据,会退化成完整握手。这个坑我踩过——负载均衡后面挂了五台机器,每台各自生成票据密钥,结果复用率始终上不去,后来统一配置了共享密钥才解决。
还有一点要注意:0-RTT 数据有重放风险。因为 0-RTT 的数据在握手的第一个包里就发出去了,服务端还没完成身份验证,攻击者可以把这个包录下来重放。所以 0-RTT 只适合幂等请求(比如 GET),涉及状态变更的接口必须禁用。
5.4 几个容易被忽略的握手参数
SNI(Server Name Indication)让客户端在握手时告诉服务端自己要访问哪个域名,服务端据此选择对应证书。没有 SNI 的话,一个 IP 只能配一张证书。SNI 是明文传输的,所以旁观者能看出你访问哪个站点,这是目前 TLS 的一个已知隐私弱点。
ALPN(Application-Layer Protocol Negotiation)用于协商应用层协议,比如 h2、http/1.1。HTTP/2 的启用就靠它。
OCSP Stapling让服务端在握手时顺便把证书的吊销状态证明带过来,省掉客户端单独去查 OCSP 服务器的一次请求。这个对首屏时间影响不小,建议开启。
会话票据密钥轮换要配置。票据密钥定期换,旧的保留一段时间用于解密老票据。Nginx 里用ssl_session_ticket_key指定密钥文件,可以配多个实现平滑轮换。
6. CA 证书体系:信任是怎么批发的
6.1 证书里到底装了什么
一张 X.509 证书本质是一个结构化的数据结构,主要字段包括:
| 字段 | 含义 | 实际关注点 |
|---|---|---|
| version | 版本号 | 基本都是 v3 |
| serialNumber | 序列号 | CA 内唯一,吊销时用 |
| signature | 签名算法 | 如 sha256WithRSAEncryption |
| issuer | 颁发者 | 上级 CA 的 DN |
| validity | 有效期 | notBefore / notAfter |
| subject | 主体 | 证书持有者的 DN |
| subjectPublicKeyInfo | 公钥 | 算法 + 公钥值 |
| extensions | 扩展 | SAN、KeyUsage、EKU、基本约束等 |
SAN(Subject Alternative Name)是现代证书里最重要的扩展。浏览器早就不看 CN(Common Name)字段了,只认 SAN 里列的域名。一张证书可以列多个域名,通配符写法是*.example.com,注意通配符只匹配一级,*.example.com匹配a.example.com但不匹配a.b.example.com。
KeyUsage限定这把密钥能干什么,比如 digitalSignature、keyEncipherment。ExtendedKeyUsage更细,serverAuth 表示能用于服务端认证,clientAuth 表示能用于客户端认证。如果证书缺了 serverAuth,某些客户端会直接拒绝。
基本约束(Basic Constraints)里的CA:TRUE标记这是一张 CA 证书,能签发下级证书。终端证书的这个值必须是 FALSE,否则某些严格的客户端会报错。
6.2 证书链和信任锚
证书很少是根 CA 直接签的,通常是三级结构:根证书 → 中间 CA 证书 → 终端证书。为什么要中间层?因为根证书的私钥必须离线保存在保险柜里,绝不能上网。日常签发用中间 CA 的私钥,万一中间 CA 出事,吊销它就行,根证书不受影响。
验证的时候,客户端从终端证书开始往上找:用中间 CA 的公钥验证终端证书的签名,用根 CA 的公钥验证中间 CA 证书的签名,最后发现根 CA 在自己的信任库里,链条闭合,验证通过。
这里最常见的错误是服务端没有下发完整的证书链。有些服务端只发了终端证书,没发中间 CA 证书。PC 浏览器通常能自己补全(它会去下载缺失的中间证书并缓存),所以本地测试没问题。但移动端 App、Java 客户端、curl 往往不会自动补全,直接报证书链不完整。这个坑非常隐蔽,本地一切正常,上线后一批用户连不上。
验证方法很简单:openssl s_client -connect example.com:443 -showcerts看返回了几张证书。正确的话至少两张(终端 + 中间)。或者用在线工具测一下,很多都会直接告诉你链是否完整。
6.3 从 CSR 到签发:证书怎么来的
申请证书的流程是:先在服务器上生成私钥和 CSR(证书签名请求),把 CSR 交给 CA,CA 验证你对域名的控制权,然后签发证书。
生成私钥和 CSR 的命令:
# 生成 2048 位 RSA 私钥(加密保护,会提示输入口令) openssl genrsa -aes256 -out example.com.key 2048 # 生成 CSR,交互式填写主体信息 openssl req -new -key example.com.key -out example.com.csr # 或者一条命令搞定,非交互 openssl req -new -newkey rsa:2048 -nodes \ -keyout example.com.key \ -out example.com.csr \ -subj "/C=CN/ST=SomeState/L=SomeCity/O=SomeOrg/CN=example.com" \ -addext "subjectAltName=DNS:example.com,DNS:www.example.com"注意-nodes表示私钥不加密,方便服务自动加载,但私钥文件的权限必须锁死到 600,属主是服务运行用户。有加密的话每次启动都要输口令,不适合自动化。
CA 验证域名控制权有几种方式:DNS 验证(加一条 TXT 记录)、文件验证(在网站根目录放一个指定文件)、邮件验证(发给域名 WHOIS 里的管理员邮箱)。DNS 验证最适合自动化,因为可以调云厂商的 API 自动加记录。
6.4 证书类型怎么选
按验证等级分三类。DV(域名验证)只验证域名控制权,签发快,几分钟到几小时,免费证书基本都是这类。OV(组织验证)额外验证组织真实存在,需要提交营业执照等材料,几天。EV(扩展验证)验证最严格,曾经浏览器地址栏会显示绿色公司名,现在主流浏览器已经取消了这一展示,所以性价比大降。
我的看法:绝大多数场景 DV 就够了,因为用户根本不看证书等级,他们只看有没有锁。OV 和 EV 主要用于金融、政务等有明确合规要求的场景。
按覆盖范围分:单域名、多域名(SAN 证书,一张覆盖多个不同域名)、通配符(覆盖一个域名下所有一级子域)。通配符证书在微服务架构里特别实用,*.internal.example.com一张就能覆盖所有内部服务。
6.5 吊销机制:CRL、OCSP 和 Stapling
证书没到期但私钥泄露了怎么办?走吊销流程。CA 把证书序列号加入黑名单,客户端查询黑名单来确认证书是否还有效。
CRL是 CA 定期发布的一个文件,列出所有被吊销的证书序列号。问题是文件越来越大(有些 CA 的 CRL 有几十 MB),客户端每次下载不现实。
OCSP是实时查询协议,客户端拿着证书序列号去问 CA"这张还有效吗"。问题是每次握手都要多一次网络请求,延迟增加,而且暴露了用户访问的站点隐私。
OCSP Stapling解决了这个问题:服务端定期去 CA 查询自己证书的状态,把带时间戳的响应缓存下来,握手时直接发给客户端。客户端不用额外请求,隐私也不泄露。这个功能强烈建议开启,Nginx 配置就两行:
ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate /path/to/chain.pem;注意ssl_trusted_certificate必须指向完整的证书链文件,否则 Stapling 不生效。
7. 实操:从零搭一条可验证的链路
7.1 用 openssl 自建 CA 并签发证书
自签证书是理解证书体系最快的方式。整套流程分三步:建根 CA、建中间 CA(可选但推荐,能理解链的概念)、签终端证书。
先建根 CA:
# 生成根 CA 私钥 openssl genrsa -aes256 -out rootCA.key 4096 # 生成根 CA 自签证书,有效期 20 年 openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 7300 \ -out rootCA.crt \ -subj "/C=CN/O=MyRootCA/CN=My Root CA" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign,cRLSign"再建中间 CA:
# 中间 CA 私钥 openssl genrsa -out intermediate.key 2048 # 生成 CSR openssl req -new -key intermediate.key -out intermediate.csr \ -subj "/C=CN/O=MyRootCA/CN=My Intermediate CA" # 用根 CA 签发,注意 CA:TRUE 和 pathlen openssl x509 -req -in intermediate.csr -CA rootCA.crt -CAkey rootCA.key \ -CAcreateserial -out intermediate.crt -days 3650 -sha256 \ -extfile <(printf "basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n")最后签终端证书:
# 服务端私钥 openssl genrsa -out server.key 2048 # 生成 CSR,带 SAN openssl req -new -key server.key -out server.csr \ -subj "/C=CN/O=MyOrg/CN=app.example.com" # 用中间 CA 签发 openssl x509 -req -in server.csr -CA intermediate.crt -CAkey intermediate.key \ -CAcreateserial -out server.crt -days 365 -sha256 \ -extfile <(printf "subjectAltName=DNS:app.example.com,DNS:*.app.example.com\nbasicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n") # 打包完整链(服务端证书在前,中间 CA 在后) cat server.crt intermediate.crt > fullchain.pem这里的关键点是pathlen:0,它限制中间 CA 只能签终端证书,不能再签下级 CA,防止链条被无限延长。还有extendedKeyUsage=serverAuth,缺了它某些客户端会拒绝。
7.2 Nginx 配置示例与参数解读
server { listen 443 ssl; http2 on; server_name app.example.com; # 证书和完整链 ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/server.key; # 协议版本:只留 1.2 和 1.3 ssl_protocols TLSv1.2 TLSv1.3; # 密码套件:优先 ECDHE + AEAD,禁用无前向保密的 ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; ssl_prefer_server_ciphers off; # TLS1.3 下应由客户端选择 # 会话缓存与票据 ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets on; ssl_session_ticket_key /etc/nginx/ssl/ticket.key; # 集群内共享 # OCSP Stapling ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate /etc/nginx/ssl/chain.pem; resolver 8.8.8.8 1.1.1.1 valid=300s; resolver_timeout 5s; # HSTS:告知浏览器只用 HTTPS add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; }几个参数值得解释。ssl_prefer_server_ciphers off在只开 TLS 1.2/1.3 的情况下是合理的,因为现在的客户端选择的套件都是现代算法,让客户端选能利用其硬件加速。ssl_session_cache shared:SSL:10m分配 10MB 共享内存,大约能存 4 万个会话。ssl_session_timeout 1d配合票据有效期,太短会导致频繁完整握手。
HSTS 要谨慎。一旦设了max-age一年,浏览器在一年内会强制用 HTTPS 访问这个域名,你就算想把服务降回 HTTP 也做不到(除非用户手动清缓存)。上线前先设个短时间(比如 300 秒)测试,确认没问题再加长。includeSubDomains更危险,会把所有子域都拉进来,如果有子域还没上 HTTPS,会直接挂掉。
7.3 验证:三条命令搞定
配完先自测:
# 1. 检查证书链是否完整,看输出里是否有 "Verify return code: 0" openssl s_client -connect app.example.com:443 -servername app.example.com -showcerts # 2. 检查协议和套件 nmap --script ssl-enum-ciphers -p 443 app.example.com # 3. 用 curl 完整验证 curl -Iv https://app.example.com --cacert /path/to/rootCA.crt第一条命令的输出里,Certificate chain部分应该能看到至少两张证书。如果只有一张,说明中间证书没配。Verify return code应该是 0,非 0 就说明有问题,具体错误码对照表后面会给。
第二条命令能列出所有支持的协议版本和套件,重点看有没有出现 TLS 1.0/1.1、CBC 模式套件、RC4 这些不该出现的。
第三条命令是端到端验证,如果你用的是自签 CA,必须显式指定信任的根证书,否则 curl 会报SSL certificate problem: unable to get local issuer certificate。
7.4 证书轮换与自动化
手工续证书早晚出事故。我的做法是全部自动化,用 ACME 协议走 DNS 验证,证书到期前 30 天自动续,续完自动 reload 服务。
# 以某 ACME 客户端为例,DNS 验证模式 certbot certonly \ --manual \ --preferred-challenges dns \ -d app.example.com \ -d '*.app.example.com' \ --server https://acme-v02.api.example-directory/directory在自动化流程里,DNS 验证通常通过调用云厂商的 DNS API 完成,一条 TXT 记录的增删都在脚本里做。续期成功后触发nginx -t && nginx -s reload,先测试配置再 reload,避免配置错了把服务搞挂。
注意 reload 是有成本的,它会让 worker 进程平滑重启。如果证书很多、配置很大,可以考虑用单独的证书加载机制或者独立进程管理。
8. 常见问题与排查实录
8.1 报错速查表
| 报错信息 | 原因 | 解决 |
|---|---|---|
| unable to get local issuer certificate | 证书链不完整 | 服务端配置 fullchain,包含中间证书 |
| certificate has expired | 证书过期 | 续期并 reload |
| hostname mismatch | 域名不在 SAN 里 | 重签证书或换证书 |
| self signed certificate | 用了自签证书 | 客户端信任该 CA,或换正式证书 |
| unsupported protocol | 协议版本不匹配 | 调整 ssl_protocols,保留兼容版本 |
| no shared cipher | 套件列表无交集 | 扩充套件列表,兼容老客户端 |
| dh key too small | DH 参数过短 | 用 2048 位以上的 DH 参数或改 ECDHE |
| certificate verify failed | 签名验证失败 | 检查时钟、检查链、检查签名算法 |
8.2 几个我踩过的坑
第一个坑:本地测试全绿,上线一片红。原因是本地用的是 PC 浏览器,它会自动补全缺失的中间证书,所以看不出问题。上线后 iOS 和 Java 客户端大面积报错。教训是验证一定要用openssl s_client看实际下发的链,别信浏览器的宽容度。
第二个坑:服务器时间不准导致证书验证失败。现象很奇怪,一部分用户能访问,一部分不能,报的还是签名验证错误。查下来是某几台服务器 NTP 没配好,时间差了几个小时,导致证书"还没生效"或者"已过期"。现在我的标准动作是每台机器都开 NTP 同步,并且监控时间偏移超过 5 秒就告警。
第三个坑:GCM 的 IV 重用。有个批量加密的模块,为了省事把 IV 写死了,测试阶段数据量小没暴露,生产上加密大批量相同结构的报文后,被安全扫描发现了密文重复模式。改法是每次加密都用安全随机数生成新的 IV,并且把 IV 拼在密文前面一起传输。
第四个坑:会话票据密钥没共享。负载均衡后面五台机器,每台各自随机生成票据密钥,客户端每次连到新机器都要完整握手。优化前握手率达到 60%,加上共享密钥后降到 5% 以下。这个改动很小,收益很大。
第五个坑:HTTP/2 和某些老中间设备不兼容。上了 ALPN 协商 h2 之后,部分用户通过企业内网访问时页面白屏。抓包发现中间设备把 ALPN 扩展搞坏了,导致协商失败。最后的处理是保留 HTTP/1.1 回退,并且不强制 h2。
8.3 上线前的自检清单
我整理了一份每次上线前必过的清单,大概十几项:
- 协议版本是否只开了 1.2 和 1.3
- 套件是否全部为 ECDHE + AEAD,无 CBC、无 RSA 密钥交换
- 证书链是否完整(终端 + 中间)
- 证书是否为 2048 位以上 RSA 或 256 位以上 ECC
- 证书有效期是否覆盖到下次例行维护之后
- 私钥文件权限是否为 600,属主是否正确
- 是否开启了 OCSP Stapling 并验证生效
- 会话缓存和票据是否正确配置,集群密钥是否共享
- HSTS 是否设置,max-age 是否合理
- 是否配置了自动续期和续期后的 reload
- 是否有证书过期监控告警(提前 30 天)
- 是否禁用了 SSLv3、TLS 1.0、TLS 1.1
- 是否检查过 SNI 场景下多域名证书的正确性
最后分享一个我自己养成的小习惯:每次配完证书,我都会用手机 4G 网络而不是公司 WiFi 访问一遍。因为公司网络里经常有企业级设备做 TLS 拦截,会导致本地测试结果失真。用蜂窝网络测出来的才是真实用户的体验。另外,抓包看握手的时候,建议同时开openssl s_client -tlsextdebug -msg把扩展和消息都打出来,比看浏览器开发者工具里的简化信息有用得多。