From 34a5250818112c7d86fc04b69ce7ea04a570b351 Mon Sep 17 00:00:00 2001 From: Eygene Ryabinkin Date: Mon, 12 Jan 2015 02:01:27 +0300 Subject: [PATCH 5/5] SSL: properly configure certificate and fingerprint validation - Allow user to explicitely request OS-default CA bundle. - Use OS-default bundle when fingerprint validation is enabled only when it was explicitely requested. - Don't miserably fail with generic "validation failed" message when server certificate can't be checked via configured CA bundle, but try to get server's certificate fingerprint and point user to the proper direction w.r.t. CA bundle and fingerprint validation. - Hint user with remote server fingerprint when there is neither fingerprint validation , nor CA bundle configured, but user wants SSL. - Document interplay between sslcacertfile and cert_fingerprint. Signed-off-by: Eygene Ryabinkin --- offlineimap.conf | 15 ++++++++++ offlineimap/imaplibutil.py | 66 ++++++++++++++++++++++++++++++++++++++++-- offlineimap/imapserver.py | 19 ++++++++---- offlineimap/repository/IMAP.py | 30 ++++++++++++++++--- offlineimap/utils/distro.py | 4 ++- 5 files changed, 121 insertions(+), 13 deletions(-) diff --git a/offlineimap.conf b/offlineimap.conf index d953f0e..3f641e0 100644 --- a/offlineimap.conf +++ b/offlineimap.conf @@ -406,6 +406,21 @@ ssl = yes # The certificate should be in PEM format. # # Tilde and environment variable expansions will be performed. +# +# Normally, OfflineIMAP tries to automatically determine system-wide +# location of standard trusted CA roots file for known OS +# distributions and will set the value of sslcacertfile accordingly. +# However, if cert_fingerprint is specified, default location will +# not be used, because it is unexpected by users to see the validation +# failures when their CA is not in the default OS bundle, they had +# explicitely set fingerprint value(s), but had not set any values +# for sslcacertfile. +# +# A value of OS-DEFAULT is treated specially: it will set CA bundle +# location to the OS-default value (if any), as explained in the above +# paragraph. This will enable users to have both fingerprint +# validation and server certificate validation for the case when +# server's issuing CA is in the standard bundle. # sslcacertfile = /path/to/cacertfile.crt diff --git a/offlineimap/imaplibutil.py b/offlineimap/imaplibutil.py index 917c726..e1273ff 100644 --- a/offlineimap/imaplibutil.py +++ b/offlineimap/imaplibutil.py @@ -17,6 +17,7 @@ import os import fcntl import time +from ssl import SSLError import subprocess from sys import exc_info import threading @@ -149,14 +150,39 @@ class WrappedIMAP4_SSL(UsefulIMAPMixIn, IMAP4_SSL): def open(self, host=None, port=None): if not self.ca_certs and not self._fingerprint: + fingerprint = self.__get_peer_fingerprint(host, port) raise OfflineImapError("No CA certificates " "and no server fingerprints configured. " "You must configure at least something, otherwise " - "having SSL helps nothing.", OfflineImapError.ERROR.REPO) - super(WrappedIMAP4_SSL, self).open(host, port) + "having SSL helps nothing. " + "For the record, fingerprint for " + "'%s' is '%s'" % (host, fingerprint) + \ + ", but it is better to use proper CA bundle.", \ + OfflineImapError.ERROR.REPO) + # We want to handle the case when there is no fingerprint + # configured, but CA verification was requested and had failed. + # It is good to provide user with the current fingerprint + # and some explanation on what can be done. + try: + super(WrappedIMAP4_SSL, self).open(host, port) + except SSLError, e: + if not str(e).find("certificate verify failed"): + raise + fingerprint = self.__get_peer_fingerprint(host, port) + msg = "Remote server certificate verification " + \ + "with CA bundle %s " % self.ca_certs + \ + "was failed: %s. " % (e) + \ + "Current fingerprint for " + \ + "server '%s' is '%s'. " % (host, fingerprint) + \ + "You may want to " + \ + ("re" if self._fingerprint else "") + \ + "configure fingerprint validation via cert_fingerprint option " + \ + "or to use the proper CA bundle." + raise OfflineImapError(msg, OfflineImapError.ERROR.REPO), \ + None, exc_info()[2] if self._fingerprint: # compare fingerprints - fingerprint = sha1(self.sock.getpeercert(True)).hexdigest() + fingerprint = self.__fingerprint_from_socket() if fingerprint not in self._fingerprint: raise OfflineImapError("Server SSL fingerprint '%s' " "for hostname '%s' " @@ -167,6 +193,40 @@ class WrappedIMAP4_SSL(UsefulIMAPMixIn, IMAP4_SSL): OfflineImapError.ERROR.REPO) + def __fingerprint_from_socket(self): + """ + Returns certificate fingerprint of the remote server. + + This method must be called only for connected self.sock. + + """ + return sha1(self.sock.getpeercert(True)).hexdigest() + + + def __get_peer_fingerprint(self, host, port): + """ + Obtains fingerprint of remote server's certificate + by using non-verifying SSL mode. + + """ + + saved_ca_certs = self.ca_certs + saved_cb = self.cert_verify_cb + + # Set no-verification mode and do open/fingerprint/close dance. + self.ca_certs = None + self.cert_verify_cb = None + super(WrappedIMAP4_SSL, self).open(host, port) + fingerprint = self.__fingerprint_from_socket() + self.shutdown() + + self.ca_certs = saved_ca_certs + self.cert_verify_cb = saved_cb + + return fingerprint + + + class WrappedIMAP4(UsefulIMAPMixIn, IMAP4): """Improved version of imaplib.IMAP4 overriding select().""" diff --git a/offlineimap/imapserver.py b/offlineimap/imapserver.py index bac681e..48d52e6 100644 --- a/offlineimap/imapserver.py +++ b/offlineimap/imapserver.py @@ -80,10 +80,20 @@ class IMAPServer: self.port = 993 if self.usessl else 143 self.sslclientcert = repos.getsslclientcert() self.sslclientkey = repos.getsslclientkey() - self.sslcacertfile = repos.getsslcacertfile() - self.sslversion = repos.getsslversion() + (self.sslcacertfile, self.cacert_isimplicit) = repos.getsslcacertfile() + self.fingerprint = repos.get_ssl_fingerprint() + # If server fingerprint is configured and we use default CA bundle + # that was not explicitely requested by user via configuration, + # don't do X.509 verification: if default CA bundle has no CA that + # verifies server certificate current SSL implementation will + # reject the session prior to the point where we will have a + # chance to verify fingerprint. So, do both certificate and + # fingerprint validation only when both are explicitely configured. + if self.fingerprint and self.cacert_isimplicit: + self.sslcacertfile = None if self.sslcacertfile is None: self.__verifycert = None # disable cert verification + self.sslversion = repos.getsslversion() self.delim = None self.root = None @@ -394,7 +404,6 @@ class IMAPServer: success = 1 elif self.usessl: self.ui.connecting(self.hostname, self.port) - fingerprint = self.repos.get_ssl_fingerprint() imapobj = imaplibutil.WrappedIMAP4_SSL(self.hostname, self.port, self.sslclientkey, @@ -403,7 +412,7 @@ class IMAPServer: self.__verifycert, self.sslversion, timeout=socket.getdefaulttimeout(), - fingerprint=fingerprint + fingerprint=self.fingerprint ) else: self.ui.connecting(self.hostname, self.port) @@ -468,7 +477,7 @@ class IMAPServer: (self.hostname, self.repos) raise OfflineImapError(reason, severity), None, exc_info()[2] - elif isinstance(e, SSLError) and e.errno == 1: + elif isinstance(e, SSLError) and e.errno == errno.EPERM: # SSL unknown protocol error # happens e.g. when connecting via SSL to a non-SSL service if self.port != 993: diff --git a/offlineimap/repository/IMAP.py b/offlineimap/repository/IMAP.py index 2baa046..1ea94af 100644 --- a/offlineimap/repository/IMAP.py +++ b/offlineimap/repository/IMAP.py @@ -201,17 +201,39 @@ class IMAPRepository(BaseRepository): return self.getconf_xform('sslclientkey', xforms, None) def getsslcacertfile(self): - """Return the absolute path of the CA certfile to use, if any""" + """ + Returns tuple with CA bundle location and implicit value flag. + + CA bundle location will be the absolute path of the file to be used + or None if no file can be found in configuration and in OS-default + locations. + + Implicit value flag tells the caller if returned value is an implicit + default (configuration has no sslcacertfile value at all) or it was + explicitely specified by user either as a full path or via + "OS-DEFAULT" mechanism. + + The said mechanism is the special handling of "OS-DEFAULT" value + for sslcacertfile configuration option that means "I want to use + OS-default CA bundle location, if any". + + """ xforms = [os.path.expanduser, os.path.expandvars, os.path.abspath] + default_certfile = get_os_sslcertfile() + is_implicit = False cacertfile = self.getconf_xform('sslcacertfile', xforms, - get_os_sslcertfile()) + None) + if cacertfile == None or \ + self.getconf('sslcacertfile') == "OS-DEFAULT": + is_implicit = (cacertfile == None) + cacertfile = default_certfile if cacertfile is None: - return None + return (None, False) if not os.path.isfile(cacertfile): raise SyntaxWarning("CA certfile for repository '%s' could " "not be found. No such file: '%s'" \ % (self.name, cacertfile)) - return cacertfile + return (cacertfile, is_implicit) def getsslversion(self): return self.getconf('ssl_version', None) diff --git a/offlineimap/utils/distro.py b/offlineimap/utils/distro.py index 7c944b9..c1e5c7a 100644 --- a/offlineimap/utils/distro.py +++ b/offlineimap/utils/distro.py @@ -12,7 +12,7 @@ import os # we will walk through the values and will return the first # one that corresponds to the existing file. __DEF_OS_LOCATIONS = { - 'freebsd': '/usr/local/share/certs/ca-root-nss.crt', + 'freebsd': None, 'openbsd': '/etc/ssl/cert.pem', 'netbsd': None, 'dragonfly': '/etc/ssl/cert.pem', @@ -63,6 +63,8 @@ def get_os_sslcertfile(): if OS in __DEF_OS_LOCATIONS: l = __DEF_OS_LOCATIONS[OS] + if not l: + return None if not hasattr(l, '__iter__'): l = (l, ) for f in l: -- 2.1.2