diff --git a/mercurial/help/config.txt b/mercurial/help/config.txt --- a/mercurial/help/config.txt +++ b/mercurial/help/config.txt @@ -1000,10 +1000,22 @@ For example:: ``hostsecurity`` ---------------- -Used to specify per-host security settings. - -Options in this section have the form ``hostname``:``setting``. This allows -multiple settings to be defined on a per-host basis. +Used to specify global and per-host security settings for connecting to +other machines. + +The following options control default behavior for all hosts. + +``minimumprotocol`` + Defines the minimum channel encryption protocol to use. + + By default, the highest version of TLS - 1.0 or greater - supported by + both client and server is used. + + Allowed values are: ``tls1.0`` (the default), ``tls1.1``, ``tls1.2``. + +Options in the ``[hostsecurity]`` section can have the form +``hostname``:``setting``. This allows multiple settings to be defined on a +per-host basis. The following per-host settings can be defined. @@ -1026,6 +1038,10 @@ The following per-host settings can be d This option takes precedence over ``verifycertsfile``. +``minimumprotocol`` + This behaves like ``minimumprotocol`` as described above except it + only applies to the host on which it is defined. + ``verifycertsfile`` Path to file a containing a list of PEM encoded certificates used to verify the server certificate. Environment variables and ``~user`` @@ -1058,6 +1074,13 @@ For example:: hg2.example.com:fingerprints = sha1:914f1aff87249c09b6859b88b1906d30756491ca, sha1:fc:e2:8d:d9:51:cd:cb:c1:4d:18:6b:b7:44:8d:49:72:57:e6:cd:33 foo.example.com:verifycertsfile = /etc/ssl/trusted-ca-certs.pem +To change the default minimum protocol version to TLS 1.2 but to allow TLS 1.1 +when connecting to ``hg.example.com``:: + + [hostsecurity] + minimumprotocol = tls1.2 + hg.example.com:minimumprotocol = tls1.1 + ``http_proxy`` -------------- diff --git a/mercurial/sslutil.py b/mercurial/sslutil.py --- a/mercurial/sslutil.py +++ b/mercurial/sslutil.py @@ -29,14 +29,13 @@ from . import ( # modern/secure or legacy/insecure. Many operations in this module have # separate code paths depending on support in Python. -hassni = getattr(ssl, 'HAS_SNI', False) +configprotocols = set([ + 'tls1.0', + 'tls1.1', + 'tls1.2', +]) -try: - OP_NO_SSLv2 = ssl.OP_NO_SSLv2 - OP_NO_SSLv3 = ssl.OP_NO_SSLv3 -except AttributeError: - OP_NO_SSLv2 = 0x1000000 - OP_NO_SSLv3 = 0x2000000 +hassni = getattr(ssl, 'HAS_SNI', False) try: # ssl.SSLContext was added in 2.7.9 and presence indicates modern @@ -136,7 +135,7 @@ def _hostsettings(ui, hostname): # Despite its name, PROTOCOL_SSLv23 selects the highest protocol # that both ends support, including TLS protocols. On legacy stacks, - # the highest it likely goes in TLS 1.0. On modern stacks, it can + # the highest it likely goes is TLS 1.0. On modern stacks, it can # support TLS 1.2. # # The PROTOCOL_TLSv* constants select a specific TLS version @@ -145,19 +144,26 @@ def _hostsettings(ui, hostname): # disable protocols via SSLContext.options and OP_NO_* constants. # However, SSLContext.options doesn't work unless we have the # full/real SSLContext available to us. - if modernssl: - s['protocol'] = ssl.PROTOCOL_SSLv23 - else: - s['protocol'] = ssl.PROTOCOL_TLSv1 + + # Allow minimum TLS protocol to be specified in the config. + def validateprotocol(protocol, key): + if protocol not in configprotocols: + raise error.Abort( + _('unsupported protocol from hostsecurity.%s: %s') % + (key, protocol), + hint=_('valid protocols: %s') % + ' '.join(sorted(configprotocols))) - # SSLv2 and SSLv3 are broken. We ban them outright. - # WARNING: ctxoptions doesn't have an effect unless the modern ssl module - # is available. Be careful when adding flags! - s['ctxoptions'] = OP_NO_SSLv2 | OP_NO_SSLv3 + key = 'minimumprotocol' + # Default to TLS 1.0+ as that is what browsers are currently doing. + protocol = ui.config('hostsecurity', key, 'tls1.0') + validateprotocol(protocol, key) - # Prevent CRIME. - # There is no guarantee this attribute is defined on the module. - s['ctxoptions'] |= getattr(ssl, 'OP_NO_COMPRESSION', 0) + key = '%s:minimumprotocol' % hostname + protocol = ui.config('hostsecurity', key, protocol) + validateprotocol(protocol, key) + + s['protocol'], s['ctxoptions'] = protocolsettings(protocol) # Look for fingerprints in [hostsecurity] section. Value is a list # of : strings. @@ -250,6 +256,46 @@ def _hostsettings(ui, hostname): return s +def protocolsettings(protocol): + """Resolve the protocol and context options for a config value.""" + if protocol not in configprotocols: + raise ValueError('protocol value not supported: %s' % protocol) + + # Legacy ssl module only supports up to TLS 1.0. Ideally we'd use + # PROTOCOL_SSLv23 and options to disable SSLv2 and SSLv3. However, + # SSLContext.options doesn't work in our implementation since we use + # a fake SSLContext on these Python versions. + if not modernssl: + if protocol != 'tls1.0': + raise error.Abort(_('current Python does not support protocol ' + 'setting %s') % protocol, + hint=_('upgrade Python or disable setting since ' + 'only TLS 1.0 is supported')) + + return ssl.PROTOCOL_TLSv1, 0 + + # WARNING: returned options don't work unless the modern ssl module + # is available. Be careful when adding options here. + + # SSLv2 and SSLv3 are broken. We ban them outright. + options = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 + + if protocol == 'tls1.0': + # Defaults above are to use TLS 1.0+ + pass + elif protocol == 'tls1.1': + options |= ssl.OP_NO_TLSv1 + elif protocol == 'tls1.2': + options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + else: + raise error.Abort(_('this should not happen')) + + # Prevent CRIME. + # There is no guarantee this attribute is defined on the module. + options |= getattr(ssl, 'OP_NO_COMPRESSION', 0) + + return ssl.PROTOCOL_SSLv23, options + def wrapsocket(sock, keyfile, certfile, ui, serverhostname=None): """Add SSL/TLS to a socket. @@ -306,7 +352,7 @@ def wrapsocket(sock, keyfile, certfile, try: sslsocket = sslcontext.wrap_socket(sock, server_hostname=serverhostname) - except ssl.SSLError: + except ssl.SSLError as e: # If we're doing certificate verification and no CA certs are loaded, # that is almost certainly the reason why verification failed. Provide # a hint to the user. @@ -318,6 +364,13 @@ def wrapsocket(sock, keyfile, certfile, 'were loaded; see ' 'https://mercurial-scm.org/wiki/SecureConnections for ' 'how to configure Mercurial to avoid this error)\n')) + # Try to print more helpful error messages for known failures. + if util.safehasattr(e, 'reason'): + if e.reason == 'UNSUPPORTED_PROTOCOL': + ui.warn(_('(could not negotiate a common protocol; see ' + 'https://mercurial-scm.org/wiki/SecureConnections ' + 'for how to configure Mercurial to avoid this ' + 'error)\n')) raise # check if wrap_socket failed silently because socket had been @@ -349,14 +402,28 @@ def wrapserversocket(sock, ui, certfile= Typically ``cafile`` is only defined if ``requireclientcert`` is true. """ + protocol, options = protocolsettings('tls1.0') + + # This config option is intended for use in tests only. It is a giant + # footgun to kill security. Don't define it. + exactprotocol = ui.config('devel', 'serverexactprotocol') + if exactprotocol == 'tls1.0': + protocol = ssl.PROTOCOL_TLSv1 + elif exactprotocol == 'tls1.1': + protocol = ssl.PROTOCOL_TLSv1_1 + elif exactprotocol == 'tls1.2': + protocol = ssl.PROTOCOL_TLSv1_2 + elif exactprotocol: + raise error.Abort(_('invalid value for serverexactprotocol: %s') % + exactprotocol) + if modernssl: # We /could/ use create_default_context() here since it doesn't load - # CAs when configured for client auth. - sslcontext = SSLContext(ssl.PROTOCOL_SSLv23) - # SSLv2 and SSLv3 are broken. Ban them outright. - sslcontext.options |= OP_NO_SSLv2 | OP_NO_SSLv3 - # Prevent CRIME - sslcontext.options |= getattr(ssl, 'OP_NO_COMPRESSION', 0) + # CAs when configured for client auth. However, it is hard-coded to + # use ssl.PROTOCOL_SSLv23 which may not be appropriate here. + sslcontext = SSLContext(protocol) + sslcontext.options |= options + # Improve forward secrecy. sslcontext.options |= getattr(ssl, 'OP_SINGLE_DH_USE', 0) sslcontext.options |= getattr(ssl, 'OP_SINGLE_ECDH_USE', 0) diff --git a/tests/test-https.t b/tests/test-https.t --- a/tests/test-https.t +++ b/tests/test-https.t @@ -345,11 +345,79 @@ Fingerprints $ hg -R copy-pull id https://127.0.0.1:$HGPORT/ --config hostfingerprints.127.0.0.1=ecd87cd6b386d04fc1b8b41c9d8f5e168eef1c03 5fed3813f7f5 -HGPORT1 is reused below for tinyproxy tests. Kill that server. +Ports used by next test. Kill servers. + + $ killdaemons.py hg0.pid $ killdaemons.py hg1.pid + $ killdaemons.py hg2.pid + +#if sslcontext +Start servers running supported TLS versions + + $ cd test + $ hg serve -p $HGPORT -d --pid-file=../hg0.pid --certificate=$PRIV \ + > --config devel.serverexactprotocol=tls1.0 + $ cat ../hg0.pid >> $DAEMON_PIDS + $ hg serve -p $HGPORT1 -d --pid-file=../hg1.pid --certificate=$PRIV \ + > --config devel.serverexactprotocol=tls1.1 + $ cat ../hg1.pid >> $DAEMON_PIDS + $ hg serve -p $HGPORT2 -d --pid-file=../hg2.pid --certificate=$PRIV \ + > --config devel.serverexactprotocol=tls1.2 + $ cat ../hg2.pid >> $DAEMON_PIDS + $ cd .. + +Clients talking same TLS versions work + + $ P="$CERTSDIR" hg --config hostsecurity.minimumprotocol=tls1.0 id https://localhost:$HGPORT/ + 5fed3813f7f5 + $ P="$CERTSDIR" hg --config hostsecurity.minimumprotocol=tls1.1 id https://localhost:$HGPORT1/ + 5fed3813f7f5 + $ P="$CERTSDIR" hg --config hostsecurity.minimumprotocol=tls1.2 id https://localhost:$HGPORT2/ + 5fed3813f7f5 + +Clients requiring newer TLS version than what server supports fail + + $ P="$CERTSDIR" hg --config hostsecurity.minimumprotocol=tls1.1 id https://localhost:$HGPORT/ + (could not negotiate a common protocol; see https://mercurial-scm.org/wiki/SecureConnections for how to configure Mercurial to avoid this error) + abort: error: *unsupported protocol* (glob) + [255] + $ P="$CERTSDIR" hg --config hostsecurity.minimumprotocol=tls1.2 id https://localhost:$HGPORT/ + (could not negotiate a common protocol; see https://mercurial-scm.org/wiki/SecureConnections for how to configure Mercurial to avoid this error) + abort: error: *unsupported protocol* (glob) + [255] + $ P="$CERTSDIR" hg --config hostsecurity.minimumprotocol=tls1.2 id https://localhost:$HGPORT1/ + (could not negotiate a common protocol; see https://mercurial-scm.org/wiki/SecureConnections for how to configure Mercurial to avoid this error) + abort: error: *unsupported protocol* (glob) + [255] + +The per-host config option overrides the default + + $ P="$CERTSDIR" hg id https://localhost:$HGPORT/ \ + > --config hostsecurity.minimumprotocol=tls1.2 \ + > --config hostsecurity.localhost:minimumprotocol=tls1.0 + 5fed3813f7f5 + +The per-host config option by itself works + + $ P="$CERTSDIR" hg id https://localhost:$HGPORT/ \ + > --config hostsecurity.localhost:minimumprotocol=tls1.2 + (could not negotiate a common protocol; see https://mercurial-scm.org/wiki/SecureConnections for how to configure Mercurial to avoid this error) + abort: error: *unsupported protocol* (glob) + [255] + + $ killdaemons.py hg0.pid + $ killdaemons.py hg1.pid + $ killdaemons.py hg2.pid +#endif Prepare for connecting through proxy + $ hg serve -R test -p $HGPORT -d --pid-file=hg0.pid --certificate=$PRIV + $ cat hg0.pid >> $DAEMON_PIDS + $ hg serve -R test -p $HGPORT2 -d --pid-file=hg2.pid --certificate=server-expired.pem + $ cat hg2.pid >> $DAEMON_PIDS +tinyproxy.py doesn't fully detach, so killing it may result in extra output +from the shell. So don't kill it. $ tinyproxy.py $HGPORT1 localhost >proxy.log &1 & $ while [ ! -f proxy.pid ]; do sleep 0; done $ cat proxy.pid >> $DAEMON_PIDS