news 2026/9/12 16:27:27

如何用 OpenSSL 编写一个阻塞式 TLS 客户端应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
如何用 OpenSSL 编写一个阻塞式 TLS 客户端应用

如何用 OpenSSL 编写一个阻塞式 TLS 客户端应用

【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl

如果你要给自己的 C 程序加上 TLS 能力,最常见的起点是写一个阻塞式 TLS 客户端:连接到服务器、发起 TLS 握手、发送一个 HTTP/1.1 请求、读回响应并优雅关闭连接。OpenSSL 的官方指南页 ossl-guide-tls-client-block(7) 完整地演示了这个客户端的写法,完整可编译的源码就在 demos/guide/tls-client-block.c。

"阻塞式"意味着:从没有数据的套接字读取会一直等待直到数据到达(比如已发出请求、还在等服务器响应时);写入不可用的套接字也会阻塞直到可写。这种行为让实现简单得多——你不需要处理"数据还没到"的各种分支,应用会自动等下去。

适用前提:系统上已安装或从源码构建了 OpenSSL,你知道如何编写 C 代码并链接 libcrypto 与 libssl,对 TCP/IP 与 socket 有基本了解,并具备默认的受信证书库(检查方法见下文"验证默认证书库是否可用")。

从源码树构建并运行官方示例

官方示例不需要你自己敲代码就能跑起来。进入 demos/guide 目录,README 给出的构建方式是:

cd demos/guide make

demos/guide/Makefile 中的关键编译参数为CFLAGS = -I../../include -g -WallLDFLAGS = -L../..LDLIBS = -lcrypto -lssl,即针对本源码树的 include 目录编译、链接源码树根目录下的 libcrypto/libssl,一次make会构建tls-client-blocktls-server-block等多个 demo 可执行文件。

由于 demo 默认链接共享库,运行时要把 OpenSSL 库加入库路径。demos/guide/README.md 给出的运行方式是:

LD_LIBRARY_PATH=../.. ./tls-client-block hostname port

其中hostnameport替换为你要连接的服务器的主机名与端口。程序支持可选的-6参数(对应源码中对AF_INET/AF_INET6地址族的切换),用于连接 IPv6 主机:

LD_LIBRARY_PATH=../.. ./tls-client-block -6 hostname port

读懂源码:程序按什么顺序工作

对照 tls-client-block.c 和指南页,整个客户端按以下顺序工作:创建SSL_CTXSSL对象 → 创建并连接底层 socket、包装成 BIO → 设置服务器主机名(SNI + 证书校验)→ 执行握手 → 发送/接收数据 → 关闭连接 → 释放资源。

第一步:创建 SSL_CTX 与 SSL 对象

SSL_CTX是创建SSL对象的"工厂",客户端使用TLS_client_method()来创建它。这个方法会自动协商双方都支持的最高 TLS 版本,写 TLS 客户端时应始终使用它:

ctx = SSL_CTX_new(TLS_client_method()); if (ctx == NULL) { printf("Failed to create the SSL_CTX\n"); goto end; }

接下来三件事构成客户端的安全基线:

/* * 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); /* 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; }
  • SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL)让客户端在证书校验失败时中止握手;第三个参数是可选的自定义校验回调,多数应用不需要,传NULL使用默认处理即可。
  • SSL_CTX_set_default_verify_paths(ctx)把受信证书库指到系统默认位置——没有它证书校验无从谈起。

指南页还建议显式把最低协议版本限制为 TLSv1.2,因为更早的版本已被 IETF 弃用、应尽量回避:

if (!SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION)) { printf("Failed to set the minimum TLS protocol version\n"); goto end; }

实际连接由SSL对象表示。注意SSL_CTX应当复用(内部有缓存,复用性能更好),每开一条新 TLS 连接时只需创建新的SSL对象:

ssl = SSL_new(ctx); if (ssl == NULL) { printf("Failed to create the SSL object\n"); goto end; }

第二步:创建 socket 并关联 BIO

TLS 数据跑在底层传输层(通常是 TCP socket)上,创建 socket 并把它关联到SSL对象(通过 BIO)是应用自己的责任。示例没有用裸的socket/connect系统调用,而是使用 OpenSSL 的可移植辅助函数,这样出错时错误信息会进入 OpenSSL 错误栈:

/* * 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)) { 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; } break; } BIO_ADDRINFO_free(res);

hostnameport是字符串(如"www.example.com""443"),familyAF_INETAF_INET6,对应命令行-6选项。上述方式创建的 socket 默认就是阻塞式的,正是本示例需要的。

连上之后用 BIO 包装 socket:

/* Create a BIO to wrap the socket */ bio = BIO_new(BIO_s_socket()); if (bio == NULL) { BIO_closesocket(sock); return NULL; } /* * BIO_CLOSE: the socket will be automatically closed when the BIO is * freed. With BIO_NOCLOSE you must close the socket explicitly. */ BIO_set_fd(bio, sock, BIO_CLOSE);

