TLS协议与编程浅析

"TLS"

Posted by leiyiming on July 31, 2018

对称加密,非对称加密,数字证书,数字签名


对称加密:采用单钥密码系统的加密方法,同一个密钥可以同时用作信息的加密和解密,这种加密方法称为对称加密,也称为单密钥加密。

非对称加密:采用两个密钥来进行加密和解密,这两个密钥是公开密钥(public key,简称公钥)和私有密钥(private key,简称私钥)。

对称加密与非对称加密在SSL中的应用:

对称加密速度快,占用资源小;非对称加密解密速度慢,占用资源大。

但是对称加密需要双方都拥有密钥,并且必须保证其不被泄漏,对于长期通信来说很难保证这一点。

非对称加密只需要一端拥有并保护密钥,较易保障其安全性。所以TLS协议中握手的过程总结来说就是通过非对称加密的形式协商出对称加密的密码,传输数据使用协商的密钥进行对称加密解密。因为每次连接都会协商一个新的密钥,所以这样既能够保证解密速度,又能保证每次传输的安全性。

数字签名:数字签名是对信息的发送者和发送信息真实性的一个有效证明。其具体过程是:事先双方将商议好一个哈希算法,数据发送方将要传输的信息进行哈希,并使用自己的私钥对哈希值进行非对称加密生产签名,然后将数据和签名一并发送给接受者。接受者收到数据后,使用发送者的公钥解密签名生成待验证的哈希值,并利用事先商议好的哈希算法将数据进行哈希计算,最终对比这两个哈希值,如果相同,那么就可以确保数据和发送方的正确性。

数字证书:数字证书是一个经证书授权中心数字签名的包含公钥拥有者信息以及公钥的文件。

数字签名与数字证书在SSL中的应用:

之前在介绍对称加密和非对称加密中提到,SSL握手过程其实是利用非对称加密协商对称加密密钥的过程。

但是,怎么确保客户端够拿到的是服务端的公钥呢?如果提前将公钥放在客户端,黑客黑入客户端并将服务端公钥替换成自己的公钥,再进行中间人攻击,这就很容易地控制通信内容,造成危害了。

这个时候就需要数字证书了,TLS握手过程中,服务端将数字证书发送给客户端,客户端使用权威证书认证机构(简称CA)的公钥去验证数字证书的数字签名,如果验证通过,并且证书信息和服务端信息吻合,那么客户端就可以肯定通信的对方是服务端,而不是中间人了。

TODO:如果中间人将自签名的公钥放入了CA公钥文件中,该怎么办?

TLS 握手详细过程


TLS协议握手过程发生于TCP连接建立之后,目的是认证以及协商出安全的传输密钥。其主要过程如下:

  1. 客户端向服务端发送Client Hello消息。其中包括客户端生成的随机值Random1和支持的加密算法以及TLS Version等信息。
  2. 服务端响应发送Server Hello消息。其中包括从客户端支持的加密算法中选出一个作为后续加密和生成摘要的算法,和服务端生成的随机值Random2
  3. 服务端发送包含服务端证书的Certificate消息,以便客户端校验确认服务端身份。
  4. 可选:服务端发送请求客户端证书的Certificate Request的消息,以便校验确认客户端身份。这一步通常会在安全性要求高的场合用到,例如网上银行的操作。
  5. 服务端发送Server Hello Done,通知客户端结束。
  6. 如果服务器从客户端请求证书,则客户端将其发送其证书。
  7. 客户端发送Client Key Exchange消息,包含PreMaster Key。这一步客户端从CA验证服务端传来的证书的合法性,验证通过后取出服务端公钥。然后再生成随机值Random3,并使用服务端公钥加密生成PreMaster Key 。此时客户端和服务端将会同时拥有三个随机数 Random1 + Random2 + Random3
  8. 客户端发送Change Cipher Spec消息,通知服务端后面的消息将会是加密消息。
  9. 客户端发送Encrypted Handshake Message消息,客户端会将上述的握手消息生成摘要,再用协商好的秘钥加密。这是客户端发出的第一条加密消息。服务端接收后会用秘钥解密,能解出来说明前面协商出来的秘钥是一致的。
  10. 服务端发送Change Cipher Spec消息,通知客户端后面的消息将会是加密消息。
  11. 服务端发送Encrypted Handshake Message消息,服务端也会将握手过程的消息生成摘要再用秘钥加密,这是服务端发出的第一条加密消息。客户端接收后会用秘钥解密,能解出来说明协商的秘钥是一致的。
  12. 至此,双方已安全地协商出了同一份秘钥,所有的应用层数据都会用这个秘钥加密后再通过 TCP 进行可靠传输 。

