56、HTTPS客户端:使用OpenSSL实现HTTPS客户端、证书验证

说实话,HTTPS 这东西,现在已经是网络通信的标配了。你想想看,如果还在用明文 HTTP 传输敏感数据,那跟在大街上喊银行卡密码有什么区别?我早年做嵌入式设备时,就吃过这个亏——设备上报的数据被中间人抓包篡改,差点酿成大祸。从那以后,我对 HTTPS 客户端这块就格外上心。

今天咱们就聊聊,怎么用 OpenSSL 库手写一个 HTTPS 客户端。核心就两件事:建立加密连接、验证服务器证书。嗯,咱们一步步来。

HTTPS 的本质是什么?

说白了,HTTPS 就是 HTTP 跑在 TLS/SSL 协议之上。客户端和服务器先通过 TLS 握手协商出对称密钥,然后用这个密钥加密后续的 HTTP 请求和响应。

我习惯把整个过程拆成三个阶段:

  1. TCP 连接建立——先跟服务器打通底层通道
  2. TLS 握手——交换证书、协商密钥、验证身份
  3. HTTP 通信——在加密隧道里收发数据

其中第二步最复杂,也最容易出问题。咱们重点讲这个。

核心要点:证书验证是 HTTPS 安全性的基石。如果跳过证书验证,你的加密连接跟明文传输没什么两样——中间人攻击分分钟就能搞定你。

OpenSSL 客户端的基本流程

用 OpenSSL 写客户端,我建议你记住这几个关键步骤。我在项目中反复用过,基本是固定套路:

  1. 初始化 OpenSSL 库:SSL_library_init()OpenSSL_add_all_algorithms()
  2. 创建 SSL 上下文:SSL_CTX_new(),指定 TLS 方法
  3. 加载 CA 证书:SSL_CTX_load_verify_locations()
  4. 建立 TCP 连接:用标准 socket 连到服务器
  5. 创建 SSL 对象:SSL_new(),绑定 socket
  6. 执行 TLS 握手:SSL_connect()
  7. 验证证书:SSL_get_peer_certificate() + SSL_get_verify_result()
  8. 收发 HTTP 数据:SSL_read() / SSL_write()
  9. 清理资源:关闭 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 客户端核心流程 ① TCP 连接建立 ② TLS 握手 ③ 证书验证 链式验证 有效期验证 主机名验证 验证通过? ✅ 通过 → HTTP 通信 ❌ 失败 → 断开连接 ④ HTTP 请求/响应

常见问题与避坑指南

写 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