Node 网络编程 —— TLS 模块

序言

TLS 是 Node 服务端工程师的"隐形日常":每个 https.get、每个反向代理后的流量、每次微服务调用都经过它。平时无感,出事全是它------证书过期、验签失败、握手打爆 CPU。本模块的学习路径是五站:

arduino 复制代码
第一站 世界观/协议机制  → 证书、公私钥、信任链是什么
第二站 基础 API         → createServer/connect、自建 CA、验签失败矩阵
第三站 核心机制深挖      → 1.2 vs 1.3、加密层定位、会话复用、握手成本
第四站 高级特性          → SNI 多证书、mTLS、ALPN、OCSP、错误码精确区分
第五站 真实场景实战      → 部署形态、生产清单、排障 SOP(本站以清单形式沉淀在本讲义)

先给三个贯穿全讲义的总纲:

  1. 证书 = 公钥的"公证包装盒"。公钥裸发会被中间人掉包,所以 CA 把「域名+公钥」打包签字。数字签名是包装盒上的防伪封条。
  2. 信任不来自密码学,来自"备案分发"。谁都能用 openssl 自建 CA 签任何域名的证书(密码学不拦你),但客户端只信本地信任库里的锚。根证书预装 = 公章备案。
  3. TLS 不是替代 TCP,是套在 TCP 上的透明加密壳TLSSocketnet.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=falseauthorizationError 保留失败原因。生产出现直接 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 )+ certfullchain,公开可进 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

两个实测踩出的坑(讲义级重要):

  1. 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 })
  1. 多实例票不认 :票用本进程 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=truenew 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 服务端清单

  • certfullchain (叶子+中间),否则 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_connecttime_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=falseauthorizationError 保留原因。

Q2-4 传对 caservername: '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_SIGNATUREUNABLE_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 带校验的加密,机密性+完整性一起保
相关推荐
倔强的石头1062 小时前
【Linux指南】动静态库系列(七):符号表与重定位:链接器如何把函数调用接起来
linux·前端·javascript
山岚的运维笔记2 小时前
mysql 专业笔记 -- 第 8 章:使用变量
运维·数据库·笔记·后端·学习·mysql·dba
图扑软件2 小时前
中篇・运笔|统一 DataModel 底座,HT UI 组件万物同源
javascript·低代码·ui·性能优化·数据可视化
平头哥AI3 小时前
Day 01 | go run 跑通第一个 Go 程序,go build 留下一个能拷走的 exe
开发语言·后端·golang
小小尚@4 小时前
WebGIS/ECharts 地图开发|MapLand 在线行政区划边界一键下载 GeoJSON 工具
前端·javascript·echarts
葡萄城技术团队4 小时前
工业数据可视化:使用活字格 AIcoding + Three.js 构建轻量化三维设备监控大屏
开发语言·javascript·信息可视化
默_笙4 小时前
🚍 一条 Todo 的奇幻漂流:TypeScript 全栈类型安全的"护照检查"
前端·javascript
ZGG0034 小时前
线程池详解:从 7 大参数到生产避坑
java·数据库·后端
weixin_422555425 小时前
el-menu子菜单点击就被激活,恢复之前的激活状态
javascript·vue.js·elementui