基於OpenSSL的HTTPS通訊C++實現

鐵芒箕發表於2018-10-30

  HTTPS是以安全為目標的HTTP通道,簡單講是HTTP的安全版。即HTTP下加入SSL層,HTTPS的安全基礎是SSL,因此加密的詳細內容就需要SSL。Nebula是一個為開發者提供一個快速開發高併發網路服務程式或搭建高併發分散式服務叢集的高效能事件驅動網路框架。Nebula作為通用網路框架提供HTTPS支援十分重要,Nebula既可用作https伺服器,又可用作https客戶端。本文將結合Nebula框架的https實現詳細講述基於openssl的SSL程式設計。如果覺得本文對你有用,幫忙到Nebula的Github碼雲給個star,謝謝。Nebula不僅是一個框架,還提供了一系列基於這個框架的應用,目標是打造一個高效能分散式服務叢集解決方案。Nebula的主要應用領域:即時通訊(成功應用於一款IM)、訊息推送平臺、資料實時分析計算(成功案例)等,Bwar還計劃基於Nebula開發爬蟲應用。

1. SSL加密通訊

  HTTPS通訊是在TCP通訊層與HTTP應用層之間增加了SSL層,如果應用層不是HTTP協議也是可以使用SSL加密通訊的,比如WebSocket協議WS的加上SSL層之後的WSS。Nebula框架可以通過更換Codec達到不修改程式碼變更通訊協議目的,Nebula增加SSL支援後,所有Nebula支援的通訊協議都有了SSL加密通訊支援,基於Nebula的業務程式碼無須做任何修改。

https_communication

  Socket連線建立後的SSL連線建立過程:

ssl_communication

2. OpenSSL API

  OpenSSL的API很多,但並不是都會被使用到,如果需要檢視某個API的詳細使用方法可以閱讀API文件

2.1 初始化OpenSSL

  OpenSSL在使用之前,必須進行相應的初始化工作。在建立SSL連線之前,要為Client和Server分別指定本次連線採用的協議及其版本,目前能夠使用的協議版本包括SSLv2、SSLv3、SSLv2/v3和TLSv1.0。SSL連線若要正常建立,則要求Client和Server必須使用相互相容的協議。   下面是Nebula框架SocketChannelSslImpl::SslInit()函式初始化OpenSSL的程式碼,根據OpenSSL的不同版本呼叫了不同的API進行初始化。

#if OPENSSL_VERSION_NUMBER >= 0x10100003L

    if (OPENSSL_init_ssl(OPENSSL_INIT_LOAD_CONFIG, NULL) == 0)
    {
        pLogger->WriteLog(neb::Logger::ERROR, __FILE__, __LINE__, __FUNCTION__, "OPENSSL_init_ssl() failed!");
        return(ERR_SSL_INIT);
    }

    /*
     * OPENSSL_init_ssl() may leave errors in the error queue
     * while returning success
     */

    ERR_clear_error();

#else

    OPENSSL_config(NULL);

    SSL_library_init();         // 初始化SSL演算法庫函式( 載入要用到的演算法 ),呼叫SSL函式之前必須呼叫此函式
    SSL_load_error_strings();   // 錯誤資訊的初始化

    OpenSSL_add_all_algorithms();

#endif

2.2 建立CTX

  CTX是SSL會話環境,建立連線時使用不同的協議,其CTX也不一樣。建立CTX的相關OpenSSL函式:

//客戶端、服務端都需要呼叫
SSL_CTX_new();                       //申請SSL會話環境

//若有驗證對方證書的需求,則需呼叫
SSL_CTX_set_verify();                //指定證書驗證方式
SSL_CTX_load_verify_location();      //為SSL會話環境載入本應用所信任的CA證書列表

//若有載入證書的需求,則需呼叫
int SSL_CTX_use_certificate_file();      //為SSL會話載入本應用的證書
int SSL_CTX_use_certificate_chain_file();//為SSL會話載入本應用的證書所屬的證書鏈
int SSL_CTX_use_PrivateKey_file();       //為SSL會話載入本應用的私鑰
int SSL_CTX_check_private_key();         //驗證所載入的私鑰和證書是否相匹配 

2.3 建立SSL套接字

  在建立SSL套接字之前要先建立Socket套接字,建立TCP連線。建立SSL套接字相關函式:

SSL *SSl_new(SSL_CTX *ctx);          //建立一個SSL套接字
int SSL_set_fd(SSL *ssl, int fd);     //以讀寫模式繫結流套接字
int SSL_set_rfd(SSL *ssl, int fd);    //以只讀模式繫結流套接字
int SSL_set_wfd(SSL *ssl, int fd);    //以只寫模式繫結流套接字

2.4 完成SSL握手

  在這一步,我們需要在普通TCP連線的基礎上,建立SSL連線。與普通流套接字建立連線的過程類似:Client使用函式SSL_connect()【類似於流套接字中用的connect()】發起握手,而Server使用函式SSL_ accept()【類似於流套接字中用的accept()】對握手進行響應,從而完成握手過程。兩函式原型如下:

