
做了这么多年 Node 后端有个现象一直挺有意思大家每天都在用 HTTPS 发请求但真要你亲手写一个 TLS 服务端、或者对接一个要求双向证书认证的支付/IM 协议时很多人的第一反应是去翻 npm 上的https库而不是 Node 内置的tls模块。实际上tls模块才是 Node 网络编程里真正干加密通信这道糙活的底层模块https、http2、websocket的加密层最终都落在它身上。这篇文章我把 Nodetls模块从证书准备、服务端/客户端实现、到抓包调试、生产环境配置的完整链路捋一遍。适合两类人看一类是写自定义 TCP 长连接、需要自己上加密的另一类是业务里要对接 mTLS 双向认证、被证书校验搞得头皮发麻的。看完你至少能自己搭一套可信的 TLS 通信环境并且知道出问题时该从哪里下手。1. 为什么要亲自动手写 TLS场景与基本职责划分1.1 处理 TLS 的真实项目场景先说场景。大多数情况下我们用https.request买东西、调接口Node 内部早就帮你把 TLS 封装好了根本不用碰tls模块。但一旦遇到下面这些事tls就是你绕不开的工具自定义 TCP 协议的上层加密比如物联网设备的长连接、IM 服务端之间的私有协议TCP 裸奔肯定不行直接套一层 TLS 是最省钱可靠的方案。对接支付和开放平台回调很多平台要求验签证书链、或者要求你用指定客户端证书发起请求这时候光用https的默认行为可能不够用你得手动控制secureContext。内部微服务间的 mTLS服务间通信走双向证书认证比如 K8s 里 service mesh 常见做法但不用 mesh 时你需要在 Node 里自己配requestCert: true。调试和抓包用 Wireshark 抓 TLS 流量需要把 Node 进程的密钥导出到抓包工具里这也要理解tls层的握手机制。所以别把tls模块理解成“另一个网络库”它其实是“TCP 加密握手 证书认证”的完整封装。你写的net模块代码能做的tls基本都能做只是多了一道握手和证书校验。1.2 TLS 模块到底包办了什么加密、认证与完整性很多新手会把 TLS 简单理解成“数据加密了”其实它一次干了三件事缺一个都不能称为安全传输身份认证通过证书链验证对方确实是它声称的那台机器防中间人。这一点最容易被忽略很多人把rejectUnauthorized: false一写等于裸奔。机密性握手完成后协商出对称密钥后续的数据都用这个密钥加密传输。用对称加密而非非对称加密是为了性能。完整性每条记录都带 MAC/HMAC 校验防止数据在传输中被篡改。这三件事在 Nodetls模块里对应到具体的参数上就是ca信任哪些证书、key/cert我是谁、ciphers用哪些加密套件、minVersion最低 TLS 版本。你配置tls.createServer的时候本质上就是在回答上面三件事。2. 证书准备所有坑的起点2.1 用 OpenSSL 生成自签名证书与 CA 体系不管你是自测还是内部环境用第一步永远是搞证书。我见过太多人在这一步直接openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365一条命令自签完就跑然后客户端一连接就报错为什么因为你拿一个“自签名根证书”当“服务端证书”用客户端没有这个根证书的信任记录自然校验不过。正确做法是建立一个小的CA 体系先造一个本地 CA 根证书这个根证书自己信任就行再用它给服务端/客户端分别签发证书。这样以后你新增设备、新增域名都用同一个 CA 签就行客户端也只需要信任 CA 根证书一份文件。生成 CA 根证书openssl req -x509 -newkey rsa:2048 -nodes \ -keyout ca.key -out ca.crt -days 3650 \ -subj /CNMyLocalCA生成服务端私钥和签名请求openssl req -newkey rsa:2048 -nodes \ -keyout server.key -out server.csr \ -subj /CNlocalhost用 CA 签发出带 SANSubject Alternative Name的服务端证书openssl x509 -req -in server.csr \ -CA ca.crt -CAkey ca.key -CAcreateserial \ -out server.crt -days 825 \ -extfile (printf subjectAltNameDNS:localhost,IP:127.0.0.1)注意最后一步的-extfile在 bash/zsh 里可以直接用进程替换Windows 下建议先写一个san.ext文件subjectAltNameDNS:localhost,IP:127.0.0.1然后-extfile san.ext。为什么要加 SAN因为现在 Node 和浏览器做证书校验时主机名校验只认 SAN不认 Common Name。你证书里 CN 写了localhost但没有 SAN 字段客户端连接localhost依然会报Hostname/IP does not match certificates altnames。2.2 证书链、SAN 扩展与常见验证错误如果你用的是互联网上正规 CA 签的证书一般不用关心证书链拼接顺序。但内网自建 CA 时服务端证书文件里最好把“服务端证书 CA 证书”拼在一起例如cat server.crt ca.crt server-fullchain.crtNode 的tls.createServer里cert字段支持这种拼接链能减少客户端因为中间证书缺失导致的unable to verify the first certificate报错。我在实际项目里常见的证书相关错误基本就这几类错误现象根因排查方向self-signed certificate客户端没有把自建 CA 加入ca检查客户端ca配置Hostname/IP does not match certificates altnames证书 SAN 缺失或写错重签证书、补 SANcertificate has expired证书过期看签发时-days参数unable to verify the first certificate证书链不完整拼接server.crt ca.crtwrong version number对端不是 TLS 服务比如连到了裸 TCP 端口检查端口、确认对端开启了 TLS这里多说一句自测环境证书有效期别图方便设 36500 天。有些老设备/老库对证书有效期超过 825 天约两年多会直接拒绝Apple 和 Chromium 现在也都不信任超过 398 天的证书。内网自签设 825 天左右最保险。3. 手写一个 TLS 服务端与客户端从连接建立到数据交互3.1 服务端代码tls.createServer 与 secureContext 的关系先看一个最小可用的服务端实现const tls require(tls); const fs require(fs); const options { key: fs.readFileSync(./server.key), cert: fs.readFileSync(./server-fullchain.crt), ca: fs.readFileSync(./ca.crt), // 如果不注释下面这行客户端必须出示由 ca.crt 签发的证书 // requestCert: true, // rejectUnauthorized: true, }; const server tls.createServer(options, (socket) { console.log(client connected, authorized:, socket.authorized); socket.on(secureConnect, () { console.log(TLS handshake done, protocol:, socket.getProtocol()); }); socket.write(hello from secure server); socket.on(data, (data) { console.log(received:, data.toString()); }); socket.on(error, (err) { console.error(socket error:, err.message); }); }); server.listen(8443, () { console.log(TLS server listening on 8443); });这里你要理解tls.createServer(options)和tls.createSecureContext(options)的关系前者底层其实也是创建了一个SecureContext只是帮你把监听 socket 的逻辑封装好了。createSecureContext本身不产生 socket它只是“凭据包”。如果你想做 SNI 多域名、或者想复用同一套凭据给多个服务就应该先createSecureContext再传给createServer({ secureContext })。还有一个隐蔽的点服务端和客户端的rejectUnauthorized含义不一样。服务端默认rejectUnauthorized: false意思是“我不强制验证客户端证书”客户端默认rejectUnauthorized: true意思是“我连接服务端时要校验服务端证书”。所以不要看到服务端默认不校验就慌这是协议设计使然。authorized这个属性我要提一嘴它表示这个 socket 的客户端证书是否通过了你ca的验证。但只有当requestCert: true且客户端提供了证书时它才有意义。否则即使值为false连接也会正常建立因为服务端没要求对方出示证书。判断错误时千万别只盯着socket.authorized看。3.2 客户端代码tls.connect 的证书校验与双向认证对应的最小客户端const tls require(tls); const fs require(fs); // 如果连接的是双向认证服务需要提供客户端证书和私钥 const options { host: localhost, port: 8443, // ca 是你要信任的根证书指向自建 CA ca: fs.readFileSync(./ca.crt), // 下面是 mTLS 用到的客户端身份 key: fs.readFileSync(./client.key), cert: fs.readFileSync(./client.crt), servername: localhost, // SNI必须写尤其当证书 CN/SAN 是域名时 rejectUnauthorized: true, // 生产环境保持 true }; const socket tls.connect(options, () { console.log(connected, authorized:, socket.authorized); if (!socket.authorized) { console.error(cert verify error:, socket.authorizationError); socket.destroy(); return; } socket.write(hello from client); }); socket.on(data, (data) { console.log(server says:, data.toString()); socket.end(); }); socket.on(error, (err) { console.error(connection error:, err.message); });这里有一个非常容易踩的坑servername参数。即使你的连接目标是 IP比如127.0.0.1只要服务端证书里配置了 SAN 的 DNS 字段Node 做主机名校验时依然可能因为 SNI 和证书匹配问题报错。建议连接时始终显式传servername: localhost和证书 SAN 里的 DNS 一致不要依赖 npm 包里自动从 host 推断的行为。如果你只想做“单向认证”服务端有证书、客户端不提供证书客户端代码里的key和cert就可以注释掉。反过来如果服务端要求客户端证书但你 client 没带服务端握手时会直接报ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED此时不要怀疑服务端代码写错了去检查客户端有没有配key和cert。3.3 用 openssl s_client 验证服务端状态写代码之前先用系统自带的 openssl 验证一遍服务端证书链最靠谱省得把问题甩给 Nodeopenssl s_client -connect localhost:8443 \ -CAfile ca.crt \ -servername localhost \ -state -tlsextdebug看到Verification: OK说明服务端证书链没问题。如果服务端开了双向认证加参数openssl s_client -connect localhost:8443 \ -CAfile ca.crt \ -key client.key -cert client.crt \ -servername localhostopenssl s_client还能看到协商出来的协议版本、加密套件和证书内容是我调试 TLS 的第一顺位工具。很多 Node 层的报错用s_client一测就能确认到底是“我代码写错”还是“证书/端口本身有问题”排查效率翻倍。4. 握手失败的完整排查链路从内部错误状态 10013 说起4.1 10013 错误在 Windows 上的真实含义与现场搜过 TLS 相关报错的人大概率见过这条Error creating TLS client credentials: Internal error state is 10013。我第一次遇到是在 Windows 服务器上跑一个需要客户端证书的 Node 脚本明明代码在 Linux 上跑得好好的挪到 Windows 上直接握手失败日志只有这一句。10013 这个数字在 Winsock 错误码里对应的是WSAEACCES意思是“权限被拒绝”。在 TLS 客户端凭据创建这个场景下大多数情况是 Node 在调用 Windows 的 SChannel/CryptoAPI 访问证书存储时没有拿到对应私钥的访问权限。我当时的环境是企业域控管理的一台 Windows Server本机证书库里装了不少组织下发的证书和智能卡中间件。Node 创建客户端凭据时如果扫描证书存储、或者读取某个私钥文件时被系统策略拒绝就会抛这个错。最终的解决路径是这样的先把 Node 的客户端凭据来源从“系统证书库”改成“项目内相对路径下的 PEM 文件”。不要依赖 Windows 证书管理器里的人工导入直接用key/cert字段指向文件绕开系统证书库的访问权限问题。确认私钥文件没有被其他进程占用且当前账号对文件有完整读取权限。Windows 下文件 ACL 继承不清时服务账号读不了其它账号生成的私钥文件是常事。不要给私钥设置密码或者如果你设了必须在createSecureContext里通过passphrase传进去。有些内部组件在无干预环境下没法弹出密码框就会报凭据创建失败。检查进程是否以服务方式运行。Windows 服务默认账号是 LocalSystem它访问用户级证书存储时视角和普通用户不一样也会出现 10013。所以如果你遇到这个“内部错误状态 10013”第一反应不应该是去翻 Node 代码而是去查系统权限层私钥文件能不能读、证书存储能不能访问、当前进程身份是什么。排掉这几项绝大多数情况下问题就消失了。4.2 另外三个高频弄崩握手的坑CVE-2016-2183、TLS 版本弃用、证书格式错位我再列三个实际项目中高频出现的 TLS 握手问题希望你能提前避开。第一个等保扫描报 CVE-2016-2183SWEET32。这是针对 DES/3DES 这类 64 位分组密码算法的攻击。安全扫描器扫到你的 TLS 服务启用了 3DES 套件就会报这个漏洞。虽然 Node 18 的默认 OpenSSL 策略通常已经弱化了这类老套件但如果你用的是旧版本、或者扫描器要求非常严格最好显式屏蔽。在createSecureContext里这样处理const crypto require(crypto); const secureContext tls.createSecureContext({ key: fs.readFileSync(./server.key), cert: fs.readFileSync(./server-fullchain.crt), minVersion: TLSv1.2, ciphers: HIGH:!aNULL:!eNULL:!EXPORT:!DES:!3DES:!MD5:!PSK:!RC4, secureOptions: crypto.constants.SSL_OP_NO_TLSv1 | crypto.constants.SSL_OP_NO_TLSv1_1, });第二个火狐等浏览器提示“该网站使用了已弃用的 TLS 版本”。这通常是服务端协商到了 TLS 1.0/1.1而现代浏览器已经默认禁用。Node 端的解决方案就是设minVersion: TLSv1.2不用管客户端的最大版本。我见过一些人把这个锅甩给 Node 版本其实和 Node 版本无关纯粹是 OpenSSL 策略默认值造成的。显式设置后旧浏览器再来访问就会握手失败但这大概率是你想要的因为还会用 TLS 1.0 的客户端本身就意味着风险。第三个证书格式错位。常见的有两种表现。一种是key字段填成了证书内容启动时直接报error:0D0680A8:asn1 encoding routines:ASN1_CHECK_TLEN:wrong tag这种错一眼就能看出来。另一种更迷惑key个cert内容调换后Node 启动不报错但客户端握手时可能报tls server cert is not matching its key。原因是格式本身合法但证书里的公钥和私钥对不上。排查方法很简单用下面命令校验证书和私钥的模数是否一致openssl x509 -in server.crt -noout -modulus | openssl sha256 openssl rsa -in server.key -noout -modulus | openssl sha256两个哈希一致才能确认是同一对密钥。4.3 用 Wireshark 和 SSLKEYLOGFILE 抓包定位问题刚才讲的是服务端侧能查的参数但如果问题出在“协议协商”“证书发送时机”“分片异常”这类更底层的地方就要上抓包了。Node 自带的调试手段其实不太够好在 OpenSSL 系的应用都支持一个环境变量SSLKEYLOGFILE。只要在启动 Node 前设置它握手阶段的主密钥就会被写入指定文件export SSLKEYLOGFILE/tmp/node-tls.keys node your-server.js然后打开 Wireshark捕获 8443 端口流量在 Wireshark 的 TLS 协议首选项里指定(Pre)-Master-Secret log filename为/tmp/node-tls.keys就能看到解密后的 HTTP/自定义协议明文。实际排错时我一般先过滤tls.handshake.type 1找 ClientHello再往下看 ServerHello 里的selected_version和cipher_suite。如果客户端最高支持 TLS 1.3但服务端选回 TLS 1.2那说明服务端没开 1.3或者 ALPN 协商有冲突。如果连接在 Certificate 消息之后立刻断开十有八九是证书链校验失败这时候在 Wireshark 里展开 Certificate 消息能直接看到服务端发了哪些证书、顺序对不对。这个能力在生产环境救过我一次当时两个服务通过网关转发 TCP 流量网关配置的负载均衡策略把 TLS 流量拆包重组搞坏了客户端一直报bad record type。从 Node 日志看完全正常但 Wireshark 里能看到 TLS 记录被拦腰截断确认问题在中间链路的透明代理而不是应用代码。5. 生产级配置的进阶项SNI、ALPN、会话复用5.1 用 SNI 在同一端口承载多个证书域名如果你只有一个 TLS 端口却要服务多个域名每个域名有各自证书就需要SNIServer Name Indication。原理也很直白客户端在握手的第一条消息里就把自己想访问的域名servername告诉服务端服务端据此选择对应的证书返回。Nodetls模块的写法很优雅不用自己解析域名const server tls.createServer(); server.addContext(api.example.com, tls.createSecureContext({ key: fs.readFileSync(./api.key), cert: fs.readFileSync(./api.crt), })); server.addContext(admin.example.com, tls.createSecureContext({ key: fs.readFileSync(./admin.key), cert: fs.readFileSync(./admin.crt), })); const defaultContext tls.createSecureContext({ key: fs.readFileSync(./default.key), cert: fs.readFileSync(./default.crt), }); server.on(secureConnection, (socket) { console.log(sni servername:, socket.servername); });注意tls.createServer()如果不传options默认 context 是空的此时只有addContext注册过的域名能正常完成证书协商没匹配到的域名会握手失败。所以生产上建议用tls.createServer({ SNICallback: (servername, cb) ... })做更灵活的路由或者在 options 里传一个默认 context再对特殊域名覆盖。5.2 ALPN 与 HTTP/2 的联动ALPNApplication-Layer Protocol Negotiation是 TLS 握手里的一个小扩展作用是在加密协商阶段顺便告诉对方“我这层之上跑的是什么协议”。比如h2表示 HTTP/2http/1.1表示 HTTP/1.1。Node 里开启 ALPN 很简单const server tls.createServer({ key: ..., cert: ..., ALPNProtocols: [h2, http/1.1], }); server.on(secureConnection, (socket) { console.log(alpn negotiated:, socket.alpnProtocol); });如果你写的是自定义协议不想用https库但第三方客户端要求必须走 HTTP/2那你就得在 TLS 层把h2协商出来再自己实现 HTTP/2 帧解析。这是一个比较硬核的活儿但至少alpnProtocol能让你在服务端快速判断对端最终选定了哪个协议。调试 ALPN 问题时openssl s_client -alpn h2是标准的验证命令。5.3 会话恢复与性能平衡TLS 握手最耗时的部分在于非对称密钥交换。为了减少握手次数TLS 支持会话恢复客户端如果在短时间内再次连接同一服务端可以直接复用之前的会话密钥。Node 里对应两个参数const server tls.createServer({ key: ..., cert: ..., sessionTimeout: 300, // 会话 5 分钟过期 sessionIdContext: myapp-v1, // 不同服务用不同标识防止会话串用 });这里有个多实例部署的坑如果你用 PM2 起了 4 个 Node 进程、或者 K8s 多副本默认情况下它们的内存里各自维护一套会话缓存。客户端第一次连接打到 Pod A第二次连接被调度到 Pod BPod B 没有那个会话记录就会重新走完整握手。这不会导致错误但会把会话恢复的优势抵消掉。真要共享会话缓存要么用外部存储如 Redis自研sessionStore要么把负载均衡策略改成会话保持sticky session。内网环境我一般建议先做 sticky session 就够了不值得为一个握手性能搞一套复杂的共享缓存。另外提醒一个细节sessionIdContext改变后所有存量会话缓存全部失效。线上轮换证书、重启服务时TLS 握手量会在短时间内上升监控的握手耗时指标会有一次明显的毛刺这是正常现象不用紧张。最后分享一个我自己常用的调试小技巧开发 TLS 服务时别写一堆console.log去验证握手直接开两个终端一边跑nodemon或node --watch自动重启另一边用openssl s_client反复验证。改完证书配好即生效整个迭代周期压缩到几秒钟。配合SSLKEYLOGFILE一边抓包一边看明文基本没有定位不了的 TLS 问题。这个路子你跑通一次后面再遇到客户端证书、SNI、协议版本相关的报错心里就有底了。