C语言开发TLS客户端,建立TSL连接进行通信

本文介绍如何在 C 语言中开发 TLS 客户端,连接 TLS 服务进行通信。TLS 协议的基本原理并不复杂,但实现细节却相当繁琐。因此,我们采用开源的 openssl 库,站在巨人的肩膀上开发。

目前 HTTPS 网站唾手可得,而 HTTPS 协议其实就是以 TLS 作为传输层的 HTTP 。本文便以此为例,介绍如何使用 C 语言来建立 TLS 连接,以及如何通过 TLS 连接来收发数据。

SSLTLS 的前身,但 openssl 等类库代码仍广泛使用 SSL 这个术语。本文不严格区分 SSLTLS 这两个术语,两者在本文中均表示严格意义上的 TLS

依赖包安装

开始之前,我们要先安装 openssl 库和相关头文件,以 Ubuntu 为例:

1
apt install -y openssl libssl-dev

连接建立

前文讲过,TLS 依赖 TCP 提供可靠流式传输能力。因此,建立 TLS 连接之前,必须先建立 TCP 连接。假如建好的 TCP 套接字描述符为 sfd ,则发起 TLS 连接的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
	// init openssl lib
	SSL_library_init();
	SSL_load_error_strings();
	OpenSSL_add_all_algorithms();

	// create ssl context object
	SSL_CTX *sslctx = SSL_CTX_new(TLS_method());
	if (sslctx == NULL) {
		ERR_print_errors_fp(stderr);
		exit_code = 3;
		goto close_socket;
	}

	// use default pathes for ca-cert
	if (!SSL_CTX_set_default_verify_paths(sslctx)) {
		ERR_print_errors_fp(stderr);
		exit_code = 4;
		goto free_ssl_ctx;
	}

	// create ssl object
	SSL *ssl = SSL_new(sslctx);
	if (ssl == NULL) {
		ERR_print_errors_fp(stderr);
		exit_code = 5;
		goto free_ssl_ctx;
	}

	// set the bachend tcp socket
	if (!SSL_set_fd(ssl, sfd)) {
		ERR_print_errors_fp(stderr);
		exit_code = 6;
		goto free_ssl;
	}

	// initiate ssl connecting
	if (SSL_connect(ssl) != 1) {
		ERR_print_errors_fp(stderr);
		exit_code = 7;
		goto free_ssl;
	}
  1. 初始化 openssl 库(第 2~4 行);
  2. 创建 SSL 上下文对象,用于保存一些配置信息(第 7~12 行);
  3. 设置默认的 CA 证书加载路径(第 14~19 行);
  4. 创建 SSL 连接对象(第 22~27 行);
  5. SSL 连接对象设置底层 TCP 连接套接字(第 30~34 行);
  6. 发起 SSL 握手,建立连接(第 37~41 行);

在这个过程中,程序会申请各种资源,包括 TCP 套接字、SSL 上下文对象、SSL 连接对象等等。这些资源用完必须及时销毁,以免泄露。在 C 语言中,资源销毁逻辑通常在函数末尾进行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
free_ssl:
	SSL_free(ssl);

free_ssl_ctx:
	SSL_CTX_free(sslctx);

close_socket:
	close(sfd);

	return exit_code;

注意到,资源销毁顺序与申请顺序相反。如果函数执行过程中出错,则执行 goto 语句,跳到函数末尾对应位置,销毁前面申请到的资源。

  • 如果设置默认证书路径失败,则跳到 free_ssl_ctx ,释放上下文对象,并关闭 TCP 套接字;
  • 如果 SSL 连接设置 TCP 套接字失败,则跳到 free_ssl ,释放 SSL 连接对象和上下文对象,并关闭 TCP 套接字;

证书校验

TLS 连接建立后,我们需要校验服务端证书的合法性:

1
2
3
4
5
6
	// verify tls cert
	if (SSL_get_verify_result(ssl) != X509_V_OK) {
		ERR_print_errors_fp(stderr);
		exit_code = 8;
		goto shutdown_ssl;
	}

发送数据

证书校验完毕后,我们就可以通过 TLS 连接来收发数据了。由于例子连接的是 HTTPS 服务,接下来我们可以组织 HTTP 请求报文,并通过 TLS 连接发给服务器。