BIO_set_fd的第三个参数决定 socket 的生命周期归属:传BIO_CLOSE时 socket 随 BIO 释放而自动关闭;传BIO_NOCLOSE则需要自己显式关闭。

最后把SSL对象与 BIO 关联:

SSL_set_bio(ssl, bio, bio);

SSL_set_bio会把 BIO 的所有权移交给SSL对象——之后由SSL负责管理并在释放SSL时自动释放它,因此调用过SSL_set_bio之后不要再对这个 BIO 调用BIO_free

第三步:设置服务器主机名

socket 虽然已经连上了,但客户端仍需显式告诉 OpenSSL 服务器的主机名,且有两处要设:

/* * 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; } /* * Ensure we check during certificate verification that the server has * supplied a certificate for the hostname that we were expecting. */ if (!SSL_set1_dnsname(ssl, hostname)) { printf("Failed to set the certificate verification hostname"); goto end; }
  • SSL_set_tlsext_host_name设置 SNI(Server Name Indication):主机名会包含在初始 ClientHello 中。多台主机名挂在同一台服务器后面时,不设 SNI 可能导致握手失败,或连到"默认"站点而不是你预期的那台。
  • SSL_set1_dnsname设置证书校验时期望的主机名。不设置的话,OpenSSL 不会校验证书中的主机名是否匹配,任何证书都会被接受(除非应用自己检查)。

这两个设置(以及前面的所有配置)必须在握手发起之前完成,否则不生效。

第四步:执行握手

握手之前不能收发应用数据。显式执行握手:

/* 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返回 1、0 或负数,只有 1 算成功;对简单的阻塞式客户端来说,非 1 即连接失败。失败最常见的来源是服务器证书校验问题(证书过期、签发 CA 不在受信库中等),此时用SSL_get_verify_result拿结果:等于X509_V_OK说明校验通过、连接错误另有原因,否则用X509_verify_cert_error_string把错误转成可读文本。

第五步:发送与接收数据

握手完成后按应用层协议收发数据。示例使用简单的 HTTP GET 请求,分三段写入(请求头、主机名、结尾空行):

const char *request_start = "GET / HTTP/1.1\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成功返回 1、失败返回 0。然后循环读取响应,直到服务器关闭连接:

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. */ fwrite(buf, 1, readbytes, stdout); } printf("\n");

SSL_read_ex返回 0 表示读不到数据,但 0 有两种含义:服务器发完全部数据后发送了 TLS 协议级的 "close_notify" 告警(正常结束),或者发生了错误。用SSL_get_error区分(参数 0 是刚才SSL_read_ex的返回值):

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_ERROR_ZERO_RETURN说明是对端优雅关闭,其余都是错误。

第六步:关闭连接并清理

读完后调用SSL_shutdown,它会向服务器发送 close_notify 告警:

ret = SSL_shutdown(ssl); if (ret < 1) { /* * ret < 0 indicates an error. ret == 0 would be unexpected here * because we already got a close_notify from the peer (the * SSL_ERROR_ZERO_RETURN above). */ printf("Error shutting down\n"); goto end; }

SSL_shutdown返回 1 表示"已发送 close_notify 且已收到对端的 close_notify";返回 0 表示"已发送但还没收到",通常应当再次调用(阻塞套接字下会等到收到为止)。本示例中前面的SSL_ERROR_ZERO_RETURN已证明对端关闭是优雅的,所以 ret == 0 属于不应出现的情形,直接按错误处理。

程序退出前的收尾:失败时把 OpenSSL 错误栈打印到 stderr,然后释放SSLSSL_CTX。注意不要释放 BIO,它的所有权早已移交:

end: if (res == EXIT_FAILURE) ERR_print_errors_fp(stderr); SSL_free(ssl); SSL_CTX_free(ctx); return res;

验证:用官方测试服务器跑通完整流程

不需要自己搭服务器。demos/guide 目录提供了测试用的证书和私钥(servercert.pem、serverkey.pem、rootcert.pem),README 给出的测试路径是:

  1. openssl s_server命令行工具在localhost:4443起一个 HTTPS 测试服务器:
LD_LIBRARY_PATH=../.. ../../apps/openssl s_server -www -accept localhost:4443 -cert servercert.pem -key serverkey.pem
  1. 在另一个终端运行客户端。注意该测试证书使用的 CA 不在系统默认受信证书库中,需要通过SSL_CERT_FILE环境变量把受信库指到本目录的rootcert.pem
SSL_CERT_FILE=rootcert.pem LD_LIBRARY_PATH=../.. ./tls-client-block localhost 4443

