Node 网络编程 —— TLS 模块

54 阅读29分钟

序言

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

第一站 世界观/协议机制  → 证书、公私钥、信任链是什么
第二站 基础 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 实测):

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 实测:

server.verify(真签发者的公钥)   → true
server.verify(不相干根的公钥)   → false   // 拿错公钥立刻露馅
根证书.verify(自己的公钥)       → true    // 根 = 自签名

信任链为什么有中间 CA:根私钥锁在 HSM(Hardware Security Module,硬件安全模块)离线保存,只低频签发中间 CA 证书;日常签网站用中间 CA 私钥;中间私钥泄露 → 根吊销这一个中间 CA 即可切割。链条:根(预装本地) → 签中间 → 中间签叶子

链式验签方向(铁律):验一张证书用签发者的公钥,不是持有者自己的。

服务器握手时发来:服务器证书 + 中间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 四步):

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):

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

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(); });
});

实测输出:

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 签的服务器,四种客户端姿势:

① 默认信任库直连            → 握手失败 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 实验结论)

无证书服务器      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 实验实测)

默认(双方支持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 端到端穿过代理:

// ② 裸 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 计算和私钥签名/验签

实测:

第一次连接(全握手) : 9.28ms | isSessionReused() = false
第二次连接(带上票) : 1.48ms | isSessionReused() = true

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

  1. TLS 1.3 的票是握手完成后才补发的——服务器秒关连接票就丢了。客户端必须用 'session' 事件拿票(getSession() 只对 ≤1.2 有效,官方文档 tls.md:1478 明说)。正确姿势:
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 实验实测,完整代码见文件)

// 三种成本各测 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

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

// 服务器
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 });

实测矩阵:

① 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 实验实测)

服务器 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,对照实测:

④ 服务器只配叶子(漏中间证书) → 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 在哪终结"

用户 ──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_REQUIREDmTLS 客户端没带证客户端带 key/cert

第二步:openssl 命令组

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 或用域名访问

附录 B:openssl 速查

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)
SNIClientHello 明文带的访问域名
ALPN握手时协商应用层协议
ECDH(E)只交换临时公钥、各自算共享秘密的密钥交换;E=临时→PFS
PFS完全前向保密:私钥泄露也解不开历史流量
会话票 / ticketKeys复用握手的凭证 / 加密票的服务器密钥(多实例要统一)
mTLS双向 TLS:客户端也出证书
OCSP / Stapling在线查吊销 / 服务器代查装订
AIA证书里"签发者证书下载地址"扩展(浏览器补链用,Node 不补)
AEAD带校验的加密,机密性+完整性一起保