OpenSSL

密码学和 SSL/TLS 工具包

ossl-guide-tls-client-block

名称

ossl-guide-tls-client-block - OpenSSL 指南:编写简单的阻塞式 TLS 客户端

简单的阻塞式 TLS 客户端示例

本页将提供各种源代码示例,演示如何编写一个简单的 TLS 客户端应用程序,该应用程序连接到服务器,向其发送 HTTP/1.0 请求,并读取响应。

为了便于示例,我们使用阻塞套接字。这意味着尝试从没有可用数据的套接字读取数据将被阻塞(并且函数不会返回),直到数据可用。例如,如果我们已经发送了请求,但仍在等待服务器的响应,就会发生这种情况。同样,任何尝试写入无法立即写入的套接字都将被阻塞,直到可以写入为止。

这种阻塞行为简化了客户端的实现,因为您不必担心数据是否可用。应用程序将简单地等待数据可用。

此示例阻塞式 TLS 客户端的完整源代码在 OpenSSL 源代码分发版中的 **demos/guide** 目录下 **tls-client-block.c** 文件中。它也可以在线获得,网址为 https://github.com/openssl/openssl/blob/master/demos/guide/tls-client-block.c

我们假设您已经安装了 OpenSSL;您已经对 OpenSSL 概念和 TLS 有了一些基本了解(请参阅 ossl-guide-libraries-introduction(7)ossl-guide-tls-introduction(7));并且您知道如何编写和构建 C 代码,并将其与 OpenSSL 提供的 libcrypto 和 libssl 库链接起来。它还假设您对 TCP/IP 和套接字有基本了解。

创建 SSL_CTX 和 SSL 对象

第一步是为我们的客户端创建一个 **SSL_CTX** 对象。我们使用 SSL_CTX_new(3) 函数来实现此目的。如果我们想将 **SSL_CTX** 与特定的 **OSSL_LIB_CTX** 关联,我们可以选择使用 SSL_CTX_new_ex(3) (有关 **OSSL_LIB_CTX** 的信息,请参阅 ossl-guide-libraries-introduction(7))。我们将 TLS_client_method(3) 函数的返回值作为参数传递。在编写 TLS 客户端时,您应该使用此方法。此方法将自动使用 TLS 版本协商来选择客户端和服务器共同支持的最高协议版本。

/*
 * Create an SSL_CTX which we can use to create SSL objects from. We
 * want an SSL_CTX for creating clients so we use TLS_client_method()
 * here.
 */
ctx = SSL_CTX_new(TLS_client_method());
if (ctx == NULL) {
    printf("Failed to create the SSL_CTX\n");
    goto end;
}

由于我们正在编写一个客户端,因此我们必须确保验证服务器的证书。为此,我们调用 SSL_CTX_set_verify(3) 函数,并将 **SSL_VERIFY_PEER** 值传递给它。此函数的最后一个参数是一个回调函数,您可以选择提供它来覆盖证书验证的默认处理方式。大多数应用程序不需要这样做,因此可以安全地将其设置为 NULL 以获得默认处理方式。

/*
 * Configure the client to abort the handshake if certificate
 * verification fails. Virtually all clients should do this unless you
 * really know what you are doing.
 */
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);

为了使证书验证成功,您必须配置要使用的受信任证书存储的位置(请参阅 ossl-guide-tls-introduction(7))。在大多数情况下,您只需要使用默认存储,因此我们调用 SSL_CTX_set_default_verify_paths(3)

/* Use the default trusted certificate store */
if (!SSL_CTX_set_default_verify_paths(ctx)) {
    printf("Failed to set the default trusted certificate store\n");
    goto end;
}

我们还希望将我们愿意接受的 TLS 版本限制为 TLSv1.2 或更高版本。一般来说,应尽量避免使用比这更早的 TLS 协议版本。我们可以使用 SSL_CTX_set_min_proto_version(3) 函数来实现。

/*
 * TLSv1.1 or earlier are deprecated by IETF and are generally to be
 * avoided if possible. We require a minimum TLS version of TLSv1.2.
 */
if (!SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION)) {
    printf("Failed to set the minimum TLS protocol version\n");
    goto end;
}

