summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDamian Johnson <atagar@torproject.org>2015-02-28 21:12:46 -0800
committerDamian Johnson <atagar@torproject.org>2015-02-28 21:12:53 -0800
commit14beceaa9da6e816ad3da0c28049e682003efb85 (patch)
tree1522b7cfb95ac3e523902d4a8735c1c42f5eb952
parent48abb6894de6b2d29593f28f326bbbf18919b8b9 (diff)
parent44f3e5fe4d8586da2dd2cf0a662d4ab801676d6e (diff)
Hidden service descriptor support
Support for parsing hidden service descriptors, as defined in the rend-spec... https://trac.torproject.org/projects/tor/ticket/15004 https://gitweb.torproject.org/torspec.git/tree/rend-spec.txt#n222 This doesn't yet support encrypted introduction-points, but otherwise is a complete parser. Like server descriptors, this validates our signature's integrity if pycrypto is available.
-rw-r--r--docs/api.rst1
-rw-r--r--docs/change_log.rst1
-rw-r--r--docs/contents.rst1
-rw-r--r--stem/descriptor/__init__.py119
-rw-r--r--stem/descriptor/hidden_service_descriptor.py311
-rw-r--r--stem/descriptor/server_descriptor.py138
-rw-r--r--stem/util/connection.py44
-rw-r--r--test/mocking.py47
-rw-r--r--test/settings.cfg1
-rw-r--r--test/unit/descriptor/data/hidden_service_basic_auth67
-rw-r--r--test/unit/descriptor/data/hidden_service_duckduckgo60
-rw-r--r--test/unit/descriptor/data/hidden_service_facebook60
-rw-r--r--test/unit/descriptor/data/hidden_service_stealth_auth60
-rw-r--r--test/unit/descriptor/hidden_service_descriptor.py499
-rw-r--r--test/unit/descriptor/server_descriptor.py4
-rw-r--r--test/unit/tutorial.py2
16 files changed, 1265 insertions, 150 deletions
diff --git a/docs/api.rst b/docs/api.rst
index 4f1a01c6..85cfdcb7 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -34,6 +34,7 @@ remotely like Tor does.
* `stem.descriptor.microdescriptor <api/descriptor/microdescriptor.html>`_ - Minimalistic counterpart for server descriptors.
* `stem.descriptor.networkstatus <api/descriptor/networkstatus.html>`_ - Network status documents which make up the Tor consensus.
* `stem.descriptor.router_status_entry <api/descriptor/router_status_entry.html>`_ - Relay entries within a network status document.
+ * `stem.descriptor.hidden_service_descriptor <api/descriptor/hidden_service_descriptor.html>`_ - Descriptors generated for hidden services.
* `stem.descriptor.tordnsel <api/descriptor/tordnsel.html>`_ - `TorDNSEL <https://www.torproject.org/projects/tordnsel.html.en>`_ exit lists.
* `stem.descriptor.reader <api/descriptor/reader.html>`_ - Reads and parses descriptor files from disk.
diff --git a/docs/change_log.rst b/docs/change_log.rst
index 71436552..a9228940 100644
--- a/docs/change_log.rst
+++ b/docs/change_log.rst
@@ -58,6 +58,7 @@ conversion (:trac:`14075`).
* **Descriptors**
* Lazy-loading descriptors, improving performance by 25-70% depending on what type it is (:trac:`14011`)
+ * Added `support for hidden service descriptors <api/descriptor/hidden_service_descriptor.html>`_ (:trac:`15004`)
* The :class:`~stem.descriptor.networkstatus.DirectoryAuthority` 'fingerprint' attribute was actually its 'v3ident'
* Updating Faravahar's address (:trac:`14487`)
diff --git a/docs/contents.rst b/docs/contents.rst
index db703a24..a402dd61 100644
--- a/docs/contents.rst
+++ b/docs/contents.rst
@@ -42,6 +42,7 @@ Contents
api/descriptor/microdescriptor
api/descriptor/networkstatus
api/descriptor/router_status_entry
+ api/descriptor/hidden_service_descriptor
api/descriptor/tordnsel
api/descriptor/export
diff --git a/stem/descriptor/__init__.py b/stem/descriptor/__init__.py
index abcdab21..85ff986d 100644
--- a/stem/descriptor/__init__.py
+++ b/stem/descriptor/__init__.py
@@ -50,7 +50,10 @@ __all__ = [
'Descriptor',
]
+import base64
+import codecs
import copy
+import hashlib
import os
import re
import tarfile
@@ -122,6 +125,7 @@ def parse_file(descriptor_file, descriptor_type = None, validate = False, docume
torperf 1.0 **unsupported**
bridge-pool-assignment 1.0 **unsupported**
tordnsel 1.0 :class:`~stem.descriptor.tordnsel.TorDNSEL`
+ hidden-service-descriptor 1.0 :class:`~stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor`
========================================= =====
If you're using **python 3** then beware that the open() function defaults to
@@ -307,6 +311,11 @@ def _parse_metrics_file(descriptor_type, major_version, minor_version, descripto
for desc in stem.descriptor.tordnsel._parse_file(descriptor_file, validate = validate, **kwargs):
yield desc
+ elif descriptor_type == 'hidden-service-descriptor' and major_version == 1:
+ document_type = stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor
+
+ for desc in stem.descriptor.hidden_service_descriptor._parse_file(descriptor_file, validate = validate, **kwargs):
+ yield desc
else:
raise TypeError("Unrecognized metrics descriptor format. type: '%s', version: '%i.%i'" % (descriptor_type, major_version, minor_version))
@@ -493,6 +502,97 @@ class Descriptor(object):
def _name(self, is_plural = False):
return str(type(self))
+ def _digest_for_signature(self, signing_key, signature):
+ """
+ Provides the signed digest we should have given this key and signature.
+
+ :param str signing_key: key block used to make this signature
+ :param str signature: signed digest for this descriptor content
+
+ :returns: the digest string encoded in uppercase hex
+
+ :raises: ValueError if unable to provide a validly signed digest
+ """
+
+ if not stem.prereq.is_crypto_available():
+ raise ValueError('Generating the signed digest requires pycrypto')
+
+ from Crypto.Util import asn1
+ from Crypto.Util.number import bytes_to_long, long_to_bytes
+
+ # get the ASN.1 sequence
+
+ seq = asn1.DerSequence()
+ seq.decode(_bytes_for_block(signing_key))
+ modulus, public_exponent = seq[0], seq[1]
+
+ sig_as_bytes = _bytes_for_block(signature)
+ sig_as_long = bytes_to_long(sig_as_bytes) # convert signature to an int
+ blocksize = 128 # block size will always be 128 for a 1024 bit key
+
+ # use the public exponent[e] & the modulus[n] to decrypt the int
+
+ decrypted_int = pow(sig_as_long, public_exponent, modulus)
+
+ # convert the int to a byte array
+
+ decrypted_bytes = long_to_bytes(decrypted_int, blocksize)
+
+ ############################################################################
+ # The decrypted bytes should have a structure exactly along these lines.
+ # 1 byte - [null '\x00']
+ # 1 byte - [block type identifier '\x01'] - Should always be 1
+ # N bytes - [padding '\xFF' ]
+ # 1 byte - [separator '\x00' ]
+ # M bytes - [message]
+ # Total - 128 bytes
+ # More info here http://www.ietf.org/rfc/rfc2313.txt
+ # esp the Notes in section 8.1
+ ############################################################################
+
+ try:
+ if decrypted_bytes.index(b'\x00\x01') != 0:
+ raise ValueError('Verification failed, identifier missing')
+ except ValueError:
+ raise ValueError('Verification failed, malformed data')
+
+ try:
+ identifier_offset = 2
+
+ # find the separator
+ seperator_index = decrypted_bytes.index(b'\x00', identifier_offset)
+ except ValueError:
+ raise ValueError('Verification failed, seperator not found')
+
+ digest_hex = codecs.encode(decrypted_bytes[seperator_index + 1:], 'hex_codec')
+ return stem.util.str_tools._to_unicode(digest_hex.upper())
+
+ def _digest_for_content(self, start, end):
+ """
+ Provides the digest of our descriptor's content in a given range.
+
+ :param bytes start: start of the range to generate a digest for
+ :param bytes end: end of the range to generate a digest for
+
+ :returns: the digest string encoded in uppercase hex
+
+ :raises: ValueError if the digest canot be calculated
+ """
+
+ raw_descriptor = self.get_bytes()
+
+ start_index = raw_descriptor.find(start)
+ end_index = raw_descriptor.find(end, start_index)
+
+ if start_index == -1:
+ raise ValueError("Digest is for the range starting with '%s' but that isn't in our descriptor" % start)
+ elif end_index == -1:
+ raise ValueError("Digest is for the range ending with '%s' but that isn't in our descriptor" % end)
+
+ digest_content = raw_descriptor[start_index:end_index + len(end)]
+ digest_hash = hashlib.sha1(stem.util.str_tools._to_bytes(digest_content))
+ return stem.util.str_tools._to_unicode(digest_hash.hexdigest().upper())
+
def __getattr__(self, name):
# If attribute isn't already present we might be lazy loading it...
@@ -587,6 +687,24 @@ def _read_until_keywords(keywords, descriptor_file, inclusive = False, ignore_fi
return content
+def _bytes_for_block(content):
+ """
+ Provides the base64 decoded content of a pgp-style block.
+
+ :param str content: block to be decoded
+
+ :returns: decoded block content
+
+ :raises: **TypeError** if this isn't base64 encoded content
+ """
+
+ # strip the '-----BEGIN RSA PUBLIC KEY-----' header and footer
+
+ content = ''.join(content.split('\n')[1:-1])
+
+ return base64.b64decode(stem.util.str_tools._to_bytes(content))
+
+
def _get_pseudo_pgp_block(remaining_contents):
"""
Checks if given contents begins with a pseudo-Open-PGP-style block and, if
@@ -720,3 +838,4 @@ import stem.descriptor.extrainfo_descriptor
import stem.descriptor.networkstatus
import stem.descriptor.microdescriptor
import stem.descriptor.tordnsel
+import stem.descriptor.hidden_service_descriptor
diff --git a/stem/descriptor/hidden_service_descriptor.py b/stem/descriptor/hidden_service_descriptor.py
new file mode 100644
index 00000000..73f5edf2
--- /dev/null
+++ b/stem/descriptor/hidden_service_descriptor.py
@@ -0,0 +1,311 @@
+# Copyright 2015, Damian Johnson and The Tor Project
+# See LICENSE for licensing information
+
+"""
+Parsing for Tor hidden service descriptors as described in Tor's `rend-spec
+<https://gitweb.torproject.org/torspec.git/tree/rend-spec.txt>`_.
+
+Unlike other descriptor types these describe a hidden service rather than a
+relay. They're created by the service, and can only be fetched via relays with
+the HSDir flag.
+
+**Module Overview:**
+
+::
+
+ HiddenServiceDescriptor - Tor hidden service descriptor.
+"""
+
+# TODO: Add a description for how to retrieve them when tor supports that
+# (#14847) and then update #15009.
+
+import collections
+import io
+
+import stem.util.connection
+
+from stem.descriptor import (
+ PGP_BLOCK_END,
+ Descriptor,
+ _get_descriptor_components,
+ _read_until_keywords,
+ _bytes_for_block,
+ _value,
+ _parse_simple_line,
+ _parse_timestamp_line,
+ _parse_key_block,
+)
+
+try:
+ # added in python 3.2
+ from functools import lru_cache
+except ImportError:
+ from stem.util.lru_cache import lru_cache
+
+REQUIRED_FIELDS = (
+ 'rendezvous-service-descriptor',
+ 'version',
+ 'permanent-key',
+ 'secret-id-part',
+ 'publication-time',
+ 'protocol-versions',
+ 'signature',
+)
+
+INTRODUCTION_POINTS_ATTR = {
+ 'identifier': None,
+ 'address': None,
+ 'port': None,
+ 'onion_key': None,
+ 'service_key': None,
+ 'intro_authentication': [],
+}
+
+IntroductionPoint = collections.namedtuple('IntroductionPoints', INTRODUCTION_POINTS_ATTR.keys())
+
+
+class DecryptionFailure(Exception):
+ """
+ Failure to decrypt the hidden service descriptor's introduction-points.
+ """
+
+
+def _parse_file(descriptor_file, validate = False, **kwargs):
+ """
+ Iterates over the hidden service descriptors in a file.
+
+ :param file descriptor_file: file with descriptor content
+ :param bool validate: checks the validity of the descriptor's content if
+ **True**, skips these checks otherwise
+ :param dict kwargs: additional arguments for the descriptor constructor
+
+ :returns: iterator for :class:`~stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor`
+ instances in the file
+
+ :raises:
+ * **ValueError** if the contents is malformed and validate is **True**
+ * **IOError** if the file can't be read
+ """
+
+ while True:
+ descriptor_content = _read_until_keywords('signature', descriptor_file)
+
+ # we've reached the 'signature', now include the pgp style block
+ block_end_prefix = PGP_BLOCK_END.split(' ', 1)[0]
+ descriptor_content += _read_until_keywords(block_end_prefix, descriptor_file, True)
+
+ if descriptor_content:
+ if descriptor_content[0].startswith(b'@type'):
+ descriptor_content = descriptor_content[1:]
+
+ yield HiddenServiceDescriptor(bytes.join(b'', descriptor_content), validate, **kwargs)
+ else:
+ break # done parsing file
+
+
+def _parse_version_line(descriptor, entries):
+ value = _value('version', entries)
+
+ if value.isdigit():
+ descriptor.version = int(value)
+ else:
+ raise ValueError('version line must have a positive integer value: %s' % value)
+
+
+def _parse_protocol_versions_line(descriptor, entries):
+ value = _value('protocol-versions', entries)
+
+ try:
+ versions = [int(entry) for entry in value.split(',')]
+ except ValueError:
+ raise ValueError('protocol-versions line has non-numeric versoins: protocol-versions %s' % value)
+
+ for v in versions:
+ if v <= 0:
+ raise ValueError('protocol-versions must be positive integers: %s' % value)
+
+ descriptor.protocol_versions = versions
+
+
+def _parse_introduction_points_line(descriptor, entries):
+ _, block_type, block_contents = entries['introduction-points'][0]
+
+ if not block_contents or block_type != 'MESSAGE':
+ raise ValueError("'introduction-points' should be followed by a MESSAGE block, but was a %s" % block_type)
+
+ descriptor.introduction_points_encoded = block_contents
+
+ try:
+ decoded_field = _bytes_for_block(block_contents)
+ except TypeError:
+ raise ValueError("'introduction-points' isn't base64 encoded content:\n%s" % block_contents)
+
+ auth_types = []
+
+ while decoded_field.startswith(b'service-authentication ') and b'\n' in decoded_field:
+ auth_line, decoded_field = decoded_field.split(b'\n', 1)
+ auth_line_comp = auth_line.split(b' ')
+
+ if len(auth_line_comp) < 3:
+ raise ValueError("Within introduction-points we expected 'service-authentication [auth_type] [auth_data]', but had '%s'" % auth_line)
+
+ auth_types.append((auth_line_comp[1], auth_line_comp[2]))
+
+ descriptor.introduction_points_auth = auth_types
+ descriptor.introduction_points_content = decoded_field
+
+_parse_rendezvous_service_descriptor_line = _parse_simple_line('rendezvous-service-descriptor', 'descriptor_id')
+_parse_permanent_key_line = _parse_key_block('permanent-key', 'permanent_key', 'RSA PUBLIC KEY')
+_parse_secret_id_part_line = _parse_simple_line('secret-id-part', 'secret_id_part')
+_parse_publication_time_line = _parse_timestamp_line('publication-time', 'published')
+_parse_signature_line = _parse_key_block('signature', 'signature', 'SIGNATURE')
+
+
+class HiddenServiceDescriptor(Descriptor):
+ """
+ Hidden service descriptor.
+
+ :var str descriptor_id: **\*** identifier for this descriptor, this is a base32 hash of several fields
+ :var int version: **\*** hidden service descriptor version
+ :var str permanent_key: **\*** long term key of the hidden service
+ :var str secret_id_part: **\*** hash of the time period, cookie, and replica
+ values so our descriptor_id can be validated
+ :var datetime published: **\*** time in UTC when this descriptor was made
+ :var list protocol_versions: **\*** list of **int** versions that are supported when establishing a connection
+ :var str introduction_points_encoded: raw introduction points blob
+ :var list introduction_points_auth: **\*** tuples of the form
+ (auth_method, auth_data) for our introduction_points_content
+ :var bytes introduction_points_content: decoded introduction-points content
+ without authentication data, if using cookie authentication this is
+ encrypted
+ :var str signature: signature of the descriptor content
+
+ **\*** attribute is either required when we're parsed with validation or has
+ a default value, others are left as **None** if undefined
+ """
+
+ ATTRIBUTES = {
+ 'descriptor_id': (None, _parse_rendezvous_service_descriptor_line),
+ 'version': (None, _parse_version_line),
+ 'permanent_key': (None, _parse_permanent_key_line),
+ 'secret_id_part': (None, _parse_secret_id_part_line),
+ 'published': (None, _parse_publication_time_line),
+ 'protocol_versions': ([], _parse_protocol_versions_line),
+ 'introduction_points_encoded': (None, _parse_introduction_points_line),
+ 'introduction_points_auth': ([], _parse_introduction_points_line),
+ 'introduction_points_content': (None, _parse_introduction_points_line),
+ 'signature': (None, _parse_signature_line),
+ }
+
+ PARSER_FOR_LINE = {
+ 'rendezvous-service-descriptor': _parse_rendezvous_service_descriptor_line,
+ 'version': _parse_version_line,
+ 'permanent-key': _parse_permanent_key_line,
+ 'secret-id-part': _parse_secret_id_part_line,
+ 'publication-time': _parse_publication_time_line,
+ 'protocol-versions': _parse_protocol_versions_line,
+ 'introduction-points': _parse_introduction_points_line,
+ 'signature': _parse_signature_line,
+ }
+
+ def __init__(self, raw_contents, validate = False):
+ super(HiddenServiceDescriptor, self).__init__(raw_contents, lazy_load = not validate)
+ entries = _get_descriptor_components(raw_contents, validate)
+
+ if validate:
+ for keyword in REQUIRED_FIELDS:
+ if keyword not in entries:
+ raise ValueError("Hidden service descriptor must have a '%s' entry" % keyword)
+ elif keyword in entries and len(entries[keyword]) > 1:
+ raise ValueError("The '%s' entry can only appear once in a hidden service descriptor" % keyword)
+
+ if 'rendezvous-service-descriptor' != list(entries.keys())[0]:
+ raise ValueError("Hidden service descriptor must start with a 'rendezvous-service-descriptor' entry")
+ elif 'signature' != list(entries.keys())[-1]:
+ raise ValueError("Hidden service descriptor must end with a 'signature' entry")
+
+ self._parse(entries, validate)
+
+ if stem.prereq.is_crypto_available():
+ signed_digest = self._digest_for_signature(self.permanent_key, self.signature)
+ content_digest = self._digest_for_content(b'rendezvous-service-descriptor ', b'\nsignature\n')
+
+ if signed_digest != content_digest:
+ raise ValueError('Decrypted digest does not match local digest (calculated: %s, local: %s)' % (signed_digest, content_digest))
+ else:
+ self._entries = entries
+
+ @lru_cache()
+ def introduction_points(self):
+ """
+ Provided this service's introduction points. This provides a list of
+ IntroductionPoint instances, which have the following attributes...
+
+ * **identifier** (str): hash of this introduction point's identity key
+ * **address** (str): address of this introduction point
+ * **port** (int): port where this introduction point is listening
+ * **onion_key** (str): public key for communicating with this introduction point
+ * **service_key** (str): public key for communicating with this hidden service
+ * **intro_authentication** (list): tuples of the form (auth_type, auth_data)
+ for establishing a connection
+
+ :returns: **list** of IntroductionPoints instances
+
+ :raises:
+ * **ValueError** if the our introduction-points is malformed
+ * **DecryptionFailure** if unable to decrypt this field
+ """
+
+ # TODO: Support fields encrypted with a desriptor-cookie. (#15004)
+
+ if not self.introduction_points_content:
+ return []
+ elif not self.introduction_points_content.startswith(b'introduction-point '):
+ raise DecryptionFailure('introduction-point content is encrypted')
+
+ introduction_points = []
+ content_io = io.BytesIO(self.introduction_points_content)
+
+ while True:
+ content = b''.join(_read_until_keywords('introduction-point', content_io, ignore_first = True))
+
+ if not content:
+ break # reached the end
+
+ 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 == 'introduction-point':
+ attr['identifier'] = value
+ elif keyword == 'ip-address':
+ if not stem.util.connection.is_valid_ipv4_address(value):
+ raise ValueError("'%s' is an invalid IPv4 address" % value)
+
+ attr['address'] = value
+ elif keyword == 'onion-port':
+ if not stem.util.connection.is_valid_port(value):
+ raise ValueError("'%s' is an invalid port" % value)
+
+ attr['port'] = int(value)
+ elif keyword == 'onion-key':
+ attr['onion_key'] = block_contents
+ elif keyword == 'service-key':
+ attr['service_key'] = block_contents
+ elif keyword == 'intro-authentication':
+ auth_entries = []
+
+ for auth_value, _, _ in values:
+ if ' ' not in auth_value:
+ raise ValueError("We expected 'intro-authentication [auth_type] [auth_data]', but had '%s'" % auth_value)
+
+ auth_type, auth_data = auth_value.split(' ')[:2]
+ auth_entries.append((auth_type, auth_data))
+
+ introduction_points.append(IntroductionPoint(**attr))
+
+ return introduction_points
diff --git a/stem/descriptor/server_descriptor.py b/stem/descriptor/server_descriptor.py
index 55b4183e..54ee645e 100644
--- a/stem/descriptor/server_descriptor.py
+++ b/stem/descriptor/server_descriptor.py
@@ -31,8 +31,6 @@ etc). This information is provided from a few sources...
+- get_annotation_lines - lines that provided the annotations
"""
-import base64
-import codecs
import functools
import hashlib
import re
@@ -46,13 +44,13 @@ import stem.util.tor_tools
import stem.version
from stem import str_type
-from stem.util import log
from stem.descriptor import (
PGP_BLOCK_END,
Descriptor,
_get_descriptor_components,
_read_until_keywords,
+ _bytes_for_block,
_value,
_values,
_parse_simple_line,
@@ -670,9 +668,18 @@ class RelayDescriptor(ServerDescriptor):
def __init__(self, raw_contents, validate = False, annotations = None):
super(RelayDescriptor, self).__init__(raw_contents, validate, annotations)
- # validate the descriptor if required
if validate:
- self._validate_content()
+ if self.fingerprint:
+ key_hash = hashlib.sha1(_bytes_for_block(self.signing_key)).hexdigest()
+
+ if key_hash != self.fingerprint.lower():
+ raise ValueError('Fingerprint does not match the hash of our signing key (fingerprint: %s, signing key hash: %s)' % (self.fingerprint.lower(), key_hash))
+
+ if stem.prereq.is_crypto_available():
+ signed_digest = self._digest_for_signature(self.signing_key, self.signature)
+
+ if signed_digest != self.digest():
+ raise ValueError('Decrypted digest does not match local digest (calculated: %s, local: %s)' % (signed_digest, self.digest()))
@lru_cache()
def digest(self):
@@ -684,112 +691,7 @@ class RelayDescriptor(ServerDescriptor):
:raises: ValueError if the digest canot be calculated
"""
- # Digest is calculated from everything in the
- # descriptor except the router-signature.
-
- raw_descriptor = self.get_bytes()
- start_token = b'router '
- sig_token = b'\nrouter-signature\n'
- start = raw_descriptor.find(start_token)
- sig_start = raw_descriptor.find(sig_token)
- end = sig_start + len(sig_token)
-
- if start >= 0 and sig_start > 0 and end > start:
- for_digest = raw_descriptor[start:end]
- digest_hash = hashlib.sha1(stem.util.str_tools._to_bytes(for_digest))
- return stem.util.str_tools._to_unicode(digest_hash.hexdigest().upper())
- else:
- raise ValueError('unable to calculate digest for descriptor')
-
- def _validate_content(self):
- """
- Validates that the descriptor content matches the signature.
-
- :raises: ValueError if the signature does not match the content
- """
-
- key_as_bytes = RelayDescriptor._get_key_bytes(self.signing_key)
-
- # ensure the fingerprint is a hash of the signing key
-
- if self.fingerprint:
- # calculate the signing key hash
-
- key_der_as_hash = hashlib.sha1(stem.util.str_tools._to_bytes(key_as_bytes)).hexdigest()
-
- if key_der_as_hash != self.fingerprint.lower():
- log.warn('Signing key hash: %s != fingerprint: %s' % (key_der_as_hash, self.fingerprint.lower()))
- raise ValueError('Fingerprint does not match hash')
-
- self._verify_digest(key_as_bytes)
-
- def _verify_digest(self, key_as_der):
- # check that our digest matches what was signed
-
- if not stem.prereq.is_crypto_available():
- return
-
- from Crypto.Util import asn1
- from Crypto.Util.number import bytes_to_long, long_to_bytes
-
- # get the ASN.1 sequence
-
- seq = asn1.DerSequence()
- seq.decode(key_as_der)
- modulus = seq[0]
- public_exponent = seq[1] # should always be 65537
-
- sig_as_bytes = RelayDescriptor._get_key_bytes(self.signature)
-
- # convert the descriptor signature to an int
-
- sig_as_long = bytes_to_long(sig_as_bytes)
-
- # use the public exponent[e] & the modulus[n] to decrypt the int
-
- decrypted_int = pow(sig_as_long, public_exponent, modulus)
-
- # block size will always be 128 for a 1024 bit key
-
- blocksize = 128
-
- # convert the int to a byte array.
-
- decrypted_bytes = long_to_bytes(decrypted_int, blocksize)
-
- ############################################################################
- # The decrypted bytes should have a structure exactly along these lines.
- # 1 byte - [null '\x00']
- # 1 byte - [block type identifier '\x01'] - Should always be 1
- # N bytes - [padding '\xFF' ]
- # 1 byte - [separator '\x00' ]
- # M bytes - [message]
- # Total - 128 bytes
- # More info here http://www.ietf.org/rfc/rfc2313.txt
- # esp the Notes in section 8.1
- ############################################################################
-
- try:
- if decrypted_bytes.index(b'\x00\x01') != 0:
- raise ValueError('Verification failed, identifier missing')
- except ValueError:
- raise ValueError('Verification failed, malformed data')
-
- try:
- identifier_offset = 2
-
- # find the separator
- seperator_index = decrypted_bytes.index(b'\x00', identifier_offset)
- except ValueError:
- raise ValueError('Verification failed, seperator not found')
-
- digest_hex = codecs.encode(decrypted_bytes[seperator_index + 1:], 'hex_codec')
- digest = stem.util.str_tools._to_unicode(digest_hex.upper())
-
- local_digest = self.digest()
-
- if digest != local_digest:
- raise ValueError('Decrypted digest does not match local digest (calculated: %s, local: %s)' % (digest, local_digest))
+ return self._digest_for_content(b'router ', b'\nrouter-signature\n')
def _compare(self, other, method):
if not isinstance(other, RelayDescriptor):
@@ -809,20 +711,6 @@ class RelayDescriptor(ServerDescriptor):
def __le__(self, other):
return self._compare(other, lambda s, o: s <= o)
- @staticmethod
- def _get_key_bytes(key_string):
- # Remove the newlines from the key string & strip off the
- # '-----BEGIN RSA PUBLIC KEY-----' header and
- # '-----END RSA PUBLIC KEY-----' footer
-
- key_as_string = ''.join(key_string.split('\n')[1:4])
-
- # get the key representation in bytes
-
- key_bytes = base64.b64decode(stem.util.str_tools._to_bytes(key_as_string))
-
- return key_bytes
-
class BridgeDescriptor(ServerDescriptor):
"""
diff --git a/stem/util/connection.py b/stem/util/connection.py
index fa2a3d5f..475c7f56 100644
--- a/stem/util/connection.py
+++ b/stem/util/connection.py
@@ -26,19 +26,21 @@ Connection and networking based utility functions.
Method for resolving a process' connections.
.. versionadded:: 1.1.0
-
- ================= ===========
- Resolver Description
- ================= ===========
- **PROC** /proc contents
- **NETSTAT** netstat
- **NETSTAT_WINDOWS** netstat command under Windows
- **SS** ss command
- **LSOF** lsof command
- **SOCKSTAT** sockstat command under *nix
- **BSD_SOCKSTAT** sockstat command under FreeBSD
- **BSD_PROCSTAT** procstat command under FreeBSD
- ================= ===========
+ .. versionchanged:: 1.4.0
+ Added **NETSTAT_WINDOWS**.
+
+ ==================== ===========
+ Resolver Description
+ ==================== ===========
+ **PROC** /proc contents
+ **NETSTAT** netstat
+ **NETSTAT_WINDOWS** netstat command under Windows
+ **SS** ss command
+ **LSOF** lsof command
+ **SOCKSTAT** sockstat command under *nix
+ **BSD_SOCKSTAT** sockstat command under FreeBSD
+ **BSD_PROCSTAT** procstat command under FreeBSD
+ ==================== ===========
"""
import collections
@@ -140,14 +142,14 @@ RESOLVER_FILTER = {
def get_connections(resolver, process_pid = None, process_name = None):
"""
- Retrieves a list of the current connections for a given process. The provides
- a list of Connection instances, which have five attributes...
-
- * local_address (str)
- * local_port (int)
- * remote_address (str)
- * remote_port (int)
- * protocol (str, generally either 'tcp' or 'udp')
+ Retrieves a list of the current connections for a given process. This
+ provides a list of Connection instances, which have five attributes...
+
+ * **local_address** (str)
+ * **local_port** (int)
+ * **remote_address** (str)
+ * **remote_port** (int)
+ * **protocol** (str, generally either 'tcp' or 'udp')
.. versionadded:: 1.1.0
diff --git a/test/mocking.py b/test/mocking.py
index fdfc71d8..156ac4af 100644
--- a/test/mocking.py
+++ b/test/mocking.py
@@ -33,14 +33,19 @@ Helper functions for creating mock objects.
get_router_status_entry_v2 - RouterStatusEntryV2
get_router_status_entry_v3 - RouterStatusEntryV3
get_router_status_entry_micro_v3 - RouterStatusEntryMicroV3
+
+ stem.descriptor.hidden-service_descriptor
+ get_hidden_service_descriptor - HiddenServiceDescriptor
"""
import base64
import hashlib
import itertools
import re
+import textwrap
import stem.descriptor.extrainfo_descriptor
+import stem.descriptor.hidden_service_descriptor
import stem.descriptor.microdescriptor
import stem.descriptor.networkstatus
import stem.descriptor.router_status_entry
@@ -178,6 +183,20 @@ NETWORK_STATUS_DOCUMENT_FOOTER = (
('directory-signature', '%s %s\n%s' % (DOC_SIG.identity, DOC_SIG.key_digest, DOC_SIG.signature)),
)
+HIDDEN_SERVICE_HEADER = (
+ ('rendezvous-service-descriptor', 'y3olqqblqw2gbh6phimfuiroechjjafa'),
+ ('version', '2'),
+ ('permanent-key', '\n-----BEGIN RSA PUBLIC KEY-----%s-----END RSA PUBLIC KEY-----' % CRYPTO_BLOB),
+ ('secret-id-part', 'e24kgecavwsznj7gpbktqsiwgvngsf4e'),
+ ('publication-time', '2015-02-23 20:00:00'),
+ ('protocol-versions', '2,3'),
+ ('introduction-points', '\n-----BEGIN MESSAGE-----\n-----END MESSAGE-----'),
+)
+
+HIDDEN_SERVICE_FOOTER = (
+ ('signature', '\n-----BEGIN SIGNATURE-----%s-----END SIGNATURE-----' % CRYPTO_BLOB),
+)
+
def get_all_combinations(attr, include_empty = False):
"""
@@ -353,7 +372,7 @@ def get_relay_server_descriptor(attr = None, exclude = (), content = False, sign
if sign_content:
desc_content = sign_descriptor_content(desc_content)
- with patch('stem.descriptor.server_descriptor.RelayDescriptor._verify_digest', Mock()):
+ with patch('stem.prereq.is_crypto_available', Mock(return_value = False)):
desc = stem.descriptor.server_descriptor.RelayDescriptor(desc_content, validate = True)
return desc
@@ -499,6 +518,32 @@ def get_router_status_entry_micro_v3(attr = None, exclude = (), content = False)
return stem.descriptor.router_status_entry.RouterStatusEntryMicroV3(desc_content, validate = True)
+def get_hidden_service_descriptor(attr = None, exclude = (), content = False, introduction_points_lines = None):
+ """
+ Provides the descriptor content for...
+ stem.descriptor.hidden_service_descriptor.HidenServiceDescriptor
+
+ :param dict attr: keyword/value mappings to be included in the descriptor
+ :param list exclude: mandatory keywords to exclude from the descriptor
+ :param bool content: provides the str content of the descriptor rather than the class if True
+ :param list introduction_points_lines: lines to be included in the introduction-points field
+
+ :returns: HidenServiceDescriptor for the requested descriptor content
+ """
+
+ if (not attr or 'introduction-points' not in attr) and introduction_points_lines is not None:
+ encoded = base64.b64encode(introduction_points_lines('\n'))
+ attr['introduction-points'] = '\n-----BEGIN MESSAGE-----\n%s\n-----END MESSAGE-----' % '\n'.join(textwrap.wrap(encoded, 64))
+
+ desc_content = _get_descriptor_content(attr, exclude, HIDDEN_SERVICE_HEADER, HIDDEN_SERVICE_FOOTER)
+
+ if content:
+ return desc_content
+ else:
+ with patch('stem.prereq.is_crypto_available', Mock(return_value = False)):
+ return stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor(desc_content, validate = True)
+
+
def get_directory_authority(attr = None, exclude = (), is_vote = False, content = False):
"""
Provides the descriptor content for...
diff --git a/test/settings.cfg b/test/settings.cfg
index c0c83424..21f5eb29 100644
--- a/test/settings.cfg
+++ b/test/settings.cfg
@@ -172,6 +172,7 @@ test.unit_tests
|test.unit.descriptor.networkstatus.document_v2.TestNetworkStatusDocument
|test.unit.descriptor.networkstatus.document_v3.TestNetworkStatusDocument
|test.unit.descriptor.networkstatus.bridge_document.TestBridgeNetworkStatusDocument
+|test.unit.descriptor.hidden_service_descriptor.TestHiddenServiceDescriptor
|test.unit.exit_policy.rule.TestExitPolicyRule
|test.unit.exit_policy.policy.TestExitPolicy
|test.unit.version.TestVersion
diff --git a/test/unit/descriptor/data/hidden_service_basic_auth b/test/unit/descriptor/data/hidden_service_basic_auth
new file mode 100644
index 00000000..d93c42a6
--- /dev/null
+++ b/test/unit/descriptor/data/hidden_service_basic_auth
@@ -0,0 +1,67 @@
+@type hidden-service-descriptor 1.0
+rendezvous-service-descriptor yfmvdrkdbyquyqk5vygyeylgj2qmrvrd
+version 2
+permanent-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBANHC3bZRrlFFlSu22u18wBG57JpvIhglJE+u0ctMwTnw6iyJJqqxgU6U
+R1gnXJlorQYDC5eHoM8nbBJu7LvUjYnqINz+En+FlpZhT+eqLk0v83IB4r0Fd6Bg
+ypYLygEXUfh0bwHwYfF4mkvl6YOB6I6G5jHC46I3LH15qrdHW40FAgMBAAE=
+-----END RSA PUBLIC KEY-----
+secret-id-part fluw7z3s5cghuuirq3imh5jjj5ljips6
+publication-time 2015-02-24 20:00:00
+protocol-versions 2,3
+introduction-points
+-----BEGIN MESSAGE-----
+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
+-----END MESSAGE-----
+signature
+-----BEGIN SIGNATURE-----
+mhGQNtyvf5QqeFrn1SNzdp8wN5bhEH5gxZhST5t4pFxxNCv3//ZgaQ83kIzcwex9
+xzgGREFHpTrqVPXXs8nZZgMpmnhWuloAyT1c3HSCvjnbWXX9Y82wPbVV5OYx1CYb
+jLides7vbdQuS0UwEkcGMl62Ripwi0pkesgw9ZvJy+k=
+-----END SIGNATURE-----
diff --git a/test/unit/descriptor/data/hidden_service_duckduckgo b/test/unit/descriptor/data/hidden_service_duckduckgo
new file mode 100644
index 00000000..f3f4868b
--- /dev/null
+++ b/test/unit/descriptor/data/hidden_service_duckduckgo
@@ -0,0 +1,60 @@
+@type hidden-service-descriptor 1.0
+rendezvous-service-descriptor y3olqqblqw2gbh6phimfuiroechjjafa
+version 2
+permanent-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAJ/SzzgrXPxTlFrKVhXh3buCWv2QfcNgncUpDpKouLn3AtPH5Ocys0jE
+aZSKdvaiQ62md2gOwj4x61cFNdi05tdQjS+2thHKEm/KsB9BGLSLBNJYY356bupg
+I5gQozM65ENelfxYlysBjJ52xSDBd8C4f/p9umdzaaaCmzXG/nhzAgMBAAE=
+-----END RSA PUBLIC KEY-----
+secret-id-part e24kgecavwsznj7gpbktqsiwgvngsf4e
+publication-time 2015-02-23 20:00:00
+protocol-versions 2,3
+introduction-points
+-----BEGIN MESSAGE-----
+aW50cm9kdWN0aW9uLXBvaW50IGl3a2k3N3h0YnZwNnF2ZWRmcndkem5jeHMzY2th
+eWV1CmlwLWFkZHJlc3MgMTc4LjYyLjIyMi4xMjkKb25pb24tcG9ydCA0NDMKb25p
+b24ta2V5Ci0tLS0tQkVHSU4gUlNBIFBVQkxJQyBLRVktLS0tLQpNSUdKQW9HQkFL
+OTRCRVlJSFo0S2RFa2V5UGhiTENwUlc1RVNnKzJXUFFock00eXVLWUd1cTh3Rldn
+dW1aWVI5CmsvV0EvL0ZZWE1CejBiQitja3Vacy9ZdTluSytITHpwR2FwVjBjbHN0
+NEdVTWNCSW5VQ3pDY3BqSlRRc1FEZ20KMy9ZM2NxaDBXNTVnT0NGaG9tUTQvMVdP
+WWc3WUNqazRYWUhKRTIwT2RHMkxsNXpvdEs2ZkFnTUJBQUU9Ci0tLS0tRU5EIFJT
+QSBQVUJMSUMgS0VZLS0tLS0Kc2VydmljZS1rZXkKLS0tLS1CRUdJTiBSU0EgUFVC
+TElDIEtFWS0tLS0tCk1JR0pBb0dCQUpYbUpiOGxTeWRNTXFDZ0NnZmd2bEIyRTVy
+cGQ1N2t6L0FxZzcvZDFIS2MzK2w1UW9Vdkh5dXkKWnNBbHlrYThFdTUzNGhsNDFv
+cUVLcEFLWWNNbjFUTTB2cEpFR05WT2MrMDVCSW54STloOWYwTWcwMVBEMHRZdQpH
+Y0xIWWdCemNyZkVtS3dNdE04V0VtY01KZDduMnVmZmFBdko4NDZXdWJiZVY3TVcx
+WWVoQWdNQkFBRT0KLS0tLS1FTkQgUlNBIFBVQkxJQyBLRVktLS0tLQppbnRyb2R1
+Y3Rpb24tcG9pbnQgZW00Z2prNmVpaXVhbGhtbHlpaWZyemM3bGJ0cnNiaXAKaXAt
+YWRkcmVzcyA0Ni40LjE3NC41Mgpvbmlvbi1wb3J0IDQ0Mwpvbmlvbi1rZXkKLS0t
+LS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JR0pBb0dCQUxCbWhkRjV3SHhI
+cnBMU21qQVpvdHR4MjIwKzk5NUZkTU9PdFpOalJ3MURCU3ByVVpacXR4V2EKUDhU
+S3BIS3p3R0pLQ1ZZSUlqN2xvaGJ2OVQ5dXJtbGZURTA1VVJHZW5ab2lmT0ZOejNZ
+d01KVFhTY1FFQkoxMAo5aVdOTERUc2tMekRLQ0FiR2hibi9NS3dPZllHQmhOVGxq
+ZHlUbU5ZNUVDUmJSempldjl2QWdNQkFBRT0KLS0tLS1FTkQgUlNBIFBVQkxJQyBL
+RVktLS0tLQpzZXJ2aWNlLWtleQotLS0tLUJFR0lOIFJTQSBQVUJMSUMgS0VZLS0t
+LS0KTUlHSkFvR0JBTXhNSG9BbXJiVU1zeGlJQ3AzaVRQWWdobjBZdWVLSHgyMTl3
+dThPL1E1MVF5Y1ZWTHBYMjdkMQpoSlhrUEIzM1hRQlhzQlM3U3hzU3NTQ1EzR0V1
+clFKN0d1QkxwWUlSL3Zxc2FrRS9sOHdjMkNKQzVXVWh5RkZrCisxVFdJVUk1dHhu
+WEx5V0NSY0tEVXJqcWRvc0RhRG9zZ0hGZzIzTW54K3hYY2FRL2ZyQi9BZ01CQUFF
+PQotLS0tLUVORCBSU0EgUFVCTElDIEtFWS0tLS0tCmludHJvZHVjdGlvbi1wb2lu
+dCBqcWhmbDM2NHgzdXBlNmxxbnhpem9sZXdsZnJzdzJ6eQppcC1hZGRyZXNzIDYy
+LjIxMC44Mi4xNjkKb25pb24tcG9ydCA0NDMKb25pb24ta2V5Ci0tLS0tQkVHSU4g
+UlNBIFBVQkxJQyBLRVktLS0tLQpNSUdKQW9HQkFQVWtxeGdmWWR3MFBtL2c2TWJo
+bVZzR0tsdWppZm1raGRmb0VldXpnbyt3bkVzR3Z3VWVienJ6CmZaSlJ0MGNhWEZo
+bkNHZ1FEMklnbWFyVWFVdlAyNGZYby80bVl6TGNQZUk3Z1puZXVBUUpZdm05OFl2
+OXZPSGwKTmFNL1d2RGtDc0ozR1ZOSjFIM3dMUFFSSTN2N0tiTnVjOXRDT1lsL3Iw
+OU9oVmFXa3phakFnTUJBQUU9Ci0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0tLS0K
+c2VydmljZS1rZXkKLS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JR0pB
+b0dCQUxieDhMZXFSb1Avcjl3OWhqd0Q0MVlVbTdQbzY5N3hSdHl0RjBNY3lMQ1M3
+R1JpVVluamk3S1kKZmVwWGR2Ti9KbDVxUUtISUJiNjAya3VPVGwwcE44UStZZUZV
+U0lJRGNtUEJMcEJEaEgzUHZyUU1jR1ZhaU9XSAo4dzBITVpDeGd3QWNDQzUxdzVW
+d2l1bXhFSk5CVmNac094MG16TjFDbG95KzkwcTBsRlhMQWdNQkFBRT0KLS0tLS1F
+TkQgUlNBIFBVQkxJQyBLRVktLS0tLQoK
+-----END MESSAGE-----
+signature
+-----BEGIN SIGNATURE-----
+VKMmsDIUUFOrpqvcQroIZjDZTKxqNs88a4M9Te8cR/ZvS7H2nffv6iQs0tom5X4D
+4Dy4iZiy+pwYxdHfaOxmdpgMCRvgPb34MExWr5YemH0QuGtnlp5Wxr8GYaAQVuZX
+cZjQLW0juUYCbgIGdxVEBnlEt2rgBSM9+1oR7EAfV1U=
+-----END SIGNATURE-----
diff --git a/test/unit/descriptor/data/hidden_service_facebook b/test/unit/descriptor/data/hidden_service_facebook
new file mode 100644
index 00000000..705c7761
--- /dev/null
+++ b/test/unit/descriptor/data/hidden_service_facebook
@@ -0,0 +1,60 @@
+@type hidden-service-descriptor 1.0
+rendezvous-service-descriptor utjk4arxqg6s6zzo7n6cjnq6ot34udhr
+version 2
+permanent-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGKAoGBAL7zXRnwSycHlKSqK+B8PFvy7RhkQ+OytYtxBwOLzhD82oY7zbpjSHY4
+BZ+hsnceXVjB+f1mXGjvLY6pnYxuufV4wsMsk7a58aJOqUvZFFI2vXhJtnLICxoZ
+AEHWzajz4ULagahB1Vi62loQE84OEcuFBekTvnHca1ZTxwp16aZtAgQvoiLZ
+-----END RSA PUBLIC KEY-----
+secret-id-part 6355jaerje3bqozopwq2qmpf4iviizdn
+publication-time 2014-10-31 23:00:00
+protocol-versions 2,3
+introduction-points
+-----BEGIN MESSAGE-----
+aW50cm9kdWN0aW9uLXBvaW50IHJ6Y3V3am5jbDdpYXpkbm41NXV5cmEybWVlamxz
+eXVoCmlwLWFkZHJlc3MgMTkzLjExLjExNC40Nwpvbmlvbi1wb3J0IDkwMDQKb25p
+b24ta2V5Ci0tLS0tQkVHSU4gUlNBIFBVQkxJQyBLRVktLS0tLQpNSUdKQW9HQkFM
+dndQc012TkhqMTY3YlJyLzlLbi9iVGtPUTN0VC9qOFdSOWV5NTlZU2NBVEQ4TXVz
+OEV6ZWpNCjc0RjdhTGR1VjZuRk15djlFYmhEbVNIZDZRMmhwNkYwb2FGODB0MHMv
+bThXYmVWTUF1aTRvVWRSU1ZRb0drY20KTDJXTlViNy84UWYwb2hFKzZ1K1pTL0FO
+U2NEc0FDT2hwNkliMWxHWjhaZGxWZFJzeWxtckFnTUJBQUU9Ci0tLS0tRU5EIFJT
+QSBQVUJMSUMgS0VZLS0tLS0Kc2VydmljZS1rZXkKLS0tLS1CRUdJTiBSU0EgUFVC
+TElDIEtFWS0tLS0tCk1JR0pBb0dCQU1YVUJRMVd0cXJSZHRkaTdhODkvWU9EQUJ6
+d2U2L0JSS3dEcGFBUENyMC9CZlppVnFadXFFMzcKYmlxbW1pODBPVm5uQzd6eis3
+cDZYQ1QycitEemxtSmFCOGdzTjZCZlU2ek45Wklwd3ptcm1XTy94SGNoZ1BkOQpK
+SUNrSUxHWEJlQnorNmtnSTZiZDcrbFZEYmxJYXNRNHkrUjhMWWxTeCtRMk9mK1gy
+eDFwQWdNQkFBRT0KLS0tLS1FTkQgUlNBIFBVQkxJQyBLRVktLS0tLQppbnRyb2R1
+Y3Rpb24tcG9pbnQgcXE0YXZmdnZhcHljMnhyNmNpYmpuaXFoNTdpY2FkMnEKaXAt
+YWRkcmVzcyAxODguNDAuODMuMjA4Cm9uaW9uLXBvcnQgNDQ0Cm9uaW9uLWtleQot
+LS0tLUJFR0lOIFJTQSBQVUJMSUMgS0VZLS0tLS0KTUlHSkFvR0JBTWZySUJ1TlVF
+MGZxa3ZySDlEZzJkNkM5SFUvNWR2TGxQL0NGVGFFYlVhMURvUHhyQm5iQ1BEcQp5
+bHhwNGs3M04yVDM3aXJrN3FacmVZQ3B6aUVPQ3paRU4xdGlkM0t1c0tFczgzL0FQ
+aDFreTVRRmpkRmpBU0NxCmovRDl0VXNYYkVaa1FEN203SUlmWU40SG52KzROM0dV
+WjVqNkhFMWJvYmxsbURyNkd2U0xBZ01CQUFFPQotLS0tLUVORCBSU0EgUFVCTElD
+IEtFWS0tLS0tCnNlcnZpY2Uta2V5Ci0tLS0tQkVHSU4gUlNBIFBVQkxJQyBLRVkt
+LS0tLQpNSUdKQW9HQkFNRXMyc2tRLzJsVjRFUGpRTDFrZk0zZjExUzRNZUdPcDFC
+aWgrcm1ESGhic09yWDRoZk1GV1NuCjY1dTlZUE9zY1hldUlHbFp4YmFyUUY2Nzkz
+bGMrbk9KbjVyQ016a0pYNWt0OHdobkZoVlFXN0ZoejUxRmdMNFkKNUFKKzdkaW9l
+WTNKdjVNVE1rMUF4Qk1hbkJsR2YxazZrU05kZWxtMCs2bWtZVmVOcVJzckFnTUJB
+QUU9Ci0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0tLS0KaW50cm9kdWN0aW9uLXBv
+aW50IGI1ZHNpY29oZ2Fqb3Vhc3VxZ3lyNWtwNXIzdzdpaWYyCmlwLWFkZHJlc3Mg
+MTg1LjUuNTMuNzIKb25pb24tcG9ydCA5MDAxCm9uaW9uLWtleQotLS0tLUJFR0lO
+IFJTQSBQVUJMSUMgS0VZLS0tLS0KTUlHSkFvR0JBTWVUc0VqS1ZMeGsyL3o4bnRG
+T2xWU09ZTWFjWTZBeFJTTXdQdkxoT2FsajVkNkRraitnSVA5agpsWDFoekRpZ0VP
+b2FBV3FwWjRjRENLa3p6UGdzWEc3ZXVnSmFxTGQydUliZmh4WEdISTJDNWtWQWZZ
+VnVyL1N3CnBXNFRRbmhKUytvbG1BWG84b1hvT2JNWGt1YURUdmlLSXZLa3lQWCs0
+dGJ4a1RxWWt5ZURBZ01CQUFFPQotLS0tLUVORCBSU0EgUFVCTElDIEtFWS0tLS0t
+CnNlcnZpY2Uta2V5Ci0tLS0tQkVHSU4gUlNBIFBVQkxJQyBLRVktLS0tLQpNSUdK
+QW9HQkFPM05SSGMwTld5MFo5aFQrZEUxZ3hKRnFsb0dhdmdLYzZJZ0E1UU5ORDNO
+RHJJakdBYmNsRVh4Ck1tY1BBR2hhc1VlNnJ2aFN0VlRkZEVvN1V0SElCM3F2YXNL
+d0NPem82Z2dLSHZtVVRFOWRNTk9LSEcyLy9xTi8KVkducGdJUkRoeDRwbE9DOXV6
+NGFKU0RZdlhiSXVtZmtxdGJzb1BacUZMYUFqdGFWR015VkFnTUJBQUU9Ci0tLS0t
+RU5EIFJTQSBQVUJMSUMgS0VZLS0tLS0KCg==
+-----END MESSAGE-----
+signature
+-----BEGIN SIGNATURE-----
+tFCNLEOFeqeDZOruKt4SBGvJ2Y2lLo29XUzd09FlIalnwb71TKcKSWi1JVB0vDqF
+ftGlP4M+nZGh6YGxdEriV0ikFbcW+F4XOpWR5NEPV5cCoQAMd1a8mqivGjM/lQwe
+41p+e4XCex2Brsl3o+pSyHy/U+p+xdQkmNPqrHpXVK8=
+-----END SIGNATURE-----
diff --git a/test/unit/descriptor/data/hidden_service_stealth_auth b/test/unit/descriptor/data/hidden_service_stealth_auth
new file mode 100644
index 00000000..df25c832
--- /dev/null
+++ b/test/unit/descriptor/data/hidden_service_stealth_auth
@@ -0,0 +1,60 @@
+@type hidden-service-descriptor 1.0
+rendezvous-service-descriptor ubf3xeibzlfil6s4larq6y5peup2z3oj
+version 2
+permanent-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAL1f7GdEObH+xMhf4GsaTCtfVH7ZpanegC65jn0/Kz9wlkpF+SQdIvTu
+Ha2iZB34GDT2PvTy98chSxz+E3Kv2h45pQWbrwLN3Fj4qa+klclIXWcIa7GT4Pct
+YZzAvHIh9t9EAe6ejYu8I+h4yL6QNAA2bYOi1d66+qCNCAFBgAqpAgMBAAE=
+-----END RSA PUBLIC KEY-----
+secret-id-part jczvydhzetbpdiylj3d5nsnjvaigs7xm
+publication-time 2015-02-24 20:00:00
+protocol-versions 2,3
+introduction-points
+-----BEGIN MESSAGE-----
+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
+-----END MESSAGE-----
+signature
+-----BEGIN SIGNATURE-----
+c8HgXcZesCwAzgDlE3kRYsq059yCIE7MH7r2jBHqJVYPRrtm/HF/mTUykwFPzwsY
+ulcuoNlPfgGMKS8qBL4kFVZ9uR2Y6P4zLchoVS6wjL+cNYOQfeQs3sNZkiIrOjbb
+590tr1/yrt0qUtITGhUGhBZVs9gvkuqaThTIXleEseI=
+-----END SIGNATURE-----
diff --git a/test/unit/descriptor/hidden_service_descriptor.py b/test/unit/descriptor/hidden_service_descriptor.py
new file mode 100644
index 00000000..911e9fa1
--- /dev/null
+++ b/test/unit/descriptor/hidden_service_descriptor.py
@@ -0,0 +1,499 @@
+"""
+Unit tests for stem.descriptor.hidden_service_descriptor.
+"""
+
+import datetime
+import unittest
+
+import stem.descriptor
+
+from test.mocking import CRYPTO_BLOB, get_hidden_service_descriptor
+from test.unit.descriptor import get_resource
+
+from stem.descriptor.hidden_service_descriptor import (
+ REQUIRED_FIELDS,
+ DecryptionFailure,
+ HiddenServiceDescriptor,
+)
+
+MESSAGE_BLOCK = """
+-----BEGIN MESSAGE-----
+%s
+-----END MESSAGE-----\
+"""
+
+EXPECTED_DDG_PERMANENT_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAJ/SzzgrXPxTlFrKVhXh3buCWv2QfcNgncUpDpKouLn3AtPH5Ocys0jE
+aZSKdvaiQ62md2gOwj4x61cFNdi05tdQjS+2thHKEm/KsB9BGLSLBNJYY356bupg
+I5gQozM65ENelfxYlysBjJ52xSDBd8C4f/p9umdzaaaCmzXG/nhzAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+EXPECTED_DDG_INTRODUCTION_POINTS_ENCODED = """\
+-----BEGIN MESSAGE-----
+aW50cm9kdWN0aW9uLXBvaW50IGl3a2k3N3h0YnZwNnF2ZWRmcndkem5jeHMzY2th
+eWV1CmlwLWFkZHJlc3MgMTc4LjYyLjIyMi4xMjkKb25pb24tcG9ydCA0NDMKb25p
+b24ta2V5Ci0tLS0tQkVHSU4gUlNBIFBVQkxJQyBLRVktLS0tLQpNSUdKQW9HQkFL
+OTRCRVlJSFo0S2RFa2V5UGhiTENwUlc1RVNnKzJXUFFock00eXVLWUd1cTh3Rldn
+dW1aWVI5CmsvV0EvL0ZZWE1CejBiQitja3Vacy9ZdTluSytITHpwR2FwVjBjbHN0
+NEdVTWNCSW5VQ3pDY3BqSlRRc1FEZ20KMy9ZM2NxaDBXNTVnT0NGaG9tUTQvMVdP
+WWc3WUNqazRYWUhKRTIwT2RHMkxsNXpvdEs2ZkFnTUJBQUU9Ci0tLS0tRU5EIFJT
+QSBQVUJMSUMgS0VZLS0tLS0Kc2VydmljZS1rZXkKLS0tLS1CRUdJTiBSU0EgUFVC
+TElDIEtFWS0tLS0tCk1JR0pBb0dCQUpYbUpiOGxTeWRNTXFDZ0NnZmd2bEIyRTVy
+cGQ1N2t6L0FxZzcvZDFIS2MzK2w1UW9Vdkh5dXkKWnNBbHlrYThFdTUzNGhsNDFv
+cUVLcEFLWWNNbjFUTTB2cEpFR05WT2MrMDVCSW54STloOWYwTWcwMVBEMHRZdQpH
+Y0xIWWdCemNyZkVtS3dNdE04V0VtY01KZDduMnVmZmFBdko4NDZXdWJiZVY3TVcx
+WWVoQWdNQkFBRT0KLS0tLS1FTkQgUlNBIFBVQkxJQyBLRVktLS0tLQppbnRyb2R1
+Y3Rpb24tcG9pbnQgZW00Z2prNmVpaXVhbGhtbHlpaWZyemM3bGJ0cnNiaXAKaXAt
+YWRkcmVzcyA0Ni40LjE3NC41Mgpvbmlvbi1wb3J0IDQ0Mwpvbmlvbi1rZXkKLS0t
+LS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JR0pBb0dCQUxCbWhkRjV3SHhI
+cnBMU21qQVpvdHR4MjIwKzk5NUZkTU9PdFpOalJ3MURCU3ByVVpacXR4V2EKUDhU
+S3BIS3p3R0pLQ1ZZSUlqN2xvaGJ2OVQ5dXJtbGZURTA1VVJHZW5ab2lmT0ZOejNZ
+d01KVFhTY1FFQkoxMAo5aVdOTERUc2tMekRLQ0FiR2hibi9NS3dPZllHQmhOVGxq
+ZHlUbU5ZNUVDUmJSempldjl2QWdNQkFBRT0KLS0tLS1FTkQgUlNBIFBVQkxJQyBL
+RVktLS0tLQpzZXJ2aWNlLWtleQotLS0tLUJFR0lOIFJTQSBQVUJMSUMgS0VZLS0t
+LS0KTUlHSkFvR0JBTXhNSG9BbXJiVU1zeGlJQ3AzaVRQWWdobjBZdWVLSHgyMTl3
+dThPL1E1MVF5Y1ZWTHBYMjdkMQpoSlhrUEIzM1hRQlhzQlM3U3hzU3NTQ1EzR0V1
+clFKN0d1QkxwWUlSL3Zxc2FrRS9sOHdjMkNKQzVXVWh5RkZrCisxVFdJVUk1dHhu
+WEx5V0NSY0tEVXJqcWRvc0RhRG9zZ0hGZzIzTW54K3hYY2FRL2ZyQi9BZ01CQUFF
+PQotLS0tLUVORCBSU0EgUFVCTElDIEtFWS0tLS0tCmludHJvZHVjdGlvbi1wb2lu
+dCBqcWhmbDM2NHgzdXBlNmxxbnhpem9sZXdsZnJzdzJ6eQppcC1hZGRyZXNzIDYy
+LjIxMC44Mi4xNjkKb25pb24tcG9ydCA0NDMKb25pb24ta2V5Ci0tLS0tQkVHSU4g
+UlNBIFBVQkxJQyBLRVktLS0tLQpNSUdKQW9HQkFQVWtxeGdmWWR3MFBtL2c2TWJo
+bVZzR0tsdWppZm1raGRmb0VldXpnbyt3bkVzR3Z3VWVienJ6CmZaSlJ0MGNhWEZo
+bkNHZ1FEMklnbWFyVWFVdlAyNGZYby80bVl6TGNQZUk3Z1puZXVBUUpZdm05OFl2
+OXZPSGwKTmFNL1d2RGtDc0ozR1ZOSjFIM3dMUFFSSTN2N0tiTnVjOXRDT1lsL3Iw
+OU9oVmFXa3phakFnTUJBQUU9Ci0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0tLS0K
+c2VydmljZS1rZXkKLS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JR0pB
+b0dCQUxieDhMZXFSb1Avcjl3OWhqd0Q0MVlVbTdQbzY5N3hSdHl0RjBNY3lMQ1M3
+R1JpVVluamk3S1kKZmVwWGR2Ti9KbDVxUUtISUJiNjAya3VPVGwwcE44UStZZUZV
+U0lJRGNtUEJMcEJEaEgzUHZyUU1jR1ZhaU9XSAo4dzBITVpDeGd3QWNDQzUxdzVW
+d2l1bXhFSk5CVmNac094MG16TjFDbG95KzkwcTBsRlhMQWdNQkFBRT0KLS0tLS1F
+TkQgUlNBIFBVQkxJQyBLRVktLS0tLQoK
+-----END MESSAGE-----\
+"""
+
+EXPECTED_DDG_INTRODUCTION_POINTS_CONTENT = b"""\
+introduction-point iwki77xtbvp6qvedfrwdzncxs3ckayeu
+ip-address 178.62.222.129
+onion-port 443
+onion-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAK94BEYIHZ4KdEkeyPhbLCpRW5ESg+2WPQhrM4yuKYGuq8wFWgumZYR9
+k/WA//FYXMBz0bB+ckuZs/Yu9nK+HLzpGapV0clst4GUMcBInUCzCcpjJTQsQDgm
+3/Y3cqh0W55gOCFhomQ4/1WOYg7YCjk4XYHJE20OdG2Ll5zotK6fAgMBAAE=
+-----END RSA PUBLIC KEY-----
+service-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAJXmJb8lSydMMqCgCgfgvlB2E5rpd57kz/Aqg7/d1HKc3+l5QoUvHyuy
+ZsAlyka8Eu534hl41oqEKpAKYcMn1TM0vpJEGNVOc+05BInxI9h9f0Mg01PD0tYu
+GcLHYgBzcrfEmKwMtM8WEmcMJd7n2uffaAvJ846WubbeV7MW1YehAgMBAAE=
+-----END RSA PUBLIC KEY-----
+introduction-point em4gjk6eiiualhmlyiifrzc7lbtrsbip
+ip-address 46.4.174.52
+onion-port 443
+onion-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBALBmhdF5wHxHrpLSmjAZottx220+995FdMOOtZNjRw1DBSprUZZqtxWa
+P8TKpHKzwGJKCVYIIj7lohbv9T9urmlfTE05URGenZoifOFNz3YwMJTXScQEBJ10
+9iWNLDTskLzDKCAbGhbn/MKwOfYGBhNTljdyTmNY5ECRbRzjev9vAgMBAAE=
+-----END RSA PUBLIC KEY-----
+service-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAMxMHoAmrbUMsxiICp3iTPYghn0YueKHx219wu8O/Q51QycVVLpX27d1
+hJXkPB33XQBXsBS7SxsSsSCQ3GEurQJ7GuBLpYIR/vqsakE/l8wc2CJC5WUhyFFk
++1TWIUI5txnXLyWCRcKDUrjqdosDaDosgHFg23Mnx+xXcaQ/frB/AgMBAAE=
+-----END RSA PUBLIC KEY-----
+introduction-point jqhfl364x3upe6lqnxizolewlfrsw2zy
+ip-address 62.210.82.169
+onion-port 443
+onion-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAPUkqxgfYdw0Pm/g6MbhmVsGKlujifmkhdfoEeuzgo+wnEsGvwUebzrz
+fZJRt0caXFhnCGgQD2IgmarUaUvP24fXo/4mYzLcPeI7gZneuAQJYvm98Yv9vOHl
+NaM/WvDkCsJ3GVNJ1H3wLPQRI3v7KbNuc9tCOYl/r09OhVaWkzajAgMBAAE=
+-----END RSA PUBLIC KEY-----
+service-key
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBALbx8LeqRoP/r9w9hjwD41YUm7Po697xRtytF0McyLCS7GRiUYnji7KY
+fepXdvN/Jl5qQKHIBb602kuOTl0pN8Q+YeFUSIIDcmPBLpBDhH3PvrQMcGVaiOWH
+8w0HMZCxgwAcCC51w5VwiumxEJNBVcZsOx0mzN1Cloy+90q0lFXLAgMBAAE=
+-----END RSA PUBLIC KEY-----
+
+"""
+
+EXPECTED_DDG_SIGNATURE = """\
+-----BEGIN SIGNATURE-----
+VKMmsDIUUFOrpqvcQroIZjDZTKxqNs88a4M9Te8cR/ZvS7H2nffv6iQs0tom5X4D
+4Dy4iZiy+pwYxdHfaOxmdpgMCRvgPb34MExWr5YemH0QuGtnlp5Wxr8GYaAQVuZX
+cZjQLW0juUYCbgIGdxVEBnlEt2rgBSM9+1oR7EAfV1U=
+-----END SIGNATURE-----\
+"""
+
+EXPECT_POINT_1_ONION_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAK94BEYIHZ4KdEkeyPhbLCpRW5ESg+2WPQhrM4yuKYGuq8wFWgumZYR9
+k/WA//FYXMBz0bB+ckuZs/Yu9nK+HLzpGapV0clst4GUMcBInUCzCcpjJTQsQDgm
+3/Y3cqh0W55gOCFhomQ4/1WOYg7YCjk4XYHJE20OdG2Ll5zotK6fAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+EXPECT_POINT_1_SERVICE_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAJXmJb8lSydMMqCgCgfgvlB2E5rpd57kz/Aqg7/d1HKc3+l5QoUvHyuy
+ZsAlyka8Eu534hl41oqEKpAKYcMn1TM0vpJEGNVOc+05BInxI9h9f0Mg01PD0tYu
+GcLHYgBzcrfEmKwMtM8WEmcMJd7n2uffaAvJ846WubbeV7MW1YehAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+EXPECT_POINT_2_ONION_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBALBmhdF5wHxHrpLSmjAZottx220+995FdMOOtZNjRw1DBSprUZZqtxWa
+P8TKpHKzwGJKCVYIIj7lohbv9T9urmlfTE05URGenZoifOFNz3YwMJTXScQEBJ10
+9iWNLDTskLzDKCAbGhbn/MKwOfYGBhNTljdyTmNY5ECRbRzjev9vAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+EXPECT_POINT_2_SERVICE_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAMxMHoAmrbUMsxiICp3iTPYghn0YueKHx219wu8O/Q51QycVVLpX27d1
+hJXkPB33XQBXsBS7SxsSsSCQ3GEurQJ7GuBLpYIR/vqsakE/l8wc2CJC5WUhyFFk
++1TWIUI5txnXLyWCRcKDUrjqdosDaDosgHFg23Mnx+xXcaQ/frB/AgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+EXPECT_POINT_3_ONION_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAPUkqxgfYdw0Pm/g6MbhmVsGKlujifmkhdfoEeuzgo+wnEsGvwUebzrz
+fZJRt0caXFhnCGgQD2IgmarUaUvP24fXo/4mYzLcPeI7gZneuAQJYvm98Yv9vOHl
+NaM/WvDkCsJ3GVNJ1H3wLPQRI3v7KbNuc9tCOYl/r09OhVaWkzajAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+EXPECT_POINT_3_SERVICE_KEY = """\
+-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBALbx8LeqRoP/r9w9hjwD41YUm7Po697xRtytF0McyLCS7GRiUYnji7KY
+fepXdvN/Jl5qQKHIBb602kuOTl0pN8Q+YeFUSIIDcmPBLpBDhH3PvrQMcGVaiOWH
+8w0HMZCxgwAcCC51w5VwiumxEJNBVcZsOx0mzN1Cloy+90q0lFXLAgMBAAE=
+-----END RSA PUBLIC KEY-----\
+"""
+
+EXPECTED_BASIC_AUTH_INTRODUCTION_POINTS_ENCODED = """\
+-----BEGIN MESSAGE-----
+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
+-----END MESSAGE-----\
+"""
+
+
+class TestHiddenServiceDescriptor(unittest.TestCase):
+ def test_for_duckduckgo_with_validation(self):
+ """
+ Parse duckduckgo's descriptor.
+ """
+
+ descriptor_file = open(get_resource('hidden_service_duckduckgo'), 'rb')
+ desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = True))
+ self._assert_matches_duckduckgo(desc)
+
+ def test_for_duckduckgo_without_validation(self):
+ """
+ Parse duckduckgo's descriptor
+ """
+
+ descriptor_file = open(get_resource('hidden_service_duckduckgo'), 'rb')
+ desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = False))
+ self._assert_matches_duckduckgo(desc)
+
+ def test_for_facebook(self):
+ """
+ Parse facebook's descriptor.
+ """
+
+ descriptor_file = open(get_resource('hidden_service_facebook'), 'rb')
+
+ desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = True))
+ self.assertEqual('utjk4arxqg6s6zzo7n6cjnq6ot34udhr', desc.descriptor_id)
+ self.assertEqual(2, desc.version)
+ self.assertEqual('6355jaerje3bqozopwq2qmpf4iviizdn', desc.secret_id_part)
+ self.assertEqual(datetime.datetime(2014, 10, 31, 23, 0, 0), desc.published)
+ self.assertEqual([2, 3], desc.protocol_versions)
+
+ def test_with_basic_auth(self):
+ """
+ Parse a descriptor with introduction-points encrypted with basic auth.
+ """
+
+ 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))
+ self.assertEqual('yfmvdrkdbyquyqk5vygyeylgj2qmrvrd', desc.descriptor_id)
+ self.assertEqual(2, desc.version)
+ self.assertEqual('fluw7z3s5cghuuirq3imh5jjj5ljips6', desc.secret_id_part)
+ self.assertEqual(datetime.datetime(2015, 2, 24, 20, 0, 0), desc.published)
+ self.assertEqual([2, 3], desc.protocol_versions)
+ self.assertEqual(EXPECTED_BASIC_AUTH_INTRODUCTION_POINTS_ENCODED, desc.introduction_points_encoded)
+ self.assertEqual([], desc.introduction_points_auth)
+
+ self.assertRaises(DecryptionFailure, desc.introduction_points)
+
+ def test_with_stealth_auth(self):
+ """
+ Parse a descriptor with introduction-points encrypted with stealth auth.
+ """
+
+ 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))
+ self.assertEqual('ubf3xeibzlfil6s4larq6y5peup2z3oj', desc.descriptor_id)
+ self.assertEqual(2, desc.version)
+ self.assertEqual('jczvydhzetbpdiylj3d5nsnjvaigs7xm', desc.secret_id_part)
+ self.assertEqual(datetime.datetime(2015, 2, 24, 20, 0, 0), desc.published)
+ self.assertEqual([2, 3], desc.protocol_versions)
+ self.assertEqual([], desc.introduction_points_auth)
+
+ self.assertRaises(DecryptionFailure, desc.introduction_points)
+
+ def _assert_matches_duckduckgo(self, desc):
+ self.assertEqual('y3olqqblqw2gbh6phimfuiroechjjafa', desc.descriptor_id)
+ self.assertEqual(2, desc.version)
+ self.assertEqual(EXPECTED_DDG_PERMANENT_KEY, desc.permanent_key)
+ self.assertEqual('e24kgecavwsznj7gpbktqsiwgvngsf4e', desc.secret_id_part)
+ self.assertEqual(datetime.datetime(2015, 2, 23, 20, 0, 0), desc.published)
+ self.assertEqual([2, 3], desc.protocol_versions)
+ self.assertEqual(EXPECTED_DDG_INTRODUCTION_POINTS_ENCODED, desc.introduction_points_encoded)
+ self.assertEqual([], desc.introduction_points_auth)
+ self.assertEqual(EXPECTED_DDG_INTRODUCTION_POINTS_CONTENT, desc.introduction_points_content)
+ self.assertEqual(EXPECTED_DDG_SIGNATURE, desc.signature)
+
+ introduction_points = desc.introduction_points()
+ self.assertEqual(3, len(introduction_points))
+
+ point = introduction_points[0]
+ self.assertEqual('iwki77xtbvp6qvedfrwdzncxs3ckayeu', point.identifier)
+ self.assertEqual('178.62.222.129', point.address)
+ self.assertEqual(443, point.port)
+ self.assertEqual(EXPECT_POINT_1_ONION_KEY, point.onion_key)
+ self.assertEqual(EXPECT_POINT_1_SERVICE_KEY, point.service_key)
+ self.assertEqual([], point.intro_authentication)
+
+ point = introduction_points[1]
+ self.assertEqual('em4gjk6eiiualhmlyiifrzc7lbtrsbip', point.identifier)
+ self.assertEqual('46.4.174.52', point.address)
+ self.assertEqual(443, point.port)
+ self.assertEqual(EXPECT_POINT_2_ONION_KEY, point.onion_key)
+ self.assertEqual(EXPECT_POINT_2_SERVICE_KEY, point.service_key)
+ self.assertEqual([], point.intro_authentication)
+
+ point = introduction_points[2]
+ self.assertEqual('jqhfl364x3upe6lqnxizolewlfrsw2zy', point.identifier)
+ self.assertEqual('62.210.82.169', point.address)
+ self.assertEqual(443, point.port)
+ self.assertEqual(EXPECT_POINT_3_ONION_KEY, point.onion_key)
+ self.assertEqual(EXPECT_POINT_3_SERVICE_KEY, point.service_key)
+ self.assertEqual([], point.intro_authentication)
+
+ def test_minimal_hidden_service_descriptor(self):
+ """
+ Basic sanity check that we can parse a hidden service descriptor with minimal attributes.
+ """
+
+ desc = get_hidden_service_descriptor()
+
+ self.assertEqual('y3olqqblqw2gbh6phimfuiroechjjafa', desc.descriptor_id)
+ self.assertEqual(2, desc.version)
+ self.assertTrue(CRYPTO_BLOB in desc.permanent_key)
+ self.assertEqual('e24kgecavwsznj7gpbktqsiwgvngsf4e', desc.secret_id_part)
+ self.assertEqual(datetime.datetime(2015, 2, 23, 20, 0, 0), desc.published)
+ self.assertEqual([2, 3], desc.protocol_versions)
+ self.assertEqual('-----BEGIN MESSAGE-----\n-----END MESSAGE-----', desc.introduction_points_encoded)
+ self.assertEqual([], desc.introduction_points_auth)
+ self.assertEqual(b'', desc.introduction_points_content)
+ self.assertTrue(CRYPTO_BLOB in desc.signature)
+ self.assertEqual([], desc.introduction_points())
+
+ def test_unrecognized_line(self):
+ """
+ Includes unrecognized content in the descriptor.
+ """
+
+ desc = get_hidden_service_descriptor({'pepperjack': 'is oh so tasty!'})
+ self.assertEqual(['pepperjack is oh so tasty!'], desc.get_unrecognized_lines())
+
+ def test_proceeding_line(self):
+ """
+ Includes a line prior to the 'rendezvous-service-descriptor' entry.
+ """
+
+ desc_text = b'hibernate 1\n' + get_hidden_service_descriptor(content = True)
+ self._expect_invalid_attr(desc_text)
+
+ def test_trailing_line(self):
+ """
+ Includes a line after the 'router-signature' entry.
+ """
+
+ desc_text = get_hidden_service_descriptor(content = True) + b'\nhibernate 1'
+ self._expect_invalid_attr(desc_text)
+
+ def test_required_fields(self):
+ """
+ Check that we require the mandatory fields.
+ """
+
+ line_to_attr = {
+ 'rendezvous-service-descriptor': 'descriptor_id',
+ 'version': 'version',
+ 'permanent-key': 'permanent_key',
+ 'secret-id-part': 'secret_id_part',
+ 'publication-time': 'published',
+ 'introduction-points': 'introduction_points_encoded',
+ 'protocol-versions': 'protocol_versions',
+ 'signature': 'signature',
+ }
+
+ for line in REQUIRED_FIELDS:
+ desc_text = get_hidden_service_descriptor(content = True, exclude = (line,))
+
+ expected = [] if line == 'protocol-versions' else None
+ self._expect_invalid_attr(desc_text, line_to_attr[line], expected)
+
+ def test_invalid_version(self):
+ """
+ Checks that our version field expects a numeric value.
+ """
+
+ test_values = (
+ '',
+ '-10',
+ 'hello',
+ )
+
+ for test_value in test_values:
+ desc_text = get_hidden_service_descriptor({'version': test_value}, content = True)
+ self._expect_invalid_attr(desc_text, 'version')
+
+ def test_invalid_protocol_versions(self):
+ """
+ Checks that our protocol-versions field expects comma separated numeric
+ values.
+ """
+
+ test_values = (
+ '',
+ '-10',
+ 'hello',
+ '10,',
+ ',10',
+ '10,-10',
+ '10,hello',
+ )
+
+ for test_value in test_values:
+ desc_text = get_hidden_service_descriptor({'protocol-versions': test_value}, content = True)
+ self._expect_invalid_attr(desc_text, 'protocol_versions', [])
+
+ def test_introduction_points_when_empty(self):
+ """
+ It's valid to advertise zero introduciton points. I'm not clear if this
+ would mean an empty protocol-versions field or that it's omitted but either
+ are valid according to the spec.
+ """
+
+ missing_field_desc = get_hidden_service_descriptor(exclude = ('introduction-points',))
+
+ self.assertEqual(None, missing_field_desc.introduction_points_encoded)
+ self.assertEqual([], missing_field_desc.introduction_points_auth)
+ self.assertEqual(None, missing_field_desc.introduction_points_content)
+ self.assertEqual([], missing_field_desc.introduction_points())
+
+ empty_field_desc = get_hidden_service_descriptor({'introduction-points': MESSAGE_BLOCK % ''})
+
+ self.assertEqual((MESSAGE_BLOCK % '').strip(), empty_field_desc.introduction_points_encoded)
+ self.assertEqual([], empty_field_desc.introduction_points_auth)
+ self.assertEqual(b'', empty_field_desc.introduction_points_content)
+ self.assertEqual([], empty_field_desc.introduction_points())
+
+ def test_introduction_points_when_not_base64(self):
+ """
+ Checks the introduction-points field when the content isn't base64 encoded.
+ """
+
+ test_values = (
+ MESSAGE_BLOCK % '12345',
+ MESSAGE_BLOCK % 'hello',
+ )
+
+ for test_value in test_values:
+ desc_text = get_hidden_service_descriptor({'introduction-points': test_value}, content = True)
+
+ desc = self._expect_invalid_attr(desc_text, 'introduction_points_encoded', test_value.strip())
+ self.assertEqual([], desc.introduction_points_auth)
+ self.assertEqual(None, desc.introduction_points_content)
+ self.assertEqual([], desc.introduction_points())
+
+ def _expect_invalid_attr(self, desc_text, attr = None, expected_value = None):
+ """
+ Asserts that construction will fail due to desc_text having a malformed
+ attribute. If an attr is provided then we check that it matches an expected
+ value when we're constructed without validation.
+ """
+
+ self.assertRaises(ValueError, HiddenServiceDescriptor, desc_text, True)
+ desc = HiddenServiceDescriptor(desc_text, validate = False)
+
+ if attr:
+ # check that the invalid attribute matches the expected value when
+ # constructed without validation
+
+ self.assertEqual(expected_value, getattr(desc, attr))
+ else:
+ # check a default attribute
+ self.assertEqual('y3olqqblqw2gbh6phimfuiroechjjafa', desc.descriptor_id)
+
+ return desc
diff --git a/test/unit/descriptor/server_descriptor.py b/test/unit/descriptor/server_descriptor.py
index 00d2bb34..fd0c0326 100644
--- a/test/unit/descriptor/server_descriptor.py
+++ b/test/unit/descriptor/server_descriptor.py
@@ -452,7 +452,7 @@ Qlx9HNCqCY877ztFRC624ja2ql6A2hBcuoYMbkHjcQ4=
desc_text = get_relay_server_descriptor({'opt': 'protocols Link 1 2'}, content = True)
self._expect_invalid_attr(desc_text, 'circuit_protocols')
- @patch('stem.descriptor.server_descriptor.RelayDescriptor._verify_digest', Mock())
+ @patch('stem.prereq.is_crypto_available', Mock(return_value = False))
def test_published_leap_year(self):
"""
Constructs with a published entry for a leap year, and when the date is
@@ -508,7 +508,7 @@ Qlx9HNCqCY877ztFRC624ja2ql6A2hBcuoYMbkHjcQ4=
self.assertEqual(900, desc.read_history_interval)
self.assertEqual([], desc.read_history_values)
- @patch('stem.descriptor.server_descriptor.RelayDescriptor._verify_digest', Mock())
+ @patch('stem.prereq.is_crypto_available', Mock(return_value = False))
def test_annotations(self):
"""
Checks that content before a descriptor are parsed as annotations.
diff --git a/test/unit/tutorial.py b/test/unit/tutorial.py
index 55a170b9..7d204378 100644
--- a/test/unit/tutorial.py
+++ b/test/unit/tutorial.py
@@ -201,7 +201,7 @@ class TestTutorial(unittest.TestCase):
@patch('sys.stdout', new_callable = StringIO)
@patch('stem.descriptor.remote.DescriptorDownloader')
- @patch('stem.descriptor.server_descriptor.RelayDescriptor._verify_digest', Mock())
+ @patch('stem.prereq.is_crypto_available', Mock(return_value = False))
def test_mirror_mirror_on_the_wall_5(self, downloader_mock, stdout_mock):
def tutorial_example():
from stem.descriptor.remote import DescriptorDownloader