【Nodejs】安全的网络服务 | 实现一个HTTPS服务

779 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第13天,点击查看活动详情

前言

之前我们介绍的 HTTP 服务,数据在服务器端和客户端之间传递是基于明文传输的,一旦在网络被人监控,数据就可能遭到泄露。在网络安全日益严峻的今天,这样的漏洞是致命的。为此我们需要将数据加密后再进行网络传输,这样即使数据被截获和窃听,窃听者也无法知道数据的真实内容是什么。

在数据加密之后,对于应用层协议而言仍希望能够透明地处理数据,而无须操心网络传输过程中的安全问题。这样我们就要考虑在应用层之下完成这个功能,该功能应该对应用层协议是透明的、无侵入的。

在 NetScape 浏览器推出之初就提出了SSL(Secure Sockets Layer,安全套接层)。SSL作为一种安全协议,它在传输层提供对网络连接加密的功能。对于应用层而言,它是透明的 ,数据在传递到应用层之前就已经完成了加密和解密的过程。最初的SSL应用在Web上,被服务器端和浏览器端同时支持,随后 IETF 将其标准化,称为TLS(Transport Layer Security,安全传输层协议)。

Nodejs 在网络安全上内置了三个模块,分别为 cryptotlshttps。其中 crypto 主要用于加密解密,而 tls模块 提供了与 net模块 类似的功能,区别在于它建立在 TLS/SSL 加密的TCP连接上。对于 https模块 而言,它完全与 http模块 接口一致,区别也仅在于它建立于安全的连接之上。对于 nethttp 模块,我们之前已经做了详细介绍,这能帮助你更快的理解本节内容。

TLS/SSL

密钥交换

TLS/SSL是一个公钥/私钥的结构,它基于非对称加密,每个服务器端和客户端都有自己的公私钥。公钥用来加密要传输的数据,私钥用来解密接收到的数据。公钥和私钥是配对的,通过公钥加密的数据,只有通过私钥才能解密。

所以在建立安全传输之前,客户端和服务器端之间需要互换公钥。客户端发送数据时要通过服务器端的公钥进行加密,服务器端发送数据时则需要客户端的公钥进行加密。如图:

Nodejs 在底层采用的是 openssl 来实现 TLS/SSL 的,因此,你可以同通过 openssl 生成公钥和私钥:

openssl genrsa -out server.key 2048
openssl genrsa -out client.key 2048

上面我们生成了 2048位 长的私钥文件。利用私钥我们可以生成公钥:

openssl rsa -in server.key -pubout -out server.pem
openssl rsa -in client.key -pubout -out client.pem

但是,这样做看上去解决了安全问题,实际上却面临 中间者攻击。客户端和服务器端在交换公钥的过程中,中间人对客户端扮演服务器端的角色,对服务器端扮演客户端的角色,因此客户端和服务器端几乎感受不到中间人的存在。

这个问题的本质是公钥的认证问题,即双方都无法确认公钥是否为伪造的。因此在数据传输过程中还需要对得到的公钥进行认证,以确认得到的公钥是出自目标服务器。为此,TLS/SSL引入了数字证书来进行认证。

与直接用公钥不同,数字证书中包含了服务器的名称和主机名、服务器的公钥、签名颁发机构的名称、来自签名颁发机构的签名。在连接建立前,会通过证书中的签名确认收到的公钥是来自目标服务器的,从而判断所得公钥是否为目标服务器的。

数字证书

这里我们需要引入一个权威的第三方:CA(Certificate Authority,数字证书认证中心) 。CA的作用是为站点颁发证书,且这个证书中具有CA通过自己的公钥和私钥实现的签名。

需要经历一下流程:

  1. 服务端使用自己的私钥生成 CSR(Certificate Signing Request,证书签名请求)文件
  2. CA机构 将通过 CSR文件 颁发属于该服务器端的数字证书(Certificate)。
  3. 客户端获取到数字证书后,通过使用浏览器内置的 CA根证书 与获得的证书进行对比,在确认证书发布机构及信息正确后,就能确定证书的正确性。此时证书内的、来自服务端的公钥就被认为肯定是来自服务端的、未经篡改的公钥。(默认浏览器内置的 CA根证书 是一定正确的)

而关于 Hash 算法 计算 摘要(Digest)、利用摘要获取 数字签名(Signature)数字证书(Certificate) 的具体内容,涉及密码学和一些基本原理,在此我们并不赘述。

自签证书

但是,通过CA机构颁发证书非常麻烦,因此我们可以采用自签名证书来构建安全网络的。所谓自签名证书,就是自己扮演CA机构,给自己的服务器端颁发签名证书。

以下为扮演CA生成私钥、生成CSR文件、通过私钥自签名生成证书的过程:

