Translation

This post is also available in Simplified Chinese.

The final TLS 1.3 specification, RFC 8446, was published in August 2018. Out of curiosity, I installed OpenSSL 1.1.1 to enable TLS 1.3 support. Recently, however, I noticed that Nginx always selected TLS_AES_256_GCM_SHA384 under TLS 1.3, regardless of the cipher-suite order in ssl_ciphers; even explicitly requesting AES-128 made no difference. This was not a serious problem, but to a would-be perfectionist it made the configuration feel incomplete.

Searching online revealed little investigation of this behavior. BoringSSL, with its equal-preference cipher groups, seemed to be the only implementation offering arbitrary selection. The OpenSSL Wiki explains under “Ciphersuites” that applications should configure TLS 1.3 suites with SSL_CTX_set_ciphersuites() or SSL_set_ciphersuites(). The older SSL_CTX_set_cipher_list() interface configures TLS 1.2 and earlier instead. The command-line tools likewise provide a separate -ciphersuites option containing a colon-separated preference list.

With that distinction established, we can modify the Nginx code that configures cipher suites. In src/http/modules/ngx_http_ssl_module.c, ngx_http_ssl_merge_srv_conf() configures the certificate, private key, and ciphers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
static char *
ngx_http_ssl_merge_srv_conf(ngx_conf_t *cf, void *parent, void *child)
{
    ...
    if (ngx_ssl_certificates(cf, &conf->ssl, conf->certificates,
                             conf->certificate_keys, conf->passwords)
        != NGX_OK)
    {
        return NGX_CONF_ERROR;
    }

    if (ngx_ssl_ciphers(cf, &conf->ssl, &conf->ciphers,
                        conf->prefer_server_ciphers)
        != NGX_OK)
    {
        return NGX_CONF_ERROR;
    }
    ...
    return NGX_CONF_OK;
}

conf->ciphers is the ngx_str_t holding the value of ssl_ciphers. The following ngx_ssl_ciphers() function calls OpenSSL’s SSL configuration API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
ngx_int_t
ngx_ssl_ciphers(ngx_conf_t *cf, ngx_ssl_t *ssl, ngx_str_t *ciphers,
    ngx_uint_t prefer_server_ciphers)
{
    if (SSL_CTX_set_cipher_list(ssl->ctx, (char *) ciphers->data) == 0) {
        ngx_ssl_error(NGX_LOG_EMERG, ssl->log, 0,
                      "SSL_CTX_set_cipher_list(\"%V\") failed",
                      ciphers);
        return NGX_ERROR;
    }

    if (prefer_server_ciphers) {
        SSL_CTX_set_options(ssl->ctx, SSL_OP_CIPHER_SERVER_PREFERENCE);
    }

#if (OPENSSL_VERSION_NUMBER < 0x10100001L && !defined LIBRESSL_VERSION_NUMBER)
    /* a temporary 512-bit RSA key is required for export versions of MSIE */
    SSL_CTX_set_tmp_rsa_callback(ssl->ctx, ngx_ssl_rsa512_key_callback);
#endif

    return NGX_OK;
}

My somewhat destructive solution splits the configured suites at |, placing TLS 1.3 suites first. SSL configuration for HTTP, mail, stream, and HTTP gRPC ultimately reaches ngx_ssl_ciphers() in src/event/ngx_event_openssl.c, so changing that function covers all of them. The function separates the two lists and passes each to the appropriate OpenSSL API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
diff --git a/src/event/ngx_event_openssl.c b/src/event/ngx_event_openssl.c
index a281fba..7e2809b 100644
--- a/src/event/ngx_event_openssl.c
+++ b/src/event/ngx_event_openssl.c
@@ -662,7 +662,35 @@ ngx_int_t
 ngx_ssl_ciphers(ngx_conf_t *cf, ngx_ssl_t *ssl, ngx_str_t *ciphers,
     ngx_uint_t prefer_server_ciphers)
 {
-    if (SSL_CTX_set_cipher_list(ssl->ctx, (char *) ciphers->data) == 0) {
+    u_char *ciphers_data;
+
+    ciphers_data = ciphers->data;
+#if (OPENSSL_VERSION_NUMBER >= 0x10101000L)
+    /* Find TLS 1.3 ciphersuite config */
+    size_t i;
+    for (i = 0; i < ciphers->len; i++)
+    {
+        if (ciphers->data[i] == '|')
+        {
+            break;
+        }
+    }
+    if (i < ciphers->len)
+    {
+        ciphers->data[i] = '\0';
+        if (SSL_CTX_set_ciphersuites(ssl->ctx, (char *)ciphers->data) == 0)
+        {
+            ngx_ssl_error(NGX_LOG_EMERG, ssl->log, 0,
+                          "SSL_CTX_set_ciphersuites(\"%V\") failed",
+                          ciphers);
+            return NGX_ERROR;
+        }
+        ciphers->data[i] = '|';
+        ciphers_data = &(ciphers->data[i + 1]);
+    }
+#endif
+
+    if (SSL_CTX_set_cipher_list(ssl->ctx, (char *) ciphers_data) == 0) {
         ngx_ssl_error(NGX_LOG_EMERG, ssl->log, 0,
                       "SSL_CTX_set_cipher_list(\"%V\") failed",
                       ciphers);

After recompiling, configure ssl_ciphers as follows, using | to separate TLS 1.3 from the older suites:

1
ssl_ciphers TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384|ECDHE+AESGCM:CHACHA20+ECDHE:+AES256:HIGH:!aNULL:!eNULL:!MD5;

Finally, test it with Chromium:

Chromium using the TLS_AES_128_GCM_SHA256 cipher suite

The selected suite is now the first configured entry, TLS_AES_128_GCM_SHA256.

Warning

My programming ability is limited. I accept no responsibility if this causes your server to misbehave in the future.

The patch is available in this GitHub Gist. Suggestions and corrections are welcome.