示意图如下:

TLS握手

为什么要使用三个随机数来生成密钥?

随机数的生成并不是真正的随机,三个伪随机数通过算法计算出来的密钥可以认为是完全随机的。

握手过程中,密钥计算算法以及前两个随机数都是明文传输的,所以非对称加密第三个随机数保证了密钥的安全性。

TLS会话快速恢复以及Poco实现


TLS的握手过程比较复杂,在TCP握手上还多了两个来回。如果重连重新握手的话,还需要再传递一次证书,非常损耗性能。TLS协议提出了会话复用的概念。

Session ID

Session ID(会话标识符),是用于记录TLS会话的ID。

TLS 会话恢复的大致流程:

  1. 在开启 session_cache_mode 之后,服务端会生成一个 Session ID,并随发送 Server Hello 发送给客户端。
  2. 客户端重连时,会将上一次会话的 Session ID 放入 Client Hello 中发送给服务端。
  3. 服务端收到之后,如果在缓存中发现存在相应的 Session ID,就直接使用上一次协商的结构进行加密传输,省去了发送证书以及计算密钥的过程。

这样就可以完成一次快速握手。抓包信息如下:

快速恢复

Session Ticket

Session ID 储存在一台服务器上,如果使用了负载均衡,Session ID是不会做同步的。所以,有可能客户端连上的服务并没有之前的 Session ID,这样就没有办法进行快速恢复。

为了避免这个弊端,TLS 中的 Session Ticket 很好的解决了这个问题。

Session Ticket的思想类似于cookie,其中包含了加密参数等连接信息。握手时,服务器会将 Session Ticket数据结构发由客户端管理。当需要重连的时候,客户端将Session Ticket发送给服务器。这样双方就都得到了重用的加密参数,根据加密参数就可以快速恢复会话了。

Session Ticket 使用只有服务端知道的安全密钥进行加密,最终保存在客户端。

Poco::NetSSL_OpenSSL 快速恢复实现方法

服务端 Context 调用 enableSessionCache() 方法,开启 session_cache_mode。

//开启 session_cache_mode
_serverContext->enableSessionCache(true, sessionIdContext);

//默认开启 Session Ticket
//_serverContext->disableStatelessSessionResumption();

客户端复用 Context 对象,如果是重连,则调用 currentSession 获取上一次的 Session 对象,然后传入 SecureStreamSocket 对象。

if (nullptr == _pClientContext)
{
    _pClientContext = new Poco::Net::Context(Poco::Net::Context::CLIENT_USE, "", "", ".//cacert.pem",
                                             Poco::Net::Context::VERIFY_STRICT, 9,
                                             false);
    _pClientContext->enableSessionCache(true);
}
else
{
    //获取上一次Session
    _pClientSession = _pSecureStreamSocket->currentSession();
}

            Poco::Net::SSLManager::instance().initializeClient(pConsoleHandler, pInvalidCertHandler, _pClientContext);

try
{
    if (nullptr == _pClientSession)
    {
        _pSecureStreamSocket = new Poco::Net::SecureStreamSocket(
            Poco::Net::SecureStreamSocket::attach(ClientSocketConnector<ServiceHandler>::socket(),
                                                  "1.missmiaomiao.com", _pClientContext));
    }
    else
    {
        //通过最后一个参数传入上一次的Session
        _pSecureStreamSocket = new Poco::Net::SecureStreamSocket(
            Poco::Net::SecureStreamSocket::attach(ClientSocketConnector<ServiceHandler>::socket(),
                                                  "1.missmiaomiao.com", _pClientContext, _pClientSession));
    }

}