openssl genrsa -out ca.key 2048
openssl req -new -sha256 -key ca.key -out ca.csr
openssl x509 -req -in ca.csr -signkey ca.key -out ca.crt

然后服务端需要向CA申请签名证书:

# 生成CSR文件
openssl req -new -key server.key -out server.csr
# 向CA申请签名证书
openssl x509 -req \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-in server.csr -out server.crt

客户端验证流程

之后,客户端在发起安全连接前会去获取服务器端的证书,并通过CA根证书来验证服务器端证书的真伪。除了验证真伪外,通常还含有对服务器名称、IP地址等进行匹配验证。

如果是权威知名的CA机构,它们的证书一般预装在浏览器中。如果是自己扮演CA机构,颁发自有签名证书则需要自己将CA证书导入浏览器,或者想办法让客户端在需要获取CA的证书自取。如在使用 curl 命令时通过 --cacert 命令设置 ca.key 的目标地址。

接下来我们分别用 tls 模块和 https 模块构建一个简单的安全通信服务

TLS 服务

创建服务端

准备好所需的证书之后,我们可以通过以下代码创建一个安全 TCP 服务:

const tls = require('tls');
const fs = require('fs');

const listionOptions = {
  host: 'localhost',
  port: 3302
};

const options = {
  key: fs.readFileSync('../server/server.key'),
  cert: fs.readFileSync('../server/server.crt'),
  requestCert: true,
  ca: [ fs.readFileSync('../ca/ca.crt') ],
};

const server = tls.createServer(options, (stream) => {
  console.log('server connected, authorized?', stream.authorized);

  stream.write("hello tls!\n");
  stream.setEncoding('utf-8');
  stream.pipe(stream);
});

server.listen(listionOptions, () => {
  console.log('server bound!');
});

💡 你可以通过 openssl s_client -connect 127.0.0.1:3302 来查看证书信息!

创建客户端

在构建客户端之前,你先要为其生成自己的私钥和签名。你可以参考上面服务端的代码:

  1. 创建私钥。
  2. 生成CSR文件。
  3. 通过CA生成签名证书。

然后就能利用 tls 提供的 connect() 方法来连接服务端了:

const tls = require('tls');
const fs = require('fs');

const options = {
  key: fs.readFileSync('../client/client.key'),
  cert:  fs.readFileSync('../client/client.crt'),
  ca: [ fs.readFileSync('../ca/ca.crt') ],
};

const stream = tls.connect(3301, options, () => {
  console.log('client connected! authorized?', stream.authorized);

  process.stdin.pipe(stream);
});

stream.setEncoding('utf-8');
stream.on('data', (data) => {
  console.log(data);
});
stream.on('end', () => {
  console.log('end', stream.closed());
});

至此我们完成了TLS的服务器端和客户端的创建。

💡 不难发现,与普通的TCP服务器端和客户端相比,TLS的服务器端和客户端仅仅是增加了一个证书配置,其余部分基本相同。

HTTPS 服务

同理,你可以简单的创建出基于 HTTPS 的服务:

const https = require('https');
const fs = require('fs');

const listionOptions = {
  host: '127.0.0.1',
  port: 3300
};

const options = {
  key: fs.readFileSync('../server/server.key'),
  cert: fs.readFileSync('../server/server.crt'),
};

const server = https.createServer(options, (req, res) => {
  console.log('server connected: ', req);

  res.writeHead(200);
  res.end("hello world\n");
});

server.listen(listionOptions, () => {
  console.log('server bound!');
});

这时,你可以通过浏览器或者 curl 进行测试:

curl https://localhost:3300

但是,由于证书是你自己做的,自然无法通过安全校验。你可以通过 -k 命令无视风险,这样虽然通信还是经过加解密,但是无法避免我们一开始提到的 中间者攻击。此时结果如下:

$ curl -k https://localhost:3300
hello world

另外,你可以通过我们刚才提到的方法,自己指定证书位置。

curl --cacert ../ca/ca.crt https://localhost:3300

总结

最后,你还可以利用该模块实现 HTTPS 的客户端,其与服务端的代码差别不大,就留给读者自行完成了。至此,我们完成了 Nodejs 的网络模块的介绍,包括:

  • Socket 编程:TCPUDPTSL/SSL
  • 应用层协议:HTTPHTTPSWebSocket

Nodejs 有很多后端应用框架已经封装好了通信方法,能够让你更快的实现网络通信,而不需要关心应用层甚至是传输层的具体实现。例如 ExpressJS、KoaJS、EggJS、NestJS等。

接下来我们的核心关注点将放在 I/OAsynchronousProcess测试 方面。IO和进程偏向于操作系统的内容,有时间的话我想抽点时间介绍一下操作系统的知识点;而测试则涉及软件测试和测试方法、工具的内容,更偏向工程化的实践。至于异步,这是 Nodejs 的核心特性之一,值得我们讨论一番。