sslutil.py
925 lines
| 35.8 KiB
| text/x-python
|
PythonLexer
/ mercurial / sslutil.py
|
r14204 | # sslutil.py - SSL handling for mercurial | ||
# | ||||
|
r47575 | # Copyright 2005, 2006, 2007, 2008 Olivia Mackall <olivia@selenic.com> | ||
|
r14204 | # Copyright 2006, 2007 Alexis S. L. Carvalho <alexis@cecm.usp.br> | ||
# Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com> | ||||
# | ||||
# This software may be used and distributed according to the terms of the | ||||
# GNU General Public License version 2 or any later version. | ||||
|
r25977 | |||
|
r14204 | |||
|
r29341 | import hashlib | ||
|
r25977 | import os | ||
|
r29452 | import re | ||
|
r25977 | import ssl | ||
|
r49930 | import warnings | ||
|
r25977 | |||
from .i18n import _ | ||||
|
r43359 | from .pycompat import getattr | ||
|
r46729 | from .node import hex | ||
|
r28577 | from . import ( | ||
|
r42455 | encoding, | ||
|
r28577 | error, | ||
|
r30639 | pycompat, | ||
|
r28577 | util, | ||
) | ||||
|
r37102 | from .utils import ( | ||
|
r44518 | hashutil, | ||
|
r44067 | resourceutil, | ||
|
r37102 | stringutil, | ||
) | ||||
|
r24291 | |||
|
r28647 | # Python 2.7.9+ overhauled the built-in SSL/TLS features of Python. It added | ||
# support for TLS 1.1, TLS 1.2, SNI, system CA stores, etc. These features are | ||||
# all exposed via the "ssl" module. | ||||
# | ||||
|
r45411 | # We require in setup.py the presence of ssl.SSLContext, which indicates modern | ||
# SSL/TLS support. | ||||
|
r28647 | |||
|
r32291 | configprotocols = { | ||
|
r43347 | b'tls1.0', | ||
b'tls1.1', | ||||
b'tls1.2', | ||||
|
r32291 | } | ||
|
r26622 | |||
|
r29559 | hassni = getattr(ssl, 'HAS_SNI', False) | ||
|
r28648 | |||
|
r45434 | # ssl.HAS_TLSv1* are preferred to check support but they were added in Python | ||
# 3.7. Prior to CPython commit 6e8cda91d92da72800d891b2fc2073ecbc134d98 | ||||
# (backported to the 3.7 branch), ssl.PROTOCOL_TLSv1_1 / ssl.PROTOCOL_TLSv1_2 | ||||
# were defined only if compiled against a OpenSSL version with TLS 1.1 / 1.2 | ||||
# support. At the mentioned commit, they were unconditionally defined. | ||||
supportedprotocols = set() | ||||
if getattr(ssl, 'HAS_TLSv1', util.safehasattr(ssl, 'PROTOCOL_TLSv1')): | ||||
supportedprotocols.add(b'tls1.0') | ||||
if getattr(ssl, 'HAS_TLSv1_1', util.safehasattr(ssl, 'PROTOCOL_TLSv1_1')): | ||||
|
r43347 | supportedprotocols.add(b'tls1.1') | ||
|
r45434 | if getattr(ssl, 'HAS_TLSv1_2', util.safehasattr(ssl, 'PROTOCOL_TLSv1_2')): | ||
|
r43347 | supportedprotocols.add(b'tls1.2') | ||
|
r29601 | |||
|
r43346 | |||
|
r29258 | def _hostsettings(ui, hostname): | ||
"""Obtain security settings for a hostname. | ||||
Returns a dict of settings relevant to that hostname. | ||||
""" | ||||
|
r36760 | bhostname = pycompat.bytesurl(hostname) | ||
|
r29258 | s = { | ||
|
r29288 | # Whether we should attempt to load default/available CA certs | ||
# if an explicit ``cafile`` is not defined. | ||||
|
r43347 | b'allowloaddefaultcerts': True, | ||
|
r29258 | # List of 2-tuple of (hash algorithm, hash). | ||
|
r43347 | b'certfingerprints': [], | ||
|
r29260 | # Path to file containing concatenated CA certs. Used by | ||
# SSLContext.load_verify_locations(). | ||||
|
r43347 | b'cafile': None, | ||
|
r29287 | # Whether certificate verification should be disabled. | ||
|
r43347 | b'disablecertverification': False, | ||
|
r29268 | # Whether the legacy [hostfingerprints] section has data for this host. | ||
|
r43347 | b'legacyfingerprint': False, | ||
|
r29618 | # String representation of minimum protocol to be used for UI | ||
# presentation. | ||||
|
r45435 | b'minimumprotocol': None, | ||
|
r29259 | # ssl.CERT_* constant used by SSLContext.verify_mode. | ||
|
r43347 | b'verifymode': None, | ||
|
r29577 | # OpenSSL Cipher List to use (instead of default). | ||
|
r43347 | b'ciphers': None, | ||
|
r29258 | } | ||
|
r29559 | # Allow minimum TLS protocol to be specified in the config. | ||
def validateprotocol(protocol, key): | ||||
if protocol not in configprotocols: | ||||
raise error.Abort( | ||||
|
r43347 | _(b'unsupported protocol from hostsecurity.%s: %s') | ||
|
r43346 | % (key, protocol), | ||
|
r43347 | hint=_(b'valid protocols: %s') | ||
% b' '.join(sorted(configprotocols)), | ||||
|
r43346 | ) | ||
|
r29507 | |||
|
r45431 | # We default to TLS 1.1+ because TLS 1.0 has known vulnerabilities (like | ||
# BEAST and POODLE). We allow users to downgrade to TLS 1.0+ via config | ||||
# options in case a legacy server is encountered. | ||||
|
r45438 | # setup.py checks that TLS 1.1 or TLS 1.2 is present, so the following | ||
# assert should not fail. | ||||
|
r45431 | assert supportedprotocols - {b'tls1.0'} | ||
defaultminimumprotocol = b'tls1.1' | ||||
|
r29560 | |||
|
r43347 | key = b'minimumprotocol' | ||
|
r45425 | minimumprotocol = ui.config(b'hostsecurity', key, defaultminimumprotocol) | ||
validateprotocol(minimumprotocol, key) | ||||
|
r29508 | |||
|
r43347 | key = b'%s:minimumprotocol' % bhostname | ||
|
r45425 | minimumprotocol = ui.config(b'hostsecurity', key, minimumprotocol) | ||
validateprotocol(minimumprotocol, key) | ||||
|
r29559 | |||
|
r49931 | ciphers = ui.config(b'hostsecurity', b'ciphers') | ||
ciphers = ui.config(b'hostsecurity', b'%s:ciphers' % bhostname, ciphers) | ||||
|
r29617 | # If --insecure is used, we allow the use of TLS 1.0 despite config options. | ||
# We always print a "connection security to %s is disabled..." message when | ||||
# --insecure is used. So no need to print anything more here. | ||||
if ui.insecureconnections: | ||||
|
r45425 | minimumprotocol = b'tls1.0' | ||
|
r49931 | if not ciphers: | ||
ciphers = b'DEFAULT' | ||||
|
r29558 | |||
|
r45435 | s[b'minimumprotocol'] = minimumprotocol | ||
|
r43347 | s[b'ciphers'] = ciphers | ||
|
r29577 | |||
|
r29267 | # Look for fingerprints in [hostsecurity] section. Value is a list | ||
# of <alg>:<fingerprint> strings. | ||||
|
r43347 | fingerprints = ui.configlist( | ||
b'hostsecurity', b'%s:fingerprints' % bhostname | ||||
) | ||||
|
r29267 | for fingerprint in fingerprints: | ||
|
r43347 | if not (fingerprint.startswith((b'sha1:', b'sha256:', b'sha512:'))): | ||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b'invalid fingerprint for %s: %s') % (bhostname, fingerprint), | ||
|
r43387 | hint=_(b'must begin with "sha1:", "sha256:", or "sha512:"'), | ||
|
r43346 | ) | ||
|
r29267 | |||
|
r43347 | alg, fingerprint = fingerprint.split(b':', 1) | ||
fingerprint = fingerprint.replace(b':', b'').lower() | ||||
|
r49322 | # pytype: disable=attribute-error | ||
# `s` is heterogeneous, but this entry is always a list of tuples | ||||
|
r43347 | s[b'certfingerprints'].append((alg, fingerprint)) | ||
|
r49322 | # pytype: enable=attribute-error | ||
|
r29267 | |||
|
r29258 | # Fingerprints from [hostfingerprints] are always SHA-1. | ||
|
r43347 | for fingerprint in ui.configlist(b'hostfingerprints', bhostname): | ||
fingerprint = fingerprint.replace(b':', b'').lower() | ||||
|
r49322 | # pytype: disable=attribute-error | ||
# `s` is heterogeneous, but this entry is always a list of tuples | ||||
|
r43347 | s[b'certfingerprints'].append((b'sha1', fingerprint)) | ||
|
r49322 | # pytype: enable=attribute-error | ||
|
r43347 | s[b'legacyfingerprint'] = True | ||
|
r29258 | |||
|
r29259 | # If a host cert fingerprint is defined, it is the only thing that | ||
# matters. No need to validate CA certs. | ||||
|
r43347 | if s[b'certfingerprints']: | ||
s[b'verifymode'] = ssl.CERT_NONE | ||||
s[b'allowloaddefaultcerts'] = False | ||||
|
r29259 | |||
# If --insecure is used, don't take CAs into consideration. | ||||
elif ui.insecureconnections: | ||||
|
r43347 | s[b'disablecertverification'] = True | ||
s[b'verifymode'] = ssl.CERT_NONE | ||||
s[b'allowloaddefaultcerts'] = False | ||||
|
r29259 | |||
|
r43347 | if ui.configbool(b'devel', b'disableloaddefaultcerts'): | ||
s[b'allowloaddefaultcerts'] = False | ||||
|
r29288 | |||
|
r29334 | # If both fingerprints and a per-host ca file are specified, issue a warning | ||
# because users should not be surprised about what security is or isn't | ||||
# being performed. | ||||
|
r43347 | cafile = ui.config(b'hostsecurity', b'%s:verifycertsfile' % bhostname) | ||
if s[b'certfingerprints'] and cafile: | ||||
|
r43346 | ui.warn( | ||
_( | ||||
|
r43347 | b'(hostsecurity.%s:verifycertsfile ignored when host ' | ||
b'fingerprints defined; using host fingerprints for ' | ||||
b'verification)\n' | ||||
|
r43346 | ) | ||
% bhostname | ||||
) | ||||
|
r29334 | |||
|
r29260 | # Try to hook up CA certificate validation unless something above | ||
# makes it not necessary. | ||||
|
r43347 | if s[b'verifymode'] is None: | ||
|
r29334 | # Look at per-host ca file first. | ||
|
r29260 | if cafile: | ||
cafile = util.expandpath(cafile) | ||||
if not os.path.exists(cafile): | ||||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b'path specified by %s does not exist: %s') | ||
% ( | ||||
b'hostsecurity.%s:verifycertsfile' % (bhostname,), | ||||
cafile, | ||||
) | ||||
|
r43346 | ) | ||
|
r43347 | s[b'cafile'] = cafile | ||
|
r29260 | else: | ||
|
r29334 | # Find global certificates file in config. | ||
|
r43347 | cafile = ui.config(b'web', b'cacerts') | ||
|
r29334 | |||
|
r29260 | if cafile: | ||
|
r29334 | cafile = util.expandpath(cafile) | ||
if not os.path.exists(cafile): | ||||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b'could not find web.cacerts: %s') % cafile | ||
|
r43346 | ) | ||
|
r43347 | elif s[b'allowloaddefaultcerts']: | ||
|
r29482 | # CAs not defined in config. Try to find system bundles. | ||
|
r29483 | cafile = _defaultcacerts(ui) | ||
|
r29334 | if cafile: | ||
|
r43347 | ui.debug(b'using %s for CA file\n' % cafile) | ||
|
r29260 | |||
|
r43347 | s[b'cafile'] = cafile | ||
|
r29260 | |||
# Require certificate validation if CA certs are being loaded and | ||||
# verification hasn't been disabled above. | ||||
|
r45416 | if cafile or s[b'allowloaddefaultcerts']: | ||
|
r43347 | s[b'verifymode'] = ssl.CERT_REQUIRED | ||
|
r29260 | else: | ||
# At this point we don't have a fingerprint, aren't being | ||||
# explicitly insecure, and can't load CA certs. Connecting | ||||
|
r29411 | # is insecure. We allow the connection and abort during | ||
# validation (once we have the fingerprint to print to the | ||||
# user). | ||||
|
r43347 | s[b'verifymode'] = ssl.CERT_NONE | ||
|
r29260 | |||
|
r43347 | assert s[b'verifymode'] is not None | ||
|
r29259 | |||
|
r29258 | return s | ||
|
r43346 | |||
|
r45437 | def commonssloptions(minimumprotocol): | ||
|
r46554 | """Return SSLContext options common to servers and clients.""" | ||
|
r45425 | if minimumprotocol not in configprotocols: | ||
raise ValueError(b'protocol value not supported: %s' % minimumprotocol) | ||||
|
r29559 | |||
# SSLv2 and SSLv3 are broken. We ban them outright. | ||||
options = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ||||
|
r45425 | if minimumprotocol == b'tls1.0': | ||
|
r29559 | # Defaults above are to use TLS 1.0+ | ||
pass | ||||
|
r45425 | elif minimumprotocol == b'tls1.1': | ||
|
r29559 | options |= ssl.OP_NO_TLSv1 | ||
|
r45425 | elif minimumprotocol == b'tls1.2': | ||
|
r29559 | options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ||
else: | ||||
|
r43347 | raise error.Abort(_(b'this should not happen')) | ||
|
r29559 | |||
# Prevent CRIME. | ||||
# There is no guarantee this attribute is defined on the module. | ||||
options |= getattr(ssl, 'OP_NO_COMPRESSION', 0) | ||||
|
r45437 | return options | ||
|
r29559 | |||
|
r43346 | |||
|
r29249 | def wrapsocket(sock, keyfile, certfile, ui, serverhostname=None): | ||
|
r28653 | """Add SSL/TLS to a socket. | ||
This is a glorified wrapper for ``ssl.wrap_socket()``. It makes sane | ||||
choices based on what security options are available. | ||||
In addition to the arguments supported by ``ssl.wrap_socket``, we allow | ||||
the following additional arguments: | ||||
* serverhostname - The expected hostname of the remote server. If the | ||||
server (and client) support SNI, this tells the server which certificate | ||||
to use. | ||||
""" | ||||
|
r29224 | if not serverhostname: | ||
|
r43347 | raise error.Abort(_(b'serverhostname argument is required')) | ||
|
r29224 | |||
|
r42455 | if b'SSLKEYLOGFILE' in encoding.environ: | ||
try: | ||||
|
r47543 | import sslkeylog # pytype: disable=import-error | ||
|
r43346 | |||
sslkeylog.set_keylog( | ||||
pycompat.fsdecode(encoding.environ[b'SSLKEYLOGFILE']) | ||||
) | ||||
|
r43350 | ui.warnnoi18n( | ||
|
r43346 | b'sslkeylog enabled by SSLKEYLOGFILE environment variable\n' | ||
) | ||||
|
r42455 | except ImportError: | ||
|
r43350 | ui.warnnoi18n( | ||
|
r43346 | b'sslkeylog module missing, ' | ||
b'but SSLKEYLOGFILE set in environment\n' | ||||
) | ||||
|
r42455 | |||
|
r33381 | for f in (keyfile, certfile): | ||
if f and not os.path.exists(f): | ||||
|
r36762 | raise error.Abort( | ||
|
r43347 | _(b'certificate file (%s) does not exist; cannot connect to %s') | ||
|
r36762 | % (f, pycompat.bytesurl(serverhostname)), | ||
|
r43346 | hint=_( | ||
|
r43347 | b'restore missing file or fix references ' | ||
b'in Mercurial config' | ||||
|
r43346 | ), | ||
) | ||||
|
r33381 | |||
|
r29259 | settings = _hostsettings(ui, serverhostname) | ||
|
r29249 | |||
|
r29557 | # We can't use ssl.create_default_context() because it calls | ||
# load_default_certs() unless CA arguments are passed to it. We want to | ||||
# have explicit control over CA loading because implicitly loading | ||||
# CAs may undermine the user's intent. For example, a user may define a CA | ||||
# bundle with a specific CA cert removed. If the system/default CA bundle | ||||
# is loaded and contains that removed CA, you've just undone the user's | ||||
# choice. | ||||
|
r49930 | |||
|
r50277 | if util.safehasattr(ssl, 'TLSVersion'): | ||
|
r49930 | # python 3.7+ | ||
sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | ||||
minimumprotocol = settings[b'minimumprotocol'] | ||||
if minimumprotocol == b'tls1.0': | ||||
with warnings.catch_warnings(): | ||||
warnings.filterwarnings( | ||||
'ignore', | ||||
'ssl.TLSVersion.TLSv1 is deprecated', | ||||
DeprecationWarning, | ||||
) | ||||
sslcontext.minimum_version = ssl.TLSVersion.TLSv1 | ||||
elif minimumprotocol == b'tls1.1': | ||||
with warnings.catch_warnings(): | ||||
warnings.filterwarnings( | ||||
'ignore', | ||||
'ssl.TLSVersion.TLSv1_1 is deprecated', | ||||
DeprecationWarning, | ||||
) | ||||
sslcontext.minimum_version = ssl.TLSVersion.TLSv1_1 | ||||
elif minimumprotocol == b'tls1.2': | ||||
sslcontext.minimum_version = ssl.TLSVersion.TLSv1_2 | ||||
else: | ||||
raise error.Abort(_(b'this should not happen')) | ||||
# Prevent CRIME. | ||||
# There is no guarantee this attribute is defined on the module. | ||||
sslcontext.options |= getattr(ssl, 'OP_NO_COMPRESSION', 0) | ||||
else: | ||||
# Despite its name, PROTOCOL_SSLv23 selects the highest protocol that both | ||||
# ends support, including TLS protocols. commonssloptions() restricts the | ||||
# set of allowed protocols. | ||||
sslcontext = ssl.SSLContext(ssl.PROTOCOL_SSLv23) | ||||
sslcontext.options |= commonssloptions(settings[b'minimumprotocol']) | ||||
# We check the hostname ourselves in _verifycert | ||||
sslcontext.check_hostname = False | ||||
|
r43347 | sslcontext.verify_mode = settings[b'verifymode'] | ||
|
r28848 | |||
|
r43347 | if settings[b'ciphers']: | ||
|
r29577 | try: | ||
|
r43347 | sslcontext.set_ciphers(pycompat.sysstr(settings[b'ciphers'])) | ||
|
r29577 | except ssl.SSLError as e: | ||
|
r36762 | raise error.Abort( | ||
|
r43347 | _(b'could not set ciphers: %s') | ||
|
r37102 | % stringutil.forcebytestr(e.args[0]), | ||
|
r43347 | hint=_(b'change cipher string (%s) in config') | ||
% settings[b'ciphers'], | ||||
|
r43346 | ) | ||
|
r29577 | |||
|
r28652 | if certfile is not None: | ||
|
r43346 | |||
|
r28652 | def password(): | ||
f = keyfile or certfile | ||||
|
r43347 | return ui.getpass(_(b'passphrase for %s: ') % f, b'') | ||
|
r43346 | |||
|
r28652 | sslcontext.load_cert_chain(certfile, keyfile, password) | ||
|
r28848 | |||
|
r43347 | if settings[b'cafile'] is not None: | ||
|
r29446 | try: | ||
|
r43347 | sslcontext.load_verify_locations(cafile=settings[b'cafile']) | ||
|
r29446 | except ssl.SSLError as e: | ||
|
r43346 | if len(e.args) == 1: # pypy has different SSLError args | ||
|
r29927 | msg = e.args[0] | ||
else: | ||||
msg = e.args[1] | ||||
|
r43346 | raise error.Abort( | ||
|
r43347 | _(b'error loading CA file %s: %s') | ||
% (settings[b'cafile'], stringutil.forcebytestr(msg)), | ||||
hint=_(b'file is empty or malformed?'), | ||||
|
r43346 | ) | ||
|
r29113 | caloaded = True | ||
|
r43347 | elif settings[b'allowloaddefaultcerts']: | ||
|
r28652 | # This is a no-op on old Python. | ||
sslcontext.load_default_certs() | ||||
|
r29288 | caloaded = True | ||
else: | ||||
caloaded = False | ||||
|
r23834 | |||
|
r29449 | try: | ||
sslsocket = sslcontext.wrap_socket(sock, server_hostname=serverhostname) | ||||
|
r29559 | except ssl.SSLError as e: | ||
|
r29449 | # 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. | ||||
|
r31725 | # The exception handler is here to handle bugs around cert attributes: | ||
# https://bugs.python.org/issue20916#msg213479. (See issues5313.) | ||||
# When the main 20916 bug occurs, 'sslcontext.get_ca_certs()' is a | ||||
# non-empty list, but the following conditional is otherwise True. | ||||
|
r29631 | try: | ||
|
r43346 | if ( | ||
caloaded | ||||
|
r43347 | and settings[b'verifymode'] == ssl.CERT_REQUIRED | ||
|
r43346 | and not sslcontext.get_ca_certs() | ||
): | ||||
ui.warn( | ||||
_( | ||||
|
r43347 | b'(an attempt was made to load CA certificates but ' | ||
b'none were loaded; see ' | ||||
b'https://mercurial-scm.org/wiki/SecureConnections ' | ||||
b'for how to configure Mercurial to avoid this ' | ||||
b'error)\n' | ||||
|
r43346 | ) | ||
) | ||||
|
r29631 | except ssl.SSLError: | ||
pass | ||||
|
r41455 | |||
|
r29559 | # Try to print more helpful error messages for known failures. | ||
|
r43347 | if util.safehasattr(e, b'reason'): | ||
|
r29619 | # This error occurs when the client and server don't share a | ||
# common/supported SSL/TLS protocol. We've disabled SSLv2 and SSLv3 | ||||
# outright. Hopefully the reason for this error is that we require | ||||
# TLS 1.1+ and the server only supports TLS 1.0. Whatever the | ||||
# reason, try to emit an actionable warning. | ||||
|
r49933 | if e.reason in ( | ||
'UNSUPPORTED_PROTOCOL', | ||||
'TLSV1_ALERT_PROTOCOL_VERSION', | ||||
): | ||||
|
r29619 | # We attempted TLS 1.0+. | ||
|
r45435 | if settings[b'minimumprotocol'] == b'tls1.0': | ||
|
r29619 | # We support more than just TLS 1.0+. If this happens, | ||
# the likely scenario is either the client or the server | ||||
# is really old. (e.g. server doesn't support TLS 1.0+ or | ||||
# client doesn't support modern TLS versions introduced | ||||
# several years from when this comment was written). | ||||
|
r43347 | if supportedprotocols != {b'tls1.0'}: | ||
|
r43346 | ui.warn( | ||
_( | ||||
|
r43347 | b'(could not communicate with %s using security ' | ||
b'protocols %s; if you are using a modern Mercurial ' | ||||
b'version, consider contacting the operator of this ' | ||||
b'server; see ' | ||||
b'https://mercurial-scm.org/wiki/SecureConnections ' | ||||
b'for more info)\n' | ||||
|
r43346 | ) | ||
% ( | ||||
|
r41456 | pycompat.bytesurl(serverhostname), | ||
|
r43347 | b', '.join(sorted(supportedprotocols)), | ||
|
r43346 | ) | ||
) | ||||
|
r29619 | else: | ||
|
r43346 | ui.warn( | ||
_( | ||||
|
r43347 | b'(could not communicate with %s using TLS 1.0; the ' | ||
b'likely cause of this is the server no longer ' | ||||
b'supports TLS 1.0 because it has known security ' | ||||
b'vulnerabilities; see ' | ||||
b'https://mercurial-scm.org/wiki/SecureConnections ' | ||||
b'for more info)\n' | ||||
|
r43346 | ) | ||
% pycompat.bytesurl(serverhostname) | ||||
) | ||||
|
r29619 | else: | ||
# We attempted TLS 1.1+. We can only get here if the client | ||||
# supports the configured protocol. So the likely reason is | ||||
# the client wants better security than the server can | ||||
# offer. | ||||
|
r43346 | ui.warn( | ||
_( | ||||
|
r43347 | b'(could not negotiate a common security protocol (%s+) ' | ||
b'with %s; the likely cause is Mercurial is configured ' | ||||
b'to be more secure than the server can support)\n' | ||||
|
r43346 | ) | ||
% ( | ||||
|
r45435 | settings[b'minimumprotocol'], | ||
|
r43346 | pycompat.bytesurl(serverhostname), | ||
) | ||||
) | ||||
ui.warn( | ||||
_( | ||||
|
r43347 | b'(consider contacting the operator of this ' | ||
b'server and ask them to support modern TLS ' | ||||
b'protocol versions; or, set ' | ||||
b'hostsecurity.%s:minimumprotocol=tls1.0 to allow ' | ||||
b'use of legacy, less secure protocols when ' | ||||
b'communicating with this server)\n' | ||||
|
r43346 | ) | ||
% pycompat.bytesurl(serverhostname) | ||||
) | ||||
ui.warn( | ||||
_( | ||||
|
r43347 | b'(see https://mercurial-scm.org/wiki/SecureConnections ' | ||
b'for more info)\n' | ||||
|
r43346 | ) | ||
) | ||||
|
r33494 | |||
|
r43906 | elif e.reason == 'CERTIFICATE_VERIFY_FAILED' and pycompat.iswindows: | ||
|
r33494 | |||
|
r43346 | ui.warn( | ||
_( | ||||
|
r43347 | b'(the full certificate chain may not be available ' | ||
b'locally; see "hg help debugssl")\n' | ||||
|
r43346 | ) | ||
) | ||||
|
r29449 | raise | ||
|
r28652 | # check if wrap_socket failed silently because socket had been | ||
# closed | ||||
# - see http://bugs.python.org/issue13721 | ||||
if not sslsocket.cipher(): | ||||
|
r46527 | raise error.SecurityError(_(b'ssl connection failed')) | ||
|
r29113 | |||
|
r29225 | sslsocket._hgstate = { | ||
|
r43347 | b'caloaded': caloaded, | ||
b'hostname': serverhostname, | ||||
b'settings': settings, | ||||
b'ui': ui, | ||||
|
r29225 | } | ||
|
r29113 | |||
|
r28652 | return sslsocket | ||
|
r14204 | |||
|
r43346 | |||
def wrapserversocket( | ||||
sock, ui, certfile=None, keyfile=None, cafile=None, requireclientcert=False | ||||
): | ||||
|
r29554 | """Wrap a socket for use by servers. | ||
``certfile`` and ``keyfile`` specify the files containing the certificate's | ||||
public and private keys, respectively. Both keys can be defined in the same | ||||
file via ``certfile`` (the private key must come first in the file). | ||||
``cafile`` defines the path to certificate authorities. | ||||
``requireclientcert`` specifies whether to require client certificates. | ||||
Typically ``cafile`` is only defined if ``requireclientcert`` is true. | ||||
""" | ||||
|
r33381 | # This function is not used much by core Mercurial, so the error messaging | ||
# doesn't have to be as detailed as for wrapsocket(). | ||||
for f in (certfile, keyfile, cafile): | ||||
if f and not os.path.exists(f): | ||||
|
r43346 | raise error.Abort( | ||
|
r43387 | _(b'referenced certificate file (%s) does not exist') % f | ||
|
r43346 | ) | ||
|
r33381 | |||
|
r50278 | if util.safehasattr(ssl, 'TLSVersion'): | ||
|
r49930 | # python 3.7+ | ||
sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) | ||||
sslcontext.options |= getattr(ssl, 'OP_NO_COMPRESSION', 0) | ||||
|
r29559 | |||
|
r49930 | # This config option is intended for use in tests only. It is a giant | ||
# footgun to kill security. Don't define it. | ||||
|
r51293 | exactprotocol = ui.config(b'devel', b'server-insecure-exact-protocol') | ||
|
r49930 | if exactprotocol == b'tls1.0': | ||
if b'tls1.0' not in supportedprotocols: | ||||
raise error.Abort(_(b'TLS 1.0 not supported by this Python')) | ||||
with warnings.catch_warnings(): | ||||
warnings.filterwarnings( | ||||
'ignore', | ||||
'ssl.TLSVersion.TLSv1 is deprecated', | ||||
DeprecationWarning, | ||||
) | ||||
sslcontext.minimum_version = ssl.TLSVersion.TLSv1 | ||||
sslcontext.maximum_version = ssl.TLSVersion.TLSv1 | ||||
elif exactprotocol == b'tls1.1': | ||||
if b'tls1.1' not in supportedprotocols: | ||||
raise error.Abort(_(b'TLS 1.1 not supported by this Python')) | ||||
with warnings.catch_warnings(): | ||||
warnings.filterwarnings( | ||||
'ignore', | ||||
'ssl.TLSVersion.TLSv1_1 is deprecated', | ||||
DeprecationWarning, | ||||
) | ||||
sslcontext.minimum_version = ssl.TLSVersion.TLSv1_1 | ||||
sslcontext.maximum_version = ssl.TLSVersion.TLSv1_1 | ||||
elif exactprotocol == b'tls1.2': | ||||
if b'tls1.2' not in supportedprotocols: | ||||
raise error.Abort(_(b'TLS 1.2 not supported by this Python')) | ||||
sslcontext.minimum_version = ssl.TLSVersion.TLSv1_2 | ||||
sslcontext.maximum_version = ssl.TLSVersion.TLSv1_2 | ||||
elif exactprotocol: | ||||
raise error.Abort( | ||||
|
r51293 | _(b'invalid value for server-insecure-exact-protocol: %s') | ||
% exactprotocol | ||||
|
r49930 | ) | ||
else: | ||||
# Despite its name, PROTOCOL_SSLv23 selects the highest protocol that both | ||||
# ends support, including TLS protocols. commonssloptions() restricts the | ||||
# set of allowed protocols. | ||||
protocol = ssl.PROTOCOL_SSLv23 | ||||
options = commonssloptions(b'tls1.0') | ||||
|
r29559 | |||
|
r49930 | # This config option is intended for use in tests only. It is a giant | ||
# footgun to kill security. Don't define it. | ||||
|
r51293 | exactprotocol = ui.config(b'devel', b'server-insecure-exact-protocol') | ||
|
r49930 | if exactprotocol == b'tls1.0': | ||
if b'tls1.0' not in supportedprotocols: | ||||
raise error.Abort(_(b'TLS 1.0 not supported by this Python')) | ||||
protocol = ssl.PROTOCOL_TLSv1 | ||||
elif exactprotocol == b'tls1.1': | ||||
if b'tls1.1' not in supportedprotocols: | ||||
raise error.Abort(_(b'TLS 1.1 not supported by this Python')) | ||||
protocol = ssl.PROTOCOL_TLSv1_1 | ||||
elif exactprotocol == b'tls1.2': | ||||
if b'tls1.2' not in supportedprotocols: | ||||
raise error.Abort(_(b'TLS 1.2 not supported by this Python')) | ||||
protocol = ssl.PROTOCOL_TLSv1_2 | ||||
elif exactprotocol: | ||||
raise error.Abort( | ||||
|
r51293 | _(b'invalid value for server-insecure-exact-protocol: %s') | ||
% exactprotocol | ||||
|
r49930 | ) | ||
# We /could/ use create_default_context() here since it doesn't load | ||||
# CAs when configured for client auth. However, it is hard-coded to | ||||
# use ssl.PROTOCOL_SSLv23 which may not be appropriate here. | ||||
sslcontext = ssl.SSLContext(protocol) | ||||
sslcontext.options |= options | ||||
|
r29559 | |||
|
r45414 | # Improve forward secrecy. | ||
sslcontext.options |= getattr(ssl, 'OP_SINGLE_DH_USE', 0) | ||||
sslcontext.options |= getattr(ssl, 'OP_SINGLE_ECDH_USE', 0) | ||||
|
r29554 | |||
|
r49931 | # In tests, allow insecure ciphers | ||
# Otherwise, use the list of more secure ciphers if found in the ssl module. | ||||
if exactprotocol: | ||||
sslcontext.set_ciphers('DEFAULT') | ||||
elif util.safehasattr(ssl, b'_RESTRICTED_SERVER_CIPHERS'): | ||||
|
r45414 | sslcontext.options |= getattr(ssl, 'OP_CIPHER_SERVER_PREFERENCE', 0) | ||
|
r47544 | # pytype: disable=module-attr | ||
|
r45414 | sslcontext.set_ciphers(ssl._RESTRICTED_SERVER_CIPHERS) | ||
|
r47544 | # pytype: enable=module-attr | ||
|
r29554 | |||
if requireclientcert: | ||||
sslcontext.verify_mode = ssl.CERT_REQUIRED | ||||
else: | ||||
sslcontext.verify_mode = ssl.CERT_NONE | ||||
if certfile or keyfile: | ||||
sslcontext.load_cert_chain(certfile=certfile, keyfile=keyfile) | ||||
if cafile: | ||||
sslcontext.load_verify_locations(cafile=cafile) | ||||
return sslcontext.wrap_socket(sock, server_side=True) | ||||
|
r43346 | |||
|
r29452 | class wildcarderror(Exception): | ||
"""Represents an error parsing wildcards in DNS name.""" | ||||
|
r43346 | |||
|
r29452 | def _dnsnamematch(dn, hostname, maxwildcards=1): | ||
"""Match DNS names according RFC 6125 section 6.4.3. | ||||
This code is effectively copied from CPython's ssl._dnsname_match. | ||||
Returns a bool indicating whether the expected hostname matches | ||||
the value in ``dn``. | ||||
""" | ||||
pats = [] | ||||
if not dn: | ||||
return False | ||||
|
r36760 | dn = pycompat.bytesurl(dn) | ||
hostname = pycompat.bytesurl(hostname) | ||||
|
r29452 | |||
|
r43347 | pieces = dn.split(b'.') | ||
|
r29452 | leftmost = pieces[0] | ||
remainder = pieces[1:] | ||||
|
r43347 | wildcards = leftmost.count(b'*') | ||
|
r29452 | if wildcards > maxwildcards: | ||
raise wildcarderror( | ||||
|
r43347 | _(b'too many wildcards in certificate DNS name: %s') % dn | ||
|
r43346 | ) | ||
|
r29452 | |||
# speed up common case w/o wildcards | ||||
if not wildcards: | ||||
return dn.lower() == hostname.lower() | ||||
# RFC 6125, section 6.4.3, subitem 1. | ||||
# The client SHOULD NOT attempt to match a presented identifier in which | ||||
# the wildcard character comprises a label other than the left-most label. | ||||
|
r43347 | if leftmost == b'*': | ||
|
r29452 | # When '*' is a fragment by itself, it matches a non-empty dotless | ||
# fragment. | ||||
|
r43347 | pats.append(b'[^.]+') | ||
elif leftmost.startswith(b'xn--') or hostname.startswith(b'xn--'): | ||||
|
r29452 | # RFC 6125, section 6.4.3, subitem 3. | ||
# The client SHOULD NOT attempt to match a presented identifier | ||||
# where the wildcard character is embedded within an A-label or | ||||
# U-label of an internationalized domain name. | ||||
|
r38494 | pats.append(stringutil.reescape(leftmost)) | ||
|
r29452 | else: | ||
# Otherwise, '*' matches any dotless string, e.g. www* | ||||
|
r43347 | pats.append(stringutil.reescape(leftmost).replace(br'\*', b'[^.]*')) | ||
|
r29452 | |||
# add the remaining fragments, ignore any wildcards | ||||
for frag in remainder: | ||||
|
r38494 | pats.append(stringutil.reescape(frag)) | ||
|
r29452 | |||
|
r37684 | pat = re.compile(br'\A' + br'\.'.join(pats) + br'\Z', re.IGNORECASE) | ||
|
r29452 | return pat.match(hostname) is not None | ||
|
r43346 | |||
|
r14204 | def _verifycert(cert, hostname): | ||
|
r46554 | """Verify that cert (in socket.getpeercert() format) matches hostname. | ||
|
r14204 | CRLs is not handled. | ||
Returns error message if any problems are found and None on success. | ||||
|
r46554 | """ | ||
|
r14204 | if not cert: | ||
|
r43347 | return _(b'no certificate received') | ||
|
r14204 | |||
|
r29452 | dnsnames = [] | ||
|
r43906 | san = cert.get('subjectAltName', []) | ||
|
r29452 | for key, value in san: | ||
|
r43906 | if key == 'DNS': | ||
|
r29452 | try: | ||
if _dnsnamematch(value, hostname): | ||||
return | ||||
except wildcarderror as e: | ||||
|
r37102 | return stringutil.forcebytestr(e.args[0]) | ||
|
r29452 | |||
dnsnames.append(value) | ||||
|
r14204 | |||
|
r29452 | if not dnsnames: | ||
# The subject is only checked when there is no DNS in subjectAltName. | ||||
|
r43906 | for sub in cert.get('subject', []): | ||
|
r29452 | for key, value in sub: | ||
# According to RFC 2818 the most specific Common Name must | ||||
# be used. | ||||
|
r43906 | if key == 'commonName': | ||
|
r30332 | # 'subject' entries are unicode. | ||
|
r29452 | try: | ||
value = value.encode('ascii') | ||||
except UnicodeEncodeError: | ||||
|
r43347 | return _(b'IDN in certificate not supported') | ||
|
r29452 | |||
try: | ||||
if _dnsnamematch(value, hostname): | ||||
return | ||||
except wildcarderror as e: | ||||
|
r37102 | return stringutil.forcebytestr(e.args[0]) | ||
|
r29452 | |||
dnsnames.append(value) | ||||
|
r37889 | dnsnames = [pycompat.bytesurl(d) for d in dnsnames] | ||
|
r29452 | if len(dnsnames) > 1: | ||
|
r43347 | return _(b'certificate is for %s') % b', '.join(dnsnames) | ||
|
r29452 | elif len(dnsnames) == 1: | ||
|
r43347 | return _(b'certificate is for %s') % dnsnames[0] | ||
|
r29452 | else: | ||
|
r43347 | return _(b'no commonName or subjectAltName found in certificate') | ||
|
r14204 | |||
|
r43346 | |||
|
r23042 | def _plainapplepython(): | ||
"""return true if this seems to be a pure Apple Python that | ||||
* is unfrozen and presumably has the whole mercurial module in the file | ||||
system | ||||
* presumably is an Apple Python that uses Apple OpenSSL which has patches | ||||
for using system certificate store CAs in addition to the provided | ||||
cacerts file | ||||
""" | ||||
|
r43346 | if ( | ||
not pycompat.isdarwin | ||||
|
r44067 | or resourceutil.mainfrozen() | ||
|
r43346 | or not pycompat.sysexecutable | ||
): | ||||
|
r23042 | return False | ||
|
r30669 | exe = os.path.realpath(pycompat.sysexecutable).lower() | ||
|
r43347 | return exe.startswith(b'/usr/bin/python') or exe.startswith( | ||
b'/system/library/frameworks/python.framework/' | ||||
|
r43346 | ) | ||
|
r23042 | |||
|
r29483 | def _defaultcacerts(ui): | ||
|
r29488 | """return path to default CA certificates or None. | ||
It is assumed this function is called when the returned certificates | ||||
file will actually be used to validate connections. Therefore this | ||||
function may print warnings or debug messages assuming this usage. | ||||
|
r29500 | |||
We don't print a message when the Python is able to load default | ||||
CA certs because this scenario is detected at socket connect time. | ||||
|
r29488 | """ | ||
|
r30228 | # The "certifi" Python package provides certificates. If it is installed | ||
# and usable, assume the user intends it to be used and use it. | ||||
|
r29486 | try: | ||
import certifi | ||||
|
r43346 | |||
|
r29486 | certs = certifi.where() | ||
|
r30228 | if os.path.exists(certs): | ||
|
r43347 | ui.debug(b'using ca certificates from certifi\n') | ||
|
r42450 | return pycompat.fsencode(certs) | ||
|
r30228 | except (ImportError, AttributeError): | ||
|
r29486 | pass | ||
|
r29487 | # Apple's OpenSSL has patches that allow a specially constructed certificate | ||
# to load the system CA store. If we're running on Apple Python, use this | ||||
# trick. | ||||
|
r24288 | if _plainapplepython(): | ||
|
r31074 | dummycert = os.path.join( | ||
|
r43347 | os.path.dirname(pycompat.fsencode(__file__)), b'dummycert.pem' | ||
|
r43346 | ) | ||
|
r24288 | if os.path.exists(dummycert): | ||
return dummycert | ||||
|
r29107 | |||
return None | ||||
|
r24288 | |||
|
r43346 | |||
|
r29286 | def validatesocket(sock): | ||
|
r30332 | """Validate a socket meets security requirements. | ||
|
r18879 | |||
|
r29227 | The passed socket must have been created with ``wrapsocket()``. | ||
""" | ||||
|
r43347 | shost = sock._hgstate[b'hostname'] | ||
|
r36760 | host = pycompat.bytesurl(shost) | ||
|
r43347 | ui = sock._hgstate[b'ui'] | ||
settings = sock._hgstate[b'settings'] | ||||
|
r18879 | |||
|
r29227 | try: | ||
peercert = sock.getpeercert(True) | ||||
peercert2 = sock.getpeercert() | ||||
except AttributeError: | ||||
|
r46527 | raise error.SecurityError(_(b'%s ssl connection error') % host) | ||
|
r24288 | |||
|
r29227 | if not peercert: | ||
|
r46527 | raise error.SecurityError( | ||
|
r43387 | _(b'%s certificate error: no certificate received') % host | ||
|
r43346 | ) | ||
|
r18879 | |||
|
r43347 | if settings[b'disablecertverification']: | ||
|
r29289 | # We don't print the certificate fingerprint because it shouldn't | ||
# be necessary: if the user requested certificate verification be | ||||
# disabled, they presumably already saw a message about the inability | ||||
# to verify the certificate and this message would have printed the | ||||
# fingerprint. So printing the fingerprint here adds little to no | ||||
# value. | ||||
|
r43346 | ui.warn( | ||
_( | ||||
|
r43347 | b'warning: connection security to %s is disabled per current ' | ||
b'settings; communication is susceptible to eavesdropping ' | ||||
b'and tampering\n' | ||||
|
r43346 | ) | ||
% host | ||||
) | ||||
|
r29289 | return | ||
|
r18879 | |||
|
r29227 | # If a certificate fingerprint is pinned, use it and only it to | ||
# validate the remote cert. | ||||
|
r29262 | peerfingerprints = { | ||
|
r46729 | b'sha1': hex(hashutil.sha1(peercert).digest()), | ||
b'sha256': hex(hashlib.sha256(peercert).digest()), | ||||
b'sha512': hex(hashlib.sha512(peercert).digest()), | ||||
|
r29262 | } | ||
|
r18879 | |||
|
r29290 | def fmtfingerprint(s): | ||
|
r43347 | return b':'.join([s[x : x + 2] for x in range(0, len(s), 2)]) | ||
|
r29290 | |||
|
r43347 | nicefingerprint = b'sha256:%s' % fmtfingerprint(peerfingerprints[b'sha256']) | ||
|
r28850 | |||
|
r43347 | if settings[b'certfingerprints']: | ||
for hash, fingerprint in settings[b'certfingerprints']: | ||||
|
r29262 | if peerfingerprints[hash].lower() == fingerprint: | ||
|
r43346 | ui.debug( | ||
|
r43347 | b'%s certificate matched fingerprint %s:%s\n' | ||
|
r43346 | % (host, hash, fmtfingerprint(fingerprint)) | ||
) | ||||
|
r43347 | if settings[b'legacyfingerprint']: | ||
|
r43346 | ui.warn( | ||
_( | ||||
|
r43347 | b'(SHA-1 fingerprint for %s found in legacy ' | ||
b'[hostfingerprints] section; ' | ||||
b'if you trust this fingerprint, remove the old ' | ||||
b'SHA-1 fingerprint from [hostfingerprints] and ' | ||||
b'add the following entry to the new ' | ||||
b'[hostsecurity] section: %s:fingerprints=%s)\n' | ||||
|
r43346 | ) | ||
% (host, host, nicefingerprint) | ||||
) | ||||
|
r29291 | return | ||
|
r28850 | |||
|
r29293 | # Pinned fingerprint didn't match. This is a fatal error. | ||
|
r43347 | if settings[b'legacyfingerprint']: | ||
section = b'hostfingerprint' | ||||
nice = fmtfingerprint(peerfingerprints[b'sha1']) | ||||
|
r29293 | else: | ||
|
r43347 | section = b'hostsecurity' | ||
nice = b'%s:%s' % (hash, fmtfingerprint(peerfingerprints[hash])) | ||||
|
r46527 | raise error.SecurityError( | ||
|
r43387 | _(b'certificate for %s has unexpected fingerprint %s') | ||
|
r43346 | % (host, nice), | ||
|
r43347 | hint=_(b'check %s configuration') % section, | ||
|
r43346 | ) | ||
|
r28850 | |||
|
r29411 | # Security is enabled but no CAs are loaded. We can't establish trust | ||
# for the cert so abort. | ||||
|
r43347 | if not sock._hgstate[b'caloaded']: | ||
|
r46527 | raise error.SecurityError( | ||
|
r43346 | _( | ||
|
r43347 | b'unable to verify security of %s (no loaded CA certificates); ' | ||
b'refusing to connect' | ||||
|
r43346 | ) | ||
% host, | ||||
hint=_( | ||||
|
r43347 | b'see https://mercurial-scm.org/wiki/SecureConnections for ' | ||
b'how to configure Mercurial to avoid this error or set ' | ||||
b'hostsecurity.%s:fingerprints=%s to trust this server' | ||||
|
r43346 | ) | ||
% (host, nicefingerprint), | ||||
) | ||||
|
r29113 | |||
|
r36760 | msg = _verifycert(peercert2, shost) | ||
|
r29227 | if msg: | ||
|
r46527 | raise error.SecurityError( | ||
|
r43347 | _(b'%s certificate error: %s') % (host, msg), | ||
|
r43346 | hint=_( | ||
|
r43347 | b'set hostsecurity.%s:certfingerprints=%s ' | ||
b'config setting or use --insecure to connect ' | ||||
b'insecurely' | ||||
|
r43346 | ) | ||
% (host, nicefingerprint), | ||||
) | ||||