OpenSSL 的使用 - 边缘触发模式
过去的几周我使用 OpenSSL 对我们的某个服务端程序提供了 SSL/TLS 的支持,在这过程中看了不少资料,踩了很多坑。网络能查到的例子中,有的使用的是阻塞 IO,这与实际场景很不符合。还有一些虽然给出了事件驱动的例子,但也只是使用水平触发的模式。在实践中我发现使用 epoll 的边缘触发时,编程方式很不一样,需要注意的点也更多,而网络上这方面的例子较少。本文是我实践中的一些心得体会,希望能够对之后尝试使用 OpenSSL 的同学有些帮助。
什么是 OpenSSL
OpenSSL 是一个商业级、全功能、稳定的加密通信套件,它提供了供 C 语言使用的编程库,还包含数字证书生成、调试等一系列的工具。目前要想实现 SSL/TLS 功能,OpenSSL 是首选。因为 OpenSSL 包含的功能很多,暴露的接口自然也很多,这让初次使用 OpenSSL 的我无从下手。阅读了大量的文档和例程后,才算对它的正确使用有了些了解。本文将描述如何使用 OpenSSL 来对服务端实现 SSL/TLS 的支持。
编译安装 OpenSSL
OpenSSL 包含 C 语言的 SDK 和一些工具,要想使用 OpenSSL 自然需要先去下载它。不过你的机器上很可能已经安装了 OpenSSL,可以在终端输入 openssl 来检验是否有安装。但即使安装了,有可能和你所需的版本不匹配,因此这里我简单描述如何从源码来安装。
第一步:去 OpenSSL 的下载页面 下载源码:
$ wget https://www.openssl.org/source/openssl-1.1.1l.tar.gz
$ tar xf openssl-1.1.1l.tar.gz
第二步:编译安装:
$ export DIR="/home/wangyu/openssl" # 指定需要安装的路径
$ ./config --prefix=${DIR} --openssldir=${DIR}
$ make -j 8
$ make install
执行成功后,在你指定的路径 $DIR 下会出现如下目录和文件:
.
├── bin/
├── certs/
├── ct_log_list.cnf
├── ct_log_list.cnf.dist
├── include/
├── lib64/
├── misc/
├── openssl.cnf
├── openssl.cnf.dist
├── private/
└── share/
其中 include/ 中包含头文件,lib64/ 中包含静态链接库和动态链接库。有了这两个目录,就可以开始动手写代码了。
使用 OpenSSL 编程整体流程
1. 初始化 OpenSSL
程序启动后,需要做的第一件事情,就是初始化 OpenSSL,这里做的事情就是设置支持的协议,设置安全等级,读取证书、密钥等等。
SSL_CTX *ssl_ctx = NULL;
int tls_ctx_init(const char *tls_cert_file, const char *tls_key_file) {
/* SSL library initialisation */
SSL_library_init();
OpenSSL_add_all_algorithms();
// 创建 SSL 的上下文,TLS_server_method 是指尽可能使用最新 TLS 协议
ssl_ctx = SSL_CTX_new(TLS_server_method());
// 设置一些选项,这里意思是避开使用 SSLv2 和 SSLv3 这两个不安全的协议
SSL_CTX_set_options(ssl_ctx, SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3);
// 设置是否验证客户端的证书,这里设置为不验证客户端的身份
SSL_CTX_set_verify(ssl_ctx, SSL_VERIFY_NONE, NULL);
// 加载证书
SSL_CTX_use_certificate_file(ssl_ctx, tls_cert_file, SSL_FILETYPE_PEM);
// 加载密钥
SSL_CTX_use_PrivateKey_file(ssl_ctx, tls_key_file, SSL_FILETYPE_PEM);
// 验证密钥是否正确
SSL_CTX_check_private_key(ssl_ctx);
return 0;
}
2. 初始化链接
在 OpenSSL 中,会将文件描述符包装为一个 SSL 的对象,管理握手中的各种细节。在 server 端 accept 完成后,需要使用此 socket 创建一个 SSL 对象。这个 SSL 对象,可以看做是 OpenSSL 眼中的文件描述符。
SSL* ssl_new(int fd) {
SSL *ssl = SSL_new(ssl_ctx);
// 将 fd 与 SSL 对象关联
SSL_set_fd(ssl, fd);
// 设置这个 SSL 对象为服务端 SSL 对象
SSL_set_accept_state(ssl);
return ssl;
}
3. 握手
SSL/TLS 是建立在 TCP 之上的协议,在 TCP 三次握手完成成功建立连接后,需要再进行 SSL/TLS 的握手,这个过程做的事情包含发送证书、协商加密协议、加密算法,交互对称加密的密钥等等。
链接初始化后需要在 SSL 对象上完成 SSL/TLS 的握手,在实际编程中,根据底层 socket 是否为阻塞式 IO,SSL_accept 的行为是不同的。
阻塞式 IO
如果是阻塞式 IO,那么可以调用 SSL_accept(ssl) 来完成握手。SSL/TLS 的握手可能需要多次的网络交互,在阻塞模式下,一次 SSL_accept 调用就够了,如果返回值为 1 则握手成功。
阻塞 IO 中,SSL_accept 调用会在 SSL/TLS 握手完成后返回,只要返回值无异常,则握手成功。此函数的调用中,会进行多次的网络通信。这对于高性能的网络服务是不可接受的。
非阻塞 IO
非阻塞 IO 中,SSL_accept 中的网络读写如果会阻塞,则会立刻返回,需要择机再次调用。所以 SSL_accept 需要被调用多次,通常可以结合事件驱动来执行调用。在非阻塞 IO 中,SSL_accept 若未能完成握手,会出现错误,这就像非阻塞 socket 在没数据可读的时候,如果尝试读取会返回 -1,且设置 errno 为 EAGAIN 或者 EWOULDBLOCK 一样。
OpenSSL 中的错误码中,和 EAGAIN 对应的就是 SSL_ERROR_WANT_WRITE 和 SSL_ERROR_WANT_READ。在 OpenSSL 握手期间,如果想要给对端发送数据,但此时执行读取操作,就会出现 SSL_ERROR_WANT_WRITE 错误。反之,如果希望读取数据,但此时却执行写操作,则会出现 SSL_ERROR_WANT_READ 错误。
4. 读写
SSL/TLS 握手完成后,可以使用封装了文件描述符的 SSL 对象来调用 SSL_read 和 SSL_write 进行读写。这里写入和读取的都是明文,使用 SSL_write 写入的数据会由 OpenSSL 加密传输到对端,用 SSL_read 读取的数据是由 OpenSSL 解密后的数据。
SSL_read(ssl, buffer, sizeof buffer);
SSL_write(ssl, buffer, sizeof buffer);
如果底层 socket 是阻塞式 IO,这里的读写也是阻塞的。如果是阻塞 IO 则会稍微麻烦一点。执行读操作时候,如果底层的 socket 中没有数据了,也会返回 SSL_ERROR_WANT_READ 错误。由此可见,SSL_ERROR_WANT_READ 意思就是 SSL/TLS 层想从底层 TCP 层读取数据,但此时没数据可读。在事件驱动的编程模式中,此时就应该注册可读时间,等待有数据可读了,在从 SSL/TLS 层读数据。
以上就是使用 OpenSSL 实现加密的大体流程。
非阻塞 IO 中 OpenSSL 编程的注意事项
在非阻塞 IO 中,执行 SSL_accept 时,如果网络读写会阻塞,则会立刻返回,并出现错误。非阻塞的 socket 在没数据可读的时候,如果尝试读取会返回 -1,且设置 errno 为 EAGAIN 或者 EWOULDBLOCK ,在 OpenSSL 中也有同样的机制。
int ret = SSL_accept(ssl);
if (ret <= 0) {
// 错误处理
int err = SSL_get_error(ssl, ret);
switch (err) {
case SSL_ERROR_NONE:
return 0;
case SSL_ERROR_WANT_WRITE:
update_event(ssl, EVENT_WRITE);
return 0;
case SSL_ERROR_WANT_READ:
update_event(ssl, EVENT_READ);
return 0;
case SSL_ERROR_SYSCALL:
case SSL_ERROR_ZERO_RETURN:
default:
return -1;
}
}
OpenSSL 中的错误码中,和 EAGAIN 对应的就是 SSL_ERROR_WANT_WRITE 和 SSL_ERROR_WANT_READ。在 OpenSSL 握手期间,如果想要给对端发送数据,但此时执行读取操作,就会出现 SSL_ERROR_WANT_WRITE 错误。反之,如果希望读取数据,但此时却执行写操作,则会出现 SSL_ERROR_WANT_READ 错误。
由此可见,SSL_ERROR_WANT_READ 意思就是 SSL/TLS 层想从 TCP 层读取数据,但此时没数据可读。在事件驱动的编程模式中,此时就应该注册可读时间,等待有数据可读了,再从 SSL/TLS 层读数据。SSL_ERROR_WANT_WRITE 同理。
因此,使用非阻塞 IO 时,一定要注意合理地处理错误码。SSL_ERROR_NONE 指一切正常,通常只有给 SSL_get_error 传入正值时才会得到这个错误码。 对 SSL_ERROR_WANT_WRITE 和 SSL_ERROR_WANT_READ 两个错误码的处理,可以是注册对应的事件。 SSL_ERROR_ZERO_RETURN 意味着对端已经关闭连接。SSL_ERROR_SYSCALL 意味着系统调用出错。更多错误码的含义详见文档。
边缘触发(ET)中 OpenSSL 编程的注意事项
若使用 epoll 的边缘触发来实现事件驱动,那就尤其需要注意边缘触发的特点。使用边缘触发,会在注册事件之后触发一次。对端有数据发送过来时,如果一次性没有读完,则不会再次触发。因此使用边缘触发时,要一次性把数据全都读取出来。
当 epoll 在 listen socket 上触发 EPOLLIN 事件时,这说明有数据可读,此时会调用 accept 得到 client fd,然后调用 SSL_accept 执行 SSL/TLS 握手。 因为是 non-blocking socket,调用 SSL_accept 会推进握手进度,但不会一次完成,SSL_accept 会返回 SSL_ERROR_WANT_READ / SSL_ERROR_WANT_WRITE 错误,期待有数据可读、可写时候再次被调用。
这里存在一个需要注意的点,在边缘触发模式下,在新数据到来之前,EPOLLIN 事件都不会再次触发。所以,SSL_accept 调用完成后,如果不使用 SSL_read 读完 socket 中的数据,则有可能不会等到下一次 EPOLLIN 事件的触发,此时客户端可能还在等服务端响应呢。这里存在一个问题,即握手数据是否会和应用数据混在一起。
通过 RFC5246 看出,TLS 1.2 中,在服务端中,客户端发来的握手数据不会和应用数据混在一起。
Client Server
ClientHello -------->
ServerHello
Certificate*
ServerKeyExchange*
CertificateRequest*
<-------- ServerHelloDone
Certificate*
ClientKeyExchange
CertificateVerify*
[ChangeCipherSpec]
Finished -------->
[ChangeCipherSpec]
<-------- Finished
Application Data <-------> Application Data
TLS 1.2 Handshake Protocol Overview
由 RFC8446-Protocol Overview 可以看出,在 TLS 1.3 中,客户端握手数据是可能和应用数据混在一起的。
Client Server
Key ^ ClientHello
Exch | + key_share*
| + signature_algorithms*
| + psk_key_exchange_modes*
v + pre_shared_key* -------->
ServerHello ^ Key
+ key_share* | Exch
+ pre_shared_key* v
{EncryptedExtensions} ^ Server
{CertificateRequest*} v Params
{Certificate*} ^
{CertificateVerify*} | Auth
{Finished} v
<-------- [Application Data*]
^ {Certificate*}
Auth | {CertificateVerify*}
v {Finished} -------->
[Application Data] <-------> [Application Data]
TLS 1.3 Handshake Protocol Overview
使用 SSL_read 读数据时的注意事项
SSL_read 用于从 SSL/TLS 层读取数据,假如现在有 24KB 数据可读,传入的 buffer 空间足够大,但此时会发现可能只读到了 16KB 数据。这一点和 read 函数很不相同,使用 read 从 socket 中读取数据时,只要接收缓冲区足够大,一次性就可以读出全部可读的数据,需要注意 SSL_read 的行为不是这样的。 因为 SSL_read 是按照 record 来读取的,此时可读的数据可能存放在多个 record 中。
The record layer fragments information blocks into TLSPlaintext records carrying data in chunks of 2^14 bytes or less.
因此,在边缘触发模式下,需要多次调用 SSL_read 将所有的 record 都读出来,一般需在循环中反复调用 SSL_read 直到出现错误 SSL_ERROR_WANT_READ 为止。
使用 SSL_write 写数据时的注意事项
执行 SSL_write(ssl, buffer, len) 时,OpenSSL 会将 buffer 分段,将其放入一个个 record 中并加密传输。如果使用非阻塞 IO,且 buffer 长度比较大,这个 buffer 很可能无法发送完,此时会返回 -1 错误码为 SSL_ERROR_WANT_WRITE。当底层的 socket 可写了以后,需要再次调用 SSL_write(ssl, buffer, len) 尝试写入。这里存在一些很难理解的地方,下次执行 SSL_write 的时候,传入的 buffer 和 len 应该和上一次相同。每次调用 SSL_write(ssl, buffer, len) 都会往对端发送部分数据,但是只有 SSL_write 将所有数据都处理完成后,才会返回 len,表示数据处理完毕。
这种行为很不符合直觉,用户常常觉得,一次写不完,那至少已经写入了一部分,下次我调整偏移量再尝试写,直到我写完不就可以了。但 OpenSSL 是这样想的,我这次没有处理完,你下次再拿来让我处理,等全部处理完了那确实就全部处理完了,你不需要做什么偏移量的调整。
但有的时候,就希望分多次将数据写入,每次写了一部分数据后,就删除应用层的缓存,下次调整偏移量再来写。此时可以设置 SSL_MODE_ENABLE_PARTIAL_WRITE 这个选项,设置了这个选项后,只要部分成功写入,SSL_write 就会返回成功。
开启了 SSL_MODE_ENABLE_PARTIAL_WRITE 后,需要多次调用 SSL_write 来尝试将更多数据写入,且每次写入的数据要尽可能和 record 的 payload 接近。
这个邮件列表中讨论了 SSL_MODE_ENABLE_PARTIAL_WRITE 相关细节。