尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Node.js TLS模块实战:从证书生成到双向认证与生产加固

Node.js TLS模块实战:从证书生成到双向认证与生产加固 先说一个很典型的场景。你本地起了一个Node服务HTTP跑得飞快一换上TLS就各种幺蛾子。要么浏览器直接提示“该网站使用了已弃用的TLS版本请升级到TLS 1.2或1.3”要么客户端抛出一句让人摸不着头脑的“创建TLS客户端凭据时发生严重错误内部错误状态为10013”再或者日志里躺着一行Cannot find module查了半天发现只是证书路径写错了。这些报错看起来八竿子打不着但本质都指向同一件事对Node的tls模块底层机制理解得不够透。Node的tls模块是网络编程里被低估得最厉害的一个模块。它建立在OpenSSL之上API和net模块长得几乎一样但多出来的那一堆options才是真正决定成败的地方。这篇博文从TLS协议本身讲起把核心API、证书生成、单向和双向认证、Wireshark抓包解密、生产环境加固一次讲透。不管你是刚接触Node网络编程的新手还是在生产环境被TLS报错折磨的运维和后端工程师这篇都值得存一下。1. TLS到底在防什么三个核心目标和版本选择1.1 一个没有加密的网络请求有多危险先打个比方。不加密的TCP连接就像把内容写在明信片上寄出去沿途每个中转站都能看到你在聊什么拆开看一眼再原样封回去你也不知道。TLS要做的就是把明信片换成带身份验证的密封包裹而且要保证包裹一旦被人动过手脚收件人能立刻发现。落到技术层面TLS解决三个问题。第一是机密性数据在传输过程中是密文就算被截获也读不出内容第二是完整性数据在传输过程中如果有任何改动接收方可以通过MAC校验发现第三是身份认证客户端要确认自己连的真的是目标服务器而不是冒充的中间人服务器在某些场景下也要确认客户端的身份。这三个目标和Node的tls模块直接相关。很多人以为配置了证书就等于加密了其实证书只是身份认证那一环的载体真正用来加密数据的对称密钥是在TLS握手过程中协商出来的。这也是为什么tls模块的options里既有key和cert又有ca和rejectUnauthorized——每一类配置对应TLS要解决的一个问题。搞不清楚这些配置各管哪一段出了问题基本只能靠猜。1.2 从http到httpsNode里TLS的定位TLS在协议栈里的位置很特殊。它工作在TCP之上、应用层之下所以理论上任何基于TCP的应用层协议都可以套上TLS加密HTTP套上TLS就是HTTPSMQTT套上TLS就是MQTTSgRPC套上TLS就是gRPC over TLS。Node里有两个模块都在做这件事。https模块是专门为HTTP封装的高层模块底层用的还是tls模块的能力适合写Web服务。tls模块则是面向TCP层的通用加密套接字封装适合自定义协议、即时通讯、区块链节点、存储网关这类场景。判断标准很简单如果你的应用层协议是HTTP用https就够了如果是在裸TCP上跑自定义协议或者要精细化控制握手参数那就直接用tls模块。有句话在社区里流传很广我觉得说得很准确TLS是安全传输层协议用于在两个通信应用程序之间提供保密性和数据完整性。这句话看起来像定义其实已经把tls模块的设计意图说清楚了——tls.createServer创建出来的socket本质上还是一个net.Socket只是这个socket上多了一层加密和认证能力。API设计也延续了这个思路从net切到tls代码结构基本不用动。1.3 TLS 1.2还是1.3版本差异怎么选现在主流浏览器已经不认TLS 1.0和1.1了Firefox报“该网站使用了已弃用的TLS版本”就是在告诉你服务端还在用老协议。最低要求是TLS 1.2推荐直接TLS 1.3。TLS 1.3和1.2的差距是代差级的。TLS 1.2完整握手需要两次往返TLS 1.3压缩到一次往返支持会话恢复时甚至能做到0-RTT连接延迟大幅降低。更重要的是TLS 1.3砍掉了一堆老旧的加密算法强制要求前向保密也就是说即使服务器私钥泄露历史流量也无法被解密。Node.js对TLS 1.3的支持从12版本开始就比较完善了现代Node版本默认会优先协商TLS 1.3。如果你想显式控制版本下限可以在tls.createServer的options里设置minVersion。我在老项目里见过写死TLS 1.2的做法虽然也能用但从性能和安全性角度看没有特殊兼容需求的话我建议把下限设成TLSv1.2让客户端能协商1.3就协商1.3。具体配置在后面实操部分会详细给。2. 核心API一把梭createServer与connect的完整用法2.1 最简服务端与客户端实现tls模块的API设计非常接近net模块。服务端用tls.createServer(options, callback)客户端用tls.connect(options, callback)。和net模块最大的差别就是options里多了证书相关的配置处理TLS握手失败时会多出一些特有的错误码。先看一个最基础的单向认证服务端这段代码只需要两个文件服务器证书和私钥。const tls require(tls); const fs require(fs); const path require(path); const options { key: fs.readFileSync(path.join(__dirname, certs, server-key.pem)), cert: fs.readFileSync(path.join(__dirname, certs, server-cert.pem)), minVersion: TLSv1.2 }; const server tls.createServer(options, (socket) { console.log(client connected, protocol:, socket.getProtocol()); socket.write(welcome to tls server\n); socket.on(data, (data) { console.log(received:, data.toString()); socket.write(echo: ${data}); }); socket.on(end, () { console.log(client disconnected); }); }); server.listen(8443, () { console.log(tls server listening on 8443); });对应的客户端这样写const tls require(tls); const fs require(fs); const path require(path); const options { host: localhost, port: 8443, ca: [fs.readFileSync(path.join(__dirname, certs, ca-cert.pem))], servername: localhost }; const socket tls.connect(options, () { console.log(connected, protocol:, socket.getProtocol()); socket.write(hello tls); }); socket.on(data, (data) { console.log(server says:, data.toString()); socket.end(); }); socket.on(error, (err) { console.error(tls error:, err.message); });这里面有几个容易踩坑的点。客户端必须通过ca选项显式信任签发服务器证书的CA否则Node会直接抛UNABLE_TO_VERIFY_LEAF_SIGNATURE。生产环境如果用的是正规CA签发的证书可以不设置caNode会使用系统根证书库但开发环境用自签名CA时ca这一步绝对不能省。servername选项对应TLS SNI扩展要和你证书里签的域名一致如果你证书签的是localhost这里就必须传localhost传127.0.0.1在某些实现里会报域名不匹配。socket.getProtocol()这个方法很实用可以确认当前连接实际协商到的TLS版本我在调兼容性问题时几乎必用。2.2 证书加载、SNI与多域名证书文件常见的格式是PEM和PFX。PEM是文本格式用fs.readFileSync直接读字符串就行PFX是二进制格式如果服务端配置的是PFX就不需要分开指定key和cert直接在options里传pfx和passphrase即可。我个人在Node项目里更推荐PEM格式因为它可以配合文本工具查看、拼接排障更方便。如果你在一台服务器上要跑多个域名每个域名对应不同证书SNI就是关键。SNI允许客户端在握手阶段先告诉服务器自己访问的是哪个域名服务器再选择对应的证书返回。Node的tls.createServer支持SNICallback选项const server tls.createServer({ SNICallback: (servername, cb) { const ctx getContextByDomain(servername); if (ctx) { cb(null, ctx); } else { cb(new Error(no cert for domain: servername)); } } }, handler);这里的ctx需要由tls.createSecureContext创建相当于一个独立的证书上下文。生产环境如果要接类似Nginx的多证书场景SNICallback是你必须掌握的方案。注意SNICallback的签名在不同Node版本上略有差异老版本直接返回context新版本是回调风格写之前先确认一下你的Node版本。2.3 双向认证和rejectUnauthorized的坑单向认证只有客户端验证服务器身份双向认证mTLS还需要服务器验证客户端身份。服务端需要额外设置requestCert为true同时提供一个ca数组用来校验客户端证书是不是由受信任的CA签发的。const options { key: fs.readFileSync(...), cert: fs.readFileSync(...), ca: [fs.readFileSync(path.join(__dirname, certs, ca-cert.pem))], requestCert: true, rejectUnauthorized: true, minVersion: TLSv1.2 };开启requestCert后如果客户端没有提供证书连接会被拒如果提供了证书但签名链校验失败连接同样会被拒。注意rejectUnauthorized只有在requestCert为true时才有意义它决定服务器是否要强制校验客户端证书。调试时很多人喜欢把rejectUnauthorized设为false先跑通流程我理解这个操作但必须提醒一句生产环境千万不要这么干。关掉这个开关等于告诉服务器“不管客户端拿什么证书都放行”双向认证就形同虚设了。客户端连接双向认证服务端时需要额外带上自己的key和certconst options { host: localhost, port: 9443, key: fs.readFileSync(path.join(__dirname, certs, client-key.pem)), cert: fs.readFileSync(path.join(__dirname, certs, client-cert.pem)), ca: [fs.readFileSync(path.join(__dirname, certs, ca-cert.pem))], servername: localhost };服务端握手完成后可以通过socket.authorized判断客户端证书是否验证通过通过socket.getPeerCertificate()拿到客户端证书的详细信息比如CN字段、签发者、有效期。在企业内部系统、物联网设备认证、服务间调用这些场景里双向认证是非常常用的一套方案。3. 实操从零开始生成证书并用Node搭建双向TLS3.1 用OpenSSL生成一套测试用的证书链这一步建议在项目根目录建一个certs目录所有证书操作都在里面进行。先确认你的机器装了OpenSSLWindows用户建议装Git自带的版本或者用WSL避免路径和换行符问题。第一步生成一个自签名的根CA证书。这个CA证书在整个体系里是一切的信任源头openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ -keyout ca-key.pem -out ca-cert.pem \ -subj /CNMyTestCA参数解释一下。-x509表示直接生成自签名证书-newkey rsa:2048生成2048位RSA密钥-nodes表示私钥不加密这样Node启动时不用输入密码。-days 3650让这个测试CA十年有效省得频繁重签。-subj里的CN是证书的通用名这里随便写。第二步生成服务器私钥和证书签名请求CSR。证书里的CN或者SAN必须和你访问服务时用的域名一致openssl req -newkey rsa:2048 -nodes \ -keyout server-key.pem -out server.csr \ -subj /CNlocalhost第三步用刚才的CA给服务器签名。这里最关键的是给证书加上SANSubject Alternative Name否则现代浏览器和Node客户端会报证书域名不匹配openssl x509 -req -in server.csr -CA ca-cert.pem -CAkey ca-key.pem \ -CAcreateserial -out server-cert.pem -days 825 -sha256 \ -extfile (printf subjectAltNameDNS:localhost,IP:127.0.0.1)这段命令的意思是让ca-cert.pem给server.csr签名生成server-cert.pem有效期825天这是目前苹果对证书有效期的上限要求同时写入SAN让localhost和127.0.0.1都能匹配到这个证书。第四步生成客户端证书。如果是双向认证客户端也必须有证书流程和服务器类似openssl req -newkey rsa:2048 -nodes \ -keyout client-key.pem -out client.csr \ -subj /CNclient1 openssl x509 -req -in client.csr -CA ca-cert.pem -CAkey ca-key.pem \ -CAcreateserial -out client-cert.pem -days 825 -sha256用浏览器或直接读文件确认一下生成的证书链是完整的。所有测试做完后server.csr和client.csr这种中间文件可以删掉私钥文件注意用chmod 600保护权限。3.2 单向认证客户端验证服务器的完整实验单向上服务端只提供自己的证书客户端通过ca信任它。用上一节的证书文件直接启动2.1里的服务端和客户端代码你会看到服务端日志tls server listening on 8443 client connected, protocol: TLSv1.3客户端日志connected, protocol: TLSv1.3 server says: welcome to tls server这里多提一句如果你是用openssl命令行来测试服务端可以这样验证openssl s_client -connect 127.0.0.1:8443 -showcerts -servername localhost这个命令会输出完整的证书链、协商的TLS版本和加密套件。如果证书有问题openssl会在握手阶段直接打出来这个工具是排查TLS问题最趁手的利器。3.3 双向认证服务器也验证客户端的完整实验双向认证的服务端代码用2.3里的配置客户端也要换成带key和cert的版本。跑起来后服务端日志里会出现client authorized: true client cert CN: client1如果客户端没带证书或者证书不受信任服务端会在握手时直接抛错客户端会收到ECONNRESET或者类似TLS handshake failed的错误。测试的时候我建议故意做两个破坏性实验加深理解。第一次客户端不加载client证书看服务端报什么错第二次服务端的ca数组改成一个无关的CA看客户端证书校验失败是什么现象。这两个实验做完你就算真正理解rejectUnauthorized和requestCert是怎么协同工作的了。4. 抓包解密与报错排查Wireshark和常见坑4.1 Wireshark解密TLS流量的配置方法很多人在本地写完TLS服务后想确认数据到底是不是加密的会用Wireshark抓包。抓包能看到TLS握手没问题但应用层数据全是密文没法直接分析。其实Node原生支持导出手握过程中的密钥日志Wireshark拿到日志后就能解密。步骤很简单。先在启动Node进程前设置环境变量export SSLKEYLOGFILE$HOME/tls-keys.logWindows下就打开系统属性里的环境变量设置新建一个用户变量SSLKEYLOGFILE值指向一个本地文件路径。注意这个变量要设置在你运行客户端或服务端进程的那个终端里哪个进程做TLS握手就设置哪个进程。然后正常启动你的Node程序执行一次TLS请求。用Wireshark打开抓包文件进入“编辑 - 首选项 - Protocols - TLS”在(Pre)-Master-Secret log filename这一栏填上刚才SSLKEYLOGFILE指向的文件路径。加载之后过滤器里输入tls或者http2解密后的内容就能直接看到了。原理其实不复杂。现代TLS使用前向保密算法时任何单一私钥都无法解密流量但客户端在内存中本来就有握手协商出的对称密钥把它导出到日志文件调试工具就能靠它还原明文。这个方法只建议在本地调试或测试环境用生产环境长期开启SSLKEYLOGFILE等于把加密流量明文暴露给任何能读日志的人这个坑千万别踩。4.2 新手最容易踩的5个报错以下是我在实际使用中遇到过的报错按出现频率从高到低排个序报错信息常见原因排查方向ERR_TLS_CERT_ALTNAME_INVALID证书中的域名和访问地址不匹配生成证书时加上SAN包含所有用到的DNS和IPUNABLE_TO_VERIFY_LEAF_SIGNATURE客户端不信任证书签发者将签发证书的CA配置到ca选项DEPTH_ZERO_SELF_SIGNED_CERT自签名证书直接当服务器证书用先建CA再用CA签服务器证书别拿CA证书当站点证书ECONNRESET服务端握手失败或客户端提前关闭看服务端日志用openssl s_client复现内部错误状态10013Windows下TLS客户端凭据初始化失败更新系统根证书排查安全软件重装Node这里重点展开一下“内部错误状态10013”。这个报错在Windows上特别常见触发场景也千奇百怪有在VMware启动时报的有在IDE里跑Node时报的也有在Node服务里创建TLS客户端连接时报的。它本质上是Windows底层的TLS客户端凭据初始化环节出了问题。我踩过一次之后总结的排查顺序是先用node -p process.versions.openssl确认Node编译时用的OpenSSL版本然后跑一下certmgr.msc看系统受信任的根证书目录是否完整如果机器装了安全软件试着把Node进程加入白名单或者临时退出排除干扰最后还不行就用nvm-windows重装一个干净版本的Node。这个错误大概率不是代码问题别在业务代码里白耗时间。CVE-2016-2183这个漏洞名在漏洞扫描报告里也经常出现它针对的是3DES算法。如果你的服务端还允许协商3DES这类64位分组长度的弱加密套件扫描器就会报这个。修复办法不是升级Node而是在ciphers配置里显式去掉弱套件后面第5章会说。4.3 查TLS问题的三板斧排查TLS问题我基本就靠三把斧头。第一把openssl s_client。它能看到完整证书链、协商版本、加密套件还能主动触发握手失败看错误细节。命令我前面给过配合-servername参数一定要加不然多个域名共用IP时可能选错证书。第二把NODE_DEBUGtls。启动Node时加上这个环境变量tls模块会输出详细的握手调试信息包括收到的证书、支持的套件、验证失败的步骤。看日志时重点找Error和alert关键字。NODE_DEBUGtls node server.js第三把在业务代码里埋点。连接建立后记录socket.getProtocol()和socket.getCipher()这两个方法一个返回协商到的TLS版本一个返回加密套件。上线初期我把这两个值打印到日志里能快速发现哪些客户端还挤在旧协议上比猜快得多。5. 生产环境TLS加固从证书续期到漏洞扫描5.1 证书链、私钥权限与自动续期生产环境的第一个坑是证书链不完整。服务器证书文件里不能只放站点证书要按“站点证书 中间证书”的顺序拼接最后根证书可以不放在服务器上因为客户端本地有。拼接方法用catcat server-cert.pem intermediate-cert.pem fullchain.pem如果你的中间证书没拼进去大多数客户端能正常工作但总有那么几个老客户端会报证书链不完整排查起来很费劲。我在实际项目里遇到的这类工单最后基本都是证书链拼接问题。私钥权限也是老生常谈。Linux下私钥文件建议chmod 600进程用独立用户运行避免被其他用户读走。有些团队把私钥放进代码仓库我是极其反对的私钥泄露等于你的TLS层完全失效。现在比较规范的做法是用部署系统把私钥注入到环境变量或挂载到安全目录不要在代码里硬编码路径。证书续期建议用certbot这类工具做自动化。Lets Encrypt证书有效期90天靠人肉记日子续期等于自杀。我用cron定时任务跑续期脚本续完了用脚本自动重载Node进程顺便检查证书过期时间提前发告警openssl x509 -enddate -noout -in server-cert.pem5.2 会话复用、0-RTT与证书热更新TLS握手虽然比过去快但频繁握手还是有开销。Node的tls服务端默认支持会话缓存可以通过sessionTimeout和sessionIdContext控制const options { key, cert, sessionTimeout: 300, sessionIdContext: my-app-id };客户端也可以复用会话把第一次建立连接后拿到的session保存下来下次连接时直接附带上const session socket.getSession(); // 保存session下次连接时: const options { session, host, port, ca };这种优化在短连接频繁创建的场景下提升比较明显。TLS 1.3的0-RTT还能进一步省掉一次往返但需要处理重放攻击的问题不是所有场景都适合开启这个需要产品层面评估。线上服务证书更新是个容易被忽略的问题。Node服务如果长时间运行证书到期换新之后不重启进程就加载不到新证书。Node从12.x开始支持server.setSecureContext动态热更新证书上下文提前从文件系统读取新证书构建好新context到点切换避免重启导致连接中断。这个功能在证书自动续期链路里很关键值得仔细测一遍。5.3 安全基线弱套件清理与漏洞扫描最后给一份我生产环境常用的TLS配置模板。核心思路是用白名单加密套件只保留带前向保密能力的套件彻底关掉TLS 1.0/1.1把漏洞扫描器常见的问题挡在门外。const options { key: fs.readFileSync(server-key.pem), cert: fs.readFileSync(fullchain.pem), minVersion: TLSv1.2, ciphers: [ ECDHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-RSA-CHACHA20-POLY1305 ].join(:), honorCipherOrder: true };这段配置配合前面的全链路证书基本能过主流的漏洞扫描包括针对CVE-2016-2183的检测。扫描器报这个漏洞往往就是因为服务端仍允许3DES套件协商把ciphers白名单收紧即可。如果你在生产环境前面还挂了Nginx或负载均衡那TLS终结通常发生在那一层Node这层主要关心和上游之间的传输是否加密、证书是否完整、版本是否符合安全基线。无论在哪一层终结TLS核心原则都一样证书链完整、密钥不出仓、版本不下沉、套件收白名单。我在实际项目里养成了一个习惯每次上线TLS相关服务前先在测试环境跑一遍openssl s_client和漏洞扫描把证书链、TLS版本、加密套件这几项全部对一遍再放生产这一套流程帮我挡掉了大量线上事故。最后分享一个排查思路层面的小技巧。遇到任何TLS报错先画一条信任链谁签发了谁客户端是否信任签发者证书里的域名是否匹配访问地址。80%以上的问题都能在这条链上找到答案。Node的tls模块把复杂的密码学细节都封装好了真正需要你操心的从来不是算法而是这条信任链上每一环都站得稳。
返回列表