OpenSSL

密碼學和 SSL/TLS 工具包

ossl-guide-quic-client-block

名稱

ossl-guide-quic-client-block - OpenSSL 指南:撰寫簡單的封鎖 QUIC 應用程式

簡單的封鎖 QUIC 應用程式範例

此頁面將提供各種原始碼範例,說明如何撰寫一個簡單的封鎖 QUIC 應用程式,它會連線到伺服器、傳送 HTTP/1.0 要求給伺服器,並讀回回應。請注意,QUIC 上的 HTTP/1.0 並非標準,而且實際的伺服器不會支援它。這僅供示範用途。

我們假設您的系統上已經安裝了 OpenSSL;您已經具備一些 OpenSSL 概念、TLS 和 QUIC 的基本認識(請參閱 ossl-guide-libraries-introduction(7)ossl-guide-tls-introduction(7)ossl-guide-quic-introduction(7));而且您知道如何撰寫和建置 C 程式碼,並將它連結到 OpenSSL 提供的 libcrypto 和 libssl 函式庫。它也假設您對 UDP/IP 和 socket 有基本的認識。我們在此教學課程中建置的範例程式碼將修改 ossl-guide-tls-client-block(7) 中涵蓋的封鎖 TLS 應用程式範例。我們只會討論此應用程式和該應用程式之間的差異,因此我們也假設您已經執行並了解該教學課程。

對於此教學課程,我們的應用程式將使用單一 QUIC 串流。後續的教學課程將討論如何撰寫多串流應用程式(請參閱 ossl-guide-quic-multi-stream(7))。

此封鎖 QUIC 應用程式範例的完整原始碼可在 OpenSSL 原始碼散佈的 demos/guide 目錄中的 quic-client-block.c 檔案中取得。它也可在線上取得,網址為 https://github.com/openssl/openssl/blob/master/demos/guide/quic-client-block.c

建立 SSL_CTX 和 SSL 物件

在 TLS 教學 (ossl-guide-tls-client-block(7)) 中,我們為我們的客戶端建立了一個 SSL_CTX 物件,並使用它建立一個 SSL 物件來表示 TLS 連線。QUIC 連線的工作方式完全相同。我們首先建立一個 SSL_CTX 物件,然後使用它建立一個 SSL 物件來表示 QUIC 連線。

如同 TLS 範例,第一步是為我們的客戶端建立一個 SSL_CTX 物件。這與之前的方式相同,只不過我們使用不同的「方法」。OpenSSL 提供兩種不同的 QUIC 客戶端方法,即 OSSL_QUIC_client_method(3)OSSL_QUIC_client_thread_method(3)

第一個等同於 TLS_client_method(3),但適用於 QUIC 協定。第二個相同,但它會另外建立一個背景執行緒來處理基於時間的事件(稱為「執行緒輔助模式」,請參閱 ossl-guide-quic-introduction(7))。在本教學中,我們將使用 OSSL_QUIC_client_method(3),因為我們不會在應用程式中讓 QUIC 連線處於閒置狀態,因此不需要執行緒輔助模式。

/*
 * Create an SSL_CTX which we can use to create SSL objects from. We
 * want an SSL_CTX for creating clients so we use OSSL_QUIC_client_method()
 * here.
 */
ctx = SSL_CTX_new(OSSL_QUIC_client_method());
if (ctx == NULL) {
    printf("Failed to create the SSL_CTX\n");
    goto end;
}

我們套用於 TLS 的 SSL_CTX 的其他設定步驟也適用於 QUIC,但限制我們願意接受的 TLS 版本除外。OpenSSL 中的 QUIC 協定實作目前僅支援 TLSv1.3。不需要在 OpenSSL QUIC 應用程式中呼叫 SSL_CTX_set_min_proto_version(3)SSL_CTX_set_max_proto_version(3),任何此類呼叫都將被忽略。

建立 SSL_CTX 之後,SSL 物件的建構方式與 TLS 應用程式完全相同。

建立 socket 和 BIO

TLS 和 QUIC 之間的主要差異是底層傳輸協定。TLS 使用 TCP,而 QUIC 使用 UDP。我們在範例程式碼中建立 QUIC socket 的方式與 TLS 非常相似。我們使用 BIO_lookup_ex(3)BIO_socket(3) 輔助函數,就像我們在前一個教學中所做的一樣,只不過我們傳遞 SOCK_DGRAM 作為引數來表示 UDP(而不是 TCP 的 SOCK_STREAM)。

/*
 * Lookup IP address info for the server.
 */
