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