这是我们需要为 **SSL_CTX** 进行的所有设置,接下来我们需要创建一个 **SSL** 对象来表示 TLS 连接。在实际应用中,我们可能希望随着时间的推移创建多个 TLS 连接。在这种情况下,我们希望在每次创建连接时都重复使用已经创建的 **SSL_CTX**。无需重复这些步骤。事实上,最好不要重复,因为某些内部资源被缓存在 **SSL_CTX** 中。通过重复使用现有的 **SSL_CTX**,而不是每次都创建一个新的,您将获得更好的性能。

创建 **SSL** 对象很简单,只需调用 **SSL_new(3)** 函数,并将我们创建的 **SSL_CTX** 作为参数传递。

/* Create an SSL object to represent the TLS connection */
ssl = SSL_new(ctx);
if (ssl == NULL) {
    printf("Failed to create the SSL object\n");
    goto end;
}

创建套接字和 BIO

TLS 数据通过底层传输层进行传输。通常是 TCP 套接字。应用程序有责任确保创建套接字并将其与 SSL 对象(通过 BIO)关联。

为客户端创建套接字通常是一个两步过程,即构建套接字;并将套接字连接起来。

如何构建套接字是平台特定的,但大多数平台(包括 Windows)通过 socket 函数提供 POSIX 兼容的接口,例如创建 IPv4 TCP 套接字

int sock;

sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1)
    return NULL;

构建套接字后,必须将其连接到远程服务器。同样,详细信息是平台特定的,但大多数平台(包括 Windows)提供 POSIX 兼容的 connect 函数。例如

struct sockaddr_in serveraddr;
struct hostent *server;

server = gethostbyname("www.openssl.org");
if (server == NULL) {
    close(sock);
    return NULL;
}

memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = server->h_addrtype;
serveraddr.sin_port = htons(443);
memcpy(&serveraddr.sin_addr.s_addr, server->h_addr, server->h_length);

if (connect(sock, (struct sockaddr *)&serveraddr,
            sizeof(serveraddr)) == -1) {
    close(sock);
    return NULL;
}

OpenSSL 提供便携式辅助函数来完成这些任务,这些函数也集成到 OpenSSL 错误系统中以记录错误数据,例如

int sock = -1;
BIO_ADDRINFO *res;
const BIO_ADDRINFO *ai = NULL;

/*
 * Lookup IP address info for the server.
 */
if (!BIO_lookup_ex(hostname, port, BIO_LOOKUP_CLIENT, family, SOCK_STREAM, 0,
                   &res))
    return NULL;

/*
 * Loop through all the possible addresses for the server and find one
 * we can connect to.
 */
for (ai = res; ai != NULL; ai = BIO_ADDRINFO_next(ai)) {
    /*
     * Create a TCP socket. We could equally use non-OpenSSL calls such
     * as "socket" here for this and the subsequent connect and close
     * functions. But for portability reasons and also so that we get
     * errors on the OpenSSL stack in the event of a failure we use
     * OpenSSL's versions of these functions.
     */
    sock = BIO_socket(BIO_ADDRINFO_family(ai), SOCK_STREAM, 0, 0);
    if (sock == -1)
        continue;

    /* Connect the socket to the server's address */
    if (!BIO_connect(sock, BIO_ADDRINFO_address(ai), BIO_SOCK_NODELAY)) {
        BIO_closesocket(sock);
        sock = -1;
        continue;
    }

    /* We have a connected socket so break out of the loop */
    break;
}

/* Free the address information resources we allocated earlier */
BIO_ADDRINFO_free(res);

有关此处使用的函数的更多信息,请参阅 BIO_lookup_ex(3)BIO_socket(3)BIO_connect(3)BIO_closesocket(3)BIO_ADDRINFO_next(3)BIO_ADDRINFO_address(3)BIO_ADDRINFO_free(3)。在上面的示例代码中,**hostname** 和 **port** 变量是字符串,例如“www.example.com”和“443”。还要注意 family 变量的使用,它可以根据命令行 -6 选项采用 AF_INET 或 AF_INET6 的值,以允许特定连接到支持 ipv4 或 ipv6 的主机。

使用上述方法创建的套接字将自动成为阻塞套接字,这正是我们在此示例中需要的。

创建并连接套接字后,我们需要将其与 BIO 对象关联

BIO *bio;

/* Create a BIO to wrap the socket */
bio = BIO_new(BIO_s_socket());
if (bio == NULL) {
    BIO_closesocket(sock);
    return NULL;
}