int SSL_connect(SSL *ssl);
int SSL_accept(SSL *ssl);

  握手過程完成之後,Client通常會要求Server傳送證書資訊,以便對Server進行鑑別。其實現會用到以下兩個函式:

X509 *SSL_get_peer_certificate(SSL *ssl);  //從SSL套接字中獲取對方的證書資訊
X509_NAME *X509_get_subject_name(X509 *a); //得到證書所用者的名字

2.5 資料傳輸

  經過前面的一系列過程後,就可以進行安全的資料傳輸了。在資料傳輸階段,需要使用SSL_read( )和SSL_write( )來代替普通流套接字所使用的read( )和write( )函式,以此完成對SSL套接字的讀寫操作,兩個新函式的原型分別如下:

int SSL_read(SSL *ssl,void *buf,int num);            //從SSL套接字讀取資料
int SSL_write(SSL *ssl,const void *buf,int num);     //向SSL套接字寫入資料

2.6 會話結束

  當Client和Server之間的通訊過程完成後,就使用以下函式來釋放前面過程中申請的SSL資源:

int SSL_shutdown(SSL *ssl);       //關閉SSL套接字
void SSl_free(SSL *ssl);          //釋放SSL套接字
void SSL_CTX_free(SSL_CTX *ctx);  //釋放SSL會話環境

3. SSL 和 TLS

  HTTPS 使用 SSL(Secure Socket Layer) 和 TLS(Transport LayerSecurity)這兩個協議。 SSL 技術最初是由瀏覽器開發商網景通訊公司率先倡導的,開發過 SSL3.0之前的版本。目前主導權已轉移到 IETF(Internet Engineering Task Force,Internet 工程任務組)的手中。

  IETF 以 SSL3.0 為基準,後又制定了 TLS1.0、TLS1.1 和 TLS1.2。TSL 是以SSL 為原型開發的協議,有時會統一稱該協議為 SSL。當前主流的版本是SSL3.0 和 TLS1.0。

  由於 SSL1.0 協議在設計之初被發現出了問題,就沒有實際投入使用。SSL2.0 也被發現存在問題,所以很多瀏覽器直接廢除了該協議版本。

4. Nebula中的SSL通訊實現

  Nebula框架同時支援SSL服務端應用和SSL客戶端應用,對openssl的初始化只需要初始化一次即可(SslInit()只需呼叫一次)。Nebula框架的SSL相關程式碼(包括客戶端和服務端的實現)都封裝在SocketChannelSslImpl這個類中。Nebula的SSL通訊是基於非同步非阻塞的socket通訊,並且不使用openssl的BIO(因為沒有必要,程式碼還更復雜了)。

  SocketChannelSslImpl是SocketChannelImpl的派生類,在SocketChannelImpl常規TCP通訊之上增加了SSL通訊層,兩個類的呼叫幾乎沒有差異。SocketChannelSslImpl類宣告如下:

class SocketChannelSslImpl : public SocketChannelImpl
{
public:
    SocketChannelSslImpl(SocketChannel* pSocketChannel, std::shared_ptr<NetLogger> pLogger, int iFd, uint32 ulSeq, ev_tstamp dKeepAlive = 0.0);
    virtual ~SocketChannelSslImpl();

    static int SslInit(std::shared_ptr<NetLogger> pLogger);
    static int SslServerCtxCreate(std::shared_ptr<NetLogger> pLogger);
    static int SslServerCertificate(std::shared_ptr<NetLogger> pLogger,
                const std::string& strCertFile, const std::string& strKeyFile);
    static void SslFree();

    int SslClientCtxCreate();
    int SslCreateConnection();
    int SslHandshake();
    int SslShutdown();

    virtual bool Init(E_CODEC_TYPE eCodecType, bool bIsClient = false) override;

    // 覆蓋基類的Send()方法,實現非阻塞socket連線建立後繼續建立SSL連線,並收發資料
    virtual E_CODEC_STATUS Send() override;      
    virtual E_CODEC_STATUS Send(int32 iCmd, uint32 uiSeq, const MsgBody& oMsgBody) override;
    virtual E_CODEC_STATUS Send(const HttpMsg& oHttpMsg, uint32 ulStepSeq) override;
    virtual E_CODEC_STATUS Recv(MsgHead& oMsgHead, MsgBody& oMsgBody) override;
    virtual E_CODEC_STATUS Recv(HttpMsg& oHttpMsg) override;
    virtual E_CODEC_STATUS Recv(MsgHead& oMsgHead, MsgBody& oMsgBody, HttpMsg& oHttpMsg) override;
    virtual bool Close() override;

protected:
    virtual int Write(CBuffer* pBuff, int& iErrno) override;
    virtual int Read(CBuffer* pBuff, int& iErrno) override;

private:
    E_SSL_CHANNEL_STATUS m_eSslChannelStatus;   //在基類m_ucChannelStatus通道狀態基礎上增加SSL通道狀態
    bool m_bIsClientConnection;
    SSL* m_pSslConnection;

