Skip to content

Commit d03dd0f

Browse files
Copy SSL certificate verification from pip.
1 parent 1cd22ba commit d03dd0f

File tree

7 files changed

+288
-19
lines changed

7 files changed

+288
-19
lines changed

setup.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,28 @@
22

33
from distutils.core import setup
44

5-
setup(name='tuf',
6-
version='0.0.0',
5+
setup(
6+
name='tuf',
7+
version='0.1',
78
description='A secure updater framework for Python',
8-
author='numerous',
9+
author='https://www.updateframework.com',
910
author_email='[email protected]',
1011
url='https://www.updateframework.com',
11-
packages=['tuf',
12+
packages=[
13+
'evpy',
14+
'simplejson',
15+
'tuf',
1216
'tuf.client',
17+
'tuf.compatibility',
18+
'tuf.interposition',
1319
'tuf.pushtools',
1420
'tuf.pushtools.transfer',
15-
'tuf.repo',
16-
'tuf.interposition',
17-
'evpy',
18-
'simplejson'],
19-
scripts=['quickstart.py',
20-
'basic_client.py',
21+
'tuf.repo'
22+
],
23+
scripts=[
24+
'quickstart.py',
2125
'tuf/pushtools/push.py',
2226
'tuf/pushtools/receivetools/receive.py',
23-
'tuf/repo/signercli.py'])
27+
'tuf/repo/signercli.py'
28+
]
29+
)

