[tor-commits] [stem/master] Support for encrypted hidden service descriptor introduction-points

atagar at torproject.org atagar at torproject.org
Sat Mar 7 23:33:53 UTC 2015


commit 529f1236805730bf93031de9aaf9823beb1077ed
Author: Damian Johnson <atagar at 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)



More information about the tor-commits mailing list