序言
TLS 是 Node 服务端工程师的"隐形日常":每个 https.get、每个反向代理后的流量、每次微服务调用都经过它。平时无感,出事全是它------证书过期、验签失败、握手打爆 CPU。本模块的学习路径是五站:
arduino
第一站 世界观/协议机制 → 证书、公私钥、信任链是什么
第二站 基础 API → createServer/connect、自建 CA、验签失败矩阵
第三站 核心机制深挖 → 1.2 vs 1.3、加密层定位、会话复用、握手成本
第四站 高级特性 → SNI 多证书、mTLS、ALPN、OCSP、错误码精确区分
第五站 真实场景实战 → 部署形态、生产清单、排障 SOP(本站以清单形式沉淀在本讲义)
先给三个贯穿全讲义的总纲:
- 证书 = 公钥的"公证包装盒"。公钥裸发会被中间人掉包,所以 CA 把「域名+公钥」打包签字。数字签名是包装盒上的防伪封条。
- 信任不来自密码学,来自"备案分发"。谁都能用 openssl 自建 CA 签任何域名的证书(密码学不拦你),但客户端只信本地信任库里的锚。根证书预装 = 公章备案。
- TLS 不是替代 TCP,是套在 TCP 上的透明加密壳 。
TLSSocket是net.Socket的子类,net/stream 学的背压、drain、优雅关闭全部原样适用。
第一站 世界观与协议机制
1.1 为什么需要 TLS:裸 TCP 的三个威胁
net 模块跑的是明文 TCP。任何中间节点(路由器、运营商、公共 WiFi)都能偷看、篡改、冒充。TLS(Transport Layer Security,传输层安全协议)一一对应解决:
| 威胁 | 解法 | 工具 |
|---|---|---|
| 偷看 | 加密 | 对称加密(快,业务数据用它) |
| 篡改 | 每段数据带校验标签 | AEAD(改一个字节解密即失败) |
| 冒充 | 证书 + 信任链 | 非对称签名 + CA 体系 |
SSL(Secure Sockets Layer)是 TLS 的前身,已全部废弃;看到 SSL 字样一律读作 TLS(Node 文档标题就叫 TLS (SSL))。
1.2 协议栈位置:TLS 是层,不是协议替代品
应用层(HTTP/私有协议) ← 你的代码,看到的是明文
TLS 层(TLSSocket) ← 加密/解密/防篡改就发生在这一层内部
TCP 层(net.Socket) ← 看到的是密文
IP
铁律认知 :TCP 三次握手先照常完成,TLS 握手是在已建立的 TCP 连接上再进行的第二轮协商。实测证据(第三站 06 实验):在客户端与服务器之间插裸 TCP 代理,代理看到的 1568/2405/117/611 字节全是乱码、搜不到明文 password,而应用层收到 "password=123456"。
1.3 握手的真实成本(RTT 实测)
RTT(Round-Trip Time,往返时延)。TCP 握手 1 RTT;TLS 1.2 握手 2 RTT;TLS 1.3 握手 1 RTT。
本机实测 github.com(curl 计时):
TLS 1.3(默认): TCP完成 0.084s → TLS完成 0.172s → TLS 握手 ≈ 88ms ≈ 1 RTT
TLS 1.2(强制): TCP完成 0.091s → TLS完成 0.274s → TLS 握手 ≈ 182ms ≈ 2 RTT
诊断方法 :拿到任何 curl 计时,time_appconnect - time_connect ≈ N × RTT,N=1 是 1.3,N=2 是 1.2。
对开发的帮助:短连接场景 TLS 握手是延迟大头 → keep-alive 长连接 + 会话复用是 TLS 服务性能第一要务(第三站量化)。
1.4 对称与非对称的分工、ECDH、前向保密
- 对称加密:一把钥匙双方用,快(硬件加速)。死穴:钥匙怎么安全送达。
- 非对称:私钥签名、公钥验签(防伪);慢。
- TLS 分工:非对称只做「身份认证 + 密钥协商」,之后业务数据全走对称。
ECDH 密钥交换实测 (第三站实验,crypto.createECDH):双方各生成临时密钥对,网络上只互发临时公钥 (可偷看),各自用"我的私钥 × 你的公钥"算出同一个共享秘密 (实验中双方 computeSecret 输出完全相同),秘密本身从不上网。E = Ephemeral(临时),用完即弃 → PFS(Perfect Forward Secrecy,完全前向保密):将来服务器私钥泄露,历史被录屏的流量也无法解密。TLS 1.3 已删除不带 PFS 的静态 RSA 密钥传输。
实测佐证(s_client 连 github):Server Temp Key: ECDH, X25519, 253 bits------临时密钥交换参数。
1.5 证书与信任链(本模块最重要的一节)
一张证书里装着 :① 持有者域名(CN/SAN)② 有效期 ③ 签发者(issuer)④ 持有者的公钥 ⑤ 签发者用签发者私钥打的数字签名。
解剖真实证书(github.com 实测):
ini
subject = CN=github.com ← 谁的
SAN = github.com, www.github.com ← 对哪些域名有效
issuer = Sectigo ... CA DV E36 ← 谁公证的
notBefore/notAfter = 2026-09-01 ~ 2026-11-29
关键归属关系(实测 diff 验证) :从服务器私钥 openssl rsa -pubout 导出的公钥,与从证书 openssl x509 -pubkey 取出的公钥完全相同------证书里装的公钥就是私钥的数学另一半。
数字签名机制:签发时 CA 把证书内容算哈希、用 CA 私钥加密哈希得签名附在证书末尾;验签时用 CA 公钥解密签名得哈希 A,自算内容哈希 B,A == B 才算真。Node 官方 API 实测:
arduino
server.verify(真签发者的公钥) → true
server.verify(不相干根的公钥) → false // 拿错公钥立刻露馅
根证书.verify(自己的公钥) → true // 根 = 自签名
信任链为什么有中间 CA :根私钥锁在 HSM(Hardware Security Module,硬件安全模块)离线保存,只低频签发中间 CA 证书;日常签网站用中间 CA 私钥;中间私钥泄露 → 根吊销这一个中间 CA 即可切割。链条:根(预装本地) → 签中间 → 中间签叶子。
链式验签方向(铁律) :验一张证书用签发者的公钥,不是持有者自己的。
ini
服务器握手时发来:服务器证书 + 中间CA证书
客户端本地预装: 根证书(Node 内置 144 张,实测 tls.rootCertificates.length=144)
验:中间CA公钥(从发来的中间证书取)验服务器证书 → 根公钥(本地)验中间证书 → 撞到锚,收工
伪造实验(本讲义最重要的反直觉结论) :用自建 CA 给 CN=github.com 签假证书,openssl verify -CAfile my-ca.pem 竟然 OK------链式验签本身不鉴别真假,它只把信任从锚传到叶子。假证书死在最后一步:自建 CA 不在任何人的信任库里。TLS 防的不是"签不出假证书",是"假证书没人信"。
验签全程不联网:链证书服务器发来、根证书本地预装、验签是纯本地数学计算。唯一需要联网问 CA 的环节是吊销检查(CRL/OCSP,见 4.4)。
身份认证 = 三个独立检查,缺一不可:① 链式验签 ② 域名匹配(访问域名 vs 证书 SAN)③ 有效期。排障时三类错误码一一对应(见陷阱速查表)。
1.6 证书文件、PEM 与 base64
- 证书/密钥的真身是二进制 DER(Distinguished Encoding Rules,可辨别编码规则------ASN.1 结构的二进制编码)。
- PEM =
-----BEGIN XXX-----头尾 + base64(DER)。实测:PEM 剥壳的 base64 与 DER 重新 base64 的输出逐字符相同。二进制没法进 YAML/git/环境变量,所以要文本壳。 - 看文件头识物:
BEGIN CERTIFICATE(证书)、BEGIN RSA PRIVATE KEY(私钥)、BEGIN PUBLIC KEY(裸公钥)、BEGIN CERTIFICATE REQUEST(CSR)。 - 后缀
.pem/.crt/.cer/.key只是约定。验明正身:openssl x509 -in f -noout -subject -issuer(证书);openssl pkey -in f -noout -check(私钥)。
1.7 握手期的三个术语
- SNI(Server Name Indication,服务器名称指示):ClientHello 里明文带的"我要访问的域名",解决一个 IP 托管多站点时给哪张证书。代价:中间人可见你访问的域名(看不到内容)。
- ALPN(Application-Layer Protocol Negotiation,应用层协议协商):握手时顺便定应用层协议(h2/http/1.1),见 4.3。
- 密码套件 :算法组合。1.2 的
ECDHE-RSA-AES128-GCM-SHA256把密钥交换+签名+对称+哈希全写进名字;1.3 的TLS_AES_256_GCM_SHA384只剩对称+哈希(交换/签名被移出套件,砍选择防降级攻击)。
1.8 OpenSSL 是什么
OpenSSL(Open Secure Sockets Layer)是密码学事实标准开源工具包:一个 C 库 + 一个命令行工具 。Node 的 tls 模块底层就是内嵌的 OpenSSL(本机实测 process.versions.openssl = 3.5.5);命令行 openssl(macOS 自带实为 fork 版 LibreSSL 3.3.6)负责造证书、解剖证书、当测试客户端(s_client)。Node 侧能力边界实测:crypto.generateKeyPairSync 能生成密钥对;X509Certificate 的方法全是"读与验"(subject/issuer/verify/checkHost...),没有签发------openssl 造证书,Node 用证书。
1.9 深入清单(协议知识点标注)
| 了解哪些 | 对开发的帮助 | 实例 |
|---|---|---|
| 三个威胁 ↔ 三个解法 | 明白 HTTPS 为什么必须,mTLS 为什么兴起 | 内网零信任 |
| RTT 成本结构 | 短连接优化方向、HTTP/3 动机 | curl 计时诊断法 |
| 混合加密分工 | 面试 + 理解私钥签名开销 | 第三站 08 实测 |
| 链式验签 + 信任锚 | 一切证书报错的归因基础 | 伪造实验 |
| 三独立检查 | 看错误码反推死因 | ALTNAME_INVALID 等 |
| PEM/DER | 看懂任何证书/密钥文件 | BEGIN 头识别 |
| CA 生态(审计/预装/DigiNotar 破产案) | 理解私有 CA 的合法玩法 | 公司内网 CA |
第二站 基础 API 与对照实验
2.1 自建私有 CA 与签发证书链(核心,完整代码)
tls/02-建私有CA与签证书.js(execSync 包装 openssl 四步):
js
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const dataDir = path.join(__dirname, 'data');
fs.mkdirSync(dataDir, { recursive: true });
const sh = (cmd) => execSync(cmd, { cwd: dataDir, stdio: 'pipe' });
// 角色① CA 公司:根私钥 + 自签名根证书(信任锚)
sh('openssl genrsa -out my-ca-key.pem 2048');
sh('openssl req -x509 -new -nodes -key my-ca-key.pem -sha256 -days 3650 -subj "/CN=My-Study-CA/O=node-study" -out my-ca.pem');
// 角色② 网站运维:服务器密钥对 + CSR 申请单
sh('openssl genrsa -out server-key.pem 2048');
sh('openssl req -new -key server-key.pem -subj "/CN=localhost/O=node-study" -out server.csr');
// CA 验证域名后用 CA 私钥签发(SAN 扩展必须:现代客户端只认 SAN 的域名清单)
fs.writeFileSync(path.join(dataDir, 'san.ext'), 'subjectAltName=DNS:localhost,IP:127.0.0.1\n');
sh('openssl x509 -req -in server.csr -CA my-ca.pem -CAkey my-ca-key.pem -CAcreateserial -days 825 -sha256 -extfile san.ext -out server-cert.pem');
实测输出(链条关系对上,verify: OK):
ini
my-ca.pem: subject=CN=My-Study-CA issuer=CN=My-Study-CA ← 自签名根
server-cert.pem: subject=CN=localhost issuer=CN=My-Study-CA ← CA 签发
CSR(Certificate Signing Request,证书签名请求------装着「域名+公钥」的申请单)是过渡产物,运行时不需要。私钥绝不离开持有者:server-key.pem 不出服务器,my-ca-key.pem 签完回保险柜。
2.2 tls.createServer 对照 net.createServer(核心,完整代码)
tls/03-第一个TLS-echo对照.js------与 net 版 echo 的全部差异只有三处:服务器多传 {key, cert}、客户端多传 {ca}、事件名变成 secureConnection/secureConnect:
js
const tls = require('node:tls');
const fs = require('node:fs');
const path = require('node:path');
const opts = (p) => fs.readFileSync(path.join(__dirname, 'data', p));
const server = tls.createServer(
{ key: opts('server-key.pem'), cert: opts('server-cert.pem') },
(socket) => { socket.pipe(socket); } // echo 逻辑与 net 版一字不差
);
server.listen(9443, () => {
const client = tls.connect(9443, 'localhost', { ca: opts('my-ca.pem') }, () => {
console.log('authorized =', client.authorized);
console.log('getProtocol() =', client.getProtocol());
console.log('getCipher().name =', client.getCipher().name);
const cert = client.getPeerCertificate();
console.log('对端证书 =', cert.subject.CN, '←', cert.issuer.CN);
client.write('ping');
});
client.on('data', (d) => { client.end(); server.close(); });
});
实测输出:
ini
authorized = true
getProtocol() = TLSv1.3
getCipher().name = TLS_AES_256_GCM_SHA384
对端证书 = localhost ← My-Study-CA
交叉验证:用 LibreSSL 的 openssl s_client 连这个 20 行代码的服务器,协商出 Protocol: TLSv1.3 / AEAD-AES256-GCM-SHA384------TLS 是公开标准协议,Node 站在工业级标准件上,自家小服务器和 github 讲同一种话。
2.3 验签失败与解法矩阵(核心,完整代码与实测)
tls/04-验签失败与解法矩阵.js:同一私有 CA 签的服务器,四种客户端姿势:
ini
① 默认信任库直连 → 握手失败 UNABLE_TO_VERIFY_LEAF_SIGNATURE
② ca: 私有CA证书(正解) → 成功 authorized=true
③ rejectUnauthorized:false → 成功但 authorized=false ⚠️ 反面教材
④ ca正确但域名对不上 → 失败 ERR_TLS_CERT_ALTNAME_INVALID
关键认知:
rejectUnauthorized: false不是让握手失败,是让验签失效 ------连接照建、数据照加密,但加密的可能是中间人。authorized=false且authorizationError保留失败原因。生产出现直接 review 打回。- 域名匹配由客户端拿
servername(缺省用host)对证书 SAN 独立检查,链过了域名不对照样挂。
2.4 三种信任注入方式
| 方式 | 写法 | 适用 |
|---|---|---|
ca 选项 |
tls.connect({ ca: fs.readFileSync('my-ca.pem') }) |
代码级,只影响该连接 |
NODE_EXTRA_CA_CERTS |
环境变量(进程级,追加不动内置 144 张) | 部署级,公司私有 CA 标准解法 |
rejectUnauthorized:false |
--- | 仅本地调试,生产禁用 |
实测:NODE_EXTRA_CA_CERTS=tls/data/my-ca.pem 启动后同一客户端 authorized=true。
2.5 证书三态(01 实验结论)
yaml
无证书服务器 → curl: (35) sslv3 alert handshake failure ------ 握手都完不成,HTTPS 协议上不存在
自签名+默认校验 → curl: (60) self signed certificate / Node: DEPTH_ZERO_SELF_SIGNED_CERT
自签名+curl -k → HTTP 200 ------ 加密完全正常,缺的只是身份背书
不搞证书就没有 HTTPS:TLS 1.3 里服务器必须出示证书+签 CertificateVerify,是协议必选件。自签名证书适合开发/内网;公众服务必须走 CA(Let's Encrypt 免费)。
2.6 Node 的信任库
- Node 默认不用系统证书库 ,内置 Mozilla 根列表(本机 144 张,
tls.rootCertificates可取)。 NODE_EXTRA_CA_CERTS追加;--use-system-ca用系统库(本机 v22.22.2 实测支持)。- 实际开发放什么:服务器放
key(私钥,chmod 600,进 Secret 管理,绝不进 git )+cert(fullchain,公开可进 git);客户端默认啥都不用放,私有 CA 场景放 CA 证书(公钥性质随便发)。
第三站 核心机制深挖
3.1 TLS 1.2 vs 1.3(05 实验实测)
scss
默认(双方支持1.2~1.3) → TLSv1.3 | TLS_AES_256_GCM_SHA384
服务器强制1.2 → TLSv1.2 | ECDHE-RSA-AES128-GCM-SHA256
服务器1.2 × 客户端1.3 → ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION
套件命名暴露设计变化:1.3 把密钥交换/签名移出套件,只留 5 种全带 PFS 的套件------没得选错,降级攻击没处下手 。RTT:1.3=1、1.2=2(1.1 节实测)。Node 默认 DEFAULT_MIN_VERSION=TLSv1.2;公众 API 保持默认,纯内网可锁 1.3(minVersion/maxVersion)。
3.2 加密层定位实验(核心,完整代码)
tls/06-加密层定位与keylog.js------客户端与 TLS 服务器之间插一个裸 TCP 代理(= 中间人视角),客户端 TLS 端到端穿过代理:
js
// ② 裸 TCP 代理:转发并检查每个字节
net.createServer((clientSock) => {
const upstream = net.connect(9443, 'localhost');
clientSock.on('data', (chunk) => { inspect('C→S', chunk); upstream.write(chunk); });
upstream.on('data', (chunk) => { inspect('S→C', chunk); clientSock.write(chunk); });
}).listen(9446);
function inspect(dir, chunk) {
const text = chunk.toString('latin1');
console.log(`[代理·TCP层 ${dir}] ${chunk.length}字节 | 含明文password? ${text.includes('password')}`);
}
// ③ 客户端顺手导出会话密钥(NSS keylog 格式)
const logFile = fs.createWriteStream(path.join(__dirname, 'data', 'keylog.txt'));
const c = tls.connect(9446, 'localhost', { ca: opts('my-ca.pem') }, () => c.write('password=123456'));
c.on('keylog', (line) => logFile.write(line));
实测:代理方向所有 chunk 含明文password? false;TLS 服务器应用层解密出 "password=123456"。同一批字节,TCP 层乱码、应用层明文------加密就发生在 TLSSocket 内部。
keylog 排障法 :'keylog' 事件吐的是会话流量密钥 (不是私钥!)。tcpdump 抓包 + Wireshark 加载此文件 = 解开自己服务器的加密流量看明文。文件本身即机密(拿到它+抓到包=看到一切;TLS 1.3 有 PFS,连服务器私钥都解不开历史流量,唯一能解的就是它)。
3.3 会话复用(核心,07 实验实测)
机制:第一次握手成功后服务器发会话票 (session ticket------用服务器 ticket key 加密的"恢复凭证",内含密钥材料);下次握手带票直接恢复,跳过 ECDHE 计算和私钥签名/验签。
实测:
scss
第一次连接(全握手) : 9.28ms | isSessionReused() = false
第二次连接(带上票) : 1.48ms | isSessionReused() = true
两个实测踩出的坑(讲义级重要):
- TLS 1.3 的票是握手完成后才补发的 ------服务器秒关连接票就丢了。客户端必须用
'session'事件拿票(getSession()只对 ≤1.2 有效,官方文档 tls.md:1478 明说)。正确姿势:
js
const ticket = await new Promise((r) => socket.once('session', r));
// 下次连接:tls.connect(port, host, { ca, session: ticket })
- 多实例票不认 :票用本进程 ticket key 加密,连到别的机器/进程解不开 → 不报错,退化为全握手 (优雅降级,性能问题非正确性问题)。解法:所有实例配相同
ticketKeys(48 字节随机数,配置中心下发)并定期server.setTicketKeys()轮换;cluster模块内 Node 自动共享。
3.4 握手成本量化(08 实验实测,完整代码见文件)
text
// 三种成本各测 200 次取均值(本机回环,数字几乎全是 CPU 开销)
全新握手 1.200 ms/次
复用握手 0.613 ms/次 (省 49%)
稳态请求 0.024 ms/次
全新握手 ≈ 稳态请求的 50 倍
大头是非对称运算(ECDHE + 私钥签名 + 验签)。生产推论:
- 客户端:HTTP agent 必须开 keepAlive 连接池。实测坑:Node ≥19 起
https.globalAgent.keepAlive=true,但new https.Agent()默认仍是false(本机 v22.22.2 实测)------自建 agent 要显式开。 - 服务端:TLS 终结层 CPU 容量按新建连接速率规划,不是按 QPS。
- 压测 HTTPS 要分开报"每秒新建连接数"与"稳态吞吐"两个指标。
第四站 高级特性
4.1 SNI 多证书(09 实验,核心代码)
一个端口多个域名,SNICallback 在握手早期(ClientHello 后、证书未发前)按域名动态选 SecureContext:
js
const ctxLocalhost = tls.createSecureContext({ key: localhostKey, cert: localhostCert });
const ctxApi = tls.createSecureContext({ key: apiKey, cert: apiCert });
tls.createServer({
key: localhostKey, cert: localhostCert, // 默认兜底(无 SNI/未匹配)
SNICallback(servername, cb) {
if (servername === 'api.local') return cb(null, ctxApi);
cb(null, ctxLocalhost);
},
}, handler);
实测:访问 localhost 拿到 CN=localhost,访问 api.local 拿到 CN=api.local。
实战注意:SecureContext 预建缓存 ,回调里别读磁盘(握手路径每微秒都是延迟);servername 已转小写;同族子域更省事的方案是泛域名证书(*.example.com),SNICallback 用于"完全不同族域名"的场景(多租户 SaaS 网关标配)。
4.2 mTLS 双向认证(10 实验,核心代码)
mTLS(mutual TLS,双向 TLS):客户端也出证书。服务器三件套 + 客户端带 key/cert:
js
// 服务器
tls.createServer({
key, cert,
ca: 私有CA证书, // 用谁来验客户端证书
requestCert: true, // 向客户端要证书
rejectUnauthorized: true, // 没有/无效 → 拒
}, (s) => {
const peer = s.getPeerCertificate(); // 客户端身份,可按 CN/O 做授权
});
// 客户端
tls.connect(port, host, { ca, key: clientKey, cert: clientCert });
实测矩阵:
ini
① alice 带私有CA签的证书 → ✅ 服务器读到 CN=alice O=frontend-team authorized=true
② 不带证书 → ❌ 服务器 ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE
客户端 ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED
③ 自签名假证书 → ❌ 被拒(服务器 tlsClientError)
TLS 1.3 客户端视角陷阱(实测) :secureConnect 触发只表示"客户端发完了材料",服务器验客户端证书是随后的事------客户端可能先触发 secureConnect 再收到拒绝。判断成败看能否收到数据/是否报错,别在 secureConnect 里断言成功。
实战:k8s Service Mesh(Istio/Linkerd)自动给每个 Pod 签发/轮换客户端证书;Nginx 对应 ssl_client_certificate + ssl_verify_client;银行/政企接口常要求客户端证书。客户端证书也有有效期,轮换是必修课。
4.3 ALPN(11 实验实测)
ini
服务器 ALPNProtocols: ['h2', 'http/1.1'](按偏好序)
客户端 [http/1.1] → http/1.1;[h2] → h2;[h2,http/1.1] → h2(服务器偏好赢)
客户端不带 ALPN → alpnProtocol = false,连接继续
交集多个时服务器偏好序说了算 。浏览器只在 TLS 上经 ALPN 协商出 h2------http2 模块的前置知识。读取:socket.alpnProtocol。
4.4 OCSP 装订(12 实验,认识级)
OCSP (Online Certificate Status Protocol,在线证书状态协议------查证书是否被吊销);OCSP Stapling(装订):服务器提前问 CA 拿"状态良好"的签名证明,握手时一并发客户端 → 客户端不用自己联网问 CA(解决吊销检查的慢和隐私泄露)。
实测通道:客户端 requestOCSP: true → 服务器 'OCSPRequest' 事件触发(拿到 certificate/issuer,据此查 CA 的 OCSP 地址)→ cb(null, resp) 装订。我们的玩具 CA 没有 OCSP 服务,回 null 后握手照常------装订是加分项不是必须项 。现实中基本不在 Node 层做:Nginx ssl_stapling on;、CDN、云 LB 在边缘完成。
4.5 中间证书配漏与两个错误码的精确区分(13 实验,核心)
建三级链 leaf ← inter ← root,对照实测:
ini
④ 服务器只配叶子(漏中间证书) → UNABLE_TO_VERIFY_LEAF_SIGNATURE
⑤ 服务器配 fullchain(叶子+中间) → 握手成功 authorized=true
⑥ 链发全了但客户端信任了不相干的CA → UNABLE_TO_GET_ISSUER_CERT_LOCALLY
诊断口诀 :LEAF_SIGNATURE = 链断在中间(服务器少配中间证书,修:cert 配 fullchain.pem);GET_ISSUER = 链到顶了但顶不是锚(客户端缺根 CA 信任,修:ca 选项 / NODE_EXTRA_CA_CERTS)。
经典扯皮现场:漏配中间证书时某些浏览器能正常打开(Windows Schannel 会按证书的 AIA (Authority Information Access,颁发机构信息访问------写着"签发者证书下载地址"的扩展)自动补链,浏览器还会缓存别站送过的同一张中间证书),Node 不补链直接报错。"浏览器好好的,Node 挂了" → 就是这个。
第五站 真实场景实战(生产清单)
5.1 部署形态:先回答"我的 TLS 在哪终结"
css
用户 ──HTTPS──▶ [CDN / 云LB / Nginx / Ingress] ──HTTP──▶ Node 进程
↑ 大多数公司:TLS 在这里终结,证书配在这一层
业务代码不碰证书是常态;需要自己搞的三种场景:Node 直出公网(https.createServer({key, cert}))、内网 mTLS、本地开发 HTTPS(用本讲义 2.1 的自建 CA)。出证书问题时先想终结层在哪,才知道去哪台机器改配置。
5.2 服务端清单
-
cert用 fullchain (叶子+中间),否则 Node 客户端报UNABLE_TO_VERIFY_LEAF_SIGNATURE -
minVersion至少TLSv1.2(Node 默认即如此);内网可锁 1.3 - 证书有效期监控告警(Let's Encrypt 90 天 / 商业证书 1 年),自动续期(cert-manager / acme.sh / CDN 托管)
- 多实例会话复用:统一
ticketKeys+ 定期setTicketKeys()轮换 - 多域名:
SNICallback+ 预建 SecureContext 缓存 - 私钥:chmod 600、k8s Secret/Vault/KMS 注入、绝不进 git 和镜像层
- 容量按新建连接速率规划 TLS 终结层 CPU
5.3 客户端清单
- 全局 agent 之外自建 Agent 必须显式
keepAlive: true(实测new https.Agent()默认 false) - 私有 CA:
NODE_EXTRA_CA_CERTS进容器环境变量,或连接级ca选项 - 永远不对生产开
rejectUnauthorized:false - mTLS 场景:客户端证书也要管生命周期
- 需要会话复用时用
'session'事件拿票(1.3),不要getSession()
5.4 排障 SOP
第一步:看错误码反推检查点(三独立检查 ↔ 三类错误码):
| 错误码 | 死在哪 | 修法 |
|---|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE |
链断在中间 | 服务器 cert 配 fullchain |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY |
链顶不是客户端的锚 | 客户端注入 CA 信任 |
DEPTH_ZERO_SELF_SIGNED_CERT |
自签名证书 | 开发用:追加信任;生产:换 CA 签的 |
ERR_TLS_CERT_ALTNAME_INVALID |
域名不匹配 | 检查 servername 与证书 SAN |
CERT_HAS_EXPIRED |
有效期 | 续期 + 上监控 |
ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION |
版本协商失败 | 对齐 minVersion/maxVersion |
ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED |
mTLS 客户端没带证 | 客户端带 key/cert |
第二步:openssl 命令组:
bash
openssl s_client -connect host:443 -servername 域名 # 看握手+证书链+协商结果
openssl s_client ... -showcerts # 看服务器发了几张证书(链全不全)
openssl x509 -in f.pem -noout -subject -issuer -dates # 解剖证书
openssl verify -CAfile root.pem -untrusted chain.pem leaf.pem # 离线验链
第三步:curl 计时定位 RTT/版本 :curl -sv -w '%{time_connect} %{time_appconnect}'(差值≈1RTT 是 1.3,≈2RTT 是 1.2)。
终极大招 :服务端开 'keylog' 事件写 keylog 文件 → tcpdump 抓包 → Wireshark 加载 keylog → 看明文。
5.5 安全红线
- 私钥不出持有者(不进 git/镜像/日志);
- 生产禁
rejectUnauthorized:false(等于拆报警器放中间人进来); - 别乱配密码套件(默认即安全;安全整改用
minVersion卡版本就够); - keylog 文件、ticketKeys 都是机密;
- 证书私钥泄露 = 立即吊销并重签(这就是 CRL/OCSP 存在的意义)。
自测题册
规则:先自答再对答案。每题标注对应站点;答案均为本模块实测结论。
第一站
Q1-1 TLS 和 TCP 是什么关系?TLS 握手从哪一刻开始计时?
答:TLS 是套在已建立 TCP 连接之上的独立一层(加密壳),不替代 TCP。TCP 三次握手完成后 TLS 握手才开始------curl 里
time_connect与time_appconnect是两个时间戳的原因。TLSSocket 是 net.Socket 子类。
Q1-2 为什么对称/非对称要混用?各承担什么?
答:对称快但密钥分发困难;非对称慢。非对称只做身份认证(证书签名验签)和密钥协商(ECDH),业务数据走对称。TLS 1.3 里会话密钥是双方各自"算"出来的,从不上网。
Q1-3 TCP 0.084s / TLS 0.172s 推断 TLS 版本?
答:1.3。RTT≈84ms,TLS 握手 88ms≈1RTT;1.2 要 2RTT(实测 182ms)。
Q1-4 证书里装哪五样东西?公钥是谁的?签名是谁用谁的私钥签的?
答:域名(CN/SAN)、有效期、签发者、持有者(服务器)的公钥 、签发者用签发者私钥打的签名。
Q1-5 根证书是什么?为什么验签不用联网问 CA?
答:CA 自签名、靠装机预装获得信任的证书(信任锚)。验签材料=服务器发来的链证书+本地预装根证书,验签是纯本地数学计算------CA 可离线,体系才能 scale。唯一联网环节是吊销检查(CRL/OCSP)。
Q1-6(追问题)CA 是什么?凭什么被信任?
答:Certificate Authority,证书颁发机构。核实域名/身份后用私钥签证书。信任来自根证书经 WebTrust 审计后被预装进设备/Node;作恶会被踢出信任库(DigiNotar 2011 破产)。
Q1-7(追问题)自建 CA 能签 github.com 的假证书吗?为什么没用?
答:能签,openssl 不拦任何人,链式验签自己验自己也能过(实测 OK)。但自建 CA 不在客户端信任库,链到不了锚 → 验签失败。信任=备案分发,不是密码学属性。
Q1-8(追问题)不搞证书能用 HTTPS 吗?
答:不能。TLS 1.3 里证书+CertificateVerify 是握手必选件,无证书握手直接失败(实测
sslv3 alert handshake failure)。自签名证书能跑加密但客户端默认不认(curl 60 / DEPTH_ZERO_SELF_SIGNED_CERT)。
Q1-9(追问题)中间 CA 的公钥客户端怎么拿到?
答:装在中间 CA 证书里,由服务器握手时连同服务器证书一起发来(这就是"证书链"的字面意思)。根 CA 公钥走本地预装。
第二站
Q2-1 tls.createServer 对比 net.createServer 的全部差异?
答:服务器多传
{key, cert},客户端多传{ca},事件名secureConnection/secureConnect。TLSSocket 是 net.Socket 子类,net/stream 知识全部复用。
Q2-2 访问私有 CA 签的服务默认报什么?三种信任注入方式?
答:
UNABLE_TO_VERIFY_LEAF_SIGNATURE(私有 CA 不在信任库,链式验签没有起跑线)。ca选项(代码级)/NODE_EXTRA_CA_CERTS(进程级追加)/rejectUnauthorized:false(仅调试)。
Q2-3 rejectUnauthorized:false 后握手成功还是失败?为什么危险?
答:成功 ------危险正在于它不失败。连接照建、数据照加密,但身份认证被关闭,中间人拿假证书也能建"加密"连接。
authorized=false、authorizationError保留原因。
Q2-4 传对 ca 但 servername: 'evil.example.com' 为什么还失败?
答:域名匹配是独立检查。TLS 身份认证=①链式验签∧②域名匹配∧③有效期。报
ERR_TLS_CERT_ALTNAME_INVALID。
Q2-5 openssl s_client 连我们 20 行代码的服务器协商出 TLS 1.3,说明什么?
答:TLS 是公开标准协议,实现是标准件(Node 内嵌 OpenSSL);任何实现互通。
Q2-6 (追问题)ca/key/cert 三个选项分别传什么文件?
答:
key=服务器私钥(签名证明持有身份,绝不外传);cert=服务器证书(内含服务器公钥,握手时发出);ca=客户端信任的 CA 证书(内含 CA 公钥,验签用)。key 与 cert 是同一把钥匙的两半(实测导出公钥 diff 相同)。
Q2-7(追问题)为什么证书/密钥文件都是 base64?
答:真身是 DER 二进制,PEM=头尾标记+base64(DER) 是为了能进文本世界(YAML/git/环境变量)。实测 PEM 剥壳与 DER 重新 base64 逐字符相同。
第三站
Q3-1 从套件命名看 1.3 比 1.2 砍掉了什么?解决什么问题?
答:密钥交换和签名算法移出套件,只留 5 种全带 PFS 的套件。解决降级攻击(骗双方选弱套件)。握手 1.3=1RTT / 1.2=2RTT。
Q3-2 TCP 代理为什么搜不到明文?证明什么?
答:代理在 TCP 层,只见 TLS 加密后的密文。证明加解密发生在 TLS 层(TCP 之上、应用之下)内部。明文部分只有握手开头的 ClientHello(含 SNI 域名)。
Q3-3 keylog 文件装的是什么?怎么用?为什么是机密?
答:会话流量密钥(不是私钥),NSS keylog 格式。抓包+Wireshark 加载它=解开流量看明文,排障终极手段。拿到它+抓到包=看到一切;且 1.3 PFS 下它是唯一能解密的东西。
Q3-4 ECDH 密钥交换"交换"的是什么?共享秘密上过网吗?
答:网络上只互发临时公钥(实测双方 computeSecret 相同)。共享秘密各自本地算出,从不上网。复用握手快就是因为票里已装好密钥材料,跳过这轮重活。
Q3-5 TLS 1.3 客户端怎么拿会话票?服务器秒关连接会怎样?多实例部署呢?
答:用
'session'事件(getSession()只对 ≤1.2)。票是握手后补发的,秒关会丢票。多实例:票用进程内 ticket key 加密,跨机解不开→退化为全握手(不报错);解法统一ticketKeys+轮换。
Q3-6 握手与稳态请求的成本比是多少?大头是什么运算?
答:实测全新握手 1.2ms vs 稳态请求 0.024ms ≈ 50 倍(本机回环,几乎全是 CPU)。大头是非对称运算(ECDHE+签名/验签)。所以 keepAlive 连接池是第一要务;自建
new https.Agent()默认 keepAlive=false 是坑。
第四站
Q4-1 SNICallback 什么时候触发?解决什么?多域名还有什么省事方案?
答:握手早期(ClientHello 后、证书未发前)按 SNI 域名动态选 SecureContext。解决一个 IP/端口服务多个不同族域名。同族子域用泛域名证书更省事。注意:SecureContext 预建缓存,别在回调里读磁盘。
Q4-2 mTLS 服务器三件套?1.3 下 secureConnect 触发能说明被接受吗?
答:
requestCert:true+rejectUnauthorized:true+ca:验客户端的CA。不能------1.3 里客户端 secureConnect 只是"发完材料",服务器验客户端证书随后才出结果(实测先触发后被拒)。
Q4-3 ALPN 交集多个时谁说了算?客户端不带 ALPN 怎样?
答:服务器偏好序(实测 h2,http/1.1 服务器 vs 任意客户端 → h2)。不带则
alpnProtocol=false,连接继续。
Q4-4 OCSP 装订解决什么?Node 里通道是什么?
答:解决吊销检查的慢+隐私(客户端直连 CA 的 OCSP 会暴露访问目标):服务器提前拿 CA 签名的状态证明随握手发给客户端。Node:服务器
'OCSPRequest'事件 + 客户端requestOCSP:true。现实中 Nginx/CDN 边缘做。
Q4-5 UNABLE_TO_VERIFY_LEAF_SIGNATURE 与 UNABLE_TO_GET_ISSUER_CERT_LOCALLY 精确区别?
答:前者=链断在中间(只收到叶子,找不到叶子的签发者 → 服务器配 fullchain);后者=链到顶但顶不是锚(客户端缺根 CA 信任 → 注入 CA)。
Q4-6 综合:内网 mTLS 中 A 调 B 报 UNABLE_TO_VERIFY_LEAF_SIGNATURE,列至少两种可能。
答:① B 的 cert 没配 fullchain(中间证书缺失);② A 没注入私有 CA 信任(ca/NODE_EXTRA_CA_CERTS 没配);③ B 的证书不是这家 CA 签的(签错 CA);④ 证书被重签后 A 还在用旧 CA 文件。
陷阱速查表
| 坑 | 现象 | 正解 |
|---|---|---|
rejectUnauthorized:false 上生产 |
中间人洞开 | ca / NODE_EXTRA_CA_CERTS |
| cert 只配叶子证书 | Node 客户端 UNABLE_TO_VERIFY_LEAF_SIGNATURE,但部分浏览器正常 |
cert 配 fullchain.pem |
| 私有 CA 没注入 | 验签失败 | 环境变量追加信任 |
| 以为 Node 用系统证书库 | "curl 能通 Node 不通" | Node 内置 144 张;或 --use-system-ca |
TLS1.3 用 getSession() 拿票 |
拿不到 | 'session' 事件 |
| 服务器秒关连接 | 会话票丢失 | 等票送达再关 |
| 多实例各用各的 ticket key | 复用率莫名低 | 统一 ticketKeys + setTicketKeys() 轮换 |
new https.Agent() 以为默认 keepAlive |
每请求都全新握手,CPU 飙 | 显式 keepAlive:true(globalAgent 才是默认开) |
| SNICallback 里同步读文件 | 握手延迟抖动 | 预建 SecureContext 缓存 |
| mTLS 用 secureConnect 判断成败 | 误判放行 | 1.3 下等数据/错误事件 |
| 私钥进 git/镜像 | 身份可被冒用 | Secret 管理、chmod 600 |
| 乱配密码套件 | 引入弱套件/协商失败 | 用默认;要管就管 minVersion |
| 证书过期才发现 | CERT_HAS_EXPIRED 线上事故 |
有效期监控 + 自动续期 |
| keylog/ticketKeys 乱丢 | 流量可被解密/票可被伪造 | 当机密管理 |
| 访问 IP 却想验域名 | ERR_TLS_CERT_ALTNAME_INVALID |
证书 SAN 加 IP:x.x.x.x 或用域名访问 |
附录 A:实验文件清单
| 文件 | 内容 |
|---|---|
tls/00-图解证书与握手.html |
证书体系 + TLS1.3 逐消息握手交互图解 |
tls/01-证书三态实验.js |
无证书/自签名/CA 签发三态对照 |
tls/02-建私有CA与签证书.js |
私有 CA + CSR + 签发 + 验链 |
tls/03-第一个TLS-echo对照.js |
tls vs net API 对照;getProtocol/getCipher/getPeerCertificate |
tls/04-验签失败与解法矩阵.js |
四种客户端姿势四种结局 |
tls/05-TLS版本握手对照.js |
1.2/1.3/协商破裂 |
tls/06-加密层定位与keylog.js |
TCP 代理视角 + keylog 导出 |
tls/07-会话复用实测.js |
session ticket 复用(9.28→1.48ms) |
tls/08-握手开销实测.js |
全握手/复用/稳态成本量化 |
tls/09-SNI多域名多证书.js |
SNICallback 动态选证书 |
tls/10-mTLS双向认证.js |
客户端证书认证矩阵 |
tls/11-ALPN协议协商.js |
ALPN 协商行为 |
tls/12-OCSP装订事件.js |
OCSPRequest 通道 |
tls/13-中间证书配漏复现.js |
三级链、两个错误码精确区分 |
附录 B:openssl 速查
bash
openssl genrsa -out key.pem 2048 # 生成私钥
openssl req -x509 -new -nodes -key k -subj "/CN=x" -out ca.pem -days 3650 # 自签根证书
openssl req -new -key k -subj "/CN=x" -out x.csr # 生成 CSR
openssl x509 -req -in x.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -extfile san.ext -out cert.pem -days 825 # CA 签发
openssl x509 -in f.pem -noout -subject -issuer -dates # 解剖证书
openssl x509 -in f.pem -pubkey -noout # 取证书里的公钥
openssl rsa -in key.pem -pubout # 从私钥导出公钥(可与上行 diff 验证一对)
openssl verify -CAfile root.pem -untrusted chain.pem leaf.pem # 离线验链
openssl s_client -connect host:443 -servername 域名 # 测试握手
附录 C:名词速查
| 名词 | 一句话 |
|---|---|
| TLS / SSL | 传输层安全协议;SSL 是废弃前身 |
| CA | 证书颁发机构,信任来自根证书预装备案 |
| 证书 | 「域名+公钥+有效期+签发者+签名」的公证包装盒 |
| 数字签名 | 私钥签、公钥验的防伪封条 |
| 根证书 / 信任锚 | 自签名、预装分发,验链终点 |
| CSR | 证书签名请求(域名+公钥的申请单),过渡产物 |
| PEM / DER | 文本壳(base64) / 二进制真身 |
| SAN | 证书绑定的域名清单(现代只认它不认 CN) |
| SNI | ClientHello 明文带的访问域名 |
| ALPN | 握手时协商应用层协议 |
| ECDH(E) | 只交换临时公钥、各自算共享秘密的密钥交换;E=临时→PFS |
| PFS | 完全前向保密:私钥泄露也解不开历史流量 |
| 会话票 / ticketKeys | 复用握手的凭证 / 加密票的服务器密钥(多实例要统一) |
| mTLS | 双向 TLS:客户端也出证书 |
| OCSP / Stapling | 在线查吊销 / 服务器代查装订 |
| AIA | 证书里"签发者证书下载地址"扩展(浏览器补链用,Node 不补) |
| AEAD | 带校验的加密,机密性+完整性一起保 |