在【第9章“建立TLS连接并通过它们发送数据”】中,我们了解了传输层安全(Transport Layer Security,TLS)协议以及如何直接建立TLS连接。在本章中,我们将学习TLS的高级用法和特殊用例。
第十一章 TLS的特殊用途技术要求了解TLS证书固定使用TLS证书固定更改 run_tls_client()
函数实现 cert_verify_callback()
函数运行 tls-cert-pinning
程序了解阻塞和非阻塞套接字在非阻塞套接字上使用TLS更改 run_tls_client()
函数运行 tls-client-non-blocking
程序了解非标准套接字上的TLS在非标准套接字上使用TLS实现 service_bios()
函数重新实现 run_tls_client()
函数运行 tls-client-memory-bio
程序总结
本章将包含可以在命令行上运行的命令以及可以构建和运行的C源代码。对于命令行命令,您将需要带有openssl动态库的 openssl
命令行工具。要构建C代码,您需要OpenSSL动态或静态库、库头、C编译器和链接器。
本章的源代码可以在这里找到:
https://github.com/PacktPublishing/Demystifying-Cryptography-with-OpenSSL-3/tree/main/Chapter11
有时,证书验证不是使用具有证书存储、可信证书和证书验证链的传统公钥基础设施(Public Key Infrastructure,PKI)规则完成的。非标准证书验证方法之一是TLS证书固定(pinning)。TLS客户端将特定证书固定(pins)到服务器,这意味着它希望服务器拥有该确切证书。证书固定有多种变体,例如固定几个可能的证书而不是一个,固定证书公钥而不是证书本身,或者固定特定的颁发者证书。TLS证书固定既可以作为标准证书验证的补充,也可以作为其替代。
证书固定不是公共互联网上HTTPS服务器的一种非常流行的证书验证方法。然而,虽然您可能从未想过,但公钥固定实际上是SSH协议中的主要公钥验证方法。
known_hosts
文件中;authorized_keys
文件中。当涉及到TLS协议时,证书固定用于仅与一台服务器通过互联网通信的一些应用程序中。它在移动银行应用程序中尤其受欢迎。
用于TLS的默认PKI模型允许可信证书存储中的任何证书颁发机构(Certificate Authority,CA)为任何服务器签署证书。一个典型的操作系统(Operating System,OS)或浏览器证书存储包含100多个受信任的CA证书。这意味着我们所依赖的假设是,这些CA中没有一个会颁发可用于中间人(Man in the Middle,MITM)攻击的流氓证书。但有时CA会颁发流氓证书,因为有时CA会被黑客攻击,就像DigiNotar、GlobalSign和Comodo CA一样。同样,有时CA会为执法机构使用的MITM箱颁发中间证书。这类MITM箱被放置在犯罪嫌疑人的ISP处。嫌疑人的TLS连接通过MITM盒路由。MITM盒通过为通过MITM盒访问的任何服务器实时颁发证书,对嫌疑人发起MITM攻击。这种MITM攻击允许MITM盒拦截和解密可疑对象的TLS连接。
还有另一种MITM盒子——商用MITM盒子。它们由组织的系统管理员安装在组织中,用于检查TLS流量中的病毒和木马。在这种情况下,MITM框中的CA证书不是由公知的CA颁发的,而是由该组织的CA颁发。然后,组织确保其CA的根CA证书作为受信任的证书安装到所有员工计算机上的证书存储中。
攻击默认PKI模型的另一种方法是在用户的证书存储中安装一个流氓根CA证书。这可以通过侵入用户的机器或使用社会工程方法来实现。
所描述的三种情况是否构成可接受的风险?每个人都自己决定。但许多银行决定在其应用程序中使用TLS证书固定,以降低MITM攻击的风险。
除了提高安全性外,TLS证书固定还可以节省一些成本。您可以自己生成证书,而不是从商业CA购买服务器证书。您甚至可以使用自签名证书来减少维护。在TLS客户端中检查固定服务器证书时,您可以放弃通常在证书验证期间对证书进行的许多检查。如果你已经固定了一个证书,这意味着它已经过预先验证,并且已知是好的。
TLS证书固定也有一些缺点。固定证书的常用方法是将证书或其摘要提供给客户端应用程序。如果您只信任提供给应用程序的一个固定证书,则只能通过应用程序更新来更新该固定证书。此外,在这种情况下,您必须将服务器上的证书更新与应用程序的更新同步。这不是很实际,但这个问题有几个解决方案。一种方法是将多个允许的证书固定到应用程序中的服务器上,例如当前证书和计划中的下一个证书。如果服务器证书的有效期为一年或更长,则要求用户经常更新应用程序以更新证书是合理的。另一种解决方案是在应用程序中实现自己的自动更新机制,该机制将通过当前证书保护的连接获取下一个证书。另一种解决方案是锁定颁发者(CA)证书,而不是服务器证书或作为服务器证书的补充。此解决方案提供的安全性低于固定服务器证书,但仍高于不固定。但是,在更新固定的颁发者证书时,我们也会遇到同样的问题。我们必须通过应用程序更新来更新它,或者在应用程序中实现更新机制。
使用TLS证书固定作为默认PKI替代的另一个缺点是,您必须自己实现撤销。然而,实施它并非不可能。此外,值得记住的是,当对默认PKI模型执行MITM攻击时,攻击者可以非常容易地阻止TLS客户端与证书吊销列表(Certificate Revocation List,CRL)分发服务器和在线证书状态协议(Online Certificate Status Protocol,OCSP)服务器的连接,并省略面向客户端的OCSP装订(OCSP staplin)。因此,撤销的论点并不十分有力。
总之,当使用TLS证书固定而不是默认的PKI模型时,有以下优缺点:
优点:
缺点:
在下一节中,我们将学习如何以编程方式在客户端实现简单的TLS证书固定。
为了学习如何在C代码中使用TLS证书固定,我们将编写一个小的 tls-cert-pinning
程序。我们将使用TLS证书固定的一个简单变体:我们将仅固定一个服务器证书,并使用TLS证书锁定而不是默认的PKI模型,而不是除此之外。
我们将使用 SSL_CTX_set_cert_verify_callback()
函数中的“大”证书验证回调集,而不是 SSL_CTX-set_verify()
函数的“小”回调集(callback set),以学习另一种类型的回调。
SSL_CTX_set_cert_verify_callback()
函数设置一个回调函数,该函数应该执行整个证书验证过程。该函数的默认OpenSSL实现构建证书验证链,验证证书签名和有效日期,并调用 SSL_CTX_set_verify()
函数设置的回调函数(如果有的话)。重要的是要理解,使用 SSL_CTX_set_cert_verify_callback()
函数设置回调会覆盖整个默认验证过程。因此,必须非常小心地使用 SSL_CTX_set_cert_verify_callback()
函数。“大”回调的错误实现可能会产生可怕的后果。如果您不确定,最好通过 SSL_CTX_set_verify()
函数设置一个“小”验证回调,我们在【第10章“在TLS中使用X.509证书”】中对此进行了讨论。
我们认为我们的固定证书已经过验证。因此,在检查服务器证书时,我们甚至不需要构建证书验证链;我们的回调函数将只检查服务器证书是否与固定证书匹配。
与许多其他OpenSSL函数一样,SSL_CTX_set_cert_verify_callback()
函数有一个手册页:
xxxxxxxxxx
$ man SSL_CTX_set_cert_verify_callback
我们的 tls-cert-pinning
程序将基于【第9章“建立tls连接并通过它们发送数据”】中的 tls-client
程序。我们将获取tls客户端程序源代码,并添加tls证书固定功能。
tls-cert-pinning
程序将接受以下命令行参数:
与 tls-client
程序不同,在我们的 tls-cert-pinning
程序中,第三个命令行参数是必需的。
让我们开始 tls-client
程序的实现。
run_tls_client()
函数在本节中,我们将添加证书验证回调声明,并对 run_tls_client
函数进行更改:
首先,我们必须声明我们的证书验证回调:
xxxxxxxxxx
int cert_verify_callback(
X509_STORE_CTX* x509_store_ctx, void* arg);
正如我们所看到的,它需要两个参数:
证书验证上下文由OpenSSL创建。用户数据参数由 SSL_CTX_set_cert_verify_callback()
函数在设置证书验证回调的同时设置。回调函数必须返回1表示证书验证成功,或返回0表示验证失败。
我们的下一个实现步骤是从作为第三个命令行参数提供的文件中加载固定证书。为此,我们必须替换 tls-client
程序中的一些代码。我们将替换以下代码:
xxxxxxxxxx
const char* trusted_cert_fname = argv[3];
if (trusted_cert_fname)
err = SSL_CTX_load_verify_locations(
ctx, trusted_cert_fname, NULL);
else
err = SSL_CTX_set_default_verify_paths(ctx);
if (err <= 0) {
if (error_stream)
fprintf(
error_stream,
"Could not load trusted certificates\n");
goto failure;
}
以下是我们将用以下内容替换它:
xxxxxxxxxx
const char* pinned_server_cert_fname = argv[3];
FILE* pinned_server_cert_file = NULL;
X509* pinned_server_cert = NULL;
pinned_server_cert_file = fopen(
pinned_server_cert_fname, "rb");
if (pinned_server_cert_file)
pinned_server_cert = PEM_read_X509(
pinned_server_cert_file, NULL, NULL, NULL);
if (!pinned_server_cert) {
if (error_stream)
fprintf(
error_stream,
"Could not load pinned server certificate\n");
goto failure;
}
下一步是设置证书验证回调:
xxxxxxxxxx
SSL_CTX_set_cert_verify_callback(
ctx, cert_verify_callback, pinned_server_cert);
请注意,我们已将 pinned_server_cert
设置为回调的用户数据。
在回调函数中,我们还需要另一个指针 error_stream
。我们可以将 pinned_server_cert
和 error_stream
组合成一个C结构,从该结构中创建一个对象,并将其设置为 SSL_CTX_set_cert_verify_callback()
函数的用户数据参数。但是,我们可以更容易地将error_stream指针传递给回调函数,就像我们在 【第10章“在TLS中使用X.509证书”】中一样,借助 SSL_set_app_data()
函数:
xxxxxxxxxx
SSL_set_app_data(ssl, error_stream);
这几乎就是我们在程序的 run_tls_client()
函数中要做的一切。
我们还必须在 run_tls_client()
函数末尾释放一些新使用的对象:
xxxxxxxxxx
if (pinned_server_cert)
X509_free(pinned_server_cert);
if (pinned_server_cert_file)
fclose(pinned_server_cert_file);
至此,我们完成了对 run_tls_client()
函数的更改。
cert_verify_callback()
函数现在,让我们实现 cert_verify_callback()
函数:
我们将通过从 run_tls_client()
函数获取发送给回调函数的 error_stream
和 pinned_server_cert
指针来开始实现 cert_verify_callback()
函数:
xxxxxxxxxx
int ssl_ex_data_idx = SSL_get_ex_data_X509_STORE_CTX_idx();
SSL* ssl = X509_STORE_CTX_get_ex_data(
x509_store_ctx, ssl_ex_data_idx);
FILE* error_stream = SSL_get_app_data(ssl);
X509* pinned_server_cert = arg;
我们的下一步是获取实际的服务器证书:
xxxxxxxxxx
X509* actual_server_cert =
X509_STORE_CTX_get0_cert(x509_store_ctx);
请注意,X509_STORE_CTX_get_current_cert()
函数这次不会给你服务器证书。当调用“big”证书验证回调函数时, x509_store_ctx
对象中设置的数据元素很少。当前证书尚未设置。实际上,设置当前证书、深度和验证状态是“大”回调的工作。
获得服务器证书后,让我们打印一些诊断信息:
xxxxxxxxxx
if (error_stream) {
X509_NAME* pinned_cert_subject =
X509_get_subject_name(pinned_server_cert);
X509_NAME* actual_cert_subject =
X509_get_subject_name(actual_server_cert);
fprintf(
error_stream,
"cert_verify_callback() called with the following "
"pinned certificate:\n");
X509_NAME_print_ex_fp(
error_stream,
pinned_cert_subject,
2,
XN_FLAG_ONELINE & ~ASN1_STRFLGS_ESC_MSB);
fprintf(error_stream, "\n");
fprintf(
error_stream,
"The server presented the following certificate:\n");
X509_NAME_print_ex_fp(
error_stream,
actual_cert_subject,
2,
XN_FLAG_ONELINE & ~ASN1_STRFLGS_ESC_MSB);
fprintf(error_stream, "\n");
}
下一步是比较固定证书和实际证书:
xxxxxxxxxx
int cmp = X509_cmp(pinned_server_cert, actual_server_cert);
在检查比较结果并从函数返回之前,让我们还将当前证书及其深度设置为验证上下文:
xxxxxxxxxx
X509_STORE_CTX_set_current_cert(
x509_store_ctx, actual_server_cert);
X509_STORE_CTX_set_depth(x509_store_ctx, 0);
最后,让我们检查证书比较结果,设置正确的验证错误代码,并从回调函数返回正确的结果:
xxxxxxxxxx
if (cmp == 0) {
if (error_stream)
fprintf(
error_stream,
"The certificates match. "
"Proceeding with the TLS connection.\n");
X509_STORE_CTX_set_error(x509_store_ctx, X509_V_OK);
return 1;
} else {
if (error_stream)
fprintf(
error_stream,
"The certificates do not match. "
"Aborting the TLS connection.\n");
X509_STORE_CTX_set_error(
x509_store_ctx, X509_V_ERR_APPLICATION_VERIFICATION);
return 0;
}
这就是 cert_verify_callback()
函数的结束,也是程序实现的结束。
我们的 tls-cert-pinking
程序的完整源代码可以在GitHub上找到 tls-cert-penking.c 文件:
tls-cert-pinning
程序让我们运行 tls-cert-pinning
程序,看看它是如何工作的。我们需要【第9章,建立tls连接并通过它们发送数据】中的 tls-server
程序。我们还需要服务器证书文件作为 tls-cert-pinning
程序的固定证书。
以下是我们将如何测试 tls-cert-pinning
程序的工作原理:
让我们打开两个终端窗口。
在第一个窗口中,我们将启动 tls-server
程序:
xxxxxxxxxx
$ ./tls-server 4433 server_keypair.pem server_cert.pem
*** Listening on port 4433
在第二个窗口中,我们将运行 tls-cert-pinning
程序:
xxxxxxxxxx
$ ./tls-cert-pinning localhost 4433 server_cert.pem
* cert_verify_callback() called with the following pinned certificate:
CN = localhost
The server presented the following certificate:
CN = localhost
The certificates match. Proceeding with the TLS connection.
*** Sending to the server:
GET / HTTP/1.1
Host: localhost
Connection: close
User-Agent: Example TLS client
*** Sending to the server finished
*** Receiving from the server:
HTTP/1.0 200 OK
Content-type: text/plain
Connection: close
Server: Example TLS server
Hello from the TLS server!
*** Receiving from the server finished
TLS communication succeeded
我们可以观察到,服务器证书与固定证书匹配,TLS通信已成功。
接下来,让我们还检查一下如果证书不匹配会发生什么:
xxxxxxxxxx
$ ./tls-cert-pinning www.example.org 443 server_cert.pem
* cert_verify_callback() called with the following pinned certificate:
CN = localhost
The server presented the following certificate:
C = US, ST = California, L = Los Angeles, O = Internet Corporation for Assigned Names and Numbers, CN = www.example.org
The certificates do not match. Aborting the TLS connection.
Could not connect to server www.example.org on port 443
Errors from the OpenSSL error queue:
4017BA4F1B7F0000:error:0A000086
:SSL routines:tls_post_process_server_certificate
:certificate verify failed
:../ssl/statem/statem_clnt.c:1883:
4017BA4F1B7F0000:error:0A000197
:SSL routines:SSL_shutdown
:shutdown while In init
:../ssl/ssl_lib.c:2244:
TLS communication failed
正如我们所看到的,这次服务器证书与固定证书不匹配,因此TLS连接被中止。根据我们的观察,我们可以得出结论,我们对TLS证书固定的简单实现工作正常。
我们现在已经学习了如何使用TLS证书固定,这是高级TLS用例之一。在接下来的两节中,我们将了解另一个高级TLS用例:在非阻塞套接字上使用TLS连接。非阻塞套接字可以帮助您的应用程序提高响应速度或同时处理更多连接。
网络连接可以在阻塞套接字(blocking sockets)或非阻塞套接字(non-blocking sockets)上建立。默认模式取决于操作系统,但对于大多数操作系统来说,它是阻塞模式。
在阻塞模式下,如果程序请求套接字上的输入/输出(Input/Output,I/O)操作,则必须至少部分执行该操作,否则必须发生错误,然后才能将控制返回给程序。如何进行部分手术?例如,如果一个程序试图从阻塞套接字读取100个字节,则读取函数(例如 recv()
)只有在可以从套接字读取至少一个字节时才会返回,否则会发生错误。如果没有来自网络的数据,程序当前线程的执行将被阻止,这意味着当前线程将等待一些数据到来。在某些情况下,线程可能会无限期地等待阻塞套接字。当尝试发送数据时,如果操作系统网络发送缓冲区已满,当前线程可能会阻塞。在这种情况下,发送函数(例如 send()
)将等待操作系统将缓冲的数据发送到网络,释放其发送缓冲区,并将程序想要发送到网络的数据复制到这些缓冲区。send()
函数仅在程序数据被完全或部分(取决于操作系统)复制到操作系统缓冲区,或者发生错误时才会返回。
与阻塞套接字不同,非阻塞套接字不会阻塞程序执行。如果无法执行I/O操作,相应的I/O函数(如 send()
或 recv()
)将立即返回 would block
错误。然后,程序应该在一段时间后重试I/O操作。与此同时,程序可以在同一线程上执行其他操作,例如处理一些数据、在另一个网络连接上发送或接收数据、更新进度指示器或对程序的用户界面(User Interface,UI)做出反应。程序还可以检查套接字是否已准备好发送或接收数据,或者等待套接字准备就绪。大多数操作系统提供的功能可以同时在多个套接字上等待I/O准备就绪并超时。此类函数的示例包括 select()
和 poll()
。OpenSSL为相同的目的提供了自己的函数,如 BIO_wait()
和 BIO_socket_wait()
,但这些函数仅限于一个基本输入/输出(Basic Input/Output,BIO)对象。但是,如果需要同时等待多个BIOs,可以使用 BIO_get_fd()
函数从所需的BIOs中获取套接字描述符,并提供这些套接字描述符的集合来 select()
、poll()
、 epoll_wait()
、 kevent()
,WSAEventSelect()
和 WSAPoll()
或操作系统提供的任何函数。
使用非阻塞套接字,即使在慢速网络连接上,程序也可以保持响应式UI,而无需将网络分离到单独的线程中。非阻塞套接字的另一个流行用法是在服务器端。服务器可以在一个线程上为多个网络连接提供服务,而不会被慢速连接阻塞。与在单独的线程或单独的进程中为每个连接提供服务相比,这种策略使用的系统资源更少。
我们之前的所有代码示例都使用了阻塞套接字。在下一节中,我们将学习如何在非阻塞套接字上以编程方式使用TLS。
为了学习如何在非阻塞套接字上使用TLS,我们将编写一个小型 tls-client-non-blocking
程序。
我们将使用一些以前没有使用过的OpenSSL函数。以下是他们的手册页:
xxxxxxxxxx
$ man BIO_set_nbio
$ man BIO_should_retry
$ man BIO_wait
我们的tls客户端非阻塞程序将基于【第9章“建立tls连接并通过它们发送数据”】中的 tls-client
程序。我们将获取 tls-client
程序源代码,并将其更改为使用非阻塞套接字而不是阻塞套接字。或者,更确切地说,是一个非阻断的BIO,而不是阻断的BIOS。
我们只需要更改 run_tls_client()
函数即可完成此操作。
run_tls_client()
函数以下是我们将要更改的内容:
我们要做的第一件事是将BIO切换到非阻塞模式。为了实现这一点,我们需要添加以下行:
xxxxxxxxxx
BIO_set_nbio(ssl_bio, 1);
if (err <= 0) {
if (error_stream)
fprintf(
error_stream,
"Could not enable non-blocking mode\n");
goto failure;
}
在尝试连接之前,将BIO切换到非阻塞模式非常重要——否则,切换将失败,BIO将保持在阻塞模式。在我们的例子中,这意味着必须在 BIO_do_connect()
之前调用 BIO_set_nbio()
。
我们需要做的下一件事是定义稍后将在 BIO_wait()
调用中使用的超时变量:
xxxxxxxxxx
const time_t TIMEOUT_SECONDS = 10;
const unsigned int NAP_MILLISECONDS = 100;
time_t deadline = time(NULL) + TIMEOUT_SECONDS;
在我们的示例程序中,为了简单起见,我们将对整个TLS连接使用10秒的超时。这应该没问题,因为整个程序通常需要不到1秒的时间来运行。
下一步是将连接建立代码更新为非阻塞感知。 tls-client
程序的原始连接建立代码如下:
xxxxxxxxxx
err = BIO_do_connect(ssl_bio);
if (err <= 0) {
if (error_stream)
fprintf(
error_stream,
"Could not connect to server %s on port %s\n",
hostname,
port);
goto failure;
}
我们需要添加一个重试循环,并将连接建立代码更改为以下内容:
xxxxxxxxxx
err = BIO_do_connect(ssl_bio);
while (err <= 0 && BIO_should_retry(ssl_bio)) {
int wait_err = BIO_wait(
ssl_bio,
deadline,
NAP_MILLISECONDS);
if (wait_err != 1)
break;
err = BIO_do_connect(ssl_bio);
}
if (err <= 0) {
if (error_stream)
fprintf(
error_stream,
"Could not connect to server %s on port %s\n",
hostname,
port);
goto failure;
}
在重试循环中,我们调用 BIO_should_retry()
函数来检查最后一个I/O错误是否会被阻塞,我们应该重试连接尝试。如果我们应该重试,那么我们使用 BIO_wait()
函数等待BIO上的活动,然后重试。
如您所见,我们正在向 BIO_wait()
函数提供截止日期和 NAP_MILLISECONDS
参数。 deadline
参数是 BIO_wait()
函数将等待的最大时间点。 NAP_MILLISECONDS
参数仅在OpenSSL编译时不支持套接字时使用。如果操作系统不支持标准的Berkeley套接字,而是使用自己的非标准套接字,则需要编译不支持套接字的OpenSSL。这些类型的操作系统通常可以在嵌入式设备上找到。如果OpenSSL在没有套接字支持的情况下编译, BIO_wait()
将无法检查套接字上的网络活动,只会在 NAP_MILLISECONDS
上休眠并返回。如果当前操作系统支持标准套接字(这是最常见的情况),则 NAP_MILLISECONDS
参数将被忽略,当套接字可用于重试I/O操作、已达到截止时间点或发生错误时,BIO_wait()
将返回。
BIO_wait()
是一个方便的函数,因为它接受BIO(不是套接字)作为参数,具有简单的签名,并将低级操作系统函数的复杂性从开发人员那里抽象出来。然而,重要的是要注意,与低级操作系统函数(如 select()
和 poll
)相比, BIO_wait()
的灵活性要低得多:
BIO_wait()
只能等待一个BIO,而低级操作系统函数可以同时等待多个套接字。BIO_wait()
的截止时间以整秒为单位,这不是很精细。低级操作系统函数的超时精度至少为毫秒。BIO_wait()
只能等待解决BIO上的前一个I/O错误。低级操作系统函数可以等待传递给它们的任何套接字上的不同类型的事件。但是, BIO_wait()
会自动解析要等待哪种类型的事件,这是一个优势。如果你需要比 BIO_wait()
更大的灵活性,你可以使用 BIO_get_fd()
函数提取BIO的底层文件描述符,并在其上使用低级操作系统函数。
当您的程序通过TLS交换数据时,TLS协议可能希望在您的程序想要向网络写入数据时从网络读取数据,反之亦然,TLS协议也可能希望在程序想要读取数据时写入数据。例如,通信期间TLS连接的任何一方都可以随时决定刷新会话密钥。在我们的 tls-client-non-blocking
程序中,这些情况是使用 BIO_should_retry()
和 BIO_wait()
函数自动处理的。在另一个程序中,开发人员可以直接使用操作系统套接字,并使用低级操作系统函数(如 select()
或 poll()
)而不是 BIO_wait()
来等待它们。在这种情况下,开发人员必须知道等待什么,无论是阅读还是写作。它可以通过 BIO_should_read()
、 BIO_should _write()
、 BIO_should_io_special()
和 BIO_retry_type()
等函数来确定。还可以使用 SSL_get_error()
函数并分析其返回的错误代码,该代码可以是 SSL_ERROR_WANT_READ
或 SSL_ERROR_WANT_WRITE
。假设我们的程序想使用 BIO_read()
函数读取,但TLS协议想写入,我们通过从 BIO_should_write()
函数接收 true
来确定它。我们该怎么办?我们是否需要使用 BIO_write()
函数向TLS连接写入内容?但是,如果我们没有东西要写,我们该写什么呢?调用 BIO_write()
来写零字节?不,我们不需要写任何东西。相反,我们必须等待操作系统套接字,直到可以写入该套接字。之后,我们必须重试我们首先要执行的读取操作,例如 BIO_read()
。这听起来可能违反直觉,但是的,即使TLS协议想要写入,我们也应该重试读取。OpenSSL库将负责所需的读写操作,以满足TLS协议。OpenSSL将根据需要读取和写入服务数据,然后从TLS连接读取应用程序数据并将其提供给我们。
现在,让我们继续更改 run_tls_client()
函数:
我们的下一步是更新非阻塞模式的数据发送代码。 tls-client
程序的原始代码如下:
xint nbytes_written = BIO_write(
ssl_bio, out_buf, request_length);
if (nbytes_written != request_length) {
if (error_stream)
fprintf(
error_stream,
"Could not send all data to the server\n");
goto failure;
}
请注意,为了显示更简单的示例代码,即使对于阻塞模式,前面的代码也有点简化。该代码表明,在成功的情况下, BIO_write()
只会在发送整个请求后返回。在大多数情况下,这将是正确的,因为我们的请求相当小,而且我们还没有用之前发送的数据填充操作系统网络缓冲区。但是,如果有一个发送循环来检查请求是否只被部分发送,并且必须从正确的偏移量恢复发送,那么它将更加稳健。在非阻塞模式的发送代码中,我们将引入这样一个循环。我们还将使用 BIO_should_retry()
和 BIO_wait()
函数来确定BIO何时准备好写入。以下是非阻塞模式的发送代码:
xxxxxxxxxx
int nbytes_written_total = 0;
while (nbytes_written_total < request_length) {
int nbytes_written = BIO_write(
ssl_bio,
out_buf + nbytes_written_total,
request_length – nbytes_written_total);
if (nbytes_written > 0) {
nbytes_written_total += nbytes_written;
continue;
}
if (BIO_should_retry(ssl_bio)) {
BIO_wait(
ssl_bio,
deadline,
NAP_MILLISECONDS);
continue;
}
if (error_stream)
fprintf(
error_stream,
"Could not send all data to the server\n");
goto failure;
}
下一步是将 tls-client
程序的数据接收代码更新为非阻塞模式。它看起来是这样的:
xxxxxxxxxx
while ((SSL_get_shutdown(ssl) & SSL_RECEIVED_SHUTDOWN)
!= SSL_RECEIVED_SHUTDOWN) {
int nbytes_read = BIO_read(ssl_bio, in_buf, BUF_SIZE);
if (nbytes_read <= 0) {
int ssl_error = SSL_get_error(ssl, nbytes_read);
if (ssl_error == SSL_ERROR_ZERO_RETURN)
break;
if (error_stream)
fprintf(
error_stream,
"Error %i while reading data "
"from the server\n",
ssl_error);
goto failure;
}
fwrite(in_buf, 1, nbytes_read, stdout);
}
正如我们所看到的,原始代码已经有一个接收循环。我们将添加非阻塞模式的重试代码,并将按如下方式更新接收代码:
xxxxxxxxxx
while ((SSL_get_shutdown(ssl) & SSL_RECEIVED_SHUTDOWN)
!= SSL_RECEIVED_SHUTDOWN) {
int nbytes_read = BIO_read(ssl_bio, in_buf, BUF_SIZE);
if (nbytes_read > 0) {
fwrite(in_buf, 1, nbytes_read, stdout);
continue;
}
if (BIO_should_retry(ssl_bio)) {
err = BIO_wait(
ssl_bio,
deadline,
NAP_MILLISECONDS);
continue;
}
int ssl_error = SSL_get_error(ssl, nbytes_read);
if (ssl_error == SSL_ERROR_ZERO_RETURN)
break;
if (error_stream)
fprintf(
error_stream,
"Error %i while reading data "
"from the server\n",
ssl_error);
goto failure;
}
为了在非阻塞套接字上使用TLS,我们必须进行这些更改。
我们的 tls-client-non-blocking
程序的完整源代码可以在GitHub的 tls-client-noblocking.c 文件中找到:
tls-client-non-blocking
程序让我们运行 tls-client-non-blocking
程序,看看它是如何工作的。与前面的示例一样,我们需要【第9章“建立TLS连接并通过它们发送数据”】中的 tls-server
程序。我们还需要 ca_cert.pem CA证书文件,以便 tls-client-non-blocking
程序验证服务器证书。
以下是我们将如何测试 tls-client-non-blocking
程序的工作原理:
让我们打开两个终端窗口。
在第一个窗口中,我们将启动 tls-server
程序:
xxxxxxxxxx
$ ./tls-server 4433 server_keypair.pem server_cert.pem
*** Listening on port 4433
在第二个窗口中,我们将运行 tls-client-non-blocking
程序:
xxxxxxxxxx
$ ./tls-client-non-blocking localhost 4433 ca_cert.pem
*** Sending to the server:
GET / HTTP/1.1
Host: localhost
Connection: close
User-Agent: Example TLS client
*** Sending to the server finished
*** Receiving from the server:
HTTP/1.0 200 OK
Content-type: text/plain
Connection: close
Server: Example TLS server
Hello from the TLS server!
*** Receiving from the server finished
TLS communication succeeded
正如我们所观察到的,在非阻塞套接字上与 localhost
的TLS通信已经成功,我们可以从服务器发送和接收数据。但它能在互联网上工作吗?
我们可以尝试与 www.example.org
服务器通信,并找出:
xxxxxxxxxx
$ ./tls-client-non-blocking www.example.org 443
*** Sending to the server:
GET / HTTP/1.1
Host: localhost
Connection: close
User-Agent: Example TLS client
*** Sending to the server finished
*** Receiving from the server:
… much text …
*** Receiving from the server finished
TLS communication succeeded
正如我们所看到的,与 www.example.org
的沟通也完美无瑕。我们可以得出结论,我们的非阻塞代码按预期工作。
在非阻塞套接字上使用TLS是一个非常重要的用例。在接下来的两节中,我们将学习另一个高级TLS用例:使用内存BIOs在非标准套接字上使用TLS连接。如果您的目标操作系统不支持标准套接字,您可能需要它。
大多数操作系统都支持标准的Berkeley网络套接字。但也有一些操作系统,尤其是嵌入式操作系统,只支持自己的非标准套接字或连接处理程序。我们如何在这些类型的操作系统上使用OpenSSL?在OpenSSL内存BIOs的帮助下,这是可能的。OpenSSL可以完全在内存中建立TLS连接。
下图显示了数据如何通过内存BIOs流入和流出非标准套接字:
图11.1–通过内存BIOs进出非标准套接字的TLS数据流
这样,当程序想要从TLS连接接收明文时,会发生以下情况:
当程序想要在TLS连接上发送明文时,数据会以相反的方向处理:
在下一节中,我们将学习如何以编程方式在非标准套接字上实现TLS。
为了学习如何在非标准套接字上使用TLS,我们将编写一个小型 tls-client-memory-bio
程序。
我们的 tls-client-memory-bio
程序将基于【第9章“建立tls连接并通过它们发送数据”】中的 tls-client
程序。我们将获取 tls-client
程序源代码,并将其更改为通过内存BIOs工作。
我们将在 tls-client
源代码中进行大量更改。例如,我们这次不会使用SSL BIO。SSL BIO是围绕 SSL
对象的包装器。在前面的示例程序中,使用 SSL BIO很方便,它会自动与connect BIO链接。这一次,我们不会自动与connect-BIO链接。相反,我们将直接在 SSL
对象上使用I/O,使用 SSL_read()
和 SSL_write()
等函数,而不是 BIO_read()
和 BIO_write()
。直接在SSL对象上使用I/O不仅可以简化代码,还可以演示一些新函数的使用。
当我们要使用新的OpenSSL函数时,阅读它们的手册页是有意义的:
xxxxxxxxxx
$ man SSL_new
$ man SSL_set_bio
$ man SSL_connect
$ man SSL_want_read
$ man SSL_read
$ man SSL_write
$ man SSL_shutdown
$ man SSL_free
$ man BIO_new_connect
$ man BIO_set_mem_eof_return
$ man BIO_pending
我们需要将两个底层内存BIOs附加到 SSL
对象。一个存储器BIO将用于读取密文,另一个用于写入密文。SSL对象将自动读取和写入这些BIOs的密文。对于附加到SSL对象的明文,将没有BIOs。相反, SSL_read()
和 SSL_write()
函数将用于读取和写入明文。
非标准网络套接字将由OpenSSL连接BIO表示。连接BIO并不是真正的非标准套接字,但它足以演示如何在内存BIO和网络连接之间传输数据。此外,与真正的非标准插座不同,connect-BIO在普通PC上工作,我们将在那里运行我们的示例程序。
让我们开始 tls-client-memory-bio
程序的实现。
service_bios()
函数首先,我们将实现一个函数,用于在SSL对象使用的内存BIOs和网络套接字之间传输数据,由connect BIO表示:
该函数将在 SSL_read()
之前或其他 SSL_*()
I/O函数之后被调用。由于该函数服务于三个BIOs,我们将称之为 service_bios()
:
xxxxxxxxxx
int service_bios(
BIO* mem_rbio,
BIO* mem_wbio,
BIO* tcp_bio,
int want_read);
该函数接受以下参数:
mem_rbio
——SSL对象从中读取密文的内存BIO。mem_wbio
——SSL对象写入密文的内存BIO。tcp_bio
——读写网络的连接bio。want_read
——布尔标志。如果为 true
,则 service_bios()
函数不仅被请求向网络写入挂起的数据,还被请求从网络读取数据。如果为 false
,则 service_bios()
功能将仅向网络写入数据。service_bios()
函数成功时返回 1
,失败时返回 -1
。
该函数从默认返回代码的定义开始,并分配一些缓冲区:
xxxxxxxxxx
int err = 1;
const size_t BUF_SIZE = 16 * 1024;
char* in_buf = malloc(BUF_SIZE);
char* out_buf = malloc(BUF_SIZE);
下一步是将待处理的数据写入网络:
xxxxxxxxxx
while (BIO_pending(mem_wbio)) {
int nbytes_read =
BIO_read(mem_wbio, out_buf, BUF_SIZE);
int nbytes_written_total = 0;
while (nbytes_written_total < nbytes_read) {
int nbytes_written =
BIO_write(tcp_bio, out_buf, nbytes_read);
if (nbytes_written > 0) {
nbytes_written_total += nbytes_written;
continue;
} else {
goto failure;
}
}
}
如您所见,我们只是从 mem_wbio
读取数据并将其写入 tcp_bio
。
如果有请求,下一步是从网络读取数据:
xxxxxxxxxx
if (want_read) {
int nbytes_read =
BIO_read(tcp_bio, in_buf, BUF_SIZE);
if (nbytes_read > 0) {
BIO_write(mem_rbio, in_buf, nbytes_read);
} else {
goto failure;
}
}
这一次,我们从 tcp_bio
读取数据并将其写入 mem_rbio
,以供 SSL
对象进一步使用。
通过清理并返回错误代码来完成该功能:
xxxxxxxxxx
goto cleanup;
failure:
err = -1;
cleanup:
free(out_buf);
free(in_buf);
return err;
这就是 service_bios()
函数的全部内容。
run_tls_client()
函数现在让我们重新实现包含大部分代码的 run_tls_client()
函数:
run_tls_client()
函数首先定义变量并分配缓冲区:
xxxxxxxxxx
int exit_code = 0;
int err = 1;
SSL_CTX* ctx = NULL;
BIO* tcp_bio = NULL;
BIO* mem_rbio = NULL;
BIO* mem_wbio = NULL;
SSL* ssl = NULL;
const size_t BUF_SIZE = 16 * 1024;
char* in_buf = malloc(BUF_SIZE);
char* out_buf = malloc(BUF_SIZE);
下一步是创建 SSL_CTX
对象,加载可信证书,并启用对等证书验证。它与原始 tls-client
程序中的代码相同:
xxxxxxxxxx
ERR_clear_error();
ctx = SSL_CTX_new(TLS_client_method());
if (trusted_cert_fname)
err = SSL_CTX_load_verify_locations(
ctx, trusted_cert_fname, NULL);
else
err = SSL_CTX_set_default_verify_paths(ctx);
if (err <= 0) {
if (error_stream)
fprintf(
error_stream,
"Could not load trusted certificates\n");
goto failure;
}
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
下一步是建立到服务器的TCP(不是TLS)连接。我们将在稍后的步骤中建立TLS连接。如上所述,connect BIO用于模拟非标准套接字:
xxxxxxxxxx
tcp_bio = BIO_new_connect(hostname);
BIO_set_conn_port(tcp_bio, port);
err = BIO_do_connect(tcp_bio);
if (err <= 0) {
if (error_stream)
fprintf(
error_stream,
"Could not connect to server %s on port %s\n",
hostname,
port);
goto failure;
}
下一步是为 SSL
对象创建和设置读写内存BIOs:
xxxxxxxxxx
mem_rbio = BIO_new(BIO_s_mem());
BIO_set_mem_eof_return(mem_rbio, -1);
mem_wbio = BIO_new(BIO_s_mem());
BIO_set_mem_eof_return(mem_wbio, -1);
如果BIO为空,则需要调用 BIO_set_mem_eof_turn()
以避免文件结束错误。
接下来,创建表示TLS连接的 SSL
对象,并将新创建的读写内存BIOs附加到该对象上:
xxxxxxxxxx
ssl = SSL_new(ctx);
SSL_set_bio(ssl, mem_rbio, mem_wbio);
请注意,在 SSL_set_bio()
调用之后, SSL
对象拥有附加的BIOs。当使用 SSL_free()
函数释放SSL对象时,将使用 BIO_free_all()
函数解除分配BIOs。
下一步是设置 服务器名称指示(Server Name Indication,SNI)TLS扩展的主机名和证书主机名验证。它与 tls-client
程序中的代码相同:
xxxxxxxxxx
SSL_set_tlsext_host_name(ssl, hostname);
SSL_set1_host(ssl, hostname);
下一步是TLS握手:
xxxxxxxxxx
while (1) {
err = SSL_connect(ssl);
int ssl_error = SSL_get_error(ssl, err);
if (ssl_error == SSL_ERROR_WANT_READ
|| ssl_error == SSL_ERROR_WANT_WRITE
|| BIO_pending(mem_wbio)) {
int service_bios_err = service_bios(
mem_rbio, mem_wbio, tcp_bio, SSL_want_read(ssl));
if (service_bios_err != 1) {
if (error_stream)
fprintf(
error_stream,
"Socket error during TLS handshake\n");
goto failure;
}
continue;
}
break;
}
if (err <= 0) {
if (error_stream)
fprintf(
error_stream,
"TLS error %i during TLS handshake\n",
SSL_get_error(ssl, err));
goto failure;
}
请注意,我们反复调用 SSL_connect()
和 service_bios()
,直到TLS握手完成。在这里,在握手过程中,以及稍后的写入和读取过程中,我们正在检查 SSL_ERROR_WANT_READ
和 SSL_ERROR_WANT_WRITE
。我们这样做是因为,如前所述,TLS协议可能希望随时读取或写入其服务数据。根据TLS版本以及是否存在来自先前连接的任何缓存会话数据,TLS握手最多需要两次网络数据往返。因此,在握手过程中,我们可能需要在 service_bios()
函数的操作系统套接字上读写几次。
接下来,创建将发送到服务器的HTTP请求。它与 tls-client
程序中的代码相同:
xxxxxxxxxx
snprintf(
out_buf,
BUF_SIZE,
"GET / HTTP/1.1\r\n"
"Host: %s\r\n"
"Connection: close\r\n"
"User-Agent: Example TLS client\r\n"
"\r\n",
hostname);
int request_length = strlen(out_buf);
下一步是将新创建的请求发送到服务器:
xxxxxxxxxx
printf("*** Sending to the server:\n");
printf("%s", out_buf);
int nbytes_written_total = 0;
while (nbytes_written_total < request_length) {
int nbytes_written = SSL_write(
ssl,
out_buf + nbytes_written_total,
request_length – nbytes_written_total);
if (nbytes_written > 0) {
nbytes_written_total += nbytes_written;
continue;
}
int ssl_error = SSL_get_error(ssl, err);
if (ssl_error == SSL_ERROR_WANT_READ
|| ssl_error == SSL_ERROR_WANT_WRITE
|| BIO_pending(mem_wbio)) {
int service_bios_err = service_bios(
mem_rbio, mem_wbio, tcp_bio, SSL_want_read(ssl));
if (service_bios_err != 1) {
if (error_stream)
fprintf(
error_stream,
"Socket error while sending data "
"to the server\n");
goto failure;
}
continue;
}
if (error_stream)
fprintf(
error_stream,
"TLS error %i while reading data "
"to the server\n",
ssl_error);
goto failure;
}
printf("*** Sending to the server finished\n");
与前面的代码类似,我们重复调用 SSL_write()
和 service_bios()
,直到发送整个请求。即使我们只将请求写入TLS连接,我们也会检查 SSL_ERROR_WANT_READ
和 SSL_ERROR_WANT_WRITE
,因为TLS协议可能需要读取和写入协议服务数据,我们必须在 service_bios()
函数中满足这些读取和写入请求。
发送请求后,我们应该读取服务器响应:
xxxxxxxxxx
printf("*** Receiving from the server:\n");
while ((SSL_get_shutdown(ssl) & SSL_RECEIVED_SHUTDOWN)
!= SSL_RECEIVED_SHUTDOWN) {
int service_bios_err = 1;
if (!BIO_pending(mem_rbio))
service_bios_err = service_bios(
mem_rbio, mem_wbio, tcp_bio, 1);
if (service_bios_err != 1) {
if (error_stream)
fprintf(
error_stream,
"Socket error while reading data "
"from the server\n");
goto failure;
}
int nbytes_read = SSL_read(ssl, in_buf, BUF_SIZE);
if (nbytes_read > 0) {
fwrite(in_buf, 1, nbytes_read, stdout);
continue;
}
int ssl_error = SSL_get_error(ssl, err);
if (ssl_error == SSL_ERROR_NONE
|| ssl_error == SSL_ERROR_WANT_READ
|| ssl_error == SSL_ERROR_WANT_WRITE
|| BIO_pending(mem_wbio))
continue;
if (ssl_error == SSL_ERROR_ZERO_RETURN)
break;
if (error_stream)
fprintf(
error_stream,
"TLS error %i while reading data "
"from the server\n",
ssl_error);
goto failure;
}
printf("*** Receiving from the server finished\n");
与前面的代码类似,我们重复调用 SSL_read()
和 service_bios()
,直到得到整个服务器响应。但请注意,我们这次在SSL I/O函数之前调用 service_bios()
。我们还检查读取BIO mem_rbio
中是否已经存在任何未决数据。这种函数调用顺序是有意义的,因为首先,我们必须从网络读取,只有在这之后,我们才能使用 SSL_read()
函数处理接收到的密文。如前所述,我们必须同时处理 SSL_ERROR_WANT_READ
和 SSL_ERROR_WANT_WRITE
错误,即使我们只读取应用程序数据。
读取服务器响应后,我们可以关闭TLS连接:
xxxxxxxxxx
while (1) {
err = SSL_shutdown(ssl);
int ssl_error = SSL_get_error(ssl, err);
if (ssl_error == SSL_ERROR_WANT_READ
|| ssl_error == SSL_ERROR_WANT_WRITE
|| BIO_pending(mem_wbio)) {
int service_bios_err = service_bios(
mem_rbio, mem_wbio, tcp_bio, SSL_want_read(ssl));
if (service_bios_err != 1) {
if (error_stream)
fprintf(
error_stream,
"Socket error during TLS shutdown\n");
goto failure;
}
continue;
}
break;
}
if (err != 1) {
if (error_stream)
fprintf(
error_stream,
"TLS error during TLS shutdown\n");
goto failure;
}
我们通过清理、错误报告和返回退出代码来完成 run_tls_client()
函数:
xxxxxxxxxx
failure:
exit_code = 1;
cleanup:
if (ssl)
SSL_free(ssl);
if (tcp_bio)
BIO_free_all(tcp_bio);
if (ctx)
SSL_CTX_free(ctx);
free(out_buf);
free(in_buf);
if (ERR_peek_error()) {
exit_code = 1;
if (error_stream) {
fprintf(
error_stream,
"Errors from the OpenSSL error queue:\n");
ERR_print_errors_fp(error_stream);
}
ERR_clear_error();
}
return exit_code;
我们的 tls-client-memory-bio
程序的完整源代码可以在GitHub上找到,文件名为 tls-client memory bio.c :
tls-client-memory-bio
程序让我们运行 tls-client-memory-bio
程序,看看它是如何工作的。
与前面的示例一样,我们需要【第9章“建立TLS连接并通过它们发送数据”]中的 tls-server
程序。我们还需要CA证书文件 ca_cert.pem 来验证服务器证书。
以下是我们将如何测试 tls-client-memory-bio
程序的工作原理:
让我们打开两个终端窗口。
在第一个窗口中,我们将启动 tls-server
程序:
xxxxxxxxxx
$ ./tls-server 4433 server_keypair.pem server_cert.pem
*** Listening on port 4433
在第二个窗口中,我们将运行 tls-client-memory-bio
程序:
xxxxxxxxxx
$ ./tls-client-memory-bio localhost 4433 ca_cert.pem
*** Sending to the server:
GET / HTTP/1.1
Host: localhost
Connection: close
User-Agent: Example TLS client
*** Sending to the server finished
*** Receiving from the server:
HTTP/1.0 200 OK
Content-type: text/plain
Connection: close
Server: Example TLS server
Hello from the TLS server!
*** Receiving from the server finished
TLS communication succeeded
正如我们所观察到的,通过内存BIOs和非标准套接字仿真与localhost的TLS通信已经成功。
我们还可以尝试与 www.example.org
服务器通信,以测试我们的 tls-client-memory-bio
程序是否也适用于互联网服务器:
xxxxxxxxxx
$ ./tls-client-memory-bio www.example.org 443
*** Sending to the server:
GET / HTTP/1.1
Host: localhost
Connection: close
User-Agent: Example TLS client
*** Sending to the server finished
*** Receiving from the server:
… much text …
*** Receiving from the server finished
TLS communication succeeded
正如我们所看到的,与 www.example.org
的沟通也很好。我们可以得出结论,我们的 tls-client-memory-bio
程序按预期工作。
这就把我们带到了本章的结尾。现在,我们将总结我们在这里学到的一切。
在本章中,我们了解了什么是TLS证书固定,以及在哪些情况下它会有所帮助。我们还学习了如何在证书验证回调的帮助下实现TLS证书固定。然后,我们了解了阻塞和非阻塞套接字之间的区别以及如何使用非阻塞网络连接。之后,我们学习了如何使用OpenSSL内存BIOs在非标准套接字上建立TLS连接。这些知识可以帮助您编写更安全、响应更快、性能更高的应用程序,并使您的程序适应嵌入式操作系统。
在下一章中,我们将学习如何运行一个迷你CA,该CA可以为组织内的内部使用颁发证书。