if (!BIO_lookup_ex(hostname, port, BIO_LOOKUP_CLIENT, family, SOCK_DGRAM, 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_DGRAM, 0, 0);
    if (sock == -1)
        continue;

    /* Connect the socket to the server's address */
    if (!BIO_connect(sock, BIO_ADDRINFO_address(ai), 0)) {
        BIO_closesocket(sock);
        sock = -1;
        continue;
    }

    /* Set to nonblocking mode */
    if (!BIO_socket_nbio(sock, 1)) {
        BIO_closesocket(sock);
        sock = -1;
        continue;
    }

    break;
}

if (sock != -1) {
    *peer_addr = BIO_ADDR_dup(BIO_ADDRINFO_address(ai));
    if (*peer_addr == NULL) {
        BIO_closesocket(sock);
        return NULL;
    }
}

/* Free the address information resources we allocated earlier */
BIO_ADDRINFO_free(res);

您可能會注意到此程式碼和我們用於 TLS 的版本之間有幾個其他差異。

首先,我們將 socket 設定為非封鎖模式。這必須始終針對 OpenSSL QUIC 應用程式進行。這可能會令人驚訝,因為我們正在嘗試撰寫封鎖式客戶端。儘管如此,SSL 物件仍將具有封鎖行為。請參閱 ossl-guide-quic-introduction(7) 以取得更多相關資訊。

其次,我們記下我們正在連線的對等方的 IP 位址。我們儲存該資訊。我們稍後會需要它。

請參閱 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)BIO_ADDR_dup(3) 以取得更多有關在此處使用的函數的資訊。在上述範例程式碼中,hostnameport 變數是字串,例如「www.example.com」和「443」。

至於我們的 TLS 伺服器端,一旦建立並連接 socket,我們需要將它與 BIO 物件關聯起來

BIO *bio;

/* Create a BIO to wrap the socket */
bio = BIO_new(BIO_s_datagram());
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_s_datagram(3),而不是我們用於 TLS 伺服器端的 BIO_s_socket(3)。這再次是因為 QUIC 使用 UDP 而不是 TCP 作為其傳輸層。請參閱 BIO_new(3)BIO_s_datagram(3)BIO_set_fd(3) 以進一步瞭解這些函數。

設定伺服器主機名稱

如同 TLS 教學課程中,我們需要設定伺服器主機名稱,以用於 SNI (伺服器名稱指示) 和憑證驗證。這些步驟與 TLS 教學課程相同,在此不再重複。

設定 ALPN