tuf/compatibility/__init__.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
We copy some backwards compatibility from pip.
3+
4+
https://github.com/pypa/pip/tree/d0fa66ecc03ab20b7411b35f7c7b423f31f77761/pip/backwardcompat
5+
"""
6+
7+
8+
import sys
9+
10+
11+
if sys.version_info >= (3,):
12+
import http.client as httplib
13+
import urllib.parse as urlparse
14+
import urllib.request as urllib2
15+
else:
16+
import httplib
17+
import urllib2
18+
import urlparse
19+
20+
21+
## py25 has no builtin ssl module
22+
## only >=py32 has ssl.match_hostname and ssl.CertificateError
23+
try:
24+
import ssl
25+
try:
26+
from ssl import match_hostname, CertificateError
27+
except ImportError:
28+
from tuf.compatibility.ssl_match_hostname import match_hostname, CertificateError
29+
except ImportError:
30+
ssl = None
31+
32+
33+
# patch for py25 socket to work with http://pypi.python.org/pypi/ssl/
34+
import socket
35+
if not hasattr(socket, 'create_connection'): # for Python 2.5
36+
# monkey-patch socket module
37+
from tuf.compatibility.socket_create_connection import create_connection
38+
socket.create_connection = create_connection
39+
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
We copy some functions from the Python 2.7.3 socket module.
3+
4+
http://hg.python.org/releasing/2.7.3/file/7bb96963d067/Lib/socket.py
5+
"""
6+
7+
8+
_GLOBAL_DEFAULT_TIMEOUT = object()
9+
10+
11+
def create_connection(address, timeout=_GLOBAL_DEFAULT_TIMEOUT,
12+
source_address=None):
13+
"""Connect to *address* and return the socket object.
14+
15+
Convenience function. Connect to *address* (a 2-tuple ``(host,
16+
port)``) and return the socket object. Passing the optional
17+
*timeout* parameter will set the timeout on the socket instance
18+
before attempting to connect. If no *timeout* is supplied, the
19+
global default timeout setting returned by :func:`getdefaulttimeout`
20+
is used. If *source_address* is set it must be a tuple of (host, port)
21+
for the socket to bind as a source address before making the connection.
22+
An host of '' or port 0 tells the OS to use the default.
23+
"""
24+
25+
host, port = address
26+
err = None
27+
for res in getaddrinfo(host, port, 0, SOCK_STREAM):
28+
af, socktype, proto, canonname, sa = res
29+
sock = None
30+
try:
31+
sock = socket(af, socktype, proto)
32+
if timeout is not _GLOBAL_DEFAULT_TIMEOUT:
33+
sock.settimeout(timeout)
34+
if source_address:
35+
sock.bind(source_address)
36+
sock.connect(sa)
37+
return sock
38+
39+
except error as _:
40+
err = _
41+
if sock is not None:
42+
sock.close()
43+
44+
if err is not None:
45+
raise err
46+
else:
47+
raise error("getaddrinfo returns an empty list")
48+
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
We copy some functions from the Python 3.3.0 ssl module.
3+
4+
http://hg.python.org/releasing/3.3.0/file/1465cbbc8f64/Lib/ssl.py
5+
"""
6+
7+
8+
import re
9+
10+
11+
class CertificateError(ValueError):
12+
pass
13+
14+
15+
def _dnsname_to_pat(dn):
16+
pats = []
17+
for frag in dn.split(r'.'):
18+
if frag == '*':
19+
# When '*' is a fragment by itself, it matches a non-empty dotless
20+
# fragment.
21+
pats.append('[^.]+')
22+
else:
23+
# Otherwise, '*' matches any dotless fragment.
24+
frag = re.escape(frag)
25+
pats.append(frag.replace(r'\*', '[^.]*'))
26+
return re.compile(r'\A' + r'\.'.join(pats) + r'\Z', re.IGNORECASE)
27+
28+
29+
def match_hostname(cert, hostname):
30+
"""Verify that *cert* (in decoded format as returned by
31+
SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 rules
32+
are mostly followed, but IP addresses are not accepted for *hostname*.
33+
34+
CertificateError is raised on failure. On success, the function
35+
returns nothing.
36+
"""
37+
if not cert:
38+
raise ValueError("empty or no certificate")
39+
dnsnames = []
40+
san = cert.get('subjectAltName', ())
41+
for key, value in san:
42+
if key == 'DNS':
43+
if _dnsname_to_pat(value).match(hostname):
44+
return
45+
dnsnames.append(value)
46+
if not dnsnames:
47+
# The subject is only checked when there is no dNSName entry
48+
# in subjectAltName
49+
for sub in cert.get('subject', ()):
50+
for key, value in sub:
51+
# XXX according to RFC 2818, the most specific Common Name
52+
# must be used.
53+
if key == 'commonName':
54+
if _dnsname_to_pat(value).match(hostname):
55+
return
56+
dnsnames.append(value)
57+
if len(dnsnames) > 1:
58+
raise CertificateError("hostname %r "
59+
"doesn't match either of %s"
60+
% (hostname, ', '.join(map(repr, dnsnames))))
61+
elif len(dnsnames) == 1:
62+
raise CertificateError("hostname %r "
63+
"doesn't match %r"
64+
% (hostname, dnsnames[0]))
65+
else:
66+
raise CertificateError("no appropriate commonName or "
67+
"subjectAltName fields were found")
68+
69+
70+

tuf/conf.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,8 @@
3131
# which already exists and within that directory should have the file
3232
# 'metadata/current/root.txt'. This must be set!
3333
repository_directory = None
34+
35+
# A directory where you may find certificate authorities
36+
# https://en.wikipedia.org/wiki/Certificate_authority
37+
# http://docs.python.org/2/library/ssl.html#certificates
38+
ca_certs = None

tuf/download.py

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,123 @@
2222
2323
"""
2424

25-
import urllib2
2625
import logging
26+
import os.path
27+
import socket
2728

29+
import tuf
2830
import tuf.hash
2931
import tuf.util
3032
import tuf.formats
3133

34+
from tuf.compatibility import httplib, ssl, urllib2, urlparse
35+
if ssl:
36+
from tuf.compatibility import match_hostname
37+
else:
38+
raise tuf.Error( "No SSL support!" ) # TODO: degrade gracefully
39+
40+
3241
# See 'log.py' to learn how logging is handled in TUF.
3342
logger = logging.getLogger('tuf.download')
3443

3544

45+
class VerifiedHTTPSConnection( httplib.HTTPSConnection ):
46+
"""
47+
A connection that wraps connections with ssl certificate verification.
48+
49+
https://github.com/pypa/pip/blob/d0fa66ecc03ab20b7411b35f7c7b423f31f77761/pip/download.py#L72
50+
"""
51+
def connect(self):
52+
53+
self.connection_kwargs = {}
54+
55+
#TODO: refactor compatibility logic into tuf.compatibility?
56+
57+
# for > py2.5
58+
if hasattr(self, 'timeout'):
59+
self.connection_kwargs.update(timeout = self.timeout)
60+
61+
# for >= py2.7
62+
if hasattr(self, 'source_address'):
63+
self.connection_kwargs.update(source_address = self.source_address)
64+
65+
sock = socket.create_connection((self.host, self.port), **self.connection_kwargs)
66+
67+
# for >= py2.7
68+
if getattr(self, '_tunnel_host', None):
69+
self.sock = sock
70+
self._tunnel()
71+
72+
# set location of certificate authorities
73+
assert os.path.isfile( tuf.conf.ca_certs )
74+
cert_path = tuf.conf.ca_certs
75+
76+
self.sock = ssl.wrap_socket(sock,
77+
self.key_file,
78+
self.cert_file,
79+
cert_reqs=ssl.CERT_REQUIRED,
80+
ca_certs=cert_path)
81+
82+
match_hostname(self.sock.getpeercert(), self.host)
83+
84+
85+
class VerifiedHTTPSHandler( urllib2.HTTPSHandler ):
86+
"""
87+
A HTTPSHandler that uses our own VerifiedHTTPSConnection.
88+
89+
https://github.com/pypa/pip/blob/d0fa66ecc03ab20b7411b35f7c7b423f31f77761/pip/download.py#L109
90+
"""
91+
def __init__(self, connection_class = VerifiedHTTPSConnection):
92+
self.specialized_conn_class = connection_class
93+
urllib2.HTTPSHandler.__init__(self)
94+
def https_open(self, req):
95+
return self.do_open(self.specialized_conn_class, req)
96+
97+
98+
def _get_request(url):
99+
"""
100+
Wraps the URL to retrieve to protects against "creative"
101+
interpretation of the RFC: http://bugs.python.org/issue8732
102+
103+
https://github.com/pypa/pip/blob/d0fa66ecc03ab20b7411b35f7c7b423f31f77761/pip/download.py#L147
104+
"""
105+
106+
return urllib2.Request(url, headers={'Accept-encoding': 'identity'})
107+
108+
109+
def _get_opener( scheme = None ):
110+
"""
111+
Build a urllib2 opener based on whether the user now wants SSL.
112+
113+
https://github.com/pypa/pip/blob/d0fa66ecc03ab20b7411b35f7c7b423f31f77761/pip/download.py#L178
114+
"""
115+
116+
if scheme == "https":
117+
assert os.path.isfile( tuf.conf.ca_certs )
118+
119+
# If we are going over https, use an opener which will provide SSL
120+
# certificate verification.
121+
https_handler = VerifiedHTTPSHandler()
122+
opener = urllib2.build_opener( https_handler )
123+
124+
# strip out HTTPHandler to prevent MITM spoof
125+
for handler in opener.handlers:
126+
if isinstance( handler, urllib2.HTTPHandler ):
127+
opener.handlers.remove( handler )
128+
else:
129+
# Otherwise, use the default opener.
130+
opener = urllib2.build_opener()
131+
132+
return opener
133+
134+
36135
def _open_connection(url):
37136
"""
38137
<Purpose>
39138
Helper function that opens a connection to the url. urllib2 supports http,
40139
ftp, and file. In python (2.6+) where the ssl module is available, urllib2
41140
also supports https.
42-
43-
TODO: Do proper ssl cert/name checking.
141+
44142
TODO: Disallow SSLv2.
45143
TODO: Support ssl with MCrypto.
46144
TODO: Determine whether this follows http redirects and decide if we like
@@ -71,11 +169,12 @@ def _open_connection(url):
71169
# servers do not recognize connections that originates from
72170
# Python-urllib/x.y.
73171

74-
request = urllib2.Request(url)
75-
connection = urllib2.urlopen(request)
76-
# urllib2.urlopen returns a file-like object: a handle to the remote data.
77-
return connection
172+
parsed_url = urlparse.urlparse( url )
173+
opener = _get_opener( scheme = parsed_url.scheme )
174+
request = _get_request( url )
175+
return opener.open( request )
78176
except Exception, e:
177+
raise
79178
raise tuf.DownloadError(e)
80179

81180

tuf/interposition/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
## Methods
1+
## Examples
22

33
```python
44
import tuf.interposition
@@ -65,3 +65,5 @@ unspecified path for the given network location.
6565

6666
- The entire `urllib` or `urllib2` contract is not honoured.
6767
- Downloads are not thread safe.
68+
- Uses some Python features (e.g. string formatting) not available in earlier versions (e.g. < 2.6).
69+
- Uses some Python features (e.g. `urllib, urllib2, urlparse`) not available in later versions (e.g. >= 3).

0 commit comments

Comments
 (0)