/*
 * Associate the newly created BIO with the underlying socket. By
 * passing BIO_CLOSE here the socket will be automatically closed when
 * the BIO is freed. Alternatively you can use BIO_NOCLOSE, in which
 * case you must close the socket explicitly when it is no longer
 * needed.
 */
BIO_set_fd(bio, sock, BIO_CLOSE);

有关这些函数的更多信息,请参阅 BIO_new(3)BIO_s_socket(3)BIO_set_fd(3)

最后,我们将之前创建的 **SSL** 对象与 **BIO** 对象关联,方法是使用 SSL_set_bio(3) 函数。请注意,这将 **BIO** 对象的所有权传递给 **SSL** 对象。所有权传递后,SSL 对象将负责其管理,并在释放 **SSL** 时自动释放它。因此,在调用 SSL_set_bio(3) 之后,您不应该对 **BIO** 调用 BIO_free(3)

SSL_set_bio(ssl, bio, bio);

设置服务器的主机名

我们已经将底层套接字连接到服务器,但客户端仍然需要知道服务器的主机名。它使用此信息用于两个关键目的,我们需要为每个目的设置主机名。

首先,服务器的主机名包含在客户端发送的初始 ClientHello 消息中。这被称为服务器名称指示 (SNI)。这很重要,因为多个主机名通常由一个处理所有主机名请求的服务器来处理。换句话说,单个服务器可能与多个主机名相关联,并且必须指示我们要连接到的哪个主机名。没有此信息,我们可能会遇到握手失败,或者我们可能会连接到“默认”服务器,而这可能不是我们预期的服务器。

要设置 SNI 主机名数据,我们调用 SSL_set_tlsext_host_name(3) 函数,如下所示

/*
 * Tell the server during the handshake which hostname we are attempting
 * to connect to in case the server supports multiple hosts.
 */
if (!SSL_set_tlsext_host_name(ssl, hostname)) {
    printf("Failed to set the SNI hostname\n");
    goto end;
}

此处,hostname 参数是一个字符串,表示服务器的主机名,例如“www.example.com”。

其次,我们需要告诉 OpenSSL 我们期望在从服务器返回的证书中看到什么主机名。这几乎总是与我们最初请求的主机名相同。这很重要,因为如果没有它,我们将无法验证证书中的主机名是否是我们预期的,并且任何证书都是可以接受的,除非您的应用程序自己明确地检查这一点。我们通过 SSL_set1_host(3) 函数来实现这一点

/*
 * Ensure we check during certificate verification that the server has
 * supplied a certificate for the hostname that we were expecting.
 * Virtually all clients should do this unless you really know what you
 * are doing.
 */
if (!SSL_set1_host(ssl, hostname)) {
    printf("Failed to set the certificate verification hostname");
    goto end;
}

所有上述步骤必须在我们尝试执行握手之前完成,否则将不起作用。

执行握手

在我们可以开始通过 TLS 连接发送或接收应用程序数据之前,必须执行 TLS 握手。我们可以通过 SSL_connect(3) 函数显式地完成此操作。

/* Do the handshake with the server */
if (SSL_connect(ssl) < 1) {
    printf("Failed to connect to the server\n");
    /*
     * If the failure is due to a verification error we can get more
     * information about it from SSL_get_verify_result().
     */
    if (SSL_get_verify_result(ssl) != X509_V_OK)
        printf("Verify error: %s\n",
            X509_verify_cert_error_string(SSL_get_verify_result(ssl)));
    goto end;
}

SSL_connect(3) 函数可以返回 1、0 或小于 0 的值。只有返回值为 1 才被视为成功。对于简单的阻塞客户端,我们只需要关心调用是否成功。任何其他值都表示我们无法连接到服务器。

此阶段常见的失败原因是验证服务器证书时出现问题。例如,如果证书已过期,或者它没有由我们受信任的证书存储中的 CA 签署。我们可以使用 SSL_get_verify_result(3) 函数来获取有关验证失败的更多信息。返回值为 **X509_V_OK** 表示验证成功(因此连接错误一定是由于其他原因造成的)。否则,我们将使用 X509_verify_cert_error_string(3) 函数来获取人类可读的错误消息。

发送和接收数据

握手完成后,我们就可以发送和接收应用程序数据。究竟发送什么数据以及以什么顺序发送通常由某些应用程序级协议控制。在此示例中,我们使用 HTTP 1.0,这是一种非常简单的请求和响应协议。客户端向服务器发送请求。服务器发送响应数据,然后立即关闭连接。

