source: TI12-security/trunk/NDGSecurity/python/ndg_security_common/ndg/security/common/utils/m2crypto.py @ 7681

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/NDGSecurity/python/ndg_security_common/ndg/security/common/utils/m2crypto.py@7681
Revision 7681, 22.5 KB checked in by pjkersha, 10 years ago (diff)

Incomplete - task 16: NDG Security 2.0.1 - incl. updated Paster templates

  • Fix mutable keyword defaults
  • Property svn:keywords set to Id
Line 
1"""Extend M2Crypto SSL functionality for cert verification and custom
2timeout settings.
3
4NERC DataGrid Project"""
5__author__ = "P J Kershaw"
6__date__ = "02/07/07"
7__copyright__ = "(C) 2009 Science and Technology Facilities Council"
8__license__ = "BSD - see LICENSE file in top-level directory"
9__contact__ = "Philip.Kershaw@stfc.ac.uk"
10__revision__ = '$Id$'
11import logging
12log = logging.getLogger(__name__)
13
14import os
15import re
16
17from M2Crypto import SSL, X509
18from M2Crypto.httpslib import HTTPSConnection as _HTTPSConnection
19
20from ndg.security.common.X509 import X509Cert, X509Stack, X500DN
21
22
23class InvalidCertSignature(SSL.Checker.SSLVerificationError):
24    """Raise if verification against CA cert public key fails"""
25
26
27class InvalidCertDN(SSL.Checker.SSLVerificationError):
28    """Raise if verification against a list acceptable DNs fails"""
29   
30
31class HostCheck(SSL.Checker.Checker, object):
32    """Override SSL.Checker.Checker to enable alternate Common Name
33    setting match for peer cert"""
34
35    def __init__(self, 
36                 peerCertDN=None, 
37                 peerCertCN=None,
38                 acceptedDNs=None, 
39                 caCertList=None,
40                 caCertFilePathList=None, 
41                 **kw):
42        """Override parent class __init__ to enable setting of myProxyServerDN
43        setting
44       
45        @type peerCertDN: string/list
46        @param peerCertDN: Set the expected Distinguished Name of the
47        server to avoid errors matching hostnames.  This is useful
48        where the hostname is not fully qualified. 
49
50        *param acceptedDNs: a list of acceptable DNs.  This enables validation
51        where the expected DN is where against a limited list of certs.
52       
53        @type peerCertCN: string
54        @param peerCertCN: enable alternate Common Name to peer
55        hostname
56       
57        @type caCertList: list type of M2Crypto.X509.X509 types
58        @param caCertList: CA X.509 certificates - if set the peer cert's
59        CA signature is verified against one of these.  At least one must
60        verify
61       
62        @type caCertFilePathList: list string types
63        @param caCertFilePathList: same as caCertList except input as list
64        of CA cert file paths"""
65       
66        if acceptedDNs is None:
67            acceptedDNs = []
68           
69        if caCertList is None:
70            caCertList = []
71           
72        if caCertFilePathList is None:
73            caCertFilePathList = []
74       
75        SSL.Checker.Checker.__init__(self, **kw)
76       
77        self.peerCertDN = peerCertDN
78        self.peerCertCN = peerCertCN
79        self.acceptedDNs = acceptedDNs
80       
81        if caCertList:
82            self.caCertList = caCertList
83        elif caCertFilePathList:
84            self.caCertFilePathList = caCertFilePathList
85        else:
86            # Set default to enable len() test in __call__
87            self.__caCertStack = ()
88           
89    def __call__(self, peerCert, host=None):
90        """Carry out checks on server ID
91        @param peerCert: MyProxy server host certificate as M2Crypto.X509.X509
92        instance
93        @param host: name of host to check
94        """
95        if peerCert is None:
96            raise SSL.Checker.NoCertificate('SSL Peer did not return '
97                                            'certificate')
98
99        peerCertDN = '/'+peerCert.get_subject().as_text().replace(', ', '/')
100        try:
101            SSL.Checker.Checker.__call__(self, peerCert, host=self.peerCertCN)
102           
103        except SSL.Checker.WrongHost, e:
104            # Try match against peerCertDN set   
105            if peerCertDN != self.peerCertDN:
106                raise e
107
108        # At least one match should be found in the list - first convert to
109        # NDG X500DN type to allow per field matching for DN comparison
110        peerCertX500DN = X500DN(dn=peerCertDN)
111       
112        if self.acceptedDNs:
113           matchFound = False
114           for dn in self.acceptedDNs:
115               x500dn = X500DN(dn=dn)
116               if x500dn == peerCertX500DN:
117                   matchFound = True
118                   break
119               
120           if not matchFound:
121               raise InvalidCertDN('Peer cert DN "%s" doesn\'t match '
122                                   'verification list' % peerCertDN)
123
124        if len(self.__caCertStack) > 0:
125            try:
126                self.__caCertStack.verifyCertChain(
127                           x509Cert2Verify=X509Cert(m2CryptoX509=peerCert))
128            except Exception, e:
129                raise InvalidCertSignature("Peer certificate verification "
130                                           "against CA certificate failed: %s" 
131                                           % e)
132             
133        # They match - drop the exception and return all OK instead         
134        return True
135     
136    def __setCACertList(self, caCertList):
137        """Set list of CA certs - peer cert must validate against at least one
138        of these"""
139        self.__caCertStack = X509Stack()
140        for caCert in caCertList:
141            self.__caCertStack.push(caCert)
142
143    caCertList = property(fset=__setCACertList,
144                          doc="list of CA certificates - the peer certificate "
145                              "must validate against one")
146
147    def __setCACertsFromFileList(self, caCertFilePathList):
148        '''Read CA certificates from file and add them to the X.509
149        stack
150       
151        @type caCertFilePathList: basestring, list or tuple
152        @param caCertFilePathList: list of file paths for CA certificates to
153        be used to verify certificate used to sign message.  If a single
154        string item is input then this is converted into a tuple
155        '''
156        if isinstance(caCertFilePathList, basestring):
157            caCertFilePathList = (caCertFilePathList,)
158           
159        elif not isinstance(caCertFilePathList, (list, tuple)):
160            raise TypeError('Expecting a basestring, list or tuple type for '
161                            '"caCertFilePathList"')
162
163        self.__caCertStack = X509Stack()
164
165        for caCertFilePath in caCertFilePathList:
166            self.__caCertStack.push(X509.load_cert(caCertFilePath))
167       
168    caCertFilePathList = property(fset=__setCACertsFromFileList,
169                                  doc="list of CA certificate file paths - "
170                                      "peer certificate must validate against "
171                                      "one")
172
173
174class HTTPSConnection(_HTTPSConnection):
175    """Modified version of M2Crypto equivalent to enable custom checks with
176    the peer and timeout settings
177   
178    @type defReadTimeout: M2Crypto.SSL.timeout
179    @cvar defReadTimeout: default timeout for read operations
180    @type defWriteTimeout: M2Crypto.SSL.timeout
181    @cvar defWriteTimeout: default timeout for write operations"""   
182    defReadTimeout = SSL.timeout(sec=20.)
183    defWriteTimeout = SSL.timeout(sec=20.)
184   
185    def __init__(self, *args, **kw):
186        '''Overload to enable setting of post connection check
187        callback to SSL.Connection
188       
189        type *args: tuple
190        param *args: args which apply to M2Crypto.httpslib.HTTPSConnection
191        type **kw: dict
192        param **kw: additional keywords
193        @type postConnectionCheck: SSL.Checker.Checker derivative
194        @keyword postConnectionCheck: set class for checking peer
195        @type readTimeout: M2Crypto.SSL.timeout
196        @keyword readTimeout: readTimeout - set timeout for read
197        @type writeTimeout: M2Crypto.SSL.timeout
198        @keyword writeTimeout: similar to read timeout'''
199       
200        self._postConnectionCheck = kw.pop('postConnectionCheck',
201                                           SSL.Checker.Checker)
202       
203        if 'readTimeout' in kw:
204            if not isinstance(kw['readTimeout'], SSL.timeout):
205                raise AttributeError("readTimeout must be of type "
206                                     "M2Crypto.SSL.timeout")
207            self.readTimeout = kw.pop('readTimeout')
208        else:
209            self.readTimeout = HTTPSConnection.defReadTimeout
210             
211        if 'writeTimeout' in kw:
212            if not isinstance(kw['writeTimeout'], SSL.timeout):
213                raise AttributeError("writeTimeout must be of type "
214                                     "M2Crypto.SSL.timeout") 
215            self.writeTimeout = kw.pop('writeTimeout')
216        else:
217            self.writeTimeout = HTTPSConnection.defWriteTimeout
218   
219        self._clntCertFilePath = kw.pop('clntCertFilePath', None)
220        self._clntPriKeyFilePath = kw.pop('clntPriKeyFilePath', None)
221       
222        _HTTPSConnection.__init__(self, *args, **kw)
223       
224        # load up certificate stuff
225        if (self._clntCertFilePath is not None and 
226            self._clntPriKeyFilePath is not None):
227            self.ssl_ctx.load_cert(self._clntCertFilePath, 
228                                   self._clntPriKeyFilePath)
229       
230       
231    def connect(self):
232        '''Overload M2Crypto.httpslib.HTTPSConnection to enable
233        custom post connection check of peer certificate and socket timeout'''
234
235        self.sock = SSL.Connection(self.ssl_ctx)
236        self.sock.set_post_connection_check_callback(self._postConnectionCheck)
237
238        self.sock.set_socket_read_timeout(self.readTimeout)
239        self.sock.set_socket_write_timeout(self.writeTimeout)
240
241        self.sock.connect((self.host, self.port))
242
243    def putrequest(self, method, url, **kw):
244        '''Overload to work around bug with unicode type URL'''
245        url = str(url)
246        _HTTPSConnection.putrequest(self, method, url, **kw) 
247         
248             
249class SSLContextProxy(object):
250    """Holder for M2Crypto.SSL.Context parameters"""
251    PRE_VERIFY_FAIL, PRE_VERIFY_OK = range(2)
252   
253    SSL_CERT_FILEPATH_OPTNAME = "sslCertFilePath"
254    SSL_PRIKEY_FILEPATH_OPTNAME = "sslPriKeyFilePath"
255    SSL_PRIKEY_PWD_OPTNAME = "sslPriKeyPwd"
256    SSL_CACERT_FILEPATH_OPTNAME = "sslCACertFilePath"
257    SSL_CACERT_DIRPATH_OPTNAME = "sslCACertDir"
258    SSL_VALID_DNS_OPTNAME = "sslValidDNs"
259   
260    OPTNAMES = (
261        SSL_CERT_FILEPATH_OPTNAME,
262        SSL_PRIKEY_FILEPATH_OPTNAME,
263        SSL_PRIKEY_PWD_OPTNAME,
264        SSL_CACERT_FILEPATH_OPTNAME,
265        SSL_CACERT_DIRPATH_OPTNAME,
266        SSL_VALID_DNS_OPTNAME
267    )
268   
269    __slots__ = tuple(["__%s" % name for name in OPTNAMES])
270    del name
271   
272    VALID_DNS_PAT = re.compile(',\s*')
273   
274    def __init__(self):
275        self.__sslCertFilePath = None
276        self.__sslPriKeyFilePath = None
277        self.__sslPriKeyPwd = None
278        self.__sslCACertFilePath = None
279        self.__sslCACertDir = None
280        self.__sslValidDNs = []
281
282    def createCtx(self, depth=9, **kw):
283        """Create an M2Crypto SSL Context from this objects properties
284        @type depth: int
285        @param depth: max. depth of certificate to verify against
286        @type kw: dict
287        @param kw: M2Crypto.SSL.Context keyword arguments
288        @rtype: M2Crypto.SSL.Context
289        @return M2Crypto SSL context object
290        """
291        ctx = SSL.Context(**kw)
292       
293        # Configure context according to this proxy's attributes
294        if self.sslCertFilePath and self.sslPriKeyFilePath:
295            # Pass client certificate
296            ctx.load_cert(self.sslCertFilePath, 
297                          self.__sslPriKeyFilePath, 
298                          lambda *arg, **kw: self.sslPriKeyPwd)
299            log.debug("Set client certificate and key in SSL Context")
300        else:
301            log.debug("No client certificate or key set in SSL Context")
302           
303        if self.sslCACertFilePath or self.sslCACertDir:
304            # Set CA certificates in order to verify peer
305            ctx.load_verify_locations(self.sslCACertFilePath, 
306                                      self.sslCACertDir)
307            mode = SSL.verify_peer|SSL.verify_fail_if_no_peer_cert
308        else:
309            mode = SSL.verify_fail_if_no_peer_cert
310            log.warning('No CA certificate files set: mode set to '
311                        '"verify_fail_if_no_peer_cert" only')
312           
313        if len(self.sslValidDNs) > 0:
314            # Set custom callback in order to verify peer certificate DN
315            # against whitelist
316            callback = self.createVerifySSLPeerCertCallback()
317            log.debug('Set peer certificate Distinguished Name check set in '
318                      'SSL Context')
319        else:
320            callback = None
321            log.warning('No peer certificate Distinguished Name check set in '
322                        'SSL Context')
323           
324        ctx.set_verify(mode, depth, callback=callback)
325           
326        return ctx
327 
328    def copy(self, sslCtxProxy):
329        """Copy settings from another context object
330        """
331        if not isinstance(sslCtxProxy, SSLContextProxy):
332            raise TypeError('Expecting %r for copy method input object; '
333                            'got %r' % (SSLContextProxy, type(sslCtxProxy)))
334       
335        for name in SSLContextProxy.OPTNAMES:
336            setattr(self, name, getattr(sslCtxProxy, name))
337           
338    def createVerifySSLPeerCertCallback(self):
339        """Create a callback function to enable the DN of the peer in an SSL
340        connection to be verified against a whitelist. 
341       
342        Nb. Making this function within the scope of a method of the class to
343        enables to access instance variables
344        """
345       
346        def _verifySSLPeerCertCallback(preVerifyOK, x509StoreCtx):
347            '''SSL verify callback function used to control the behaviour when
348            the SSL_VERIFY_PEER flag is set.  See:
349           
350            http://www.openssl.org/docs/ssl/SSL_CTX_set_verify.html
351           
352            This implementation applies verification in order to check the DN
353            of the peer certificate against a whitelist
354           
355            @type preVerifyOK: int
356            @param preVerifyOK: If a verification error is found, this
357            parameter will be set to 0
358            @type x509StoreCtx: M2Crypto.X509.X509_Store_Context
359            @param x509StoreCtx: locate the certificate to be verified and
360            perform additional verification steps as needed
361            @rtype: int
362            @return: controls the strategy of the further verification process.
363            - If verify_callback returns 0, the verification process is
364            immediately stopped with "verification failed" state. If
365            SSL_VERIFY_PEER is set, a verification failure alert is sent to the
366            peer and the TLS/SSL handshake is terminated.
367            - If verify_callback returns 1, the verification process is
368            continued.
369            If verify_callback always returns 1, the TLS/SSL handshake will not
370            be terminated with respect to verification failures and the
371            connection
372            will be established. The calling process can however retrieve the
373            error code of the last verification error using
374            SSL_get_verify_result or by maintaining its own error storage
375            managed by verify_callback.
376            '''
377            if preVerifyOK == 0:
378                # Something is wrong with the certificate don't bother
379                # proceeding any further
380                log.error("verifyCallback: pre-verify OK flagged an error "
381                          "with the peer certificate, returning error state "
382                          "to caller ...")
383                return preVerifyOK
384           
385            x509CertChain = x509StoreCtx.get1_chain()
386            for cert in x509CertChain:
387                x509Cert = X509Cert.fromM2Crypto(cert)
388                if x509Cert.dn in self.sslValidDNs:
389                    return preVerifyOK
390               
391                subject = cert.get_subject()
392                dn = subject.as_text()
393                log.debug("verifyCallback: dn = %r", dn)
394               
395            # No match found so return fail status
396            return SSLContextProxy.PRE_VERIFY_FAIL
397       
398        return _verifySSLPeerCertCallback
399
400    def _getSSLCertFilePath(self):
401        return self.__sslCertFilePath
402   
403    def _setSSLCertFilePath(self, filePath):
404        "Set X.509 cert file path property method"
405       
406        if isinstance(filePath, basestring):
407            filePath = os.path.expandvars(filePath)
408           
409        elif filePath is not None:
410            raise TypeError("X.509 cert. file path must be a valid string")
411       
412        self.__sslCertFilePath = filePath
413               
414    sslCertFilePath = property(fset=_setSSLCertFilePath,
415                               fget=_getSSLCertFilePath,
416                               doc="File path to X.509 cert.")
417       
418    def _getSSLCACertFilePath(self):
419        """Get file path for list of CA cert or certs used to validate SSL
420        connections
421       
422        @rtype sslCACertFilePath: basestring
423        @return sslCACertFilePathList: file path to file containing concatenated
424        PEM encoded CA certificates."""
425        return self.__sslCACertFilePath
426   
427    def _setSSLCACertFilePath(self, value):
428        """Set CA cert file path
429       
430        @type sslCACertFilePath: basestring, list, tuple or None
431        @param sslCACertFilePath: file path to CA certificate file.  If None
432        then the input is quietly ignored."""
433        if isinstance(value, basestring):
434            self.__sslCACertFilePath = os.path.expandvars(value)
435           
436        elif value is None:
437            self.__sslCACertFilePath = value
438           
439        else:
440            raise TypeError("Input CA Certificate file path must be "
441                            "a valid string or None type: %r" % type(value)) 
442       
443       
444    sslCACertFilePath = property(fget=_getSSLCACertFilePath,
445                                 fset=_setSSLCACertFilePath,
446                                 doc="Path to file containing concatenated PEM "
447                                     "encoded CA Certificates - used for "
448                                     "verification of peer certs in SSL "
449                                     "connection")
450       
451    def _getSSLCACertDir(self):
452        """Get file path for list of CA cert or certs used to validate SSL
453        connections
454       
455        @rtype sslCACertDir: basestring
456        @return sslCACertDirList: directory containing PEM encoded CA
457        certificates."""
458        return self.__sslCACertDir
459   
460    def _setSSLCACertDir(self, value):
461        """Set CA cert or certs to validate AC signatures, signatures
462        of Attribute Authority SOAP responses and SSL connections where
463        AA SOAP service is run over SSL.
464       
465        @type sslCACertDir: basestring
466        @param sslCACertDir: directory containing CA certificate files.
467        """
468        if isinstance(value, basestring):
469            self.__sslCACertDir = os.path.expandvars(value)
470        elif value is None:
471            self.__sslCACertDir = value
472        else:
473            raise TypeError("Input CA Certificate directroy must be "
474                            "a valid string or None type: %r" % type(value))     
475       
476    sslCACertDir = property(fget=_getSSLCACertDir,
477                            fset=_setSSLCACertDir,
478                            doc="Path to directory containing PEM encoded CA "
479                                "Certificates used for verification of peer "
480                                "certs in SSL connection.   Files in the "
481                                "directory must be named with the form "
482                                "<hash>.0 where <hash> can be obtained using "
483                                "openssl x509 -in cert -hash -noout or using "
484                                "the c_rehash OpenSSL script")
485   
486    def _getSslValidDNs(self):
487        return self.__sslValidDNs
488
489    def _setSslValidDNs(self, value):
490        if isinstance(value, basestring): 
491            pat = SSLContextProxy.VALID_DNS_PAT
492            self.__sslValidDNs = [X500DN.fromString(dn) 
493                                  for dn in pat.split(value)]
494           
495        elif isinstance(value, (tuple, list)):
496            self.__sslValidDNs = [X500DN.fromString(dn) for dn in value]
497        else:
498            raise TypeError('Expecting list/tuple or basestring type for "%s" '
499                            'attribute; got %r' %
500                            (SSLContextProxy.SSL_VALID_DNS_OPTNAME, 
501                             type(value)))
502   
503    sslValidDNs = property(_getSslValidDNs, 
504                           _setSslValidDNs, 
505                           doc="whitelist of acceptable certificate "
506                               "Distinguished Names for peer certificates in "
507                               "SSL requests")
508
509    def _getSSLPriKeyFilePath(self):
510        return self.__sslPriKeyFilePath
511   
512    def _setSSLPriKeyFilePath(self, filePath):
513        "Set ssl private key file path property method"
514       
515        if isinstance(filePath, basestring):
516            filePath = os.path.expandvars(filePath)
517
518        elif filePath is not None:
519            raise TypeError("Private key file path must be a valid "
520                            "string or None type")
521       
522        self.__sslPriKeyFilePath = filePath
523       
524    sslPriKeyFilePath = property(fget=_getSSLPriKeyFilePath,
525                                 fset=_setSSLPriKeyFilePath,
526                                 doc="File path to SSL private key")
527 
528    def _setSSLPriKeyPwd(self, sslPriKeyPwd):
529        "Set method for ssl private key file password"
530        if not isinstance(sslPriKeyPwd, (type(None), basestring)):
531            raise TypeError("Signing private key password must be None "
532                            "or a valid string")
533       
534        # Explicitly convert to string as M2Crypto OpenSSL wrapper fails with
535        # unicode type
536        self.__sslPriKeyPwd = str(sslPriKeyPwd)
537
538    def _getSSLPriKeyPwd(self):
539        "Get property method for SSL private key"
540        return self.__sslPriKeyPwd
541       
542    sslPriKeyPwd = property(fset=_setSSLPriKeyPwd,
543                             fget=_getSSLPriKeyPwd,
544                             doc="Password protecting SSL private key file")
545
546    def __getstate__(self):
547        '''Enable pickling for use with beaker.session'''
548        _dict = {}
549        for attrName in SSLContextProxy.__slots__:
550            # Ugly hack to allow for derived classes setting private member
551            # variables
552            if attrName.startswith('__'):
553                attrName = "_SSLContextProxy" + attrName
554               
555            _dict[attrName] = getattr(self, attrName)
556           
557        return _dict
558       
559    def __setstate__(self, attrDict):
560        '''Enable pickling for use with beaker.session'''
561        for attr, val in attrDict.items():
562            setattr(self, attr, val)
Note: See TracBrowser for help on using the repository browser.