Poco::NetSSL_OpenSSL验证证书方法


证书的验证通常分为两部分,第一部分是验证证书是否由CA签发,即验证证书的有效性。第二部分是验证证书的域名是否和提供服务的域名相同,即验证客户端想请求的服务是否由正确的服务器提供。

这两步同时保证了不会受到中间人攻击。如果缺少第一步验证证书有效性,中间人可以使用自签证书来获取客户端信任。如果缺少第二步验证证书域名,中间人可以使用CA签发的其他证书来获取客户端信任。

为一个域名申请CA签发的证书时,CA机构会确认你是否拥有该域名,例如会让你添加一条DNS TXT记录。

这里讨论的都是针对于客户端验证服务器证书,服务器验证客户端证书的场景非常少,区别比较小,但是这里不做讨论。

证书可靠性验证

Poco::NetSSL_OpenSSL 中处理证书验证失败的类主要有以下三个:

  • AcceptCertificateHandler:接受任何证书,不会抛出任何异常。
  • RejectCertificateHandler:如果证书不可信,则抛出异常。
  • ConsoleCertificateHandler:如果证书不可信,则输出到Console,让用户选择是否接受证书。

它们均继承于 InvalidCertificateHandler,在 SSLManager 初始化时传入。

在客户端中,可以使用以下方法来设置:

Poco::Net::initializeSSL();

//设置InvalidCertificateHandler
Poco::Net::SSLManager::InvalidCertificateHandlerPtr ptrHandler(new Poco::Net::AcceptCertificateHandler(false));

Poco::Net::Context::Ptr ptrContext(new Poco::Net::Context(Poco::Net::Context::CLIENT_USE, "", "", "./cacert.pem", Poco::Net::Context::VERIFY_STRICT, 9, false));
Poco::Net::SSLManager::instance().initializeClient(0, ptrHandler, ptrContext);

如果设置了 RejectCertificateHandler ,则需要在连接的地方接住可能抛出的异常。

try
{
    _pSecureStreamSocket = new Poco::Net::SecureStreamSocket(
Poco::Net::SecureStreamSocket::attach(ClientSocketConnector<ServiceHandler>::socket(), "leiyiming.com", pClientContext));
}
catch (Poco::Exception& e)
{
    poco_error_f1(_logger, "create tls socket error! %s", e.message());
    return;
}

证书域名验证

验证模式

验证证书域名是构造Poco::Net::Context 时,通过设置验证模式设定。

Context(
    Usage usage,
    const std::string& privateKeyFile,
    const std::string& certificateFile,
    const std::string& caLocation,
    VerificationMode verificationMode = VERIFY_RELAXED,
    int verificationDepth = 9,
    bool loadDefaultCAs = false,
    const std::string& cipherList = "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH");

第五个参数 VerificationMode 即设定验证模式:

  • VERIFY_NONE := SSL_VERIFY_NONE。客户端会验证服务端证书,但是结果将被忽略。
  • VERIFY_RELAXED:= SSL_VERIFY_PEER。客户端会验证服务端证书,并且验证结果将会被处理。客户端会验证连接域名是否和证书域名匹配,如果不匹配则抛出异常,如果连接域名是 127.0.0.1,则不会抛出异常。(取决于上面的Handler)
  • VERIFY_STRICT:= SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT 。作用同VERIFY_RELAXED,但是连接域名是 127.0.0.1,也会抛出异常。
  • VERIFY_ONCE:= SSL_VERIFY_PEER| SSL_VERIFY_CLIENT_ONCE 。作用同VERIFY_RELAXED

设置连接域名

连接域名默认就是连接地址,也可以通过 attach() 方法来设置连接域名,这时连接域名只是一个标识,真正的连接地址是 Poco::Net::SocketConnector 初始化时传入的地址。

