WangYu::Space

Study, think, create, and grow. Teach yourself and teach others.

OpenSSL 的使用 - 边缘触发模式

分类:网络创建时间:2021-12-13 00:00:00

过去的几周我使用 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_WRITESSL_ERROR_WANT_READ。在 OpenSSL 握手期间,如果想要给对端发送数据,但此时执行读取操作,就会出现 SSL_ERROR_WANT_WRITE 错误。反之,如果希望读取数据,但此时却执行写操作,则会出现 SSL_ERROR_WANT_READ 错误。

4. 读写

SSL/TLS 握手完成后,可以使用封装了文件描述符的 SSL 对象来调用 SSL_readSSL_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_WRITESSL_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_WRITESSL_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 相关细节。

评论 (评论内容仅博主可见,不会公开显示)