命令成功的判断方式(README 原文):客户端会连上s_server、发出简单 HTTP 请求,服务器返回一页包含本次 TLS 连接详情的信息——看到这一页响应即说明连接、握手、收发全部正常。

另外两个限制要知道:测试证书只对localhost有效;如果你的默认受信证书库已正确配置,也可以直接连真实站点(如./tls-client-block <主机名> 443)。

验证默认证书库是否可用的独立检查方法,来自 ossl-guide-tls-introduction(7):openssl version -d显示OPENSSLDIR,查看其certs子目录是否有一批.pem.0结尾的证书文件;也可以直接openssl s_client www.openssl.org:443,连接后按Q退出,看输出中的Verification一行是否为OK。若证书库缺失或位置不对,可用SSL_CERT_DIR(目录)或SSL_CERT_FILE(单文件)环境变量覆盖默认查找位置。

遇到连接或校验失败时排查什么

指南页 TROUBLESHOOTING 一节列出了两类常见问题。

底层 socket 连接失败。可能原因包括客户端与服务器之间的网络路由问题、防火墙拦截、服务器域名无法解析(不在 DNS)等——排查方向是网络配置。

服务器证书校验失败。这会导致SSL_connect失败,ERR_print_errors_fp打印的错误形如(文档示例输出):

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:

其中 "unable to get local issuer certificate" 表示在受信证书库中找不到服务器证书(或其中间 CA 证书)的签发者。指南给出的可能原因有:

  • 受信证书库没有正确配置——对照 ossl-guide-tls-introduction(7) 检查;
  • CA 不被识别——如服务器使用自签名(测试)证书,其 CA 根本不在受信库中(上面用SSL_CERT_FILE=rootcert.pem跑测试就是这种情况);
  • 缺少中间 CA 证书——这是服务器配置错误:客户端有根 CA,但服务器没有把根 CA 到服务器证书之间的完整中间链发出来,信任链无法建立;
  • 主机名不匹配——客户端期望的主机名与证书中的主机名不一致;
  • 证书过期。

下一步

指南页 FURTHER READING 给出了同系列的延伸教程,源码同样在 demos/guide 目录:

  • ossl-guide-tls-client-non-block(7):把本客户端改造为支持非阻塞 socket(对应tls-client-non-block.c,运行方式与本文相同);
  • ossl-guide-tls-server-block(7):实现每次处理一个客户端的阻塞式 TLS 服务器(tls-server-block.c);
  • ossl-guide-quic-client-block(7):把本客户端改造为 QUIC 版本(quic-client-block.c)。

【免费下载链接】opensslGeneral purpose TLS and crypto library项目地址: https://gitcode.com/GitHub_Trending/ope/openssl

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 16:26:15

Shader编程中RGB相乘的光照原理与实践

1. 光照模型中的RGB相乘原理在Shader编程中&#xff0c;RGB颜色值的相乘操作看似简单&#xff0c;实则蕴含着深刻的物理光学原理。这个操作实际上是模拟光线与物体表面材质相互作用的基本数学模型。1.1 光与材质的相互作用当光线照射到物体表面时&#xff0c;会发生三种主要的光…

作者头像 李华
网站建设 2026/9/12 16:26:13

RISC-V AIA架构迁移:从PLIC到APLIC与IMSIC的中断控制器实践

早两年给一颗自研的 RISC-V 多核 SoC 做验证时&#xff0c;我踩到了一个特别尴尬的场景&#xff1a;板子上插了 PCIe 网卡&#xff0c;MSI 中断进来之后&#xff0c;传统 PLIC 这边只能把它当成一个 INTx 电平中断来伺候&#xff1b;等到要上虚拟化&#xff0c;guest 的外部中断…

作者头像 李华
网站建设 2026/9/12 16:26:07

Java框架快速入门: Spring Security+OAuth2之云服务集成与多因子认证设计

纲要 云服务认证基础 AccessKey ID 与 AccessKey Secret 的密钥对模型短信服务要素&#xff1a;签名、模板跨平台对照&#xff1a;阿里云、Leancloud 邮件发送方案 SMTP 与 Web API 的对比与选型邮件服务中的 API Key 鉴权 多因子认证&#xff08;MFA&#xff09;架构设计 用户…

作者头像 李华
网站建设 2026/9/12 16:25:33

Substance Painter智能法线贴花库快速制作方案

1. 项目概述&#xff1a;Substance贴花库快速制作方案 在三维材质制作领域&#xff0c;法线贴图&#xff08;Normal Map&#xff09;一直是提升模型细节表现的关键技术。传统手工绘制法线贴图不仅耗时耗力&#xff0c;对美术人员的专业技能要求也极高。最近在Substance Painter…

作者头像 李华