import hashlib import os import re import time import sys from .cmd import Cli from .console_write import console_write from .open_compat import open_compat, read_compat # Have somewhere to store the CA bundle, even when not running in Sublime Text try: import sublime ca_bundle_dir = None except (ImportError): ca_bundle_dir = os.path.join(os.path.expanduser('~'), '.package_control') if not os.path.exists(ca_bundle_dir): os.mkdir(ca_bundle_dir) def find_root_ca_cert(settings, domain): runner = OpensslCli(settings.get('openssl_binary'), settings.get('debug')) binary = runner.retrieve_binary() args = [binary, 's_client', '-showcerts', '-connect', domain + ':443'] result = runner.execute(args, os.path.dirname(binary)) certs = [] temp = [] in_block = False for line in result.splitlines(): if line.find('BEGIN CERTIFICATE') != -1: in_block = True if in_block: temp.append(line) if line.find('END CERTIFICATE') != -1: in_block = False certs.append(u"\n".join(temp)) temp = [] # Remove the cert for the domain itself, just leaving the # chain cert and the CA cert certs.pop(0) # Look for the "parent" root CA cert subject = openssl_get_cert_subject(settings, certs[-1]) issuer = openssl_get_cert_issuer(settings, certs[-1]) cert = get_ca_cert_by_subject(settings, issuer) cert_hash = hashlib.md5(cert.encode('utf-8')).hexdigest() return [cert, cert_hash] def get_system_ca_bundle_path(settings): """ Get the filesystem path to the system CA bundle. On Linux it looks in a number of predefined places, however on OS X it has to be programatically exported from the SystemRootCertificates.keychain. Windows does not ship with a CA bundle, but also we use WinINet on Windows, so we don't need to worry about CA certs. :param settings: A dict to look in for `debug` and `openssl_binary` keys :return: The full filesystem path to the .ca-bundle file, or False on error """ # If the sublime module is available, we bind this value at run time # since the sublime.packages_path() is not available at import time global ca_bundle_dir platform = sys.platform debug = settings.get('debug') ca_path = False if platform == 'win32': console_write(u"Unable to get system CA cert path since Windows does not ship with them", True) return False # OS X if platform == 'darwin': if not ca_bundle_dir: ca_bundle_dir = os.path.join(sublime.packages_path(), 'User') ca_path = os.path.join(ca_bundle_dir, 'Package Control.system-ca-bundle') exists = os.path.exists(ca_path) # The bundle is old if it is a week or more out of date is_old = exists and os.stat(ca_path).st_mtime < time.time() - 604800 if not exists or is_old: if debug: console_write(u"Generating new CA bundle from system keychain", True) _osx_create_ca_bundle(settings, ca_path) if debug: console_write(u"Finished generating new CA bundle at %s" % ca_path, True) elif debug: console_write(u"Found previously exported CA bundle at %s" % ca_path, True) # Linux else: # Common CA cert paths paths = [ '/usr/lib/ssl/certs/ca-certificates.crt', '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/ca-bundle.pem' ] for path in paths: if os.path.exists(path): ca_path = path break if debug and ca_path: console_write(u"Found system CA bundle at %s" % ca_path, True) return ca_path def get_ca_cert_by_subject(settings, subject): bundle_path = get_system_ca_bundle_path(settings) with open_compat(bundle_path, 'r') as f: contents = read_compat(f) temp = [] in_block = False for line in contents.splitlines(): if line.find('BEGIN CERTIFICATE') != -1: in_block = True if in_block: temp.append(line) if line.find('END CERTIFICATE') != -1: in_block = False cert = u"\n".join(temp) temp = [] if openssl_get_cert_subject(settings, cert) == subject: return cert return False def openssl_get_cert_issuer(settings, cert): """ Uses the openssl command line client to extract the issuer of an x509 certificate. :param settings: A dict to look in for `debug` and `openssl_binary` keys :param cert: A string containing the PEM-encoded x509 certificate to extract the issuer from :return: The cert issuer """ runner = OpensslCli(settings.get('openssl_binary'), settings.get('debug')) binary = runner.retrieve_binary() args = [binary, 'x509', '-noout', '-issuer'] output = runner.execute(args, os.path.dirname(binary), cert) return re.sub('^issuer=\s*', '', output) def openssl_get_cert_name(settings, cert): """ Uses the openssl command line client to extract the name of an x509 certificate. If the commonName is set, that is used, otherwise the first organizationalUnitName is used. This mirrors what OS X uses for storing trust preferences. :param settings: A dict to look in for `debug` and `openssl_binary` keys :param cert: A string containing the PEM-encoded x509 certificate to extract the name from :return: The cert subject name, which is the commonName (if available), or the first organizationalUnitName """ runner = OpensslCli(settings.get('openssl_binary'), settings.get('debug')) binary = runner.retrieve_binary() args = [binary, 'x509', '-noout', '-subject', '-nameopt', 'sep_multiline,lname,utf8'] result = runner.execute(args, os.path.dirname(binary), cert) # First look for the common name cn = None # If there is no common name for the cert, the trust prefs use the first # orginizational unit name first_ou = None for line in result.splitlines(): match = re.match('^\s+commonName=(.*)$', line) if match: cn = match.group(1) break match = re.match('^\s+organizationalUnitName=(.*)$', line) if first_ou is None and match: first_ou = match.group(1) continue # This is the name of the cert that would be used in the trust prefs return cn or first_ou def openssl_get_cert_subject(settings, cert): """ Uses the openssl command line client to extract the subject of an x509 certificate. :param settings: A dict to look in for `debug` and `openssl_binary` keys :param cert: A string containing the PEM-encoded x509 certificate to extract the subject from :return: The cert subject """ runner = OpensslCli(settings.get('openssl_binary'), settings.get('debug')) binary = runner.retrieve_binary() args = [binary, 'x509', '-noout', '-subject'] output = runner.execute(args, os.path.dirname(binary), cert) return re.sub('^subject=\s*', '', output) def _osx_create_ca_bundle(settings, destination): """ Uses the OS X `security` command line tool to export the system's list of CA certs from /System/Library/Keychains/SystemRootCertificates.keychain. Checks the cert names against the trust preferences, ensuring that distrusted certs are not exported. :param settings: A dict to look in for `debug` and `openssl_binary` keys :param destination: The full filesystem path to the destination .ca-bundle file """ distrusted_certs = _osx_get_distrusted_certs(settings) # Export the root certs args = ['/usr/bin/security', 'export', '-k', '/System/Library/Keychains/SystemRootCertificates.keychain', '-t', 'certs', '-p'] result = Cli(None, settings.get('debug')).execute(args, '/usr/bin') certs = [] temp = [] in_block = False for line in result.splitlines(): if line.find('BEGIN CERTIFICATE') != -1: in_block = True if in_block: temp.append(line) if line.find('END CERTIFICATE') != -1: in_block = False cert = u"\n".join(temp) temp = [] if distrusted_certs: # If it is a distrusted cert, we move on to the next cert_name = openssl_get_cert_name(settings, cert) if cert_name in distrusted_certs: if settings.get('debug'): console_write(u'Skipping root certficate %s because it is distrusted' % cert_name, True) continue certs.append(cert) with open_compat(destination, 'w') as f: f.write(u"\n".join(certs)) def _osx_get_distrusted_certs(settings): """ Uses the OS X `security` binary to get a list of admin trust settings, which is what is set when a user changes the trust setting on a root certificate. By looking at the SSL policy, we can properly exclude distrusted certs from out export. Tested on OS X 10.6 and 10.8 :param settings: A dict to look in for `debug` key :return: A list of CA cert names, where the name is the commonName (if available), or the first organizationalUnitName """ args = ['/usr/bin/security', 'dump-trust-settings', '-d'] result = Cli(None, settings.get('debug')).execute(args, '/usr/bin') distrusted_certs = [] cert_name = None ssl_policy = False for line in result.splitlines(): if line == '': continue # Reset for each cert match = re.match('Cert\s+\d+:\s+(.*)$', line) if match: cert_name = match.group(1) continue # Reset for each trust setting if re.match('^\s+Trust\s+Setting\s+\d+:', line): ssl_policy = False continue # We are only interested in SSL policies if re.match('^\s+Policy\s+OID\s+:\s+SSL', line): ssl_policy = True continue distrusted = re.match('^\s+Result\s+Type\s+:\s+kSecTrustSettingsResultDeny', line) if ssl_policy and distrusted and cert_name not in distrusted_certs: if settings.get('debug'): console_write(u'Found SSL distrust setting for root certificate %s' % cert_name, True) distrusted_certs.append(cert_name) return distrusted_certs class OpensslCli(Cli): cli_name = 'openssl' def retrieve_binary(self): """ Returns the path to the openssl executable :return: The string path to the executable or False on error """ name = 'openssl' if os.name == 'nt': name += '.exe' binary = self.find_binary(name) if binary and os.path.isdir(binary): full_path = os.path.join(binary, name) if os.path.exists(full_path): binary = full_path if not binary: show_error((u'Unable to find %s. Please set the openssl_binary ' + u'setting by accessing the Preferences > Package Settings > ' + u'Package Control > Settings \u2013 User menu entry. The ' + u'Settings \u2013 Default entry can be used for reference, ' + u'but changes to that will be overwritten upon next upgrade.') % name) return False return binary