ALPN (應用層協定協商) 是 TLS 的一項功能,可讓應用程式協商將透過連線使用哪種協定。例如,如果您打算透過連線使用 HTTP/3,則該協定的 ALPN 值為「h3」(請參閱 https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xml#alpn-protocol-ids)。OpenSSL 提供讓伺服器端透過 SSL_set_alpn_protos(3) 函數指定要使用的 ALPN 的功能。這對 TLS 伺服器端來說是選用的,因此我們在 ossl-guide-tls-client-block(7) 中開發的簡單伺服器端並未使用它。然而,QUIC 要求用於建立 QUIC 連線的 TLS 交握必須使用 ALPN。

unsigned char alpn[] = { 8, 'h', 't', 't', 'p', '/', '1', '.', '0' };

/* SSL_set_alpn_protos returns 0 for success! */
if (SSL_set_alpn_protos(ssl, alpn, sizeof(alpn)) != 0) {
    printf("Failed to set the ALPN for the connection\n");
    goto end;
}

ALPN 使用長度前置陣列的無符號字元指定 (它不是以 NUL 結束的字串)。我們最初的 TLS 阻斷伺服器端示範使用 HTTP/1.0。我們將在此範例中使用相同的協定。與大多數 OpenSSL 函數不同,SSL_set_alpn_protos(3) 會傳回 0 表示成功,傳回非 0 表示失敗。

設定對等位址

OpenSSL QUIC 應用程式必須指定正在連線的伺服器目標位址。在上面的「建立 socket 和 BIO」中,我們已將該位址儲存起來以供日後使用。現在我們需要透過 SSL_set1_initial_peer_addr(3) 函數使用它。

/* Set the IP address of the remote peer */
if (!SSL_set1_initial_peer_addr(ssl, peer_addr)) {
    printf("Failed to set the initial peer address\n");
    goto end;
}

請注意,我們需要釋放稍早透過 BIO_ADDR_dup(3) 配置的 peer_addr

BIO_ADDR_free(peer_addr);

交握和應用程式資料傳輸

一旦SSL物件的初始設定完成後,我們便透過SSL_connect(3)執行握手,其方式與TLS用戶端完全相同,因此我們在此不會重複說明。

我們也可以使用自動與SSL物件關聯的預設QUIC串流執行資料傳輸。我們可以使用SSL_write_ex(3)傳輸資料,並使用SSL_read_ex(3)接收資料,其方式與TLS相同。主要的差別在於我們必須以稍微不同的方式處理失敗。使用QUIC時,串流可以由對等方重設(對該串流而言是致命的),但底層連線本身可能仍然正常。

/*
 * 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");

/*
 * 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. In
 * QUIC terms this means that the peer has sent FIN on the stream to
 * indicate that no further data will be sent.
 */
switch (SSL_get_error(ssl, 0)) {
case SSL_ERROR_ZERO_RETURN:
    /* Normal completion of the stream */
    break;

case SSL_ERROR_SSL:
    /*
     * Some stream fatal error occurred. This could be because of a stream
     * reset - or some failure occurred on the underlying connection.
     */
    switch (SSL_get_stream_read_state(ssl)) {
    case SSL_STREAM_STATE_RESET_REMOTE:
        printf("Stream reset occurred\n");
        /* The stream has been reset but the connection is still healthy. */
        break;

    case SSL_STREAM_STATE_CONN_CLOSED:
        printf("Connection closed\n");
        /* Connection is already closed. Skip SSL_shutdown() */
        goto end;

    default:
        printf("Unknown stream failure\n");
        break;
    }
    break;

default:
    /* Some other unexpected error occurred */
    printf ("Failed reading remaining data\n");
    break;
}

在上述程式碼範例中,您可以看到SSL_ERROR_SSL表示串流致命錯誤。我們可以使用SSL_get_stream_read_state(3)判斷串流是否已重設,或是否發生其他致命錯誤。

關閉連線

在TLS教學課程中,我們知道伺服器已完成傳送資料,因為SSL_read_ex(3)傳回0,而SSL_get_error(3)傳回SSL_ERROR_ZERO_RETURN。QUIC也是如此,只不過SSL_ERROR_ZERO_RETURN的解讀方式略有不同。使用TLS時,我們知道這表示伺服器已傳送「close_notify」警示。伺服器將不會再透過該連線傳送任何資料。

使用QUIC時,這表示伺服器已在串流上標示「FIN」,表示它將不再透過該串流傳送任何資料。然而,這只讓我們瞭解串流本身的資訊,並未告訴我們任何有關底層連線的資訊。伺服器仍有可能透過其他串流傳送更多資料。此外,雖然伺服器不會再傳送任何資料給用戶端,但它並未阻止用戶端傳送更多資料給伺服器。

在本教學課程中,一旦我們完成從伺服器讀取我們正在使用的單一串流上的資料,我們便會關閉連線。與之前一樣,我們透過SSL_shutdown(3)函式執行此動作。QUIC的此範例與TLS版本非常類似。然而,SSL_shutdown(3)函式需要呼叫多次

/*
 * Repeatedly call SSL_shutdown() until the connection is fully
 * closed.
 */
do {
    ret = SSL_shutdown(ssl);
    if (ret < 0) {
        printf("Error shutting down: %d\n", ret);
        goto end;
    }
} while (ret != 1);

關閉程序分為兩個階段。在第一階段,我們會等到已緩衝的所有資料都已成功傳送給對等方並獲得確認後,才會傳送CONNECTION_CLOSE給對等方,以表示連線不再可用。這會立即關閉連線,且無法再傳送或接收任何資料。SSL_shutdown(3)在完成第一階段後會傳回0。

在第二階段,連線會進入「關閉」狀態。應用程式資料無法在此狀態傳送或接收,但來自對等方的延遲封包會得到適當處理。一旦此階段成功完成,SSL_shutdown(3)會傳回1以表示成功。

進一步閱讀

請參閱 ossl-guide-quic-multi-stream(7),以閱讀有關如何修改本頁中開發的用戶端以支援多個串流的教學課程。

另請參閱

ossl-guide-introduction(7)ossl-guide-libraries-introduction(7)ossl-guide-libssl-introduction(7)ossl-guide-tls-introduction(7)ossl-guide-tls-client-block(7)ossl-guide-quic-introduction(7)

Copyright 2023 The OpenSSL Project Authors. All Rights Reserved.

在 Apache License 2.0(「授權」)下授權。您只能在遵守授權的規定下使用此檔案。您可以在原始程式碼散佈中的 LICENSE 檔案中取得副本,或在 https://www.openssl.org/source/license.html 取得副本。