ossl-guide-tls-client-block
名稱
ossl-guide-tls-client-block - OpenSSL 指南:撰寫簡單的封鎖式 TLS 應用程式
簡單的封鎖式 TLS 應用程式範例
此頁面將提供各種原始碼範例,說明如何撰寫一個簡單的 TLS 應用程式,用以連線到伺服器、傳送 HTTP/1.0 要求給伺服器,並讀回回應。
我們在此範例中使用封鎖式 socket。這表示嘗試從沒有資料可讀取的 socket 讀取資料時,會封鎖(而且函式不會傳回),直到資料可用為止。例如,這可能會在我們已傳送要求,但仍在等待伺服器回應時發生。類似地,任何嘗試寫入目前無法寫入的 socket 的動作都會封鎖,直到可以寫入為止。
這種封鎖行為簡化了應用程式的實作,因為您不必擔心在資料尚未可用時會發生什麼事。應用程式會一直等到資料可用為止。
此封鎖式 TLS 應用程式範例的完整原始碼可在 OpenSSL 原始碼散發的 demos/guide 目錄中的 tls-client-block.c 檔案取得。它也可在線上取得,網址為 https://github.com/openssl/openssl/blob/master/demos/guide/tls-client-block.c。
我們假設您已在系統上安裝 OpenSSL;您已具備 OpenSSL 概念和 TLS 的一些基本瞭解(請參閱 ossl-guide-libraries-introduction(7) 和 ossl-guide-tls-introduction(7));而且您知道如何撰寫和建置 C 程式碼,並將其連結到 OpenSSL 提供的 libcrypto 和 libssl 函式庫。它也假設您對 TCP/IP 和 socket 有基本的瞭解。
建立 SSL_CTX 和 SSL 物件
第一步是為我們的客戶端建立一個 SSL_CTX 物件。我們使用 SSL_CTX_new(3) 函數來執行這項工作。如果我們想要將 SSL_CTX 與特定的 OSSL_LIB_CTX 關聯,我們也可以使用 SSL_CTX_new_ex(3)(請參閱 ossl-guide-libraries-introduction(7) 以了解 OSSL_LIB_CTX)。我們傳遞 TLS_client_method(3) 函數的回傳值作為參數。無論何時撰寫 TLS 客戶端時,都應該使用此方法。此方法會自動使用 TLS 版本協商來選擇客戶端和伺服器都相互支援的最高版本協定。
/*
* Create an SSL_CTX which we can use to create SSL objects from. We
* want an SSL_CTX for creating clients so we use TLS_client_method()
* here.
*/
ctx = SSL_CTX_new(TLS_client_method());
if (ctx == NULL) {
printf("Failed to create the SSL_CTX\n");
goto end;
}
由於我們正在撰寫客戶端,因此我們必須確保驗證伺服器的憑證。我們呼叫 SSL_CTX_set_verify(3) 函數並傳遞 SSL_VERIFY_PEER 值給它來執行這項工作。此函數的最後一個參數是一個回呼函式,您可以選擇性地提供它來覆寫憑證驗證的預設處理方式。大多數應用程式不需要執行這項工作,因此可以安全地將它設定為 NULL 以取得預設處理方式。
/*
* Configure the client to abort the handshake if certificate
* verification fails. Virtually all clients should do this unless you
* really know what you are doing.
*/
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
要讓憑證驗證成功,您必須設定要使用的受信任憑證儲存庫的位置(請參閱 ossl-guide-tls-introduction(7))。在大部分情況下,您只需要使用預設儲存庫,因此我們呼叫 SSL_CTX_set_default_verify_paths(3)。
/* Use the default trusted certificate store */
if (!SSL_CTX_set_default_verify_paths(ctx)) {
printf("Failed to set the default trusted certificate store\n");
goto end;
}
我們也想要限制我們願意接受的 TLS 版本為 TLSv1.2 或更高版本。如果可能,通常應避免使用早於此版本的 TLS 協定版本。我們可以使用 SSL_CTX_set_min_proto_version(3) 來執行這項工作
/*
* TLSv1.1 or earlier are deprecated by IETF and are generally to be
* avoided if possible. We require a minimum TLS version of TLSv1.2.
*/
if (!SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION)) {
printf("Failed to set the minimum TLS protocol version\n");
goto end;
}
這是我們需要為 SSL_CTX 執行的所有設定,因此接下來我們需要建立一個 SSL 物件來表示 TLS 連線。在實際應用程式中,我們可能會預期隨著時間建立多個 TLS 連線。在這種情況下,我們會預期每次都重複使用我們已經建立的 SSL_CTX。沒有必要重複這些步驟。事實上,最好不要重複,因為某些內部資源會快取在 SSL_CTX 中。重複使用現有的 SSL_CTX 而不是每次都建立新的 SSL_CTX,可以讓您獲得更好的效能。
建立 SSL 物件只需呼叫 SSL_new(3) 函數,並傳遞我們建立的 SSL_CTX 作為參數即可。
/* Create an SSL object to represent the TLS connection */
ssl = SSL_new(ctx);
if (ssl == NULL) {
printf("Failed to create the SSL object\n");
goto end;
}
建立 socket 和 BIO
TLS 資料透過底層傳輸層傳輸。通常是 TCP socket。應用程式負責確保 socket 已建立並與 SSL 物件關聯(透過 BIO)。
建立 socket 供客戶端使用通常是一個兩步驟的程序,即建立 socket 和連接 socket。
如何建立 socket 取決於平台,但大多數平台(包括 Windows)都透過 socket 函式提供相容於 POSIX 的介面,例如建立 IPv4 TCP socket
int sock;
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1)
return NULL;
建立 socket 後,必須將其連接到遠端伺服器。同樣地,詳細資訊取決於平台,但大多數平台(包括 Windows)都提供相容於 POSIX 的 connect 函式。例如
struct sockaddr_in serveraddr;
struct hostent *server;
server = gethostbyname("www.openssl.org");
if (server == NULL) {
close(sock);
return NULL;
}
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = server->h_addrtype;
serveraddr.sin_port = htons(443);
memcpy(&serveraddr.sin_addr.s_addr, server->h_addr, server->h_length);
if (connect(sock, (struct sockaddr *)&serveraddr,
sizeof(serveraddr)) == -1) {
close(sock);
return NULL;
}
OpenSSL 提供可攜式輔助函式來執行這些任務,這些函式也會整合到 OpenSSL 錯誤系統中以記錄錯誤資料,例如
int sock = -1;
BIO_ADDRINFO *res;
const BIO_ADDRINFO *ai = NULL;
/*
* Lookup IP address info for the server.
*/
if (!BIO_lookup_ex(hostname, port, BIO_LOOKUP_CLIENT, family, SOCK_STREAM, 0,
&res))
return NULL;
/*
* Loop through all the possible addresses for the server and find one
* we can connect to.
*/
for (ai = res; ai != NULL; ai = BIO_ADDRINFO_next(ai)) {
/*
* Create a TCP socket. We could equally use non-OpenSSL calls such
* as "socket" here for this and the subsequent connect and close
* functions. But for portability reasons and also so that we get
* errors on the OpenSSL stack in the event of a failure we use
* OpenSSL's versions of these functions.
*/
sock = BIO_socket(BIO_ADDRINFO_family(ai), SOCK_STREAM, 0, 0);
if (sock == -1)
continue;
/* Connect the socket to the server's address */
if (!BIO_connect(sock, BIO_ADDRINFO_address(ai), BIO_SOCK_NODELAY)) {
BIO_closesocket(sock);
sock = -1;
continue;
}
/* We have a connected socket so break out of the loop */
break;
}
/* Free the address information resources we allocated earlier */
BIO_ADDRINFO_free(res);
請參閱 BIO_lookup_ex(3)、BIO_socket(3)、BIO_connect(3)、BIO_closesocket(3)、BIO_ADDRINFO_next(3)、BIO_ADDRINFO_address(3) 和 BIO_ADDRINFO_free(3) 以取得關於在此使用的函式的更多資訊。在上述範例程式碼中,hostname 和 port 變數是字串,例如「www.example.com」和「443」。另請注意 family 變數的使用,該變數可以根據命令列選項 -6 取用 AF_INET 或 AF_INET6 的值,以允許特定連線到已啟用 ipv4 或 ipv6 的主機。
使用上述方法建立的 socket 會自動成為封鎖 socket,這正是我們在此範例中想要的。
建立並連接 socket 後,我們需要將其與 BIO 物件關聯
BIO *bio;
/* Create a BIO to wrap the socket */
bio = BIO_new(BIO_s_socket());
if (bio == NULL) {
BIO_closesocket(sock);
return NULL;
}
/*
* Associate the newly created BIO with the underlying socket. By
* passing BIO_CLOSE here the socket will be automatically closed when
* the BIO is freed. Alternatively you can use BIO_NOCLOSE, in which
* case you must close the socket explicitly when it is no longer
* needed.
*/
BIO_set_fd(bio, sock, BIO_CLOSE);
請參閱 BIO_new(3)、BIO_s_socket(3) 和 BIO_set_fd(3) 以取得關於這些函式的更多資訊。
最後,我們使用 SSL_set_bio(3) 函式將我們先前建立的 SSL 物件與 BIO 關聯。請注意,這會將 BIO 物件的所有權傳遞給 SSL 物件。傳遞所有權後,SSL 物件負責管理它,並會在釋放 SSL 時自動釋放它。因此,呼叫 SSL_set_bio(3) 後,您不應在 BIO 上呼叫 BIO_free(3)。
SSL_set_bio(ssl, bio, bio);
設定伺服器的主機名稱
我們已經將我們的底層 socket 連接到伺服器,但客戶端仍需要知道伺服器的主機名稱。它將此資訊用於兩個主要目的,我們需要為每個目的設定主機名稱。
首先,伺服器的 Hostname 會包含在客戶端所傳送的初始 ClientHello 訊息中。這稱為伺服器名稱指示 (SNI)。這很重要,因為通常會有許多 Hostname 由單一伺服器提供服務,處理所有這些 Hostname 的要求。換句話說,單一伺服器可能有多個與其關聯的 Hostname,而指出我們要連線到哪一個 Hostname 非常重要。沒有這些資訊,我們可能會收到交握失敗,或我們可能會連線到「預設」伺服器,而這可能不是我們預期的伺服器。
若要設定 SNI Hostname 資料,我們呼叫 SSL_set_tlsext_host_name(3) 函數,如下所示
/*
* Tell the server during the handshake which hostname we are attempting
* to connect to in case the server supports multiple hosts.
*/
if (!SSL_set_tlsext_host_name(ssl, hostname)) {
printf("Failed to set the SNI hostname\n");
goto end;
}
這裡的 hostname
參數是一個字串,表示伺服器的 Hostname,例如「www.example.com」。
其次,我們需要告訴 OpenSSL 我們預期在從伺服器回傳的憑證中看到什麼 Hostname。這幾乎總是與我們在原始要求中要求的 Hostname 相同。這很重要,因為沒有這個 Hostname,我們無法驗證憑證中的 Hostname 是否符合我們的預期,而且任何憑證都是可以接受的,除非您的應用程式本身明確檢查這個 Hostname。我們透過 SSL_set1_host(3) 函數執行這個動作
/*
* Ensure we check during certificate verification that the server has
* supplied a certificate for the hostname that we were expecting.
* Virtually all clients should do this unless you really know what you
* are doing.
*/
if (!SSL_set1_host(ssl, hostname)) {
printf("Failed to set the certificate verification hostname");
goto end;
}
上述所有步驟都必須在我們嘗試執行交握之前進行,否則它們將不會有任何作用。
執行交握
在我們開始透過 TLS 連線傳送或接收應用程式資料之前,必須執行 TLS 交握。我們可以透過 SSL_connect(3) 函數明確執行這個動作。
/* Do the handshake with the server */
if (SSL_connect(ssl) < 1) {
printf("Failed to connect to the server\n");
/*
* If the failure is due to a verification error we can get more
* information about it from SSL_get_verify_result().
*/
if (SSL_get_verify_result(ssl) != X509_V_OK)
printf("Verify error: %s\n",
X509_verify_cert_error_string(SSL_get_verify_result(ssl)));
goto end;
}
SSL_connect(3) 函數可以傳回 1、0 或小於 0。只有傳回值 1 才會被視為成功。對於簡單的封鎖式客戶端,我們只需要關注呼叫是否成功。任何其他結果都表示我們無法連線到伺服器。
在此階段失敗的常見原因是驗證伺服器憑證時發生問題。例如,如果憑證已過期,或它不是由我們受信任憑證儲存區中的 CA 簽署的。我們可以使用 SSL_get_verify_result(3) 函數找出有關驗證失敗的更多資訊。傳回值 X509_V_OK 表示驗證成功(因此連線錯誤一定是因為其他原因)。否則,我們使用 X509_verify_cert_error_string(3) 函數取得人類可讀取的錯誤訊息。
傳送和接收資料
交握完成後,我們就能夠傳送和接收應用程式資料。傳送哪些資料以及以什麼順序傳送通常由某些應用程式層級協定控制。在此範例中,我們使用 HTTP 1.0,這是一個非常簡單的要求和回應協定。客戶端傳送要求到伺服器。伺服器傳送回應資料,然後立即關閉連線。
要將資料傳送至伺服器,我們使用 SSL_write_ex(3) 函數,而要從伺服器接收資料,我們使用 SSL_read_ex(3) 函數。在 HTTP 1.0 中,用戶端總是會先寫入資料。我們的 HTTP 要求將包含我們正在連線的主機名稱。為了簡化起見,我們將 HTTP 要求寫成三個區塊。首先,我們寫入要求的開頭。其次,我們寫入我們將要求傳送至的主機名稱。最後,我們傳送要求的結尾。
size_t written;
const char *request_start = "GET / HTTP/1.0\r\nConnection: close\r\nHost: ";
const char *request_end = "\r\n\r\n";
/* Write an HTTP GET request to the peer */
if (!SSL_write_ex(ssl, request_start, strlen(request_start), &written)) {
printf("Failed to write start of HTTP request\n");
goto end;
}
if (!SSL_write_ex(ssl, hostname, strlen(hostname), &written)) {
printf("Failed to write hostname in HTTP request\n");
goto end;
}
if (!SSL_write_ex(ssl, request_end, strlen(request_end), &written)) {
printf("Failed to write end of HTTP request\n");
goto end;
}
如果 SSL_write_ex(3) 函數失敗,它會傳回 0,如果成功,它會傳回 1。如果成功,我們就可以繼續等待伺服器的回應。
size_t readbytes;
char buf[160];
/*
* Get up to sizeof(buf) bytes of the response. We keep reading until the
* server closes the connection.
*/
while (SSL_read_ex(ssl, buf, sizeof(buf), &readbytes)) {
/*
* OpenSSL does not guarantee that the returned data is a string or
* that it is NUL terminated so we use fwrite() to write the exact
* number of bytes that we read. The data could be non-printable or
* have NUL characters in the middle of it. For this simple example
* we're going to print it to stdout anyway.
*/
fwrite(buf, 1, readbytes, stdout);
}
/* In case the response didn't finish with a newline we add one now */
printf("\n");
我們使用 SSL_read_ex(3) 函數來讀取回應。我們不知道將會收到多少資料,因此我們進入一個迴圈,從伺服器讀取資料區塊,並將我們收到的每個區塊印到螢幕上。當 SSL_read_ex(3) 傳回 0 時,迴圈就會結束,表示它無法讀取任何資料。
無法讀取資料可能表示發生了一些錯誤,或者可能只是表示伺服器已傳送所有它想要傳送的資料,並已透過傳送「close_notify」警示來表示它已完成。此警示是 TLS 協定層級訊息,表示端點已完成傳送所有資料,且它不會再傳送任何資料。這兩個條件都會導致 SSL_read_ex(3) 傳回 0,而我們需要使用 SSL_get_error(3) 函數來判斷傳回 0 的原因。
/*
* Check whether we finished the while loop above normally or as the
* result of an error. The 0 argument to SSL_get_error() is the return
* code we received from the SSL_read_ex() call. It must be 0 in order
* to get here. Normal completion is indicated by SSL_ERROR_ZERO_RETURN.
*/
if (SSL_get_error(ssl, 0) != SSL_ERROR_ZERO_RETURN) {
/*
* Some error occurred other than a graceful close down by the
* peer
*/
printf ("Failed reading remaining data\n");
goto end;
}
如果 SSL_get_error(3) 傳回 SSL_ERROR_ZERO_RETURN,我們就知道伺服器已完成傳送資料。否則,已發生錯誤。
關閉連線
當我們完成從伺服器讀取資料後,我們就可以準備關閉連線。我們透過 SSL_shutdown(3) 函數來執行此動作,此函數會傳送 TLS 協定層級訊息(「close_notify」警示)至伺服器,表示我們已完成寫入資料。
/*
* The peer already shutdown gracefully (we know this because of the
* SSL_ERROR_ZERO_RETURN above). We should do the same back.
*/
ret = SSL_shutdown(ssl);
if (ret < 1) {
/*
* ret < 0 indicates an error. ret == 0 would be unexpected here
* because that means "we've sent a close_notify and we're waiting
* for one back". But we already know we got one from the peer
* because of the SSL_ERROR_ZERO_RETURN above.
*/
printf("Error shutting down\n");
goto end;
}
SSL_shutdown(3) 函數會傳回 1、0 或小於 0。傳回值 1 表示成功,傳回值小於 0 表示錯誤。更精確地說,傳回值 1 表示我們已傳送「close_notify」警示至伺服器,且我們也已收到一個。傳回值 0 表示我們已傳送「close_notify」警示至伺服器,但我們尚未收到一個。通常在這種情況下,您會再次呼叫 SSL_shutdown(3),它(使用封鎖 socket)會封鎖,直到收到「close_notify」。然而,在本例中,我們已知道伺服器已傳送「close_notify」給我們,因為我們從 SSL_read_ex(3) 呼叫中收到了 SSL_ERROR_ZERO_RETURN。因此,在實際情況中,這種情況永遠不會發生。在本範例中,我們只將它視為錯誤。
最後清理
在應用程式結束前,我們必須清理一些我們分配的記憶體。如果我們因為錯誤而結束,我們可能也想要顯示更多關於該錯誤的資訊,如果使用者可以取得的話
/* Success! */
res = EXIT_SUCCESS;
end:
/*
* If something bad happened then we will dump the contents of the
* OpenSSL error stack to stderr. There might be some useful diagnostic
* information there.
*/
if (res == EXIT_FAILURE)
ERR_print_errors_fp(stderr);
/*
* Free the resources we allocated. We do not free the BIO object here
* because ownership of it was immediately transferred to the SSL object
* via SSL_set_bio(). The BIO will be freed when we free the SSL object.
*/
SSL_free(ssl);
SSL_CTX_free(ctx);
return res;
為了顯示錯誤,我們使用 ERR_print_errors_fp(3) 函式,它會簡單地將 OpenSSL 錯誤堆疊中的任何錯誤內容傾印到指定的位址(在本例中為 stderr)。
我們需要釋放我們為連線建立的 SSL 物件,透過 SSL_free(3) 函式。此外,由於我們不會再建立任何 TLS 連線,我們也必須透過呼叫 SSL_CTX_free(3) 來釋放 SSL_CTX。
疑難排解
在執行示範應用程式時,可能會發生許多問題。本節說明您可能會遇到的常見問題。
無法連線基礎 socket
這可能會因為許多原因而發生。例如,如果客戶端與伺服器之間的網路路由有問題;或防火牆阻擋了通訊;或伺服器不在 DNS 中。請檢查網路設定。
伺服器憑證驗證失敗
伺服器憑證驗證失敗會導致執行 SSL_connect(3) 函式時失敗。 ERR_print_errors_fp(3) 會顯示一個錯誤,看起來像這樣
Verify error: unable to get local issuer certificate
40E74AF1F47F0000:error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:2069:
伺服器憑證驗證失敗可能是因為許多原因造成的。例如
- 無法正確設定受信任憑證儲存
-
請參閱 ossl-guide-tls-introduction(7) 頁面,並檢查您的受信任憑證儲存是否正確設定
- 無法辨識 CA
-
如果伺服器憑證使用的 CA 不在客戶端的受信任憑證儲存中,則會在連線期間造成驗證失敗。如果伺服器使用自簽憑證(例如,完全未經 CA 簽署的測試憑證),則經常會發生這種情況。
- 遺失中間 CA
-
這是一個伺服器設定錯誤,其中客戶端在其信任儲存中具有相關的根 CA,但伺服器未提供該根 CA 與伺服器自己的憑證之間的所有中間 CA 憑證。因此,無法建立信任鏈。
- 主機名稱不符
-
如果出於某種原因,客戶端預期的伺服器主機名稱與憑證中的主機名稱不符,則會導致驗證失敗。
- 憑證過期
-
伺服器憑證的有效日期已過。
在上述範例中看到的「無法取得本機發行者憑證」表示我們無法在我們的受信任憑證儲存區中找到伺服器憑證的發行者(或其其中一個中間 CA 憑證)(例如,因為受信任憑證儲存區設定錯誤,或缺少中間 CA,或發行者根本無法辨識)。
進一步閱讀
參閱 ossl-guide-tls-client-non-block(7) 以閱讀有關如何修改此頁面中開發的客戶端以支援非封鎖式 socket 的教學課程。
參閱 ossl-guide-quic-client-block(7) 以閱讀有關如何修改此頁面中開發的客戶端以支援 QUIC 而非 TLS 的教學課程。
另請參閱
ossl-guide-introduction(7)、ossl-guide-libraries-introduction(7)、ossl-guide-libssl-introduction(7)、ossl-guide-tls-introduction(7)、ossl-guide-tls-client-non-block(7)、ossl-guide-quic-client-block(7)
版權
版權所有 2023 The OpenSSL Project Authors。保留所有權利。
根據 Apache 授權條款 2.0(「授權條款」)授權。您不得使用此檔案,除非符合授權條款。您可以在來源發行版中的 LICENSE 檔案或 https://www.openssl.org/source/license.html 取得副本。