    static SSL_CTX* m_pServerSslCtx;    //當開啟ssl選項編譯,啟動Nebula服務則自動建立
    static SSL_CTX* m_pClientSslCtx;    //預設為空,當開啟ssl選項編譯並且第一次發起了對其他SSL服務的連線時(比如訪問一個https地址)建立
};

  SocketChannelSslImpl類中帶override關鍵字的方法都是覆蓋基類SocketChannelImpl的同名方法,也是實現SSL通訊與非SSL通訊呼叫透明的關鍵。不帶override關鍵字的方法都是SSL通訊相關方法,這些方法裡有openssl的函式呼叫。不帶override的方法中有靜態和非靜態之分,靜態方法在程式中只會被呼叫一次,與具體Channel物件無關。SocketChannel外部不需要呼叫非靜態的ssl相關方法。

  因為是非阻塞的socket,SSL_do_handshake()和SSL_write()、SSL_read()返回值並不完全能判斷是否出錯,還需要SSL_get_error()獲取錯誤碼。SSL_ERROR_WANT_READ和SSL_ERROR_WANT_WRITE都是正常的。

  網上的大部分openssl例子程式是按順序呼叫openssl函式簡單實現同步ssl通訊,在非阻塞IO應用中,ssl通訊要複雜許多。SocketChannelSslImpl實現的是非阻塞的ssl通訊,從該類的實現上看整個通訊過程並非完全線性的。下面的SSL通訊圖更清晰地說明了Nebula框架中SSL通訊是如何實現的:

Nebula_ssl

  SocketChannelSslImpl中的靜態方法在程式生命期內只需呼叫一次,也可以理解成SSL_CTX_new()、SSL_CTX_free()等方法只需呼叫一次。更進一步理解SSL_CTX結構體在程式內只需要建立一次(在Nebula中分別為Server和Client各建立一個)就可以為所有SSL連線所用;當然,為每個SSL連線建立獨立的SSL_CTX也沒問題(Nebula 0.4中實測過為每個Client建立獨立的SSL_CTX),但一般不這麼做,因為這樣會消耗更多的記憶體資源,並且效率也會更低。

  建立SSL連線時,客戶端呼叫SSL_connect(),服務端呼叫SSL_accept(),許多openssl的demo都是這麼用的。Nebula中用的是SSL_do_handshake(),這個方法同時適用於客戶端和服務端,在兼具client和server功能的服務更適合用SSL_do_handshake()。注意呼叫SSL_do_handshake()前,如果是client端需要先呼叫SSL_set_connect_state(),如果是server端則需要先呼叫SSL_set_accept_state()。非阻塞IO中,SSL_do_handshake()可能需要呼叫多次才能完成握手,具體呼叫時機需根據SSL_get_error()獲取錯誤碼SSL_ERROR_WANT_READ和SSL_ERROR_WANT_WRITE判斷需監聽讀事件還是寫事件,在對應事件觸發時再次呼叫SSL_do_handshake()。詳細實現請參考SocketChannelSslImpl的Send和Recv方法。

  關閉SSL連線時先呼叫SSL_shutdown()正常關閉SSL層連線(非阻塞IO中SSL_shutdown()亦可能需要呼叫多次)再呼叫SSL_free()釋放SSL連線資源,最後關閉socket連線。SSL_CTX無須釋放。整個SSL通訊順利完成,Nebula 0.4在開多個終端用shell指令碼死迴圈呼叫curl簡單壓測中SSL client和SSL server功能一切正常:

while :
do 
     curl -v -k -H "Content-Type:application/json" -X POST -d `{"hello":"nebula ssl test"}` https://192.168.157.168:16003/test_ssl 
done

  測試方法如下圖:

ssl_test

  檢視資源使用情況,SSL Server端的記憶體使用一直在增長,疑似有記憶體洩漏,不過pmap -d檢視某一項anon記憶體達到近18MB時不再增長,說明可能不是記憶體洩漏,只是部分記憶體被openssl當作cache使用了。這個問題網上沒找到解決辦法。從struct ssl_ctx_st結構體定義發現端倪,再從nginx原始碼中發現了SSL_CTX_remove_session(),於是在SSL_free()之前加上SSL_CTX_remove_session()。session複用可以提高SSL通訊效率,不過Nebula暫時不需要。

  這種測試方法把NebulaInterface作為SSL服務端,NebulaLogic作為SSL客戶端,同時完成了Nebula框架SSL服務端和客戶端功能測試,簡單的壓力測試。Nebula框架的SSL通訊測試通過,也可以投入生產應用,在後續應用中肯定還會繼續完善。openssl真的難用,難怪被吐槽那麼多,或許不久之後的Nebula版本將用其他ssl庫替換掉openssl。

5. 結束

  加上SSL支援的Nebula框架測試通過,雖然不算太複雜,但過程還是蠻曲折,耗時也挺長。這裡把Nebula使用openssl開發SSL通訊分享出來,希望對準備使用openssl的開發者有用。如果覺得本文對你有用,別忘了到Nebula的Github碼雲給個star,謝謝。

<br/>

參考資料:

相關文章