我们可以调用 SSL_write 直接向 TLS 连接发送数据,也可以创建 BIO 对象来实现带缓冲 IO 。演示程序代码以 BIO 为例,先创建并初始化 BIO 对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
	// create BIO object for buffered reading/writing
	BIO *bio = BIO_new_buffer_ssl_connect(sslctx);
	if (bio == NULL) {
		ERR_print_errors_fp(stderr);
		exit_code = 9;
		goto shutdown_ssl;
	}

	// use ssl object as backend connection
	// then we can use bio to read/write ssl connection
	retval = BIO_set_ssl(bio, ssl, BIO_NOCLOSE);
	if (retval != 1) {
		ERR_print_errors_fp(stderr);
		exit_code = 10;
		goto free_bio;
	}
  1. 调用 BIO_new_buffer_ssl_connect 创建一个 BIO 对象(第 2~7 行);
  2. BIO 对象设置底层 SSL 连接对象(第 11~16 行);

此后,程序就可以通过 BIO 对象要读写 SSL 连接对象了。BIO 对象提供基础缓冲 IO 能力,底层不仅可以对接 SSL 连接对象,还可以直接对接套接字或文件等等。

  • 写数据时,BIO 可以缓存待写数据,再通过 BIO_flush 刷缓存,进而触发写操作(合并写);
  • 读数据时,BIO 可以缓存读到的数据片段,再组装成一个整体提交给调用进程(整体读);

再接下来,格式化并发送 HTTP 请求的代码就比较好理解了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
	char buffer[10240];

	// format http request
	int bytes_to_send = snprintf(buffer, sizeof(buffer), "GET %s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\n\r\n", path, host);
	if (bytes_to_send < 0) {
		perror("snprintf");
		exit_code = 11;
		goto free_bio;
	}

	// call BIO_write to send http request until all bytes sent
	while (bytes_to_send > 0) {
		int bytes_sent = BIO_write(bio, buffer, bytes_to_send);
		if (bytes_sent < 0) {
			ERR_print_errors_fp(stderr);
			exit_code = 12;
			goto free_bio;
		}

		bytes_to_send -= bytes_sent;
	}

	// flush all buffered data to server, ensure that all data is sent
	if (BIO_flush(bio) != 1) {
		ERR_print_errors_fp(stderr);
		exit_code = 13;
		goto free_bio;
	}
  1. 按照 HTTP 协议格式化请求报文(第 4~9 行);
    • HTTP 请求报文,包括 GET 请求行和两个头部( HostConnection );
    • 第 1 行, buffer 定义了一个 10K 的缓冲区,用于保存格式化结果;
  2. 调用 BIO_writeHTTP 请求报文发送出去(第 12~21 行);
    • 由于 BIO_write 写操作可能部分成功,因此需要循环调用,直到全部写完为止;
  3. 调用 BIO_flushBIO 对象内部缓冲区,确保所有数据均发出(第 24~28 行);

接收数据

同理,接收数据时,我们可以直接调用 SSL_read 系列函数,从 SSL 连接对象读取数据;也可以通过 BIO 对象来进行。演示程序以 BIO 对象为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
	for (;;) {
		// read data into buffer if any
		int bytes_read = BIO_read(bio, buffer, sizeof(buffer));
		if (bytes_read < 0) {
			ERR_print_errors_fp(stderr);
			exit_code = 14;
			goto free_bio;
		} else if (bytes_read == 0) {
			break;
		}

		// write received data to stdout
		if (write(1, buffer, bytes_read) < 0) {
			perror("write");
			exit_code = 15;
			goto free_bio;
		}
	}
  1. 循环调用 BIO_read 函数接受数据(第 2 行);
  2. 将接到的数据写到标准输出(第 11 行 );
  3. 当数据读取完毕(对方关闭连接),则结束循环(第 7 行);

完整程序

演示程序全部代码可从 Github 获取:https-get.c

编译执行

http-get.c 程序代码编写好后,即可执行 gcc 命令进行编译:

1
gcc -Wall -o https-get https-get.c -lssl -lcrypto
  • -Wall 选项,表示警告级别设为 all, 以输出全部警告信息;
  • -o 选项,指定编译结果(可执行程序)保存为 https-get
  • -lssl 选项,表示链接 ssl 动态库;
  • -lcrypto 选项,表示链接 crypto 动态库;

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: