56、HTTPS客户端:使用OpenSSL实现HTTPS客户端、证书验证
说实话,HTTPS 这东西,现在已经是网络通信的标配了。你想想看,如果还在用明文 HTTP 传输敏感数据,那跟在大街上喊银行卡密码有什么区别?我早年做嵌入式设备时,就吃过这个亏——设备上报的数据被中间人抓包篡改,差点酿成大祸。从那以后,我对 HTTPS 客户端这块就格外上心。
今天咱们就聊聊,怎么用 OpenSSL 库手写一个 HTTPS 客户端。核心就两件事:建立加密连接、验证服务器证书。嗯,咱们一步步来。
HTTPS 的本质是什么?
说白了,HTTPS 就是 HTTP 跑在 TLS/SSL 协议之上。客户端和服务器先通过 TLS 握手协商出对称密钥,然后用这个密钥加密后续的 HTTP 请求和响应。
我习惯把整个过程拆成三个阶段:
- TCP 连接建立——先跟服务器打通底层通道
- TLS 握手——交换证书、协商密钥、验证身份
- HTTP 通信——在加密隧道里收发数据
其中第二步最复杂,也最容易出问题。咱们重点讲这个。
核心要点:证书验证是 HTTPS 安全性的基石。如果跳过证书验证,你的加密连接跟明文传输没什么两样——中间人攻击分分钟就能搞定你。
OpenSSL 客户端的基本流程
用 OpenSSL 写客户端,我建议你记住这几个关键步骤。我在项目中反复用过,基本是固定套路:
- 初始化 OpenSSL 库:
SSL_library_init()和OpenSSL_add_all_algorithms() - 创建 SSL 上下文:
SSL_CTX_new(),指定 TLS 方法 - 加载 CA 证书:
SSL_CTX_load_verify_locations() - 建立 TCP 连接:用标准 socket 连到服务器
- 创建 SSL 对象:
SSL_new(),绑定 socket - 执行 TLS 握手:
SSL_connect() - 验证证书:
SSL_get_peer_certificate()+SSL_get_verify_result() - 收发 HTTP 数据:
SSL_read()/SSL_write() - 清理资源:关闭 SSL、关闭 socket
你看,其实并不复杂。但每一步都有坑,我一个个说。
代码实战:一个完整的 HTTPS 客户端
下面这个例子,是我从实际项目中抽出来的简化版。它访问 https://example.com,发送一个 GET 请求,并打印响应内容。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
#define HOST "example.com"
#define PORT 443
#define CA_FILE "/etc/ssl/certs/ca-certificates.crt"
int main() {
SSL_CTX *ctx;
SSL *ssl;
int sockfd;
struct hostent *host;
struct sockaddr_in addr;
char request[] = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
char buf[4096];
int bytes;
// 1. 初始化 OpenSSL
SSL_library_init();
OpenSSL_add_all_algorithms();
SSL_load_error_strings();
// 2. 创建 SSL 上下文
ctx = SSL_CTX_new(TLS_client_method());
if (!ctx) {
fprintf(stderr, "SSL_CTX_new failed\n");
return 1;
}
// 3. 加载 CA 证书(用于验证服务器证书)
if (SSL_CTX_load_verify_locations(ctx, CA_FILE, NULL) != 1) {
fprintf(stderr, "Failed to load CA certificates\n");
ERR_print_errors_fp(stderr);
return 1;
}
// 4. 建立 TCP 连接
host = gethostbyname(HOST);
if (!host) {
fprintf(stderr, "gethostbyname failed\n");
return 1;
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
memcpy(&addr.sin_addr, host->h_addr, host->h_length);
if (connect(sockfd, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
fprintf(stderr, "connect failed\n");
return 1;
}
// 5. 创建 SSL 对象并绑定 socket
ssl = SSL_new(ctx);
SSL_set_fd(ssl, sockfd);
// 6. TLS 握手
if (SSL_connect(ssl) != 1) {
fprintf(stderr, "SSL_connect failed\n");
ERR_print_errors_fp(stderr);
return 1;
}
// 7. 证书验证
X509 *cert = SSL_get_peer_certificate(ssl);
if (cert) {
printf("Server certificate:\n");
char *line = X509_NAME_oneline(X509_get_subject_name(cert), 0, 0);
printf(" Subject: %s\n", line);
OPENSSL_free(line);
line = X509_NAME_oneline(X509_get_issuer_name(cert), 0, 0);
printf(" Issuer: %s\n", line);
OPENSSL_free(line);
X509_free(cert);
} else {
printf("No certificate received\n");
}
long verify_result = SSL_get_verify_result(ssl);
if (verify_result != X509_V_OK) {
printf("Certificate verification failed: %ld\n", verify_result);
} else {
printf("Certificate verification passed\n");
}
// 8. 发送 HTTP 请求
SSL_write(ssl, request, strlen(request));
// 9. 读取响应
while ((bytes = SSL_read(ssl, buf, sizeof(buf)-1)) > 0) {
buf[bytes] = '\0';
printf("%s", buf);
}
// 10. 清理
SSL_shutdown(ssl);
SSL_free(ssl);
close(sockfd);
SSL_CTX_free(ctx);
return 0;
}
小提示:CA 证书文件路径因系统而异。Linux 上通常在 /etc/ssl/certs/ca-certificates.crt,macOS 上可能是 /etc/ssl/cert.pem。如果你不确定,可以用 openssl version -d 查看默认路径。
证书验证的细节
证书验证这块,我当年踩过不少坑。你以为调了 SSL_CTX_load_verify_locations 就万事大吉了?没那么简单。
OpenSSL 的证书验证分三层:
| 验证层级 | 检查内容 | 常见失败原因 |
|---|---|---|
| 链式验证 | 证书是否由受信任的 CA 签发 | CA 证书未加载、中间证书缺失 |
| 有效期验证 | 证书是否在有效期内 | 系统时间不准、证书已过期 |
| 主机名验证 | 证书中的 CN/SAN 是否匹配目标域名 | 域名不匹配、通配符证书使用不当 |
我建议你特别注意第三点。OpenSSL 默认不会自动验证主机名,需要手动调用 SSL_set1_host() 或 X509_check_host()。我曾经在项目里漏掉这一步,结果测试环境一切正常,上线后被安全审计直接打回——因为证书是合法的,但域名根本不匹配。
警告:千万不要在生产代码中设置 SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL)。这等于告诉 OpenSSL:「不用验证证书,谁都能连」。我见过有人为了调试方便这么写,结果忘了改回来,整个服务的安全防线形同虚设。
SVG 流程图:HTTPS 客户端核心流程
常见问题与避坑指南
写 HTTPS 客户端时,有几个问题我几乎每次都会遇到。分享出来,希望你能少走弯路。
- 证书链不完整——有些服务器只发送终端证书,不发送中间 CA 证书。客户端如果本地没有中间 CA,验证就会失败。解决办法是设置
SSL_CTX_set_verify_depth(),或者手动加载完整的 CA 包。 - 系统时间不准——证书有效期验证依赖系统时钟。我遇到过一台嵌入式设备,电池没电导致时间重置到 1970 年,结果所有 HTTPS 连接都报证书过期。嗯,后来加了 NTP 同步才解决。
- SNI(服务器名称指示)——如果一个 IP 上托管了多个 HTTPS 站点,客户端必须通过 SNI 告诉服务器要访问哪个域名。用
SSL_set_tlsext_host_name(ssl, HOST)即可。 - 非阻塞模式——上面的例子用的是阻塞 socket,简单直接。但在高并发场景下,我建议用非阻塞模式配合
SSL_get_error()处理重试。否则一个慢请求就能卡住整个线程。
调试小技巧:如果证书验证失败,可以用 openssl s_client -connect example.com:443 命令行工具先测试一下。它能打印出完整的证书链和验证结果,帮你快速定位问题。
总结
实现一个 HTTPS 客户端,说白了就是「TCP + TLS + HTTP」三层叠加。OpenSSL 帮我们封装了最复杂的 TLS 握手和加密解密,我们要做的就是正确配置证书验证。
我个人习惯把证书验证相关的代码单独封装成一个函数,比如 int verify_server_certificate(SSL *ssl),里面做三件事:获取对端证书、检查验证结果、验证主机名。这样主流程清晰,也方便单元测试。
最后再啰嗦一句:永远不要在生产环境关闭证书验证。你永远不知道中间人攻击什么时候会找上门来。
公众号:蓝海资料掘金营,微信deep3321