xref: /curl/tests/http/test_17_ssl_use.py (revision 57cc5233)
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3#***************************************************************************
4#                                  _   _ ____  _
5#  Project                     ___| | | |  _ \| |
6#                             / __| | | | |_) | |
7#                            | (__| |_| |  _ <| |___
8#                             \___|\___/|_| \_\_____|
9#
10# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
11#
12# This software is licensed as described in the file COPYING, which
13# you should have received as part of this distribution. The terms
14# are also available at https://curl.se/docs/copyright.html.
15#
16# You may opt to use, copy, modify, merge, publish, distribute and/or sell
17# copies of the Software, and permit persons to whom the Software is
18# furnished to do so, under the terms of the COPYING file.
19#
20# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
21# KIND, either express or implied.
22#
23# SPDX-License-Identifier: curl
24#
25###########################################################################
26#
27import json
28import logging
29import os
30import pytest
31
32from testenv import Env, CurlClient
33
34
35log = logging.getLogger(__name__)
36
37
38class TestSSLUse:
39
40    @pytest.fixture(autouse=True, scope='class')
41    def _class_scope(self, env, nghttpx):
42        if env.have_h3():
43            nghttpx.start_if_needed()
44
45    @pytest.fixture(autouse=True, scope='function')
46    def _function_scope(self, request, env, httpd):
47        httpd.clear_extra_configs()
48        if 'httpd' not in request.node._fixtureinfo.argnames:
49            httpd.reload_if_config_changed()
50
51    def test_17_01_sslinfo_plain(self, env: Env, nghttpx, repeat):
52        proto = 'http/1.1'
53        curl = CurlClient(env=env)
54        url = f'https://{env.authority_for(env.domain1, proto)}/curltest/sslinfo'
55        r = curl.http_get(url=url, alpn_proto=proto)
56        assert r.json['HTTPS'] == 'on', f'{r.json}'
57        assert 'SSL_SESSION_ID' in r.json, f'{r.json}'
58        assert 'SSL_SESSION_RESUMED' in r.json, f'{r.json}'
59        assert r.json['SSL_SESSION_RESUMED'] == 'Initial', f'{r.json}'
60
61    @pytest.mark.parametrize("tls_max", ['1.2', '1.3'])
62    def test_17_02_sslinfo_reconnect(self, env: Env, tls_max):
63        proto = 'http/1.1'
64        count = 3
65        exp_resumed = 'Resumed'
66        xargs = ['--sessionid', '--tls-max', tls_max, f'--tlsv{tls_max}']
67        if env.curl_uses_lib('gnutls'):
68            if tls_max == '1.3':
69                exp_resumed = 'Initial'  # 1.2 works in GnuTLS, but 1.3 does not, TODO
70        if env.curl_uses_lib('libressl'):
71            if tls_max == '1.3':
72                exp_resumed = 'Initial'  # 1.2 works in LibreSSL, but 1.3 does not, TODO
73        if env.curl_uses_lib('wolfssl'):
74            if tls_max == '1.3':
75                exp_resumed = 'Initial'  # 1.2 works in wolfSSL, but 1.3 does not, TODO
76        if env.curl_uses_lib('rustls-ffi'):
77            exp_resumed = 'Initial'  # Rustls does not support sessions, TODO
78        if env.curl_uses_lib('bearssl') and tls_max == '1.3':
79            pytest.skip('BearSSL does not support TLSv1.3')
80        if env.curl_uses_lib('mbedtls') and tls_max == '1.3':
81            pytest.skip('mbedtls TLSv1.3 session resume not working in 3.6.0')
82
83        curl = CurlClient(env=env)
84        # tell the server to close the connection after each request
85        urln = f'https://{env.authority_for(env.domain1, proto)}/curltest/sslinfo?'\
86               f'id=[0-{count-1}]&close'
87        r = curl.http_download(urls=[urln], alpn_proto=proto, with_stats=True,
88                               extra_args=xargs)
89        r.check_response(count=count, http_status=200)
90        # should have used one connection for each request, sessions after
91        # first should have been resumed
92        assert r.total_connects == count, r.dump_logs()
93        for i in range(count):
94            dfile = curl.download_file(i)
95            assert os.path.exists(dfile)
96            with open(dfile) as f:
97                djson = json.load(f)
98            assert djson['HTTPS'] == 'on', f'{i}: {djson}'
99            if i == 0:
100                assert djson['SSL_SESSION_RESUMED'] == 'Initial', f'{i}: {djson}'
101            else:
102                assert djson['SSL_SESSION_RESUMED'] == exp_resumed, f'{i}: {djson}'
103
104    # use host name with trailing dot, verify handshake
105    @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
106    def test_17_03_trailing_dot(self, env: Env, proto):
107        if proto == 'h3' and not env.have_h3():
108            pytest.skip("h3 not supported")
109        curl = CurlClient(env=env)
110        domain = f'{env.domain1}.'
111        url = f'https://{env.authority_for(domain, proto)}/curltest/sslinfo'
112        r = curl.http_get(url=url, alpn_proto=proto)
113        assert r.exit_code == 0, f'{r}'
114        assert r.json, f'{r}'
115        if proto != 'h3':  # we proxy h3
116            # the SNI the server received is without trailing dot
117            assert r.json['SSL_TLS_SNI'] == env.domain1, f'{r.json}'
118
119    # use host name with double trailing dot, verify handshake
120    @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
121    def test_17_04_double_dot(self, env: Env, proto):
122        if proto == 'h3' and not env.have_h3():
123            pytest.skip("h3 not supported")
124        if proto == 'h3' and env.curl_uses_lib('wolfssl'):
125            pytest.skip("wolfSSL HTTP/3 peer verification does not properly check")
126        curl = CurlClient(env=env)
127        domain = f'{env.domain1}..'
128        url = f'https://{env.authority_for(domain, proto)}/curltest/sslinfo'
129        r = curl.http_get(url=url, alpn_proto=proto, extra_args=[
130            '-H', f'Host: {env.domain1}',
131        ])
132        if r.exit_code == 0:
133            assert r.json, f'{r.stdout}'
134            # the SNI the server received is without trailing dot
135            if proto != 'h3':  # we proxy h3
136                assert r.json['SSL_TLS_SNI'] == env.domain1, f'{r.json}'
137            assert False, f'should not have succeeded: {r.json}'
138        # 7 - Rustls rejects a servername with .. during setup
139        # 35 - LibreSSL rejects setting an SNI name with trailing dot
140        # 60 - peer name matching failed against certificate
141        assert r.exit_code in [7, 35, 60], f'{r}'
142
143    # use ip address for connect
144    @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
145    def test_17_05_ip_addr(self, env: Env, proto):
146        if env.curl_uses_lib('bearssl'):
147            pytest.skip("BearSSL does not support cert verification with IP addresses")
148        if env.curl_uses_lib('mbedtls'):
149            pytest.skip("mbedTLS does not support cert verification with IP addresses")
150        if proto == 'h3' and not env.have_h3():
151            pytest.skip("h3 not supported")
152        curl = CurlClient(env=env)
153        domain = '127.0.0.1'
154        url = f'https://{env.authority_for(domain, proto)}/curltest/sslinfo'
155        r = curl.http_get(url=url, alpn_proto=proto)
156        assert r.exit_code == 0, f'{r}'
157        assert r.json, f'{r}'
158        if proto != 'h3':  # we proxy h3
159            # the SNI should not have been used
160            assert 'SSL_TLS_SNI' not in r.json, f'{r.json}'
161
162    # use localhost for connect
163    @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
164    def test_17_06_localhost(self, env: Env, proto):
165        if proto == 'h3' and not env.have_h3():
166            pytest.skip("h3 not supported")
167        curl = CurlClient(env=env)
168        domain = 'localhost'
169        url = f'https://{env.authority_for(domain, proto)}/curltest/sslinfo'
170        r = curl.http_get(url=url, alpn_proto=proto)
171        assert r.exit_code == 0, f'{r}'
172        assert r.json, f'{r}'
173        if proto != 'h3':  # we proxy h3
174            assert r.json['SSL_TLS_SNI'] == domain, f'{r.json}'
175
176    @staticmethod
177    def gen_test_17_07_list():
178        tls13_tests = [
179            [None, True],
180            [['TLS_AES_128_GCM_SHA256'], True],
181            [['TLS_AES_256_GCM_SHA384'], False],
182            [['TLS_CHACHA20_POLY1305_SHA256'], True],
183            [['TLS_AES_256_GCM_SHA384',
184              'TLS_CHACHA20_POLY1305_SHA256'], True],
185        ]
186        tls12_tests = [
187            [None, True],
188            [['ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256'], True],
189            [['ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384'], False],
190            [['ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305'], True],
191            [['ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384',
192              'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305'], True],
193        ]
194        ret = []
195        for tls_proto in ['TLSv1.3 +TLSv1.2', 'TLSv1.3', 'TLSv1.2']:
196            for [ciphers13, succeed13] in tls13_tests:
197                for [ciphers12, succeed12] in tls12_tests:
198                    ret.append([tls_proto, ciphers13, ciphers12, succeed13, succeed12])
199        return ret
200
201    @pytest.mark.parametrize("tls_proto, ciphers13, ciphers12, succeed13, succeed12", gen_test_17_07_list())
202    def test_17_07_ssl_ciphers(self, env: Env, httpd, tls_proto, ciphers13, ciphers12, succeed13, succeed12):
203        # to test setting cipher suites, the AES 256 ciphers are disabled in the test server
204        httpd.set_extra_config('base', [
205            'SSLCipherSuite SSL'
206                ' ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256'
207                ':ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305',
208            'SSLCipherSuite TLSv1.3'
209                ' TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256',
210            f'SSLProtocol {tls_proto}'
211        ])
212        httpd.reload_if_config_changed()
213        proto = 'http/1.1'
214        curl = CurlClient(env=env)
215        url = f'https://{env.authority_for(env.domain1, proto)}/curltest/sslinfo'
216        # SSL backend specifics
217        if env.curl_uses_lib('gnutls'):
218            pytest.skip('GnuTLS does not support setting ciphers')
219        elif env.curl_uses_lib('boringssl'):
220            if ciphers13 is not None:
221                pytest.skip('BoringSSL does not support setting TLSv1.3 ciphers')
222        elif env.curl_uses_lib('schannel'):  # not in CI, so untested
223            if ciphers12 is not None:
224                pytest.skip('Schannel does not support setting TLSv1.2 ciphers by name')
225        elif env.curl_uses_lib('bearssl'):
226            if tls_proto == 'TLSv1.3':
227                pytest.skip('BearSSL does not support TLSv1.3')
228            tls_proto = 'TLSv1.2'
229        elif env.curl_uses_lib('sectransp'):  # not in CI, so untested
230            if tls_proto == 'TLSv1.3':
231                pytest.skip('Secure Transport does not support TLSv1.3')
232            tls_proto = 'TLSv1.2'
233        # test
234        extra_args = ['--tls13-ciphers', ':'.join(ciphers13)] if ciphers13 else []
235        extra_args += ['--ciphers', ':'.join(ciphers12)] if ciphers12 else []
236        r = curl.http_get(url=url, alpn_proto=proto, extra_args=extra_args)
237        if tls_proto != 'TLSv1.2' and succeed13:
238            assert r.exit_code == 0, r.dump_logs()
239            assert r.json['HTTPS'] == 'on', r.dump_logs()
240            assert r.json['SSL_PROTOCOL'] == 'TLSv1.3', r.dump_logs()
241            assert ciphers13 is None or r.json['SSL_CIPHER'] in ciphers13, r.dump_logs()
242        elif tls_proto == 'TLSv1.2' and succeed12:
243            assert r.exit_code == 0, r.dump_logs()
244            assert r.json['HTTPS'] == 'on', r.dump_logs()
245            assert r.json['SSL_PROTOCOL'] == 'TLSv1.2', r.dump_logs()
246            assert ciphers12 is None or r.json['SSL_CIPHER'] in ciphers12, r.dump_logs()
247        else:
248            assert r.exit_code != 0, r.dump_logs()
249
250    @pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
251    def test_17_08_cert_status(self, env: Env, proto):
252        if proto == 'h3' and not env.have_h3():
253            pytest.skip("h3 not supported")
254        if not env.curl_uses_lib('openssl') and \
255            not env.curl_uses_lib('gnutls') and \
256            not env.curl_uses_lib('quictls'):
257            pytest.skip("TLS library does not support --cert-status")
258        curl = CurlClient(env=env)
259        domain = 'localhost'
260        url = f'https://{env.authority_for(domain, proto)}/'
261        r = curl.http_get(url=url, alpn_proto=proto, extra_args=[
262            '--cert-status'
263        ])
264        # CURLE_SSL_INVALIDCERTSTATUS, our certs have no OCSP info
265        assert r.exit_code == 91, f'{r}'
266
267    @staticmethod
268    def gen_test_17_09_list():
269        return [[tls_proto, max_ver, min_ver]
270                for tls_proto in ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']
271                for max_ver in range(5)
272                for min_ver in range(-2, 4)]
273
274    @pytest.mark.parametrize("tls_proto, max_ver, min_ver", gen_test_17_09_list())
275    def test_17_09_ssl_min_max(self, env: Env, httpd, tls_proto, max_ver, min_ver):
276        httpd.set_extra_config('base', [
277            f'SSLProtocol {tls_proto}',
278            'SSLCipherSuite ALL:@SECLEVEL=0',
279        ])
280        httpd.reload_if_config_changed()
281        proto = 'http/1.1'
282        curl = CurlClient(env=env)
283        url = f'https://{env.authority_for(env.domain1, proto)}/curltest/sslinfo'
284        # SSL backend specifics
285        if env.curl_uses_lib('bearssl'):
286            supported = ['TLSv1', 'TLSv1.1', 'TLSv1.2', None]
287        elif env.curl_uses_lib('sectransp'):  # not in CI, so untested
288            supported = ['TLSv1', 'TLSv1.1', 'TLSv1.2', None]
289        elif env.curl_uses_lib('gnutls'):
290            supported = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']
291        elif env.curl_uses_lib('quiche'):
292            supported = ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']
293        else:  # most SSL backends dropped support for TLSv1.0, TLSv1.1
294            supported = [None, None, 'TLSv1.2', 'TLSv1.3']
295        # test
296        extra_args = [[], ['--tlsv1'], ['--tlsv1.0'], ['--tlsv1.1'], ['--tlsv1.2'], ['--tlsv1.3']][min_ver+2] + \
297            [['--tls-max', '1.0'], ['--tls-max', '1.1'], ['--tls-max', '1.2'], ['--tls-max', '1.3'], []][max_ver]
298        r = curl.http_get(url=url, alpn_proto=proto, extra_args=extra_args)
299        if max_ver >= min_ver and tls_proto in supported[max(0, min_ver):min(max_ver, 3)+1]:
300            assert r.exit_code == 0 , r.dump_logs()
301            assert r.json['HTTPS'] == 'on', r.dump_logs()
302            assert r.json['SSL_PROTOCOL'] == tls_proto, r.dump_logs()
303        else:
304            assert r.exit_code != 0, r.dump_logs()
305