summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDamian Johnson <atagar@torproject.org>2019-08-24 17:17:15 -0700
committerDamian Johnson <atagar@torproject.org>2019-08-24 17:17:15 -0700
commit6790035c5756a8022e389f2cb95d2afb78d8c953 (patch)
treeed78872275d98b6dbbb05606b85a41ea17cbf5e5
parentacfcc58b011adaa43d19b80869f54404dfd480b6 (diff)
parent5b1fc94f6cb6719ff9bc2ab2c3c5620ac158d08b (diff)
Hidden service v3 descriptor support
Support for the outer later of v3 hidden service descriptors... https://trac.torproject.org/projects/tor/ticket/31369 https://gitweb.torproject.org/torspec.git/tree/rend-spec-v3.txt#n1046 This does *not* include support for reading HS-DESC-ENC (superencrypted blobs) nor validating the signature. Patches welcome for both!
-rw-r--r--docs/api.rst2
-rw-r--r--docs/api/descriptor/hidden_service.rst5
-rw-r--r--docs/api/descriptor/hidden_service_descriptor.rst5
-rw-r--r--docs/change_log.rst10
-rw-r--r--docs/contents.rst3
-rw-r--r--docs/tutorials/mirror_mirror_on_the_wall.rst2
-rw-r--r--docs/tutorials/over_the_river.rst2
-rw-r--r--stem/control.py2
-rw-r--r--stem/descriptor/__init__.py38
-rw-r--r--stem/descriptor/extrainfo_descriptor.py15
-rw-r--r--stem/descriptor/hidden_service.py552
-rw-r--r--stem/descriptor/hidden_service_descriptor.py446
-rw-r--r--stem/descriptor/server_descriptor.py34
-rw-r--r--stem/response/events.py4
-rw-r--r--test/settings.cfg11
-rw-r--r--test/unit/descriptor/data/hidden_service_v3223
-rw-r--r--test/unit/descriptor/hidden_service_v2.py (renamed from test/unit/descriptor/hidden_service_descriptor.py)60
-rw-r--r--test/unit/descriptor/hidden_service_v3.py109
18 files changed, 987 insertions, 536 deletions
diff --git a/docs/api.rst b/docs/api.rst
index a8ba7e24..cbbf0dd0 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -35,7 +35,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.hidden_service <api/descriptor/hidden_service.html>`_ - Descriptors generated for hidden services.
* `stem.descriptor.bandwidth_file <api/descriptor/bandwidth_file.html>`_ - Bandwidth authority metrics.
* `stem.descriptor.tordnsel <api/descriptor/tordnsel.html>`_ - `TorDNSEL <https://www.torproject.org/projects/tordnsel.html.en>`_ exit lists.
* `stem.descriptor.certificate <api/descriptor/certificate.html>`_ - `Ed25519 certificates <https://gitweb.torproject.org/torspec.git/tree/cert-spec.txt>`_.
diff --git a/docs/api/descriptor/hidden_service.rst b/docs/api/descriptor/hidden_service.rst
new file mode 100644
index 00000000..21b9bd7b
--- /dev/null
+++ b/docs/api/descriptor/hidden_service.rst
@@ -0,0 +1,5 @@
+Hidden Service Descriptor
+=========================
+
+.. automodule:: stem.descriptor.hidden_service
+
diff --git a/docs/api/descriptor/hidden_service_descriptor.rst b/docs/api/descriptor/hidden_service_descriptor.rst
deleted file mode 100644
index 145203e6..00000000
--- a/docs/api/descriptor/hidden_service_descriptor.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-Hidden Service Descriptor
-=========================
-
-.. automodule:: stem.descriptor.hidden_service_descriptor
-
diff --git a/docs/change_log.rst b/docs/change_log.rst
index c244d7d2..c5b5051e 100644
--- a/docs/change_log.rst
+++ b/docs/change_log.rst
@@ -52,16 +52,17 @@ The following are only available within Stem's `git repository
* Controller events could fail to be delivered in a timely fashion (:trac:`27173`)
* Adjusted :func:`~stem.control.Controller.get_microdescriptors` fallback to also use '.new' cache files (:trac:`28508`)
* ExitPolicies could raise TypeError when read concurrently (:trac:`29899`)
- * **STALE_DESC** :data:`~stem.Flag` (:spec:`d14164d8`)
+ * **STALE_DESC** :data:`~stem.Flag` (:spec:`d14164d`)
* **DORMANT** and **ACTIVE** :data:`~stem.Signal` (:spec:`4421149`)
* **QUERY_RATE_LIMITED** :data:`~stem.HSDescReason` (:spec:`bd80679`)
- * **EXTOR** and **HTTPTUNNEL** :data:`~stem.Listener`
+ * **EXTOR** and **HTTPTUNNEL** :data:`~stem.control.Listener`
* **Descriptors**
* Added the `stem.descriptor.collector <api/descriptor/collector.html>`_ module (:trac:`17979`)
+ * Added `v3 hidden service descriptor support <api/descriptor/hidden_service.html>`_ (:trac:`31369`)
* `Bandwidth file support <api/descriptor/bandwidth_file.html>`_ (:trac:`29056`)
- * `stem.descriptor.remote <api/descriptor/remote.html>`_ now raise :class:`stem.DownloadFailed`
+ * `stem.descriptor.remote <api/descriptor/remote.html>`_ methods now raise :class:`stem.DownloadFailed`
* Check Ed25519 validity though the cryptography module rather than PyNaCl (:trac:`22022`)
* Download compressed descriptors by default (:trac:`29186`)
* Added :class:`~stem.descriptor.Compression` class
@@ -78,6 +79,7 @@ The following are only available within Stem's `git repository
* Replaced the **digest** attribute of :class:`~stem.descriptor.microdescriptor.Microdescriptor` with a method by the same name (:trac:`28398`)
* Default the **version_flavor** attribute of :class:`~stem.descriptor.networkstatus.NetworkStatusDocumentV3` to 'ns' (:spec:`d97f8d9`)
* DescriptorDownloader crashed if **use_mirrors** is set (:trac:`28393`)
+ * Renamed stem.descriptor.hidden_service_descriptor to stem.descriptor.hidden_service
* Don't download from Serge, a bridge authority that frequently timeout
* Updated dizum authority's address (:trac:`31406`)
@@ -363,7 +365,7 @@ And last, Stem also now runs directly under both python2 and python3 without a
* **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`)
+ * Added `support for hidden service descriptors <api/descriptor/hidden_service.html>`_ (:trac:`15004`)
* When reading sanitised bridge descriptors (server or extrainfo), :func:`~stem.descriptor.__init__.parse_file` treated the whole file as a single descriptor
* The :class:`~stem.descriptor.networkstatus.DirectoryAuthority` 'fingerprint' attribute was actually its 'v3ident'
* Added consensus' new package attribute (:spec:`ab64534`)
diff --git a/docs/contents.rst b/docs/contents.rst
index 267979e0..87e75220 100644
--- a/docs/contents.rst
+++ b/docs/contents.rst
@@ -14,6 +14,7 @@ Contents
tutorials/down_the_rabbit_hole
tutorials/double_double_toil_and_trouble
+ tutorials/examples/bandwidth_stats
tutorials/examples/check_digests
tutorials/examples/compare_flags
tutorials/examples/download_descriptor
@@ -50,7 +51,7 @@ Contents
api/descriptor/microdescriptor
api/descriptor/networkstatus
api/descriptor/router_status_entry
- api/descriptor/hidden_service_descriptor
+ api/descriptor/hidden_service
api/descriptor/tordnsel
api/descriptor/export
diff --git a/docs/tutorials/mirror_mirror_on_the_wall.rst b/docs/tutorials/mirror_mirror_on_the_wall.rst
index 04cc86de..f16df19b 100644
--- a/docs/tutorials/mirror_mirror_on_the_wall.rst
+++ b/docs/tutorials/mirror_mirror_on_the_wall.rst
@@ -34,7 +34,7 @@ Descriptor Type
`Microdescriptor <../api/descriptor/microdescriptor.html>`_ Minimalistic document that just includes the information necessary for Tor clients to work.
`Network Status Document <../api/descriptor/networkstatus.html>`_ Though Tor relays are decentralized, the directories that track the overall network are not. These central points are called **directory authorities**, and every hour they publish a document called a **consensus** (aka, network status document). The consensus in turn is made up of **router status entries**.
`Router Status Entry <../api/descriptor/router_status_entry.html>`_ Relay information provided by the directory authorities including flags, heuristics used for relay selection, etc.
-`Hidden Service Descriptor <../api/descriptor/hidden_service_descriptor.html>`_ Information pertaining to a `Hidden Service <https://www.torproject.org/docs/hidden-services.html.en>`_. These can only be `queried through the tor process <over_the_river.html#hidden-service-descriptors>`_.
+`Hidden Service Descriptor <../api/descriptor/hidden_service.html>`_ Information pertaining to a `Hidden Service <https://www.torproject.org/docs/hidden-services.html.en>`_. These can only be `queried through the tor process <over_the_river.html#hidden-service-descriptors>`_.
================================================================================ ===========
.. _where-do-descriptors-come-from:
diff --git a/docs/tutorials/over_the_river.rst b/docs/tutorials/over_the_river.rst
index dac78827..ff8c7feb 100644
--- a/docs/tutorials/over_the_river.rst
+++ b/docs/tutorials/over_the_river.rst
@@ -171,7 +171,7 @@ its :func:`~stem.control.Controller.get_hidden_service_descriptor` method...
A hidden service's introduction points are a base64 encoded field that's
possibly encrypted. These can be decoded (and decrypted if necessary) with the
descriptor's
-:func:`~stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor.introduction_points`
+:func:`~stem.descriptor.hidden_service.HiddenServiceDescriptor.introduction_points`
method.
.. literalinclude:: /_static/example/introduction_points.py
diff --git a/stem/control.py b/stem/control.py
index d8423ffa..86f4e787 100644
--- a/stem/control.py
+++ b/stem/control.py
@@ -2136,7 +2136,7 @@ class Controller(BaseController):
:param list servers: requrest the descriptor from these specific servers
:param float timeout: seconds to wait when **await_result** is **True**
- :returns: :class:`~stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor`
+ :returns: :class:`~stem.descriptor.hidden_service.HiddenServiceDescriptorV2`
for the given service if **await_result** is **True**, or **None** otherwise
:raises:
diff --git a/stem/descriptor/__init__.py b/stem/descriptor/__init__.py
index c099ca86..3a3d1838 100644
--- a/stem/descriptor/__init__.py
+++ b/stem/descriptor/__init__.py
@@ -118,7 +118,7 @@ __all__ = [
'collector',
'export',
'extrainfo_descriptor',
- 'hidden_service_descriptor',
+ 'hidden_service',
'microdescriptor',
'networkstatus',
'reader',
@@ -329,7 +329,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`
+ hidden-service-descriptor 1.0 :class:`~stem.descriptor.hidden_service.HiddenServiceDescriptorV2`
========================================= =====
If you're using **python 3** then beware that the open() function defaults to
@@ -532,18 +532,19 @@ def _parse_metrics_file(descriptor_type, major_version, minor_version, descripto
for desc in stem.descriptor.networkstatus._parse_file(descriptor_file, document_type, validate = validate, document_handler = document_handler, **kwargs):
yield desc
elif descriptor_type == stem.descriptor.tordnsel.TorDNSEL.TYPE_ANNOTATION_NAME and major_version == 1:
- document_type = stem.descriptor.tordnsel.TorDNSEL
-
for desc in stem.descriptor.tordnsel._parse_file(descriptor_file, validate = validate, **kwargs):
yield desc
- elif descriptor_type == stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor.TYPE_ANNOTATION_NAME and major_version == 1:
- document_type = stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor
+ elif descriptor_type == stem.descriptor.hidden_service.HiddenServiceDescriptorV2.TYPE_ANNOTATION_NAME and major_version == 1:
+ desc_type = stem.descriptor.hidden_service.HiddenServiceDescriptorV2
- for desc in stem.descriptor.hidden_service_descriptor._parse_file(descriptor_file, validate = validate, **kwargs):
+ for desc in stem.descriptor.hidden_service._parse_file(descriptor_file, desc_type, validate = validate, **kwargs):
yield desc
- elif descriptor_type == stem.descriptor.bandwidth_file.BandwidthFile.TYPE_ANNOTATION_NAME and major_version == 1:
- document_type = stem.descriptor.bandwidth_file.BandwidthFile
+ elif descriptor_type == stem.descriptor.hidden_service.HiddenServiceDescriptorV3.TYPE_ANNOTATION_NAME and major_version == 1:
+ desc_type = stem.descriptor.hidden_service.HiddenServiceDescriptorV3
+ for desc in stem.descriptor.hidden_service._parse_file(descriptor_file, desc_type, validate = validate, **kwargs):
+ yield desc
+ elif descriptor_type == stem.descriptor.bandwidth_file.BandwidthFile.TYPE_ANNOTATION_NAME and major_version == 1:
for desc in stem.descriptor.bandwidth_file._parse_file(descriptor_file, validate = validate, **kwargs):
yield desc
else:
@@ -656,6 +657,23 @@ def _parse_bytes_line(keyword, attribute):
return _parse
+def _parse_int_line(keyword, attribute, allow_negative = True):
+ def _parse(descriptor, entries):
+ value = _value(keyword, entries)
+
+ try:
+ int_val = int(value)
+ except ValueError:
+ raise ValueError('%s must have a numeric value: %s' % (keyword, value))
+
+ if not allow_negative and int_val < 0:
+ raise ValueError('%s must have a positive value: %s' % (keyword, value))
+
+ setattr(descriptor, attribute, int_val)
+
+ return _parse
+
+
def _parse_timestamp_line(keyword, attribute):
# "<keyword>" YYYY-MM-DD HH:MM:SS
@@ -1521,7 +1539,7 @@ def _descriptor_components(raw_contents, validate, extra_keywords = (), non_asci
import stem.descriptor.bandwidth_file
import stem.descriptor.extrainfo_descriptor
-import stem.descriptor.hidden_service_descriptor
+import stem.descriptor.hidden_service
import stem.descriptor.microdescriptor
import stem.descriptor.networkstatus
import stem.descriptor.server_descriptor
diff --git a/stem/descriptor/extrainfo_descriptor.py b/stem/descriptor/extrainfo_descriptor.py
index 82e3154f..d55b8eef 100644
--- a/stem/descriptor/extrainfo_descriptor.py
+++ b/stem/descriptor/extrainfo_descriptor.py
@@ -88,6 +88,7 @@ from stem.descriptor import (
_value,
_values,
_parse_simple_line,
+ _parse_int_line,
_parse_timestamp_line,
_parse_forty_character_hex,
_parse_key_block,
@@ -306,19 +307,6 @@ def _parse_transport_line(descriptor, entries):
descriptor.transport = transports
-def _parse_cell_circuits_per_decline_line(descriptor, entries):
- # "cell-circuits-per-decile" num
-
- value = _value('cell-circuits-per-decile', entries)
-
- if not value.isdigit():
- raise ValueError('Non-numeric cell-circuits-per-decile value: %s' % value)
- elif int(value) < 0:
- raise ValueError('Negative cell-circuits-per-decile value: %s' % value)
-
- descriptor.cell_circuits_per_decile = int(value)
-
-
def _parse_padding_counts_line(descriptor, entries):
# "padding-counts" YYYY-MM-DD HH:MM:SS (NSEC s) key=val key=val...
@@ -538,6 +526,7 @@ _parse_dirreq_v3_share_line = functools.partial(_parse_dirreq_share_line, 'dirre
_parse_cell_processed_cells_line = functools.partial(_parse_cell_line, 'cell-processed-cells', 'cell_processed_cells')
_parse_cell_queued_cells_line = functools.partial(_parse_cell_line, 'cell-queued-cells', 'cell_queued_cells')
_parse_cell_time_in_queue_line = functools.partial(_parse_cell_line, 'cell-time-in-queue', 'cell_time_in_queue')
+_parse_cell_circuits_per_decline_line = _parse_int_line('cell-circuits-per-decile', 'cell_circuits_per_decile', allow_negative = False)
_parse_published_line = _parse_timestamp_line('published', 'published')
_parse_geoip_start_time_line = _parse_timestamp_line('geoip-start-time', 'geoip_start_time')
_parse_cell_stats_end_line = functools.partial(_parse_timestamp_and_interval_line, 'cell-stats-end', 'cell_stats_end', 'cell_stats_interval')
diff --git a/stem/descriptor/hidden_service.py b/stem/descriptor/hidden_service.py
new file mode 100644
index 00000000..52e1b0b1
--- /dev/null
+++ b/stem/descriptor/hidden_service.py
@@ -0,0 +1,552 @@
+# Copyright 2015-2019, Damian Johnson and The Tor Project
+# See LICENSE for licensing information
+
+"""
+Parsing for Tor hidden service descriptors as described in Tor's `version 2
+<https://gitweb.torproject.org/torspec.git/tree/rend-spec-v2.txt>`_ and
+`version 3 <https://gitweb.torproject.org/torspec.git/tree/rend-spec-v3.txt>`_
+rend-spec.
+
+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.
+
+These are only available through the Controller's
+:func:`~stem.control.Controller.get_hidden_service_descriptor` method.
+
+**Module Overview:**
+
+::
+
+ BaseHiddenServiceDescriptor - Common parent for hidden service descriptors
+ |- HiddenServiceDescriptorV2 - Version 2 hidden service descriptor
+ +- HiddenServiceDescriptorV3 - Version 3 hidden service descriptor
+
+.. versionadded:: 1.4.0
+"""
+
+import base64
+import binascii
+import collections
+import hashlib
+import io
+
+import stem.prereq
+import stem.util.connection
+import stem.util.str_tools
+
+from stem.descriptor import (
+ PGP_BLOCK_END,
+ Descriptor,
+ _descriptor_content,
+ _descriptor_components,
+ _read_until_keywords,
+ _bytes_for_block,
+ _value,
+ _parse_simple_line,
+ _parse_int_line,
+ _parse_timestamp_line,
+ _parse_key_block,
+ _random_date,
+ _random_crypto_blob,
+)
+
+if stem.prereq._is_lru_cache_available():
+ from functools import lru_cache
+else:
+ from stem.util.lru_cache import lru_cache
+
+REQUIRED_V2_FIELDS = (
+ 'rendezvous-service-descriptor',
+ 'version',
+ 'permanent-key',
+ 'secret-id-part',
+ 'publication-time',
+ 'protocol-versions',
+ 'signature',
+)
+
+REQUIRED_V3_FIELDS = (
+ 'hs-descriptor',
+ 'descriptor-lifetime',
+ 'descriptor-signing-key-cert',
+ 'revision-counter',
+ 'superencrypted',
+ 'signature',
+)
+
+INTRODUCTION_POINTS_ATTR = {
+ 'identifier': None,
+ 'address': None,
+ 'port': None,
+ 'onion_key': None,
+ 'service_key': None,
+ '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
+
+
+class IntroductionPoints(collections.namedtuple('IntroductionPoints', INTRODUCTION_POINTS_ATTR.keys())):
+ """
+ :var str identifier: hash of this introduction point's identity key
+ :var str address: address of this introduction point
+ :var int port: port where this introduction point is listening
+ :var str onion_key: public key for communicating with this introduction point
+ :var str service_key: public key for communicating with this hidden service
+ :var list intro_authentication: tuples of the form (auth_type, auth_data) for
+ establishing a connection
+ """
+
+
+class DecryptionFailure(Exception):
+ """
+ Failure to decrypt the hidden service descriptor's introduction-points.
+ """
+
+
+def _parse_file(descriptor_file, desc_type = None, validate = False, **kwargs):
+ """
+ Iterates over the hidden service descriptors in a file.
+
+ :param file descriptor_file: file with descriptor content
+ :param class desc_type: BaseHiddenServiceDescriptor subclass
+ :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.HiddenServiceDescriptorV2`
+ instances in the file
+
+ :raises:
+ * **ValueError** if the contents is malformed and validate is **True**
+ * **IOError** if the file can't be read
+ """
+
+ if desc_type is None:
+ desc_type = HiddenServiceDescriptorV2
+
+ # Hidden service v3 ends with a signature line, whereas v2 has a pgp style
+ # block following it.
+
+ while True:
+ descriptor_content = _read_until_keywords('signature', descriptor_file, True)
+
+ if desc_type == HiddenServiceDescriptorV2:
+ 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 desc_type(bytes.join(b'', descriptor_content), validate, **kwargs)
+ else:
+ break # done parsing file
+
+
+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
+ descriptor.introduction_points_auth = [] # field was never implemented in tor (#15190)
+
+ try:
+ descriptor.introduction_points_content = _bytes_for_block(block_contents)
+ except TypeError:
+ raise ValueError("'introduction-points' isn't base64 encoded content:\n%s" % block_contents)
+
+
+_parse_v2_version_line = _parse_int_line('version', 'version', allow_negative = False)
+_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_v2_signature_line = _parse_key_block('signature', 'signature', 'SIGNATURE')
+
+_parse_v3_version_line = _parse_int_line('hs-descriptor', 'version', allow_negative = False)
+_parse_lifetime_line = _parse_int_line('descriptor-lifetime', 'lifetime', allow_negative = False)
+_parse_signing_key_line = _parse_key_block('descriptor-signing-key-cert', 'signing_cert', 'ED25519 CERT')
+_parse_revision_counter_line = _parse_int_line('revision-counter', 'revision_counter', allow_negative = False)
+_parse_superencrypted_line = _parse_key_block('superencrypted', 'superencrypted', 'MESSAGE')
+_parse_v3_signature_line = _parse_simple_line('signature', 'signature')
+
+
+class BaseHiddenServiceDescriptor(Descriptor):
+ """
+ Hidden service descriptor.
+
+ .. versionadded:: 1.8.0
+ """
+
+ # TODO: rename this class to HiddenServiceDescriptor in stem 2.x
+
+
+class HiddenServiceDescriptorV2(BaseHiddenServiceDescriptor):
+ """
+ Version 2 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
+ (**deprecated**, always **[]**)
+ :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
+
+ .. versionchanged:: 1.6.0
+ Moved from the deprecated `pycrypto
+ <https://www.dlitz.net/software/pycrypto/>`_ module to `cryptography
+ <https://pypi.org/project/cryptography/>`_ for validating signatures.
+
+ .. versionchanged:: 1.6.0
+ Added the **skip_crypto_validation** constructor argument.
+ """
+
+ TYPE_ANNOTATION_NAME = 'hidden-service-descriptor'
+
+ ATTRIBUTES = {
+ 'descriptor_id': (None, _parse_rendezvous_service_descriptor_line),
+ 'version': (None, _parse_v2_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_v2_signature_line),
+ }
+
+ PARSER_FOR_LINE = {
+ 'rendezvous-service-descriptor': _parse_rendezvous_service_descriptor_line,
+ 'version': _parse_v2_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_v2_signature_line,
+ }
+
+ @classmethod
+ def content(cls, attr = None, exclude = (), sign = False):
+ if sign:
+ raise NotImplementedError('Signing of %s not implemented' % cls.__name__)
+
+ return _descriptor_content(attr, exclude, (
+ ('rendezvous-service-descriptor', 'y3olqqblqw2gbh6phimfuiroechjjafa'),
+ ('version', '2'),
+ ('permanent-key', _random_crypto_blob('RSA PUBLIC KEY')),
+ ('secret-id-part', 'e24kgecavwsznj7gpbktqsiwgvngsf4e'),
+ ('publication-time', _random_date()),
+ ('protocol-versions', '2,3'),
+ ('introduction-points', '\n-----BEGIN MESSAGE-----\n-----END MESSAGE-----'),
+ ), (
+ ('signature', _random_crypto_blob('SIGNATURE')),
+ ))
+
+ @classmethod
+ def create(cls, attr = None, exclude = (), validate = True, sign = False):
+ return cls(cls.content(attr, exclude, sign), validate = validate, skip_crypto_validation = not sign)
+
+ def __init__(self, raw_contents, validate = False, skip_crypto_validation = False):
+ super(HiddenServiceDescriptorV2, self).__init__(raw_contents, lazy_load = not validate)
+ entries = _descriptor_components(raw_contents, validate, non_ascii_fields = ('introduction-points'))
+
+ if validate:
+ for keyword in REQUIRED_V2_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 not skip_crypto_validation and stem.prereq.is_crypto_available():
+ signed_digest = self._digest_for_signature(self.permanent_key, self.signature)
+ digest_content = self._content_range('rendezvous-service-descriptor ', '\nsignature\n')
+ content_digest = hashlib.sha1(digest_content).hexdigest().upper()
+
+ 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, authentication_cookie = None):
+ """
+ Provided this service's introduction points.
+
+ :returns: **list** of :class:`~stem.descriptor.hidden_service.IntroductionPoints`
+
+ :raises:
+ * **ValueError** if the our introduction-points is malformed
+ * **DecryptionFailure** if unable to decrypt this field
+ """
+
+ content = self.introduction_points_content
+
+ if not content:
+ return []
+ elif authentication_cookie:
+ if not stem.prereq.is_crypto_available():
+ raise DecryptionFailure('Decrypting introduction-points requires the cryptography module')
+
+ try:
+ missing_padding = len(authentication_cookie) % 4
+ authentication_cookie = base64.b64decode(stem.util.str_tools._to_bytes(authentication_cookie) + b'=' * 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:1]), 16)
+
+ if authentication_type == BASIC_AUTH:
+ content = HiddenServiceDescriptorV2._decrypt_basic_auth(content, authentication_cookie)
+ elif authentication_type == STEALTH_AUTH:
+ content = HiddenServiceDescriptorV2._decrypt_stealth_auth(content, authentication_cookie)
+ else:
+ raise DecryptionFailure("Unrecognized authentication type '%s', currently 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 HiddenServiceDescriptorV2._parse_introduction_points(content)
+
+ @staticmethod
+ def _decrypt_basic_auth(content, authentication_cookie):
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from cryptography.hazmat.backends import default_backend
+
+ try:
+ client_blocks = int(binascii.hexlify(content[1:2]), 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:2]))
+
+ # 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
+
+ cipher = Cipher(algorithms.AES(authentication_cookie), modes.CTR(b'\x00' * len(iv)), default_backend())
+ decryptor = cipher.decryptor()
+ session_key = decryptor.update(encrypted_session_key) + decryptor.finalize()
+
+ # attempt to decrypt the intro points with the session key
+
+ cipher = Cipher(algorithms.AES(session_key), modes.CTR(iv), default_backend())
+ decryptor = cipher.decryptor()
+ decrypted = decryptor.update(encrypted) + decryptor.finalize()
+
+ # 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 cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from cryptography.hazmat.backends import default_backend
+
+ # byte 1 = authentication type, 2-17 = input vector, 18 on = encrypted content
+ iv, encrypted = content[1:17], content[17:]
+ cipher = Cipher(algorithms.AES(authentication_cookie), modes.CTR(iv), default_backend())
+ decryptor = cipher.decryptor()
+
+ return decryptor.update(encrypted) + decryptor.finalize()
+
+ @staticmethod
+ def _parse_introduction_points(content):
+ """
+ Provides the parsed list of IntroductionPoints for the unencrypted content.
+ """
+
+ introduction_points = []
+ content_io = io.BytesIO(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 = _descriptor_components(content, False)
+
+ 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':
+ 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(IntroductionPoints(**attr))
+
+ return introduction_points
+
+
+class HiddenServiceDescriptorV3(BaseHiddenServiceDescriptor):
+ """
+ Version 3 hidden service descriptor.
+
+ :var int version: **\\*** hidden service descriptor version
+ :var int lifetime: **\\*** minutes after publication this descriptor is valid
+ :var str signing_cert: **\\*** cross-certifier for the short-term descriptor signing key
+ :var int revision_counter: **\\*** descriptor revision number
+ :var str superencrypted: **\\*** encrypted HS-DESC-ENC payload
+ :var str signature: **\\*** signature of this descriptor
+
+ **\\*** attribute is either required when we're parsed with validation or has
+ a default value, others are left as **None** if undefined
+ """
+
+ # TODO: requested this @type on https://trac.torproject.org/projects/tor/ticket/31481
+
+ TYPE_ANNOTATION_NAME = 'hidden-service-descriptor-3'
+
+ ATTRIBUTES = {
+ 'version': (None, _parse_v3_version_line),
+ 'lifetime': (None, _parse_lifetime_line),
+ 'signing_cert': (None, _parse_signing_key_line),
+ 'revision_counter': (None, _parse_revision_counter_line),
+ 'superencrypted': (None, _parse_superencrypted_line),
+ 'signature': (None, _parse_v3_signature_line),
+ }
+
+ PARSER_FOR_LINE = {
+ 'hs-descriptor': _parse_v3_version_line,
+ 'descriptor-lifetime': _parse_lifetime_line,
+ 'descriptor-signing-key-cert': _parse_signing_key_line,
+ 'revision-counter': _parse_revision_counter_line,
+ 'superencrypted': _parse_superencrypted_line,
+ 'signature': _parse_v3_signature_line,
+ }
+
+ @classmethod
+ def content(cls, attr = None, exclude = (), sign = False):
+ if sign:
+ raise NotImplementedError('Signing of %s not implemented' % cls.__name__)
+
+ return _descriptor_content(attr, exclude, (
+ ('hs-descriptor', '3'),
+ ('descriptor-lifetime', '180'),
+ ('descriptor-signing-key-cert', _random_crypto_blob('ED25519 CERT')),
+ ('revision-counter', '15'),
+ ('superencrypted', _random_crypto_blob('MESSAGE')),
+ ('signature', 'wdc7ffr+dPZJ/mIQ1l4WYqNABcmsm6SHW/NL3M3wG7bjjqOJWoPR5TimUXxH52n5Zk0Gc7hl/hz3YYmAx5MvAg'),
+ ), ())
+
+ @classmethod
+ def create(cls, attr = None, exclude = (), validate = True, sign = False):
+ return cls(cls.content(attr, exclude, sign), validate = validate, skip_crypto_validation = not sign)
+
+ def __init__(self, raw_contents, validate = False, skip_crypto_validation = False):
+ super(HiddenServiceDescriptorV3, self).__init__(raw_contents, lazy_load = not validate)
+ entries = _descriptor_components(raw_contents, validate)
+
+ if validate:
+ for keyword in REQUIRED_V3_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 'hs-descriptor' != list(entries.keys())[0]:
+ raise ValueError("Hidden service descriptor must start with a 'hs-descriptor' entry")
+ elif 'signature' != list(entries.keys())[-1]:
+ raise ValueError("Hidden service descriptor must end with a 'signature' entry")
+
+ self._parse(entries, validate)
+ else:
+ self._entries = entries
+
+
+# TODO: drop this alias in stem 2.x
+
+HiddenServiceDescriptor = HiddenServiceDescriptorV2
diff --git a/stem/descriptor/hidden_service_descriptor.py b/stem/descriptor/hidden_service_descriptor.py
index 99d6414e..d77d88aa 100644
--- a/stem/descriptor/hidden_service_descriptor.py
+++ b/stem/descriptor/hidden_service_descriptor.py
@@ -1,444 +1,4 @@
-# Copyright 2015-2019, Damian Johnson and The Tor Project
-# See LICENSE for licensing information
+# TODO: This module (hidden_service_descriptor) is a temporary alias for
+# hidden_service. This alias will be removed in Stem 2.x.
-"""
-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.
-
-These are only available through the Controller's
-:func:`~stem.control.get_hidden_service_descriptor` method.
-
-**Module Overview:**
-
-::
-
- HiddenServiceDescriptor - Tor hidden service descriptor.
-
-.. versionadded:: 1.4.0
-"""
-
-# TODO: In stem 2.x rename this module to 'hidden_service' (ie, drop the
-# redundant '_descriptor' suffix).
-
-import base64
-import binascii
-import collections
-import hashlib
-import io
-
-import stem.prereq
-import stem.util.connection
-import stem.util.str_tools
-
-from stem.descriptor import (
- PGP_BLOCK_END,
- Descriptor,
- _descriptor_content,
- _descriptor_components,
- _read_until_keywords,
- _bytes_for_block,
- _value,
- _parse_simple_line,
- _parse_timestamp_line,
- _parse_key_block,
- _random_date,
- _random_crypto_blob,
-)
-
-if stem.prereq._is_lru_cache_available():
- from functools import lru_cache
-else:
- 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': [],
-}
-
-# 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
-
-
-class IntroductionPoints(collections.namedtuple('IntroductionPoints', INTRODUCTION_POINTS_ATTR.keys())):
- """
- :var str identifier: hash of this introduction point's identity key
- :var str address: address of this introduction point
- :var int port: port where this introduction point is listening
- :var str onion_key: public key for communicating with this introduction point
- :var str service_key: public key for communicating with this hidden service
- :var list intro_authentication: tuples of the form (auth_type, auth_data) for
- establishing a connection
- """
-
-
-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
- descriptor.introduction_points_auth = [] # field was never implemented in tor (#15190)
-
- try:
- descriptor.introduction_points_content = _bytes_for_block(block_contents)
- except TypeError:
- raise ValueError("'introduction-points' isn't base64 encoded content:\n%s" % block_contents)
-
-
-_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
- (**deprecated**, always **[]**)
- :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
-
- .. versionchanged:: 1.6.0
- Moved from the deprecated `pycrypto
- <https://www.dlitz.net/software/pycrypto/>`_ module to `cryptography
- <https://pypi.org/project/cryptography/>`_ for validating signatures.
-
- .. versionchanged:: 1.6.0
- Added the **skip_crypto_validation** constructor argument.
- """
-
- TYPE_ANNOTATION_NAME = 'hidden-service-descriptor'
-
- 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,
- }
-
- @classmethod
- def content(cls, attr = None, exclude = (), sign = False):
- if sign:
- raise NotImplementedError('Signing of %s not implemented' % cls.__name__)
-
- return _descriptor_content(attr, exclude, (
- ('rendezvous-service-descriptor', 'y3olqqblqw2gbh6phimfuiroechjjafa'),
- ('version', '2'),
- ('permanent-key', _random_crypto_blob('RSA PUBLIC KEY')),
- ('secret-id-part', 'e24kgecavwsznj7gpbktqsiwgvngsf4e'),
- ('publication-time', _random_date()),
- ('protocol-versions', '2,3'),
- ('introduction-points', '\n-----BEGIN MESSAGE-----\n-----END MESSAGE-----'),
- ), (
- ('signature', _random_crypto_blob('SIGNATURE')),
- ))
-
- @classmethod
- def create(cls, attr = None, exclude = (), validate = True, sign = False):
- return cls(cls.content(attr, exclude, sign), validate = validate, skip_crypto_validation = not sign)
-
- def __init__(self, raw_contents, validate = False, skip_crypto_validation = False):
- super(HiddenServiceDescriptor, self).__init__(raw_contents, lazy_load = not validate)
- entries = _descriptor_components(raw_contents, validate, non_ascii_fields = ('introduction-points'))
-
- 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 not skip_crypto_validation and stem.prereq.is_crypto_available():
- signed_digest = self._digest_for_signature(self.permanent_key, self.signature)
- digest_content = self._content_range('rendezvous-service-descriptor ', '\nsignature\n')
- content_digest = hashlib.sha1(digest_content).hexdigest().upper()
-
- 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, authentication_cookie = None):
- """
- Provided this service's introduction points.
-
- :returns: **list** of :class:`~stem.descriptor.hidden_service_descriptor.IntroductionPoints`
-
- :raises:
- * **ValueError** if the our introduction-points is malformed
- * **DecryptionFailure** if unable to decrypt this field
- """
-
- content = self.introduction_points_content
-
- if not content:
- return []
- elif authentication_cookie:
- if not stem.prereq.is_crypto_available():
- raise DecryptionFailure('Decrypting introduction-points requires the cryptography module')
-
- try:
- missing_padding = len(authentication_cookie) % 4
- authentication_cookie = base64.b64decode(stem.util.str_tools._to_bytes(authentication_cookie) + b'=' * 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:1]), 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', currently 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 cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
- from cryptography.hazmat.backends import default_backend
-
- try:
- client_blocks = int(binascii.hexlify(content[1:2]), 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:2]))
-
- # 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
-
- cipher = Cipher(algorithms.AES(authentication_cookie), modes.CTR(b'\x00' * len(iv)), default_backend())
- decryptor = cipher.decryptor()
- session_key = decryptor.update(encrypted_session_key) + decryptor.finalize()
-
- # attempt to decrypt the intro points with the session key
-
- cipher = Cipher(algorithms.AES(session_key), modes.CTR(iv), default_backend())
- decryptor = cipher.decryptor()
- decrypted = decryptor.update(encrypted) + decryptor.finalize()
-
- # 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 cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
- from cryptography.hazmat.backends import default_backend
-
- # byte 1 = authentication type, 2-17 = input vector, 18 on = encrypted content
- iv, encrypted = content[1:17], content[17:]
- cipher = Cipher(algorithms.AES(authentication_cookie), modes.CTR(iv), default_backend())
- decryptor = cipher.decryptor()
-
- return decryptor.update(encrypted) + decryptor.finalize()
-
- @staticmethod
- def _parse_introduction_points(content):
- """
- Provides the parsed list of IntroductionPoints for the unencrypted content.
- """
-
- introduction_points = []
- content_io = io.BytesIO(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 = _descriptor_components(content, False)
-
- 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':
- 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(IntroductionPoints(**attr))
-
- return introduction_points
+from stem.descriptor.hidden_service import *
diff --git a/stem/descriptor/server_descriptor.py b/stem/descriptor/server_descriptor.py
index 85e35f57..7eaf6e93 100644
--- a/stem/descriptor/server_descriptor.py
+++ b/stem/descriptor/server_descriptor.py
@@ -78,6 +78,7 @@ from stem.descriptor import (
_value,
_values,
_parse_simple_line,
+ _parse_int_line,
_parse_if_present,
_parse_bytes_line,
_parse_timestamp_line,
@@ -337,26 +338,6 @@ def _parse_hibernating_line(descriptor, entries):
descriptor.hibernating = value == '1'
-def _parse_uptime_line(descriptor, entries):
- # We need to be tolerant of negative uptimes to accommodate a past tor
- # bug...
- #
- # Changes in version 0.1.2.7-alpha - 2007-02-06
- # - If our system clock jumps back in time, don't publish a negative
- # uptime in the descriptor. Also, don't let the global rate limiting
- # buckets go absurdly negative.
- #
- # After parsing all of the attributes we'll double check that negative
- # uptimes only occurred prior to this fix.
-
- value = _value('uptime', entries)
-
- try:
- descriptor.uptime = int(value)
- except ValueError:
- raise ValueError('Uptime line must have an integer value: %s' % value)
-
-
def _parse_protocols_line(descriptor, entries):
value = _value('protocols', entries)
protocols_match = re.match('^Link (.*) Circuit (.*)$', value)
@@ -454,6 +435,19 @@ _parse_router_sig_ed25519_line = _parse_simple_line('router-sig-ed25519', 'ed255
_parse_router_digest_sha256_line = _parse_simple_line('router-digest-sha256', 'router_digest_sha256')
_parse_router_digest_line = _parse_forty_character_hex('router-digest', '_digest')
+# TODO: We need to be tolerant of negative uptimes to accommodate a past tor
+# bug...
+#
+# Changes in version 0.1.2.7-alpha - 2007-02-06
+# - If our system clock jumps back in time, don't publish a negative
+# uptime in the descriptor. Also, don't let the global rate limiting
+# buckets go absurdly negative.
+#
+# After parsing all of the attributes we'll double check that negative
+# uptimes only occurred prior to this fix.
+
+_parse_uptime_line = _parse_int_line('uptime', 'uptime', allow_negative = True)
+
class ServerDescriptor(Descriptor):
"""
diff --git a/stem/response/events.py b/stem/response/events.py
index a9f563c6..27a3e405 100644
--- a/stem/response/events.py
+++ b/stem/response/events.py
@@ -701,7 +701,7 @@ class HSDescContentEvent(Event):
:var str directory: hidden service directory servicing the request
:var str directory_fingerprint: hidden service directory's finterprint
:var str directory_nickname: hidden service directory's nickname if it was provided
- :var stem.descriptor.hidden_service_descriptor.HiddenServiceDescriptor descriptor: descriptor that was retrieved
+ :var stem.descriptor.hidden_service.HiddenServiceDescriptorV2 descriptor: descriptor that was retrieved
"""
_VERSION_ADDED = stem.version.Requirement.EVENT_HS_DESC_CONTENT
@@ -726,7 +726,7 @@ class HSDescContentEvent(Event):
self.descriptor = None
if desc_content:
- self.descriptor = list(stem.descriptor.hidden_service_descriptor._parse_file(io.BytesIO(desc_content)))[0]
+ self.descriptor = list(stem.descriptor.hidden_service._parse_file(io.BytesIO(desc_content)))[0]
class LogEvent(Event):
diff --git a/test/settings.cfg b/test/settings.cfg
index 1bdb1a0a..1b971bb4 100644
--- a/test/settings.cfg
+++ b/test/settings.cfg
@@ -172,7 +172,7 @@ pycodestyle.ignore E722
pycodestyle.ignore stem/__init__.py => E402: import stem.util.connection
pycodestyle.ignore stem/descriptor/__init__.py => E402: import stem.descriptor.bandwidth_file
pycodestyle.ignore stem/descriptor/__init__.py => E402: import stem.descriptor.extrainfo_descriptor
-pycodestyle.ignore stem/descriptor/__init__.py => E402: import stem.descriptor.hidden_service_descriptor
+pycodestyle.ignore stem/descriptor/__init__.py => E402: import stem.descriptor.hidden_service
pycodestyle.ignore stem/descriptor/__init__.py => E402: import stem.descriptor.microdescriptor
pycodestyle.ignore stem/descriptor/__init__.py => E402: import stem.descriptor.networkstatus
pycodestyle.ignore stem/descriptor/__init__.py => E402: import stem.descriptor.server_descriptor
@@ -184,9 +184,7 @@ pycodestyle.ignore test/unit/util/connection.py => W291: _tor tor 158
# issue.
pyflakes.ignore run_tests.py => 'unittest' imported but unused
-pyflakes.ignore stem/client/datatype.py => redefinition of unused 'pop' from *
pyflakes.ignore stem/control.py => undefined name 'controller'
-pyflakes.ignore stem/interpreter/__init__.py => undefined name 'raw_input'
pyflakes.ignore stem/manual.py => undefined name 'unichr'
pyflakes.ignore stem/prereq.py => 'int_to_bytes' imported but unused
pyflakes.ignore stem/prereq.py => 'int_from_bytes' imported but unused
@@ -210,6 +208,10 @@ pyflakes.ignore stem/prereq.py => 'cryptography.hazmat.primitives.ciphers.modes'
pyflakes.ignore stem/prereq.py => 'cryptography.hazmat.primitives.ciphers.Cipher' imported but unused
pyflakes.ignore stem/prereq.py => 'cryptography.hazmat.primitives.ciphers.algorithms' imported but unused
pyflakes.ignore stem/prereq.py => 'lzma' imported but unused
+pyflakes.ignore stem/client/datatype.py => redefinition of unused 'pop' from *
+pyflakes.ignore stem/descriptor/hidden_service_descriptor.py => 'stem.descriptor.hidden_service.*' imported but unused
+pyflakes.ignore stem/descriptor/hidden_service_descriptor.py => 'from stem.descriptor.hidden_service import *' used; unable to detect undefined names
+pyflakes.ignore stem/interpreter/__init__.py => undefined name 'raw_input'
pyflakes.ignore stem/response/events.py => undefined name 'long'
pyflakes.ignore stem/util/__init__.py => undefined name 'long'
pyflakes.ignore stem/util/__init__.py => undefined name 'unicode'
@@ -256,7 +258,8 @@ 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.descriptor.hidden_service_v2.TestHiddenServiceDescriptorV2
+|test.unit.descriptor.hidden_service_v3.TestHiddenServiceDescriptorV3
|test.unit.descriptor.certificate.TestEd25519Certificate
|test.unit.descriptor.bandwidth_file.TestBandwidthFile
|test.unit.exit_policy.rule.TestExitPolicyRule
diff --git a/test/unit/descriptor/data/hidden_service_v3 b/test/unit/descriptor/data/hidden_service_v3
new file mode 100644
index 00000000..c67ae292
--- /dev/null
+++ b/test/unit/descriptor/data/hidden_service_v3
@@ -0,0 +1,223 @@
+hs-descriptor 3
+descriptor-lifetime 180
+descriptor-signing-key-cert
+-----BEGIN ED25519 CERT-----
+AQgABqKwAQVql1QZETyEwJjg+Cv6f2w/cp+c3juj01NPBaJqihboAQAgBACx+FKK
+oDrFE1+ztSxzN8sApKOb5UuDtoe/E03DxZU5+r/K5AV6G0hYn21V7Xbu2pZHvIkT
+2oVY4hypWNJE58eFBRFRzBA0J2h0GyFs1pIuRh5QDJuxB5j92V0aRCNZFgM=
+-----END ED25519 CERT-----
+revision-counter 15
+superencrypted
+-----BEGIN MESSAGE-----
+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
+usMPytqLsZg2WXj4ikmGug==
+-----END MESSAGE-----
+signature wdc7ffr+dPZJ/mIQ1l4WYqNABcmsm6SHW/NL3M3wG7bjjqOJWoPR5TimUXxH52n5Zk0Gc7hl/hz3YYmAx5MvAg
diff --git a/test/unit/descriptor/hidden_service_descriptor.py b/test/unit/descriptor/hidden_service_v2.py
index e9ba012b..5191775e 100644
--- a/test/unit/descriptor/hidden_service_descriptor.py
+++ b/test/unit/descriptor/hidden_service_v2.py
@@ -1,5 +1,5 @@
"""
-Unit tests for stem.descriptor.hidden_service_descriptor.
+Unit tests for stem.descriptor.hidden_service for version 2.
"""
import datetime
@@ -10,10 +10,10 @@ import stem.descriptor
import stem.prereq
import test.require
-from stem.descriptor.hidden_service_descriptor import (
- REQUIRED_FIELDS,
+from stem.descriptor.hidden_service import (
+ REQUIRED_V2_FIELDS,
DecryptionFailure,
- HiddenServiceDescriptor,
+ HiddenServiceDescriptorV2,
)
from test.unit.descriptor import (
@@ -236,41 +236,41 @@ lj/7xMZWDrfyw5H86L0QiaZnkmD+nig1+S+Rn39mmuEgl2iwZO/ihlncUJQTEULb
-----END MESSAGE-----\
"""
-expect_invalid_attr = functools.partial(base_expect_invalid_attr, HiddenServiceDescriptor, 'descriptor_id', 'y3olqqblqw2gbh6phimfuiroechjjafa')
-expect_invalid_attr_for_text = functools.partial(base_expect_invalid_attr_for_text, HiddenServiceDescriptor, 'descriptor_id', 'y3olqqblqw2gbh6phimfuiroechjjafa')
+expect_invalid_attr = functools.partial(base_expect_invalid_attr, HiddenServiceDescriptorV2, 'descriptor_id', 'y3olqqblqw2gbh6phimfuiroechjjafa')
+expect_invalid_attr_for_text = functools.partial(base_expect_invalid_attr_for_text, HiddenServiceDescriptorV2, 'descriptor_id', 'y3olqqblqw2gbh6phimfuiroechjjafa')
-class TestHiddenServiceDescriptor(unittest.TestCase):
+class TestHiddenServiceDescriptorV2(unittest.TestCase):
def test_from_str(self):
- sig = HiddenServiceDescriptor.create()
- self.assertEqual(sig, HiddenServiceDescriptor.from_str(str(sig)))
+ sig = HiddenServiceDescriptorV2.create()
+ self.assertEqual(sig, HiddenServiceDescriptorV2.from_str(str(sig)))
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)
+ with open(get_resource('hidden_service_duckduckgo'), 'rb') as descriptor_file:
+ 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)
+ with open(get_resource('hidden_service_duckduckgo'), 'rb') as descriptor_file:
+ 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')
+ with open(get_resource('hidden_service_facebook'), 'rb') as descriptor_file:
+ desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = True))
- 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)
@@ -279,7 +279,7 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
@test.require.cryptography
def test_descriptor_signing(self):
- self.assertRaisesWith(NotImplementedError, 'Signing of HiddenServiceDescriptor not implemented', HiddenServiceDescriptor.create, sign = True)
+ self.assertRaisesWith(NotImplementedError, 'Signing of HiddenServiceDescriptorV2 not implemented', HiddenServiceDescriptorV2.create, sign = True)
@test.require.cryptography
def test_with_basic_auth(self):
@@ -287,9 +287,9 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
Parse a descriptor with introduction-points encrypted with basic auth.
"""
- descriptor_file = open(get_resource('hidden_service_basic_auth'), 'rb')
+ with open(get_resource('hidden_service_basic_auth'), 'rb') as descriptor_file:
+ desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = True))
- 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)
@@ -334,9 +334,9 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
Parse a descriptor with introduction-points encrypted with stealth auth.
"""
- descriptor_file = open(get_resource('hidden_service_stealth_auth'), 'rb')
+ with open(get_resource('hidden_service_stealth_auth'), 'rb') as descriptor_file:
+ desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor 1.0', validate = True))
- 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)
@@ -418,7 +418,7 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
Basic sanity check that we can parse a hidden service descriptor with minimal attributes.
"""
- desc = HiddenServiceDescriptor.create()
+ desc = HiddenServiceDescriptorV2.create()
self.assertEqual('y3olqqblqw2gbh6phimfuiroechjjafa', desc.descriptor_id)
self.assertEqual(2, desc.version)
@@ -435,7 +435,7 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
Includes unrecognized content in the descriptor.
"""
- desc = HiddenServiceDescriptor.create({'pepperjack': 'is oh so tasty!'})
+ desc = HiddenServiceDescriptorV2.create({'pepperjack': 'is oh so tasty!'})
self.assertEqual(['pepperjack is oh so tasty!'], desc.get_unrecognized_lines())
def test_proceeding_line(self):
@@ -443,14 +443,14 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
Includes a line prior to the 'rendezvous-service-descriptor' entry.
"""
- expect_invalid_attr_for_text(self, b'hibernate 1\n' + HiddenServiceDescriptor.content())
+ expect_invalid_attr_for_text(self, b'hibernate 1\n' + HiddenServiceDescriptorV2.content())
def test_trailing_line(self):
"""
Includes a line after the 'router-signature' entry.
"""
- expect_invalid_attr_for_text(self, HiddenServiceDescriptor.content() + b'\nhibernate 1')
+ expect_invalid_attr_for_text(self, HiddenServiceDescriptorV2.content() + b'\nhibernate 1')
def test_required_fields(self):
"""
@@ -468,8 +468,8 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
'signature': 'signature',
}
- for line in REQUIRED_FIELDS:
- desc_text = HiddenServiceDescriptor.content(exclude = (line,))
+ for line in REQUIRED_V2_FIELDS:
+ desc_text = HiddenServiceDescriptorV2.content(exclude = (line,))
expected = [] if line == 'protocol-versions' else None
expect_invalid_attr_for_text(self, desc_text, line_to_attr[line], expected)
@@ -514,14 +514,14 @@ class TestHiddenServiceDescriptor(unittest.TestCase):
are valid according to the spec.
"""
- missing_field_desc = HiddenServiceDescriptor.create(exclude = ('introduction-points',))
+ missing_field_desc = HiddenServiceDescriptorV2.create(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 = HiddenServiceDescriptor.create({'introduction-points': MESSAGE_BLOCK % ''})
+ empty_field_desc = HiddenServiceDescriptorV2.create({'introduction-points': MESSAGE_BLOCK % ''})
self.assertEqual((MESSAGE_BLOCK % '').strip(), empty_field_desc.introduction_points_encoded)
self.assertEqual([], empty_field_desc.introduction_points_auth)
diff --git a/test/unit/descriptor/hidden_service_v3.py b/test/unit/descriptor/hidden_service_v3.py
new file mode 100644
index 00000000..f6407623
--- /dev/null
+++ b/test/unit/descriptor/hidden_service_v3.py
@@ -0,0 +1,109 @@
+"""
+Unit tests for stem.descriptor.hidden_service for version 3.
+"""
+
+import functools
+import unittest
+
+import stem.descriptor
+
+from stem.descriptor.hidden_service import (
+ REQUIRED_V3_FIELDS,
+ HiddenServiceDescriptorV3,
+)
+
+from test.unit.descriptor import (
+ get_resource,
+ base_expect_invalid_attr,
+ base_expect_invalid_attr_for_text,
+)
+
+expect_invalid_attr = functools.partial(base_expect_invalid_attr, HiddenServiceDescriptorV3, 'version', 3)
+expect_invalid_attr_for_text = functools.partial(base_expect_invalid_attr_for_text, HiddenServiceDescriptorV3, 'version', 3)
+
+EXPECTED_SIGNING_CERT = """\
+-----BEGIN ED25519 CERT-----
+AQgABqKwAQVql1QZETyEwJjg+Cv6f2w/cp+c3juj01NPBaJqihboAQAgBACx+FKK
+oDrFE1+ztSxzN8sApKOb5UuDtoe/E03DxZU5+r/K5AV6G0hYn21V7Xbu2pZHvIkT
+2oVY4hypWNJE58eFBRFRzBA0J2h0GyFs1pIuRh5QDJuxB5j92V0aRCNZFgM=
+-----END ED25519 CERT-----\
+"""
+
+
+class TestHiddenServiceDescriptorV3(unittest.TestCase):
+ def test_for_riseup(self):
+ """
+ Parse riseup's descriptor...
+
+ vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.onion
+ """
+
+ with open(get_resource('hidden_service_v3'), 'rb') as descriptor_file:
+ desc = next(stem.descriptor.parse_file(descriptor_file, 'hidden-service-descriptor-3 1.0', validate = True))
+
+ self.assertEqual(3, desc.version)
+ self.assertEqual(180, desc.lifetime)
+ self.assertEqual(EXPECTED_SIGNING_CERT, desc.signing_cert)
+ self.assertEqual(15, desc.revision_counter)
+ self.assertTrue('k9uKnDpxhkH0h1h' in desc.superencrypted)
+ self.assertEqual('wdc7ffr+dPZJ/mIQ1l4WYqNABcmsm6SHW/NL3M3wG7bjjqOJWoPR5TimUXxH52n5Zk0Gc7hl/hz3YYmAx5MvAg', desc.signature)
+
+ def test_required_fields(self):
+ """
+ Check that we require the mandatory fields.
+ """
+
+ line_to_attr = {
+ 'hs-descriptor': 'version',
+ 'descriptor-lifetime': 'lifetime',
+ 'descriptor-signing-key-cert': 'signing_cert',
+ 'revision-counter': 'revision_counter',
+ 'superencrypted': 'superencrypted',
+ 'signature': 'signature',
+ }
+
+ for line in REQUIRED_V3_FIELDS:
+ desc_text = HiddenServiceDescriptorV3.content(exclude = (line,))
+ expect_invalid_attr_for_text(self, desc_text, line_to_attr[line], None)
+
+ def test_invalid_version(self):
+ """
+ Checks that our version field expects a numeric value.
+ """
+
+ test_values = (
+ '',
+ '-10',
+ 'hello',
+ )
+
+ for test_value in test_values:
+ expect_invalid_attr(self, {'hs-descriptor': test_value}, 'version')
+
+ def test_invalid_lifetime(self):
+ """
+ Checks that our lifetime field expects a numeric value.
+ """
+
+ test_values = (
+ '',
+ '-10',
+ 'hello',
+ )
+
+ for test_value in test_values:
+ expect_invalid_attr(self, {'descriptor-lifetime': test_value}, 'lifetime')
+
+ def test_invalid_revision_counter(self):
+ """
+ Checks that our revision counter field expects a numeric value.
+ """
+
+ test_values = (
+ '',
+ '-10',
+ 'hello',
+ )
+
+ for test_value in test_values:
+ expect_invalid_attr(self, {'revision-counter': test_value}, 'revision_counter')