文章目录
摘要
程序员干活可以分为三步:明确目标-需求;了解需求的背景知识;调用API实现目标。
第一部分:介绍TLS握手协议内容。(非常粗略的介绍,详见《图解密码技术》-- 结城浩 – 第14章 SSL/TLS 为了更安全的通信)
第二部分:安装wireshark,使用其进行抓数据包,为后面TLS分析做准备。
第三步:详细查看TLS握手的每一步。最后使用一个示例验证握手过程的掌握情况。
第四步:调用ssl - OpenSSL SSL/TLS library,实现一个客户端连接。
TLS握手协议介绍
本节来源:《图解密码技术》-- 结城浩 – 第14章 SSL/TLS 为了更安全的通信
TLS 协议 是由 TLS 记录协议 ( TLS record protocol) 和 TLS 握 手协议 ( TLS handshake protocol) 这两层协议叠加而成的。
TLS 握手协议分为下列 4 个子协议 : 握手协议、密码规格变更协议、警告协议和应用数据协议。
握手协议是 TLS 握手协议的一部分,负责生成共享密钥以及交换证书 。 其中,生成共享密钥是为了进行密码通信,交换证书是为了通信双方相互进行认证。
握手协议的过程如下图所示 。
下一节,我们通过抓包,验证这张图片中的握手过程。
准备工作
安装wireshark
sudo apt install wireshark # 选择yes,表允许非超级用户捕获数据包
sudo usermod -aG wireshark $(whoami)
reboot
wireshark的使用
我的抓包需求:
- 抓取的内容流进
enp2s0
接口。 - 抓取
dst
和src
为www.baidu.com
的内容。
所以,wireshark输入设置如下。
我们使用openssl-s_client,作为客户端程序。(不使用浏览器是避免一些不必要的干扰)
openssl s_client -connect www.baidu.com:443
wireshark抓取内容如下:
看见TLS握手过程
(0)TCP的三次握手
前三个数据包是TCP的三次握手过程,感兴趣的话,可以阅读实战!我用 Wireshark 让你“看见“ TCP
(1)Client Hello (客户端–>服务器)
其中的密码套件,拉出来,单独介绍下。
密码套件含义 :SSL/TLS 提供了 一种密码通信的框架,这意味着 SSL/TLS 中使用的对称密码、公钥密码 、数字签名、单向散列函数等技术,都是可以像零件一样进行替换的 。 也就是说,如果发现现在所使用的某个密码技术存在弱点,那么只要将这一部分进行替换就可以了 。尽管如此,也并不是说所有的组件都可以自由选择 。 由千实际进行对话的客户端和服务器必须使用相同的密码技术才能进行通信,因此如果选择过于自由,就难以确保整体的兼容性 。为此, SSL/TLS 就像事先搭配好的盒饭一样,规定了 一些密码技术的“推荐套餐",这种推荐套餐称为密码套件 (cipher suite)。(来源:《图解密码技术》14.2.6)
密码套件-wiki中的参考链接7–TLS/SSL (Schannel SSP) 中的密码套件,内容不错,我搬运下。
密码套件是一组加密算法。 TLS/SSL 协议的 schannel SSP 实现使用密码套件中的算法来创建密钥和加密信息。 密码套件为以下每种任务指定一种算法:
- 密钥交换算法:保护创建共享密钥所需的信息。这些算法是非对称的,并且对于相对较少的数据性能良好。
- 批量加密算法:对客户端和服务器之间交换的消息进行加密。 这些算法是对称的,适用于大量数据。
- 消息验证算法:生成消息哈希和签名,以确保消息的完整性。
开发人员通过使用ALG_ID
数据类型来指定这些元素。
密码套件名称结构,如下图所示。
可以看到TLS_AES_256_GCM_SHA384
套件,不符合密码套件的名称结构。参考TLS-mozilla,推测没有密钥交换算法,可能表示密钥交换算法可以任意。这样的好处是,避免有太多的密码套件名称。
至于每个套件的具体含义,我不知道到。遇到一个查一个呗。
(2) Server Hello (客户端<–服务器)
服务器给客户端发送的内容:使用的版本号,当前时间,服务器随机数,会话 ID,使用的密码套件,使用的压缩方式
新建立的会话,Session id length是0。参考SSL_get_session,当session还存在时,ssl 会话包含重新建立连接所需的所有信息,无需完全握手。(有点类似与web中的session)
我们来看下TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
密码套件:
-
TLS
:Transport Layer Security 协议 -
ECDHE
:Elliptic Curve Diffie-Hellman Ephemeral 密钥交换算法。关于Diffie-Hellman,可以参考《图解密码技术》11.5。使用这种算法,通信双方仅通过交换一些可以公开的信息就能够生成出共享的秘密数字,而这一秘密数字就可以被用作对称密码的密钥 。 (这里用于计算预主密钥)注:下图是DHE算法,ECDHE是它的改进版。但两者的输入和输出应该是一样的。这里贴下DHE算法的示意图。
-
RSA
:签名使用的非对称算法。对两个hello的随机数和服务端(或客户端)的DH随机数,进行签名。表明自己拥有对应证书的私钥。(RSA是在下方使用的,签名过程也在下方) -
AES_128_GCM
:对称加密,加密消息流。GCM可以提供对消息的加密和完整性校验。 -
SHA256
:计算消息认证码(Message Authentication Code,MAC)。计算位置应该是下图位置。
接下来的Certificate, Server Key Exchange, Server Hello Done在一个数据包中,从服务端发送到客户端。为了清晰起见,我们分开来看。
(3) Certificate (客户端<–服务器)
服务器向客户端发送证书清单。证书清单是一组 X.509v3 证书序列,首先发送的是服务器(发送方)的证书,然后会按顺序发送对服务器证书签名的认证机构的证书 。客户端会对服务器发送过来的证书进行验证 。
(4)ServerKeyExchange (客户端<–服务器)
当 Certificate 消息不足以满足需求时,服务器会通过 ServerKeyExchange 消息向客户端发送一些必要信息 。
具体所发送的信息内容会根据所使用的密码套件而有所不同 。
当不需要这些信息时,将不会发送 ServerKeyExchange 消息 。
上图的Pubkey应该是ECDHE算法需要的两个随机数中的一个。使用rsa算法,对client hello 的随机值,server hello 的随机值,Pubkey值进行签名,表明服务端有对应证书的私钥。
(5)CertificateRequest (客户端<–服务器)
服务端要看客户端的证书。访问百度,不需要对客户端进行认证,所以没有该消息。
(6)ServerHelloDone (客户端<–服务器)
表示从 ServerHello 消息开始的一系列消息的结束 。
(7) Certificate (客户端–>服务器)
服务器读取客应端的证书并进行验证。当服务器没有发送 CertificateRequest 消息时, 客户端不会发送 Certificate 消息 。所以,没有该消息。
接下来的Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message在一个数据包中。为了清晰起见,我们分开来看。
(8)ClientKeyExchange (客户端–>服务器)
当密码套件中包含 RSA 时, 会随 ClientKeyExchange 消息一起发送经过加密的预备主密码 。
当密 码 套 件中包含趴ffie-Hellman 密钥交换时, 会 随 ClientKeyExchange 消息 一 起发送 Diffie-Hellman 的公开值 。
(9) CertificateVerify (客户端–>服务器)
客户端只有在服务器发送 CertificateRequest 消息时才会发送 CertificateVerify 消息 。 这个消息的目的是向服务器证明自己的确持有客户端证书的私钥 。所以,没有该消息。
(10)ChangeCipherSpec (客户端–>服务器)
实际上, ChangeCipherSpec 消息并不是握手协议的消息,而是密码规格变更协议的消息 。
在 ChangeCipherSpec 消息之前,客户端和服务器之间已经交换了所有关于密码套件的信息,因此在收到这一消息时,客户端和服务器会同时切换密码 。
在这一消息之后, TLS 记录协议就开始使用双方协商决定的密码通信方式了 。
(11)Encrypted Handshake Message (客户端–>服务器)
参考: Encrypted Handshake Message、TLS/SSL 协议详解 (19) Encrypted handshake message
The TLS handshake is concluded with the two parties sending a hash of the complete handshake exchange, in order to ensure that a middleman did not try to conduct a downgrade attack.
1.这个报文的目的就是告诉对端自己在整个握手过程中收到了什么数据,发送了什么数据。来保证中间没人篡改报文。
2.其次,这个报文作用就是确认秘钥的正确性。因为Encrypted handshake message是使用对称秘钥进行加密的第一个报文,如果这个报文加解密校验成功,那么就说明对称秘钥是正确的。(来自:https://blog.csdn.net/foshengtang/article/details/109066047)
接下来的New Session Ticket, Change Cipher Spec, Encrypted Handshake Message在一个数据包中。为了清晰起见,我们分开来看。
(12)New Session Ticket (客户端<–服务器)
我们来看下它的流程,首先客户端在ClientHello的扩展中带上session_ticket扩展表示它想使用session_ticket功能,服务端如果同意则在ServerHello中回复session_ticket扩展项。服务端不用保存session,而是加密之后通过NewSessionTicket消息发送给客户端。这里session ticket key实际上是个对称密钥,它只有服务端自己知道。
后续客户端想要重用该Session,在ClientHello扩展中把之前那个session_ticket塞进去,服务端成功解密且验证通过之后就进行会话重用,否则回退到完整的握手。然后还是同样地根据主密钥重新派生出会话密钥。
这样服务端没有了保存session的负担,但是天下没有免费的午餐,session ticket对前向安全性会带来一定的损害。因为session ticket只是单纯使用session ticket key进行加密的,如果session ticket key泄漏了,那么之前基于会话重用的握手就都可以被破解了。
所以在实际使用时,session ticket key应该经常更换,减小前向安全性方面的风险。
(13)ChangeCipherSpec (客户端<–服务器)
这次轮到服务器发送 ChangeCipherSpec 消息了 。
服务器:“好,现在我要切换密码了 。 ”
(14)Encrypted Handshake Message (客户端<–服务器)
介绍同“(11)Encrypted Handshake Message (客户端–>服务器)”。
(15)到上面,握手结束,之后可以传送加密消息流(客户端<–>服务器)
在此之后,客户端和服务器会使用应用数据协议和 TLS 记录协议进行密码通信 。
我们通过抓包的方式,一步步的看见了TLS的握手过程。接下来,我们验证下,我们是否真的搞明白了整个握手过程。
下面这张图来自:catbro666-TLS ECDHE密钥交换时的认证。
这张图和上面整个过程有一个地方不同的是图中的server端生成了两个DH param,而上面显示的是server和client各生成一个DH param。
TLS安全连接的客户端程序 – 非完整代码
参考代码:基于C++和OpenSSL实现的SSL网络通信、SSL编程- 简单函数介绍
中文的互联网还不够大,没有大到可以容易的找到参考示例代码。
上面两个连接的示例也不大好:使用socket
而非BIO_socket
;没有考虑回调函数该如何实现;
因为一些原因,下面非完整代码,但它能指导写出完整代码。
客户端类 – 代码示例
总体的逻辑思路如下图所示。(图片来自:SSL/TLS安全通信)
代码如下所示。其中包含三个自定义的头文件。
exception.hpp
,是异常头文件。根据自己需要自行实现,可参考《Boost程序完全开发指南》4.7 exception。cb.hpp
,是回调函数头文件。不会给出具体的代码,但会介绍如何去实现。socket.hpp
,套接字相关的头文件。会给出具体代码,见后文。
#pragma once
#include "exception.hpp"
#include "cb.hpp"
#include "socket.hpp"
#include <openssl/ssl.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string>
class TLSClient
{
private:
int socket;
SSL_CTX* ctx = nullptr;
SSL* ssl = nullptr;
char ERR[1024] = {0};
public:
TLSClient(std::string host, std::string port,
std::string private_key_path, std::string cert_path,
std::string CAfile, std::string CApath);
~TLSClient();
};
TLSClient::TLSClient(std::string host, std::string port,
std::string private_key_path, std::string cert_path,
std::string CAfile, std::string CApath)
{
// TLS_client_method():协商最高可用 SSL/TLS 版本
// SSL_METHOD: Used to hold SSL/TLS functions
// 这里可能没有做到强异常安全。openssl似乎没有提供单独释放SSL_METHOD空间的方法。可能是动态库的静态空间?
const SSL_METHOD* meth = TLS_client_method();
ctx = SSL_CTX_new(meth);
if(ctx == nullptr) {
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
// 设置msg_callback
BIO* bio_c_out = BIO_new_fp(stdout,BIO_NOCLOSE);
if(bio_c_out == nullptr) {
SSL_CTX_free(ctx);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
SSL_CTX_set_msg_callback(ctx, msg_cb);
SSL_CTX_set_msg_callback_arg(ctx, bio_c_out);
// 客户端验证服务器证书设置
mydata_t mydata;
// mydata.verbose_mode = 1; // 打印证书信息
// mydata.always_continue = 1; // 验证失败后,继续握手
// mydata.verify_depth = 5; // ca最深验证五层
mydata_index = SSL_get_ex_new_index(0, (void*)"mydata index", NULL, NULL, NULL);
SSL_CTX_set_verify(ctx,SSL_VERIFY_PEER, verify_cb);
const char* CAfile_str = CAfile.empty() ? nullptr : CAfile.c_str();
const char* CApath_str = CApath.empty() ? nullptr : CApath.c_str();
if(!SSL_CTX_load_verify_locations(ctx, CAfile_str, CApath_str))
{
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
// 为SSL会话加载用户证书
if(!SSL_CTX_use_certificate_file(ctx, cert_path.c_str(), SSL_FILETYPE_PEM)) {
SSL_CTX_free(ctx);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
// 为SSL会话加载用户私钥
if(!SSL_CTX_use_PrivateKey_file(ctx, private_key_path.c_str(), SSL_FILETYPE_PEM)) {
SSL_CTX_free(ctx);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
// 验证私钥和证书是否相符
if(!SSL_CTX_check_private_key(ctx)) {
SSL_CTX_free(ctx);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
// 三次握手
if(!tcp_init(socket, host, port)) {
BIO_closesocket(socket);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
ssl = SSL_new(ctx);
if(ssl == nullptr) {
SSL_CTX_free(ctx);
BIO_closesocket(socket);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
if(!SSL_set_ex_data(ssl, mydata_index, &mydata)) {
SSL_CTX_free(ctx);
BIO_closesocket(socket);
SSL_free(ssl);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
if(!SSL_set_fd(ssl,socket)) {
SSL_CTX_free(ctx);
BIO_closesocket(socket);
SSL_free(ssl);
int n = ERR_get_error();
ERR_error_string(n, ERR);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str(ERR));
}
if(SSL_connect(ssl) != 1) {
SSL_CTX_free(ctx);
BIO_closesocket(socket);
SSL_free(ssl);
int err;
SSL_get_error(ssl, err);
BOOST_THROW_EXCEPTION(tls_client_err()
<<err_str("SSL_connect fail. The err code is ")
<<err_num(err));
}
}
TLSClient::~TLSClient()
{
BIO_closesocket(socket);
SSL_CTX_free(ctx);
SSL_free(ssl);
}
客户端类 – 代码分解
程序 == 数据结构 + 操作数据结构的算法
类 == 成员变量 + 成员方法
下面按照这个思路,拆解上面代码。
SSL_CTX
SSL_CTX :这是由服务器或客户端在每个程序生命周期中创建一次的全局上下文结构,它主要保存稍后为连接创建的SSL结构的默认值。
使用SSL_CTX_new()创建SSL_CTX结构:将密码列表、会话缓存设置、回调、密钥和证书以及选项初始化为其默认值。创建新的 SSL_CTX 对象可能会失败。失败时,需要检查错误堆栈以找出原因。
下面的代码中,仅仅设置“回调,密钥,证书”选项。
SSL_CTX_set_msg_callback:安装回调以观察协议消息。关键是void (*cb)(int write_p, int version, int content_type, const void *buf, size_t len, SSL *ssl, void *arg)
类型的回调函数该如何实现。我没有自己写,因为能力不够,所以我选择了移植openssl/apps/lib/s_cb.c-msg_cb。(很容易移植出来,比自己写的强很多)
SSL_CTX_set_verify:设置证书验证的各种各种 SSL/TLS 参数。难点在于int (*SSL_verify_cb)(int preverify_ok, X509_STORE_CTX *x509_ctx)
类型的回调函数该如何实现。非常棒的是,官方尽然在下方给出了示例,写的挺漂亮。
SSL_CTX_use_PrivateKey_file、SSL_CTX_use_certificate:加载用户的密钥和证书;单向认证不需要,双向认证才需要的函数;将存储在文件中的第一个证书加载到ctx中;如果需要加载证书链,可以使用SSL_CTX_use_certificate_chain_file()
;如果内部需要存储多个私钥/证书对,可以使用SSL_CTX_set_cipher_list();如果在 TLS 协商过程中需要额外的证书来完成链,则在受信任的 CA 证书的位置额外查找 CA 证书,请参阅SSL_CTX_load_verify_locations(3);从文件加载的私钥可以加密。为了成功加载加密密钥,必须提供返回密码的函数,请参阅SSL_CTX_set_default_passwd_cb(3);使用回调函数SSL_CTX_set_client_cert_cb可以实现适当的选择例程或允许用户交互来选择要发送的证书。
BIO_socket
BIO是个好东西:BIO 是一种 I/O 抽象,它对应用程序隐藏了许多底层 I/O 细节。如果应用程序将 BIO 用于其 I/O,它可以透明地处理 SSL 连接、未加密的网络连接和文件 I/O。(这篇中文博客也很不错:OpenSSL中文手册之BIO库详解)
所以相对于直接使用socket
进行连接,比如chapter05_TCP客户_服务器示例,我更加倾向于于使用BIO_socket
下面代码适用于tcp/ipv4连接。如果想写出通用的连接代码,可参考openssl/apps/lib/s_socket.c-init_client
bool tcp_init(int& socket, const std::string& host, const std::string& port)
{
// https://www.openssl.org/docs/man1.1.1/man3/BIO_ADDR.html
// https://www.openssl.org/docs/man1.1.1/man3/BIO_socket.html
socket = BIO_socket(AF_INET,SOCK_STREAM,IPPROTO_TCP,0);
struct in_addr ip;
int result = inet_pton(AF_INET,host.c_str(),&ip);
if(result == 0) {
printf("src does not contain a character string representing a valid network address \
in the specified address family.\n");
return false;
} else if(result == -1) {
printf(" af does not contain a valid address family, \
and errno is set to EAFNOSUPPORT.\n");
return false;
}
BIO_ADDR* serv_addr = BIO_ADDR_new();
if(serv_addr == nullptr) {
printf("BIO_ADDR_new fail\n");
return false;
}
if(!BIO_ADDR_rawmake(serv_addr,AF_INET,&ip,sizeof(ip),htons(std::stoi(port)))) {
printf("BIO_ADDR_rawmake fail\n");
return false;
}
if(!BIO_connect(socket, serv_addr, 0)) {
printf("BIO_connect fail\n");
return false;
}
BIO_ADDR_free(serv_addr);
return true;
}
SSL
SSL (SSL Connection):这是主要的 SSL/TLS 结构,由服务器或客户端根据已建立的连接创建。这实际上是 SSL API 中的核心结构。在运行时,应用程序通常处理这个结构,它与大多数其他结构都有链接。(创建网络连接后,可以将其分配给SSL对象。使用SSL_new()创建SSL对象后,可以使用 SSL_set_fd()或SSL_set_bio()将网络连接与对象相关联。)
SSL_new():创建一个新的SSL结构,用于保存 TLS/SSL 连接的数据。新结构继承了底层上下文ctx的设置:连接方法、选项、验证设置、超时设置。(SSL结构是引用计数的。首次创建SSL结构会增加引用计数。释放它(使用 SSL_free)会减少它。当引用计数降至零时,分配给SSL结构的所有内存或资源都将被释放。??)
SSL_set_fd() :将文件描述符fd设置为ssl的 TLS/SSL(加密)端的输入/输出工具。fd通常是网络连接的套接字文件描述符。执行操作时,会自动创建一个套接字 BIO来连接ssl和fd。BIO 和 SSL 引擎继承了fd的行为。如果fd是非阻塞的,则ssl也将具有非阻塞行为。
SSL_connect():启动与 TLS/SSL 服务器的 TLS/SSL 握手。