要向服务器发送数据,我们使用 SSL_write_ex(3) 函数,要从服务器接收数据,我们使用 SSL_read_ex(3) 函数。在 HTTP 1.0 中,客户端总是先写入数据。我们的 HTTP 请求将包含我们连接到的主机名。为了简单起见,我们将 HTTP 请求分为三个部分写入。首先,我们写入请求的开头。其次,我们写入我们向其发送请求的主机名。最后,我们发送请求的结尾。

size_t written;
const char *request_start = "GET / HTTP/1.0\r\nConnection: close\r\nHost: ";
const char *request_end = "\r\n\r\n";

/* Write an HTTP GET request to the peer */
if (!SSL_write_ex(ssl, request_start, strlen(request_start), &written)) {
    printf("Failed to write start of HTTP request\n");
    goto end;
}
if (!SSL_write_ex(ssl, hostname, strlen(hostname), &written)) {
    printf("Failed to write hostname in HTTP request\n");
    goto end;
}
if (!SSL_write_ex(ssl, request_end, strlen(request_end), &written)) {
    printf("Failed to write end of HTTP request\n");
    goto end;
}

如果 SSL_write_ex(3) 函数失败,则返回 0,如果成功,则返回 1。如果成功,我们可以继续等待服务器的响应。

size_t readbytes;
char buf[160];

/*
 * Get up to sizeof(buf) bytes of the response. We keep reading until the
 * server closes the connection.
 */
while (SSL_read_ex(ssl, buf, sizeof(buf), &readbytes)) {
    /*
    * OpenSSL does not guarantee that the returned data is a string or
    * that it is NUL terminated so we use fwrite() to write the exact
    * number of bytes that we read. The data could be non-printable or
    * have NUL characters in the middle of it. For this simple example
    * we're going to print it to stdout anyway.
    */
    fwrite(buf, 1, readbytes, stdout);
}
/* In case the response didn't finish with a newline we add one now */
printf("\n");

我们使用 SSL_read_ex(3) 函数来读取响应。我们不知道要接收多少数据,因此我们进入一个循环,从服务器读取数据块,并将接收到的每个数据块打印到屏幕上。只要 SSL_read_ex(3) 返回 0,循环就会结束,这意味着它无法读取任何数据。

无法读取数据可能意味着发生了错误,也可能仅仅意味着服务器已经发送了它想要发送的所有数据,并通过发送“close_notify”警报来指示它已经完成。此警报是 TLS 协议级消息,表示端点已经完成发送所有数据,并且不会再发送任何数据。这两种情况都会导致 SSL_read_ex(3) 返回 0,我们需要使用 SSL_get_error(3) 函数来确定返回 0 的原因。

/*
 * Check whether we finished the while loop above normally or as the
 * result of an error. The 0 argument to SSL_get_error() is the return
 * code we received from the SSL_read_ex() call. It must be 0 in order
 * to get here. Normal completion is indicated by SSL_ERROR_ZERO_RETURN.
 */
if (SSL_get_error(ssl, 0) != SSL_ERROR_ZERO_RETURN) {
    /*
     * Some error occurred other than a graceful close down by the
     * peer
     */
    printf ("Failed reading remaining data\n");
    goto end;
}

如果 SSL_get_error(3) 返回 SSL_ERROR_ZERO_RETURN,则表明服务器已完成数据发送。否则,表示发生了错误。

关闭连接

完成从服务器读取数据后,就可以关闭连接。通过 SSL_shutdown(3) 函数执行此操作,该函数会向服务器发送一个 TLS 协议级别的消息(“close_notify” 警告),表明我们已完成数据写入。

/*
 * The peer already shutdown gracefully (we know this because of the
 * SSL_ERROR_ZERO_RETURN above). We should do the same back.
 */
ret = SSL_shutdown(ssl);
if (ret < 1) {
    /*
     * ret < 0 indicates an error. ret == 0 would be unexpected here
     * because that means "we've sent a close_notify and we're waiting
     * for one back". But we already know we got one from the peer
     * because of the SSL_ERROR_ZERO_RETURN above.
     */
    printf("Error shutting down\n");
    goto end;
}

SSL_shutdown(3) 函数将返回 1、0 或小于 0 的值。返回值 1 表示成功,小于 0 的返回值表示错误。更准确地说,返回值 1 表示我们已向服务器发送“close_notify” 警告,并且也收到了服务器的回复。返回值 0 表示我们已向服务器发送“close_notify” 警告,但尚未收到回复。通常情况下,在这种情况下,您需要再次调用 SSL_shutdown(3),它将在阻塞套接字上阻塞,直到收到“close_notify”。然而,在本例中,我们已经知道服务器已发送给我们一个“close_notify”,因为我们从 SSL_read_ex(3) 的调用中收到了 SSL_ERROR_ZERO_RETURN。因此,这种情况在实践中永远不会发生。在本示例中,我们只是将其视为错误。

最终清理

在应用程序退出之前,我们需要清理一些已分配的内存。如果由于错误退出,我们可能还想显示有关该错误的更多信息,前提是该信息对用户可用。

   /* Success! */
   res = EXIT_SUCCESS;
end:
   /*
    * If something bad happened then we will dump the contents of the
    * OpenSSL error stack to stderr. There might be some useful diagnostic
    * information there.
    */
   if (res == EXIT_FAILURE)
       ERR_print_errors_fp(stderr);

   /*
    * Free the resources we allocated. We do not free the BIO object here
    * because ownership of it was immediately transferred to the SSL object
    * via SSL_set_bio(). The BIO will be freed when we free the SSL object.
    */
   SSL_free(ssl);
   SSL_CTX_free(ctx);
   return res;

为了显示错误,我们使用 ERR_print_errors_fp(3) 函数,该函数会简单地将 OpenSSL 错误堆栈上的所有错误内容转储到指定位置(在本例中为 stderr)。

我们需要释放通过 SSL_free(3) 函数为连接创建的 SSL 对象。此外,由于我们不再创建任何 TLS 连接,因此我们还必须通过调用 SSL_CTX_free(3) 来释放 SSL_CTX

故障排除

运行演示应用程序时可能会出现许多问题。本节介绍了一些常见的错误。

无法连接底层套接字

这可能是由于多种原因造成的。例如,如果客户端和服务器之间的网络路由存在问题;或者防火墙阻止了通信;或者服务器不在 DNS 中。请检查网络配置。

服务器证书验证失败

服务器证书验证失败会导致运行 SSL_connect(3) 函数时失败。 ERR_print_errors_fp(3) 会显示错误,类似于以下内容

Verify error: unable to get local issuer certificate
40E74AF1F47F0000:error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:2069:

服务器证书验证失败可能是由于多种原因造成的。例如

无法正确设置受信任的证书存储

请参阅 ossl-guide-tls-introduction(7) 页面,并检查您的受信任的证书存储是否配置正确。

无法识别的 CA

如果服务器证书使用的 CA 不在客户端的受信任证书存储中,则连接期间会发生验证失败。这通常发生在服务器使用自签名证书(即尚未由任何 CA 签名的测试证书)时。

缺少中间 CA

这是服务器配置错误,客户端在其信任存储中具有相关的根 CA,但服务器没有提供该根 CA 和服务器自己的证书之间的所有中间 CA 证书。因此,无法建立信任链。

主机名不匹配

如果由于某种原因,客户端期望的服务器主机名与证书中的主机名不匹配,则会发生验证失败。

证书过期

服务器证书的有效期已过。

我们在上面的示例中看到的“无法获取本地发行者证书”意味着我们无法在我们受信任的证书存储中找到服务器证书(或其一个中间 CA 证书)的发行者(例如,因为受信任的证书存储配置错误,或者缺少中间 CA,或者发行者根本无法识别)。

进一步阅读

请参阅 ossl-guide-tls-client-non-block(7),了解如何修改本页上开发的客户端以支持非阻塞套接字。

请参阅 ossl-guide-quic-client-block(7),了解如何修改本页上开发的客户端以支持 QUIC 而不是 TLS。

另请参阅

ossl-guide-introduction(7)ossl-guide-libraries-introduction(7)ossl-guide-libssl-introduction(7)ossl-guide-tls-introduction(7)ossl-guide-tls-client-non-block(7)ossl-guide-quic-client-block(7)

Copyright 2023 The OpenSSL Project Authors. All Rights Reserved.

根据 Apache 许可证 2.0(“许可证”)授权。除非符合许可证,否则您不得使用此文件。您可以在源代码分发中的 LICENSE 文件或 https://www.openssl.org/source/license.html 中获取副本。