Skip to content

Commit 17008ed

Browse files
committed
support new TCPConnector param expect_fingerprint
1 parent fc7cbbf commit 17008ed

File tree

6 files changed

+112
-11
lines changed

6 files changed

+112
-11
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ CHANGES
44
0.16.0 (XX-XX-XXXX)
55
-------------------
66

7+
- Support new `expect_fingerprint` param of TCPConnector to enable verifying
8+
ssl certificates via md5, sha1, or sha256 fingerprint
9+
710
- Setup uploaded filename if field value is binary and transfer
811
encoding is not specified #349
912

aiohttp/connector.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import traceback
99
import warnings
1010

11+
from binascii import hexlify, unhexlify
1112
from collections import defaultdict
13+
from hashlib import md5, sha1, sha256
1214
from itertools import chain
1315
from math import ceil
1416

@@ -17,6 +19,7 @@
1719
from .errors import ServerDisconnectedError
1820
from .errors import HttpProxyError, ProxyConnectionError
1921
from .errors import ClientOSError, ClientTimeoutError
22+
from .errors import FingerprintMismatch
2023
from .helpers import BasicAuth
2124

2225

@@ -25,6 +28,12 @@
2528
PY_34 = sys.version_info >= (3, 4)
2629
PY_343 = sys.version_info >= (3, 4, 3)
2730

31+
HASHFUNC_BY_DIGESTLEN = {
32+
32: md5,
33+
40: sha1,
34+
64: sha256,
35+
}
36+
2837

2938
class Connection(object):
3039

@@ -347,13 +356,16 @@ class TCPConnector(BaseConnector):
347356
"""TCP connector.
348357
349358
:param bool verify_ssl: Set to True to check ssl certifications.
359+
:param str expect_fingerprint: Set to the md5, sha1, or sha256 fingerprint
360+
(as a hexadecimal string) of the expected certificate (DER-encoded)
361+
to verify the cert matches. May be interspersed with colons.
350362
:param bool resolve: Set to True to do DNS lookup for host name.
351363
:param family: socket address family
352364
:param args: see :class:`BaseConnector`
353365
:param kwargs: see :class:`BaseConnector`
354366
"""
355367

