commit 529f1236805730bf93031de9aaf9823beb1077ed Author: Damian Johnson <ata...@torproject.org> Date: Sat Mar 7 15:31:15 2015 -0800
Support for encrypted hidden service descriptor introduction-points Supporting basic and stealth auth for hidden service descriptor introduction-points. Based on donncha's example script on #15004. --- stem/descriptor/hidden_service_descriptor.py | 124 +++++++++++++++++++-- test/unit/descriptor/hidden_service_descriptor.py | 65 +++++++++++ 2 files changed, 181 insertions(+), 8 deletions(-) diff --git a/stem/descriptor/hidden_service_descriptor.py b/stem/descriptor/hidden_service_descriptor.py index 1292189..f64036d 100644 --- a/stem/descriptor/hidden_service_descriptor.py +++ b/stem/descriptor/hidden_service_descriptor.py @@ -21,7 +21,10 @@ the HSDir flag. # TODO: Add a description for how to retrieve them when tor supports that # (#14847) and then update #15009. +import base64 +import binascii import collections +import hashlib import io import stem.util.connection @@ -63,6 +66,19 @@ INTRODUCTION_POINTS_ATTR = { 'intro_authentication': [], } +# introduction-point fields that can only appear once + +SINGLE_INTRODUCTION_POINT_FIELDS = [ + 'introduction-point', + 'ip-address', + 'onion-port', + 'onion-key', + 'service-key', +] + +BASIC_AUTH = 1 +STEALTH_AUTH = 2 + IntroductionPoint = collections.namedtuple('IntroductionPoints', INTRODUCTION_POINTS_ATTR.keys()) @@ -238,7 +254,7 @@ class HiddenServiceDescriptor(Descriptor): self._entries = entries @lru_cache() - def introduction_points(self): + def introduction_points(self, authentication_cookie = None): """ Provided this service's introduction points. This provides a list of IntroductionPoint instances, which have the following attributes... @@ -251,6 +267,9 @@ class HiddenServiceDescriptor(Descriptor): * **intro_authentication** (list): tuples of the form (auth_type, auth_data) for establishing a connection + :param str authentication_cookie: cookie to decrypt the introduction-points + if it's encrypted + :returns: **list** of IntroductionPoints instances :raises: @@ -258,15 +277,103 @@ class HiddenServiceDescriptor(Descriptor): * **DecryptionFailure** if unable to decrypt this field """ - # TODO: Support fields encrypted with a desriptor-cookie. (#15004) + content = self.introduction_points_content - if not self.introduction_points_content: + if not content: return [] - elif not self.introduction_points_content.startswith(b'introduction-point '): - raise DecryptionFailure('introduction-point content is encrypted') + elif authentication_cookie: + if not stem.prereq.is_crypto_available(): + raise DecryptionFailure('Decrypting introduction-points requires pycrypto') + + try: + missing_padding = len(authentication_cookie) % 4 + authentication_cookie = base64.b64decode(authentication_cookie + '=' * missing_padding) + except TypeError as exc: + raise DecryptionFailure('authentication_cookie must be a base64 encoded string (%s)' % exc) + + authentication_type = int(binascii.hexlify(content[0]), 16) + + if authentication_type == BASIC_AUTH: + content = HiddenServiceDescriptor._decrypt_basic_auth(content, authentication_cookie) + elif authentication_type == STEALTH_AUTH: + content = HiddenServiceDescriptor._decrypt_stealth_auth(content, authentication_cookie) + else: + raise DecryptionFailure("Unrecognized authentication type '%s', presently we only support basic auth (%s) and stealth auth (%s)" % (authentication_type, BASIC_AUTH, STEALTH_AUTH)) + + if not content.startswith(b'introduction-point '): + raise DecryptionFailure("Unable to decrypt the introduction-points, maybe this is the wrong key?") + elif not content.startswith(b'introduction-point '): + raise DecryptionFailure('introduction-points content is encrypted, you need to provide its authentication_cookie') + + return HiddenServiceDescriptor._parse_introduction_points(content) + + @staticmethod + def _decrypt_basic_auth(content, authentication_cookie): + from Crypto.Cipher import AES + from Crypto.Util import Counter + from Crypto.Util.number import bytes_to_long + + try: + client_blocks = int(binascii.hexlify(content[1]), 16) + except ValueError: + raise DecryptionFailure("When using basic auth the content should start with a number of blocks but wasn't a hex digit: %s" % binascii.hexlify(content[1])) + + # parse the client id and encrypted session keys + + client_entries_length = client_blocks * 16 * 20 + client_entries = content[2:2 + client_entries_length] + client_keys = [(client_entries[i:i + 4], client_entries[i + 4:i + 20]) for i in range(0, client_entries_length, 4 + 16)] + + iv = content[2 + client_entries_length:2 + client_entries_length + 16] + encrypted = content[2 + client_entries_length + 16:] + + client_id = hashlib.sha1(authentication_cookie + iv).digest()[:4] + + for entry_id, encrypted_session_key in client_keys: + if entry_id != client_id: + continue # not the session key for this client + + # try decrypting the session key + + counter = Counter.new(128, initial_value = 0) + cipher = AES.new(authentication_cookie, AES.MODE_CTR, counter = counter) + session_key = cipher.decrypt(encrypted_session_key) + + # attempt to decrypt the intro points with the session key + + counter = Counter.new(128, initial_value = bytes_to_long(iv)) + cipher = AES.new(session_key, AES.MODE_CTR, counter = counter) + decrypted = cipher.decrypt(encrypted) + + # check if the decryption looks correct + + if decrypted.startswith(b'introduction-point '): + return decrypted + + return content # nope, unable to decrypt the content + + @staticmethod + def _decrypt_stealth_auth(content, authentication_cookie): + from Crypto.Cipher import AES + from Crypto.Util import Counter + from Crypto.Util.number import bytes_to_long + + # byte 1 = authentication type, 2-17 = input vector, 18 on = encrypted content + + iv, encrypted = content[1:17], content[17:] + counter = Counter.new(128, initial_value = bytes_to_long(iv)) + cipher = AES.new(authentication_cookie, AES.MODE_CTR, counter = counter) + + return cipher.decrypt(encrypted) + + @staticmethod + def _parse_introduction_points(content): + """ + Provides the parsed list of IntroductionPoint for the unencrypted content. + """ introduction_points = [] - content_io = io.BytesIO(self.introduction_points_content) + content_io = io.BytesIO(content) while True: content = b''.join(_read_until_keywords('introduction-point', content_io, ignore_first = True)) @@ -277,11 +384,12 @@ class HiddenServiceDescriptor(Descriptor): attr = dict(INTRODUCTION_POINTS_ATTR) entries = _get_descriptor_components(content, False) - # TODO: most fields can only appear once, we should check for that - for keyword, values in list(entries.items()): value, block_type, block_contents = values[0] + if keyword in SINGLE_INTRODUCTION_POINT_FIELDS and len(values) > 1: + raise ValueError("'%s' can only appear once in an introduction-point block, but appeared %i times" % (keyword, len(values))) + if keyword == 'introduction-point': attr['identifier'] = value elif keyword == 'ip-address': diff --git a/test/unit/descriptor/hidden_service_descriptor.py b/test/unit/descriptor/hidden_service_descriptor.py index 911e9fa..7b37ef0 100644 --- a/test/unit/descriptor/hidden_service_descriptor.py +++ b/test/unit/descriptor/hidden_service_descriptor.py @@ -6,6 +6,9 @@ import datetime import unittest import stem.descriptor +import stem.prereq + +import test.runner from test.mocking import CRYPTO_BLOB, get_hidden_service_descriptor from test.unit.descriptor import get_resource @@ -269,6 +272,9 @@ class TestHiddenServiceDescriptor(unittest.TestCase): Parse a descriptor with introduction-points encrypted with basic auth. """ + if not stem.prereq.is_crypto_available(): + return test.runner.skip(self, 'requires pycrypto') + descriptor_file = open(get_resource('hidden_service_basic_auth'), 'rb') desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = True)) @@ -281,12 +287,43 @@ class TestHiddenServiceDescriptor(unittest.TestCase): self.assertEqual([], desc.introduction_points_auth) self.assertRaises(DecryptionFailure, desc.introduction_points) + self.assertRaises(DecryptionFailure, desc.introduction_points, 'aCmx3qIvArbil8A0KM4KgQ==') + + introduction_points = desc.introduction_points('dCmx3qIvArbil8A0KM4KgQ==') + self.assertEqual(3, len(introduction_points)) + + point = introduction_points[0] + self.assertEqual('hmtvoobwglmmec26alnvl7x7mgmmr7xv', point.identifier) + self.assertEqual('195.154.82.88', point.address) + self.assertEqual(443, point.port) + self.assertTrue('MIGJAoGBANbPRD07T' in point.onion_key) + self.assertTrue('MIGJAoGBAN+LAdZP/' in point.service_key) + self.assertEqual([], point.intro_authentication) + + point = introduction_points[1] + self.assertEqual('q5w6l2f4g5zw4rkr56fkyovbkkrnzcj5', point.identifier) + self.assertEqual('37.252.190.133', point.address) + self.assertEqual(9001, point.port) + self.assertTrue('MIGJAoGBAKmsbKrtt' in point.onion_key) + self.assertTrue('MIGJAoGBANwczLtzR' in point.service_key) + self.assertEqual([], point.intro_authentication) + + point = introduction_points[2] + self.assertEqual('qcvprvmvnjb4dfyqjtxskugniliwlrx3', point.identifier) + self.assertEqual('193.11.114.45', point.address) + self.assertEqual(9002, point.port) + self.assertTrue('MIGJAoGBAM1ILL+7P' in point.onion_key) + self.assertTrue('MIGJAoGBAM7B/cymp' in point.service_key) + self.assertEqual([], point.intro_authentication) def test_with_stealth_auth(self): """ Parse a descriptor with introduction-points encrypted with stealth auth. """ + if not stem.prereq.is_crypto_available(): + return test.runner.skip(self, 'requires pycrypto') + descriptor_file = open(get_resource('hidden_service_stealth_auth'), 'rb') desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = True)) @@ -298,6 +335,34 @@ class TestHiddenServiceDescriptor(unittest.TestCase): self.assertEqual([], desc.introduction_points_auth) self.assertRaises(DecryptionFailure, desc.introduction_points) + self.assertRaises(DecryptionFailure, desc.introduction_points, 'aCmx3qIvArbil8A0KM4KgQ==') + + introduction_points = desc.introduction_points('dCmx3qIvArbil8A0KM4KgQ==') + self.assertEqual(3, len(introduction_points)) + + point = introduction_points[0] + self.assertEqual('6h4bkedts3yz2exl3vu4lsyiwkjrx5ff', point.identifier) + self.assertEqual('95.85.60.23', point.address) + self.assertEqual(443, point.port) + self.assertTrue('MIGJAoGBAMX5hO5hQ' in point.onion_key) + self.assertTrue('MIGJAoGBAMNSjfydv' in point.service_key) + self.assertEqual([], point.intro_authentication) + + point = introduction_points[1] + self.assertEqual('4ghasjftsdfbbycafvlfx7czln3hrk53', point.identifier) + self.assertEqual('178.254.55.101', point.address) + self.assertEqual(9901, point.port) + self.assertTrue('MIGJAoGBAL2v/KNEY' in point.onion_key) + self.assertTrue('MIGJAoGBAOXiuIgBr' in point.service_key) + self.assertEqual([], point.intro_authentication) + + point = introduction_points[2] + self.assertEqual('76tsxvudxqx47gedk3tl5qpesdzrh6yh', point.identifier) + self.assertEqual('193.11.164.243', point.address) + self.assertEqual(9001, point.port) + self.assertTrue('MIGJAoGBALca3zEoS' in point.onion_key) + self.assertTrue('MIGJAoGBAL3rWIAQ6' in point.service_key) + self.assertEqual([], point.intro_authentication) def _assert_matches_duckduckgo(self, desc): self.assertEqual('y3olqqblqw2gbh6phimfuiroechjjafa', desc.descriptor_id) _______________________________________________ tor-commits mailing list tor-commits@lists.torproject.org https://lists.torproject.org/cgi-bin/mailman/listinfo/tor-commits