如果通过IP连接,又必须验证证书域名,就可以使用这种方式。对于多点部署的服务器来说,不可能每个服务器都拥有一个域名,通过这种方式,连接时还是按照正常的ip来连接,验证时则根据设置的连接域名来验证证书。

_pSecureStreamSocket = new Poco::Net::SecureStreamSocket(
                    Poco::Net::SecureStreamSocket::attach(ClientSocketConnector<ServiceHandler>::socket(), "leiyiming.com", pClientContext));

导入证书链方法

当服务器使用的是证书链证书时(一个pem文件中包含多个证书),如果只通过Context 的构造函数传入,客户端收到的证书总是pem文件中的第一个证书。这时需要先调用useCertificate,再额外调用addChainCertificate才能完整地导入整个证书。

_serverContext->useCertificate(cert);
_serverContext->addChainCertificate(certChain);

OpenSSL验证证书方法


在初始化时设置验证处理回调,证书验证后会调用回调,以便客户端处理各种错误:

SSL_CTX_set_verify(_context, SSL_VERIFY_PEER, DTLSCertVerification);

DTLSCertVerification 实现:

int CDTLSContext::DTLSCertVerification(int ok, X509_STORE_CTX* pStore)
{
    if (!ok)
    {
        X509* pCert = X509_STORE_CTX_get_current_cert(pStore);
        Poco::Crypto::X509Certificate x509(pCert, true);
        int depth = X509_STORE_CTX_get_error_depth(pStore);
        int err = X509_STORE_CTX_get_error(pStore);
        std::string error(X509_verify_cert_error_string(err));
        if(err != X509_V_OK)
        {
            ok = 0;
            //log:"certificate verify failed!, The certificate is valid beause" + error
        }
        else
        {
            if(x509.commonName().compare("1602.flashshan.com"))
            {
                ok = 1;
                //log:"certificate verify successed!"
            }
            else
            {
                ok = 0;
                //log:"certificate verify failed!, The certificate is valid but the domain name matches "
            }
        }
    }

    return ok;
}

问题记录

SSL_write 的 BAD_WRITE_RETRY

问题表现:

收发稍微大一点的数据时,客户端会断开连接,抛出异常:

SSLError: [SSL: BAD_WRITE_RETRY] bad write retry

解决思路:

SSL_write的官方文档,写得很清楚。调用 SSL_write 时,如果数据没有发送完,就会返回 SSL_ERROR_WANT_WRITE ,这时必须重复调用SSL_write ,并且参数需要完全一致!只有返回值大于0时,才可以使用其他参数,发送其他数据。否则就会抛出以上异常。参考Stack Overflow

只要使用 ssl_ctx_set_mode 函数设置 SSL_MODE_ENABLE_PARTIAL_WRITE 模式就可以实现ssl_write写不完时,返回已写的数据量,然后再使用其他参数,也不会抛出 bad write retry 的异常。

SSL_write() will only return with success, when the complete contents of buf of length num has been written. This default behaviour can be changed with the SSL_MODE_ENABLE_PARTIAL_WRITE option of ssl_ctx_set_mode(3). When this flag is set, SSL_write() will also return with success, when a partial write has been successfully completed. In this case the SSL_write() operation is considered completed. The bytes are sent and a new SSL_write() operation with a new buffer (with the already sent bytes removed) must be started. A partial write is performed with the size of a message block, which is 16kB for SSLv3/TLSv1.

解决方法:

//set ssl_write SSL_MODE_ENABLE_PARTIAL_WRITE
//https://linux.die.net/man/3/ssl_write
SSL_CTX* sslCtx = _serverContext->sslContext();
SSL_CTX_set_mode(sslCtx, SSL_MODE_ENABLE_PARTIAL_WRITE);

资料


申请Let’s Encrypt 免费认证证书

OpenSSL基础编程

SSL握手过程

TLS 握手优化详解

Session ticket关联TLS流方法分析