356-
def __init__(self, *, verify_ssl=True,
368+
def __init__(self, *, verify_ssl=True, expect_fingerprint=None,
357369
resolve=False, family=socket.AF_INET, ssl_context=None,
358370
**kwargs):
359371
super().__init__(**kwargs)
@@ -364,6 +376,17 @@ def __init__(self, *, verify_ssl=True,
364376
"verify_ssl=False or specify ssl_context, not both.")
365377

366378
self._verify_ssl = verify_ssl
379+
380+
if expect_fingerprint:
381+
expect_fingerprint = expect_fingerprint.replace(':', '').lower()
382+
digestlen = len(expect_fingerprint)
383+
hashfunc = HASHFUNC_BY_DIGESTLEN.get(digestlen)
384+
if not hashfunc:
385+
raise ValueError('Fingerprint is of invalid length.')
386+
self._hashfunc = hashfunc
387+
self._fingerprint_bytes = unhexlify(expect_fingerprint)
388+
389+
self._expect_fingerprint = expect_fingerprint
367390
self._ssl_context = ssl_context
368391
self._family = family
369392
self._resolve = resolve
@@ -374,6 +397,11 @@ def verify_ssl(self):
374397
"""Do check for ssl certifications?"""
375398
return self._verify_ssl
376399

400+
@property
401+
def expect_fingerprint(self):
402+
"""Expected value of ssl certificate fingerprint, if any."""
403+
return self._expect_fingerprint
404+
377405
@property
378406
def ssl_context(self):
379407
"""SSLContext instance for https requests.
@@ -464,11 +492,25 @@ def _create_connection(self, req):
464492

465493
for hinfo in hosts:
466494
try:
467-
return (yield from self._loop.create_connection(
468-
self._factory, hinfo['host'], hinfo['port'],
495+
host = hinfo['host']
496+
port = hinfo['port']
497+
conn = yield from self._loop.create_connection(
498+
self._factory, host, port,
469499
ssl=sslcontext, family=hinfo['family'],
470500
proto=hinfo['proto'], flags=hinfo['flags'],
471-
server_hostname=hinfo['hostname'] if sslcontext else None))
501+
server_hostname=hinfo['hostname'] if sslcontext else None)
502+
if req.ssl and self._expect_fingerprint:
503+
transport = conn[0]
504+
sock = transport.get_extra_info('socket')
505+
# gives DER-encoded cert as a sequence of bytes (or None)
506+
cert = sock.getpeercert(binary_form=True)
507+
got = cert and self._hashfunc(cert).digest()
508+
expected = self._fingerprint_bytes
509+
if expected != got:
510+
got = got and hexlify(got).decode('ascii')
511+
expected = hexlify(expected).decode('ascii')
512+
raise FingerprintMismatch(expected, got, host, port)
513+
return conn
472514
except OSError as e:
473515
exc = e
474516
else:

aiohttp/errors.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
'ClientError', 'ClientHttpProcessingError', 'ClientConnectionError',
1414
'ClientOSError', 'ClientTimeoutError', 'ProxyConnectionError',
1515
'ClientRequestError', 'ClientResponseError',
16+
'FingerprintMismatch',
1617

1718
'WSServerHandshakeError', 'WSClientDisconnectedError')
1819

@@ -170,3 +171,18 @@ class LineLimitExceededParserError(ParserError):
170171
def __init__(self, msg, limit):
171172
super().__init__(msg)
172173
self.limit = limit
174+
175+
176+
class FingerprintMismatch(ClientConnectionError):
177+
"""SSL certificate does not match expected fingerprint."""
178+
179+
def __init__(self, expected, got, host, port):
180+
self.expected = expected
181+
self.got = got
182+
self.host = host
183+
self.port = port
184+
185+
def __repr__(self):
186+
return '<{} expected={} got={} host={} port={}>'.format(
187+
self.__class__.__name__, self.expected, self.got,
188+
self.host, self.port)

docs/client.rst

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -396,20 +396,29 @@ By default it uses strict checks for HTTPS protocol. Certification
396396
checks can be relaxed by passing ``verify_ssl=False``::
397397

398398
>>> conn = aiohttp.TCPConnector(verify_ssl=False)
399-
>>> r = yield from aiohttp.request(
400-
... 'get', 'https://example.com', connector=conn)
399+
>>> session = aiohttp.ClientSession(connector=conn)
400+
>>> r = yield from session.get('https://example.com')
401401

402402

403403
If you need to setup custom ssl parameters (use own certification
404404
files for example) you can create a :class:`ssl.SSLContext` instance and
405405
pass it into the connector::
406406

407-
>>> sslcontext = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
408-
>>> sslcontext.verify_mode = ssl.CERT_REQUIRED
409-
>>> sslcontext.load_verify_locations("/etc/ssl/certs/ca-bundle.crt")
407+
>>> sslcontext = ssl.create_default_context(cafile='/path/to/ca-bundle.crt')
410408
>>> conn = aiohttp.TCPConnector(ssl_context=sslcontext)
411-
>>> r = yield from aiohttp.request(
412-
... 'get', 'https://example.com', connector=conn)
409+
>>> session = aiohttp.ClientSession(connector=conn)
410+
>>> r = yield from session.get('https://example.com')
411+
412+
You may also verify certificates via fingerprint::
413+
414+
>>> # hex string of md5, sha1, or sha256 of expected cert (in DER format)
415+
>>> expected_md5 = 'ca3b499c75768e7313384e243f15cacb'
416+
>>> conn = aiohttp.TCPConnector(expect_fingerprint=expected_md5)
417+
>>> session = aiohttp.ClientSession(connector=conn)
418+
>>> r = yield from session.get('https://www.python.org')
419+
Traceback (most recent call last)\:
420+
File "<stdin>", line 1, in <module>
421+
FingerprintMismatch: ('ca3b499c75768e7313384e243f15cacb', 'a20647adaaf5d85c4a995e62793b063d', 'www.python.org', 443)
413422

414423

415424
Unix domain sockets

tests/sample.crt.der

567 Bytes
Binary file not shown.

tests/test_connector.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import aiohttp
1313
from aiohttp import client
1414
from aiohttp import test_utils
15+
from aiohttp.errors import FingerprintMismatch
1516
from aiohttp.client import ClientResponse, ClientRequest
1617
from aiohttp.connector import Connection
1718

@@ -452,10 +453,40 @@ def test_cleanup3(self):
452453
def test_tcp_connector_ctor(self):
453454
conn = aiohttp.TCPConnector(loop=self.loop)
454455
self.assertTrue(conn.verify_ssl)
456+
self.assertIs(conn.expect_fingerprint, None)
455457
self.assertFalse(conn.resolve)
456458
self.assertEqual(conn.family, socket.AF_INET)
457459
self.assertEqual(conn.resolved_hosts, {})
458460

461+
def test_tcp_connector_ctor_expect_fingerprint_valid(self):
462+
valid = '7393fd3aed081d6fa9ae71391ae3c57f89e76cf9'
463+
conn = aiohttp.TCPConnector(loop=self.loop, expect_fingerprint=valid)
464+
self.assertEqual(conn.expect_fingerprint, valid)
465+
466+
def test_tcp_connector_expect_fingerprint_invalid(self):
467+
invalid = 'a1b2c3'
468+
with self.assertRaises(ValueError):
469+
aiohttp.TCPConnector(loop=self.loop, expect_fingerprint=invalid)
470+
471+
def test_tcp_connector_expect_fingerprint(self):
472+
# sha1 fingerprint of ./sample.crt.der
473+
fpgood = '7393fd3aed081d6fa9ae71391ae3c57f89e76cf9'
474+
fpbad = 'badbadbadbadbadbadbadbadbadbadbadbadbad1'
475+
for fp in (fpgood, fpbad):
476+
conn = aiohttp.TCPConnector(loop=self.loop, verify_ssl=False,
477+
expect_fingerprint=fp)
478+
with test_utils.run_server(self.loop, use_ssl=True) as httpd:
479+
coro = client.request('get', httpd.url('method', 'get'),
480+
connector=conn, loop=self.loop)
481+
if fp == fpgood:
482+
# should not raise
483+
self.loop.run_until_complete(coro)
484+
else:
485+
with self.assertRaises(FingerprintMismatch) as cm:
486+
self.loop.run_until_complete(coro)
487+
self.assertEqual(cm.exception.expected, fpbad)
488+
self.assertEqual(cm.exception.got, fpgood)
489+
459490
def test_tcp_connector_clear_resolved_hosts(self):
460491
conn = aiohttp.TCPConnector(loop=self.loop)
461492
info = object()

0 commit comments

Comments
 (0)