[tor-commits] [stem/master] Detached signature parsing support

atagar at torproject.org atagar at torproject.org
Sun Nov 25 20:15:53 UTC 2018


commit 71593fc7340e8fac6bac8806263cfd4096fa8cde
Author: Damian Johnson <atagar at torproject.org>
Date:   Sun Nov 25 11:07:26 2018 -0800

    Detached signature parsing support
    
    Adding a parser for detached signatures, per irl's request...
    
      https://trac.torproject.org/projects/tor/ticket/28495
    
    You can use stem.descriptor.remote to download these, or parse with
    DetachedSignature.from_str(). However, you cannot use parse_file()
    until we have a @type annotation for these...
    
      https://trac.torproject.org/projects/tor/ticket/28615
    
    When downloaded these are only available for five minutes each hour making them
    highly clunky to use, but irl suggested he might change that (hope so!). At
    present during the window when they're available they can be fetched as
    follows...
    
      ============================================================
      Example script
      ============================================================
    
      import stem.descriptor.remote
    
      detached_sigs = stem.descriptor.remote.get_detached_signatures().run()[0]
    
      for i, sig in enumerate(detached_sigs.signatures):
        print('Signature %i is from %s' % (i + 1, sig.identity))
    
      ============================================================
      When available (minutes 55-60 of the hour)
      ============================================================
    
      % python demo.py
      Signature 1 is from 0232AF901C31A04EE9848595AF9BB7620D4C5B2E
      Signature 2 is from 14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4
      Signature 3 is from 23D15D965BC35114467363C165C4F724B64B4F66
      Signature 4 is from 27102BC123E7AF1D4741AE047E160C91ADC76B21
      Signature 5 is from 49015F787433103580E3B66A1707A00E60F2D15B
      Signature 6 is from D586D18309DED4CD6D57C18FDB97EFA96D330566
      Signature 7 is from E8A9C45EDE6D711294FADF8E7951F4DE6CA56B58
      Signature 8 is from ED03BB616EB2F60BEC80151114BB25CEF515B226
      Signature 9 is from EFCBE720AB3A82B99F9E953CD5BF50F7EEFC7B97
    
      ============================================================
      When unavailable (minutes 0-55 of the hour)
      ============================================================
    
      % python demo.py
      Traceback (most recent call last):
        File "demo.py", line 3, in <module>
          detached_sigs = stem.descriptor.remote.get_detached_signatures().run()[0]
        File "/home/atagar/Desktop/stem/stem/descriptor/remote.py", line 476, in run
          return list(self._run(suppress))
        File "/home/atagar/Desktop/stem/stem/descriptor/remote.py", line 487, in _run
          raise self.error
      urllib2.HTTPError: HTTP Error 404: Not found
---
 docs/change_log.rst                                |   1 +
 stem/descriptor/networkstatus.py                   | 218 ++++++++++++++++++++-
 stem/descriptor/remote.py                          |  67 ++++++-
 test/settings.cfg                                  |   1 +
 test/unit/descriptor/data/detached_signatures      | 171 ++++++++++++++++
 .../descriptor/networkstatus/detached_signature.py | 206 +++++++++++++++++++
 6 files changed, 651 insertions(+), 13 deletions(-)

diff --git a/docs/change_log.rst b/docs/change_log.rst
index 48311df4..675464ae 100644
--- a/docs/change_log.rst
+++ b/docs/change_log.rst
@@ -52,6 +52,7 @@ The following are only available within Stem's `git repository
  * **Descriptors**
 
   * Added :func:`stem.descriptor.remote.get_microdescriptors`
+  * Added :class:`~stem.descriptor.networkstatus.DetachedSignature` parsing (:trac:`28495`)
   * Added :func:`~stem.descriptor.__init__.Descriptor.from_str` method (:trac:`28450`)
   * Added :func:`~stem.descriptor.__init__.Descriptor.type_annotation` method (:trac:`28397`)
   * Added the **hash_type** and **encoding** arguments to `ServerDescriptor <api/descriptor/server_descriptor.html#stem.descriptor.server_descriptor.ServerDescriptor.digest>`_ and `ExtraInfo's <api/descriptor/extrainfo_descriptor.html#stem.descriptor.extrainfo_descriptor.ExtraInfoDescriptor.digest>`_ digest methods (:trac:`28398`)
diff --git a/stem/descriptor/networkstatus.py b/stem/descriptor/networkstatus.py
index bc34f5b4..baba556c 100644
--- a/stem/descriptor/networkstatus.py
+++ b/stem/descriptor/networkstatus.py
@@ -51,6 +51,7 @@ For more information see :func:`~stem.descriptor.__init__.DocumentHandler`...
 
   KeyCertificate - Certificate used to authenticate an authority
   DocumentSignature - Signature of a document by a directory authority
+  DetachedSignature - Stand alone signature used when making the consensus
   DirectoryAuthority - Directory authority as defined in a v3 network status document
 """
 
@@ -73,6 +74,7 @@ from stem.descriptor import (
   _descriptor_components,
   _read_until_keywords,
   _value,
+  _values,
   _parse_simple_line,
   _parse_if_present,
   _parse_timestamp_line,
@@ -194,6 +196,19 @@ KEY_CERTIFICATE_PARAMS = (
   ('dir-key-certification', True),
 )
 
+# DetchedSignature fields, tuple is of the form...
+# (keyword, is_mandatory, is_multiple)
+
+DETACHED_SIGNATURE_PARAMS = (
+  ('consensus-digest', True, False),
+  ('valid-after', True, False),
+  ('fresh-until', True, False),
+  ('valid-until', True, False),
+  ('additional-digest', False, True),
+  ('additional-signature', False, True),
+  ('directory-signature', False, True),
+)
+
 # all parameters are constrained to int32 range
 MIN_PARAM, MAX_PARAM = -2147483648, 2147483647
 
@@ -259,6 +274,18 @@ class SharedRandomnessCommitment(collections.namedtuple('SharedRandomnessCommitm
   """
 
 
+class DocumentDigest(collections.namedtuple('DocumentDigest', ['flavor', 'algorithm', 'digest'])):
+  """
+  Digest of a consensus document.
+
+  .. versionadded:: 1.8.0
+
+  :var str flavor: consensus type this digest is for (for example, 'microdesc')
+  :var str algorithm: hash algorithm used to make the digest
+  :var str digest: digest value of the consensus
+  """
+
+
 def _parse_file(document_file, document_type = None, validate = False, is_microdescriptor = False, document_handler = DocumentHandler.ENTRIES, **kwargs):
   """
   Parses a network status and iterates over the RouterStatusEntry in it. The
@@ -344,11 +371,10 @@ def _parse_file_key_certs(certificate_file, validate = False):
     **True**, skips these checks otherwise
 
   :returns: iterator for :class:`stem.descriptor.networkstatus.KeyCertificate`
-    instance in the file
+    instances in the file
 
   :raises:
-    * **ValueError** if the key certificate content is invalid and validate is
-      **True**
+    * **ValueError** if the key certificates are invalid and validate is **True**
     * **IOError** if the file can't be read
   """
 
@@ -365,6 +391,31 @@ def _parse_file_key_certs(certificate_file, validate = False):
       break  # done parsing file
 
 
+def _parse_file_detached_sigs(detached_signature_file, validate = False):
+  """
+  Parses a file containing one or more detached signatures.
+
+  :param file detached_signature_file: file with detached signatures
+  :param bool validate: checks the validity of the detached signature's
+    contents if **True**, skips these checks otherwise
+
+  :returns: iterator for :class:`stem.descriptor.networkstatus.DetachedSignature`
+    instances in the file
+
+  :raises:
+    * **ValueError** if the detached signatures are invalid and validate is **True**
+    * **IOError** if the file can't be read
+  """
+
+  while True:
+    detached_sig_content = _read_until_keywords('consensus-digest', detached_signature_file, ignore_first = True)
+
+    if detached_sig_content:
+      yield stem.descriptor.networkstatus.DetachedSignature(bytes.join(b'', detached_sig_content), validate = validate)
+    else:
+      break  # done parsing file
+
+
 class NetworkStatusDocument(Descriptor):
   """
   Common parent for network status documents.
@@ -406,6 +457,36 @@ def _parse_dir_source_line(descriptor, entries):
   descriptor.dir_port = None if dir_source_comp[2] == '0' else int(dir_source_comp[2])
 
 
+def _parse_additional_digests(descriptor, entries):
+  digests = []
+
+  for val in _values('additional-digest', entries):
+    comp = val.split(' ')
+
+    if len(comp) < 3:
+      raise ValueError("additional-digest lines should be of the form 'additional-digest [flavor] [algname] [digest]' but was: %s" % val)
+
+    digests.append(DocumentDigest(*comp[:3]))
+
+  descriptor.additional_digests = digests
+
+
+def _parse_additional_signatures(descriptor, entries):
+  signatures = []
+
+  for val, block_type, block_contents in entries['additional-signature']:
+    comp = val.split(' ')
+
+    if len(comp) < 4:
+      raise ValueError("additional-signature lines should be of the form 'additional-signature [flavor] [algname] [identity] [signing_key_digest]' but was: %s" % val)
+    elif not block_contents or block_type != 'SIGNATURE':
+      raise ValueError("'additional-signature' should be followed by a SIGNATURE block, but was a %s" % block_type)
+
+    signatures.append(DocumentSignature(comp[1], comp[2], comp[3], block_contents, flavor = comp[0], validate = True))
+
+  descriptor.additional_signatures = signatures
+
+
 _parse_network_status_version_line = _parse_version_line('network-status-version', 'version', 2)
 _parse_fingerprint_line = _parse_forty_character_hex('fingerprint', 'fingerprint')
 _parse_contact_line = _parse_simple_line('contact', 'contact')
@@ -415,6 +496,7 @@ _parse_server_versions_line = _parse_simple_line('server-versions', 'server_vers
 _parse_published_line = _parse_timestamp_line('published', 'published')
 _parse_dir_options_line = _parse_simple_line('dir-options', 'options', func = lambda v: v.split())
 _parse_directory_signature_line = _parse_key_block('directory-signature', 'signature', 'SIGNATURE', value_attribute = 'signing_authority')
+_parse_consensus_digest_line = _parse_simple_line('consensus-digest', 'consensus_digest')
 
 
 class NetworkStatusDocumentV2(NetworkStatusDocument):
@@ -708,7 +790,7 @@ def _parse_footer_directory_signature_line(descriptor, entries):
     else:
       method, fingerprint, key_digest = sig_value.split(' ', 2)
 
-    signatures.append(DocumentSignature(method, fingerprint, key_digest, block_contents, True))
+    signatures.append(DocumentSignature(method, fingerprint, key_digest, block_contents, validate = True))
 
   descriptor.signatures = signatures
 
@@ -1776,12 +1858,14 @@ class DocumentSignature(object):
   :var str identity: fingerprint of the authority that made the signature
   :var str key_digest: digest of the signing key
   :var str signature: document signature
+  :var str flavor: consensus type this signature is for (such as 'microdesc'),
+    **None** if for the standard consensus
   :param bool validate: checks validity if **True**
 
   :raises: **ValueError** if a validity check fails
   """
 
-  def __init__(self, method, identity, key_digest, signature, validate = False):
+  def __init__(self, method, identity, key_digest, signature, flavor = None, validate = False):
     # Checking that these attributes are valid. Technically the key
     # digest isn't a fingerprint, but it has the same characteristics.
 
@@ -1796,12 +1880,13 @@ class DocumentSignature(object):
     self.identity = identity
     self.key_digest = key_digest
     self.signature = signature
+    self.flavor = flavor
 
   def _compare(self, other, method):
     if not isinstance(other, DocumentSignature):
       return False
 
-    for attr in ('method', 'identity', 'key_digest', 'signature'):
+    for attr in ('method', 'identity', 'key_digest', 'signature', 'flavor'):
       if getattr(self, attr) != getattr(other, attr):
         return method(getattr(self, attr), getattr(other, attr))
 
@@ -1823,6 +1908,127 @@ class DocumentSignature(object):
     return self._compare(other, lambda s, o: s <= o)
 
 
+class DetachedSignature(Descriptor):
+  """
+  Stand alone signature of the consensus. These are exchanged between directory
+  authorities when determining the next hour's consensus.
+
+  Detached signatures are defined in section 3.10 of the dir-spec, and only
+  available to be downloaded for five minutes between minute 55 until the end
+  of the hour.
+
+  .. versionadded:: 1.8.0
+
+  :var str consensus_digest: **\*** digest of the consensus being signed
+  :var datetime valid_after: **\*** time when the consensus became valid
+  :var datetime fresh_until: **\*** time when the next consensus should be produced
+  :var datetime valid_until: **\*** time when this consensus becomes obsolete
+  :var list additional_digests: **\***
+    :class:`~stem.descriptor.networkstatus.DocumentDigest` for additional
+    consensus flavors
+  :var list additional_signatures: **\***
+    :class:`~stem.descriptor.networkstatus.DocumentSignature` for additional
+    consensus flavors
+  :var list signatures: **\*** :class:`~stem.descriptor.networkstatus.DocumentSignature`
+    of the authorities that have signed the document
+
+  **\*** mandatory attribute
+  """
+
+  ATTRIBUTES = {
+    'consensus_digest': (None, _parse_consensus_digest_line),
+    'valid_after': (None, _parse_header_valid_after_line),
+    'fresh_until': (None, _parse_header_fresh_until_line),
+    'valid_until': (None, _parse_header_valid_until_line),
+    'additional_digests': ([], _parse_additional_digests),
+    'additional_signatures': ([], _parse_additional_signatures),
+    'signatures': ([], _parse_footer_directory_signature_line),
+  }
+
+  PARSER_FOR_LINE = {
+    'consensus-digest': _parse_consensus_digest_line,
+    'valid-after': _parse_header_valid_after_line,
+    'fresh-until': _parse_header_fresh_until_line,
+    'valid-until': _parse_header_valid_until_line,
+    'additional-digest': _parse_additional_digests,
+    'additional-signature': _parse_additional_signatures,
+    'directory-signature': _parse_footer_directory_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, (
+      ('consensus-digest', '6D3CC0EFA408F228410A4A8145E1B0BB0670E442'),
+      ('valid-after', _random_date()),
+      ('fresh-until', _random_date()),
+      ('valid-until', _random_date()),
+    ))
+
+  @classmethod
+  def from_str(cls, content, **kwargs):
+    # Detached signatures don't have their own @type annotation, so to make
+    # our subclass from_str() work we need to do the type inferencing ourself.
+
+    if 'descriptor_type' in kwargs:
+      raise ValueError("Detached signatures don't have their own @type annotation. As such providing a 'descriptor_type' argument with DetachedSignature.from_str() does not work. Please drop the 'descriptor_type' argument when using this method.")
+
+    is_multiple = kwargs.pop('multiple', False)
+    results = list(_parse_file_detached_sigs(io.BytesIO(stem.util.str_tools._to_bytes(content)), **kwargs))
+
+    if is_multiple:
+      return results
+    elif len(results) == 1:
+      return results[0]
+    else:
+      raise ValueError("Descriptor.from_str() expected a single descriptor, but had %i instead. Please include 'multiple = True' if you want a list of results instead." % len(results))
+
+  def __init__(self, raw_content, validate = False):
+    super(DetachedSignature, self).__init__(raw_content, lazy_load = not validate)
+    entries = _descriptor_components(raw_content, validate)
+
+    if validate:
+      if 'consensus-digest' != list(entries.keys())[0]:
+        raise ValueError("Detached signatures must start with a 'consensus-digest' line:\n%s" % (raw_content))
+
+      # check that we have mandatory fields and certain fields only appear once
+
+      for keyword, is_mandatory, is_multiple in DETACHED_SIGNATURE_PARAMS:
+        if is_mandatory and keyword not in entries:
+          raise ValueError("Detached signatures must have a '%s' line:\n%s" % (keyword, raw_content))
+
+        entry_count = len(entries.get(keyword, []))
+        if not is_multiple and entry_count > 1:
+          raise ValueError("Detached signatures can only have a single '%s' line, got %i:\n%s" % (keyword, entry_count, raw_content))
+
+      self._parse(entries, validate)
+    else:
+      self._entries = entries
+
+  def _compare(self, other, method):
+    if not isinstance(other, DetachedSignature):
+      return False
+
+    return method(str(self).strip(), str(other).strip())
+
+  def __hash__(self):
+    return hash(str(self).strip())
+
+  def __eq__(self, other):
+    return self._compare(other, lambda s, o: s == o)
+
+  def __ne__(self, other):
+    return not self == other
+
+  def __lt__(self, other):
+    return self._compare(other, lambda s, o: s < o)
+
+  def __le__(self, other):
+    return self._compare(other, lambda s, o: s <= o)
+
+
 class BridgeNetworkStatusDocument(NetworkStatusDocument):
   """
   Network status document containing bridges. This is only available through
diff --git a/stem/descriptor/remote.py b/stem/descriptor/remote.py
index 3430d3f8..b6da9d0c 100644
--- a/stem/descriptor/remote.py
+++ b/stem/descriptor/remote.py
@@ -102,6 +102,7 @@ import zlib
 import stem
 import stem.client
 import stem.descriptor
+import stem.descriptor.networkstatus
 import stem.directory
 import stem.prereq
 import stem.util.enum
@@ -130,6 +131,14 @@ MAX_MICRODESCRIPTOR_HASHES = 90
 
 SINGLETON_DOWNLOADER = None
 
+# Detached signatures do *not* have a specified type annotation. But our
+# parsers expect that all descriptors have a type. As such making one up.
+# This may change in the future if these ever get an official @type.
+#
+#   https://trac.torproject.org/projects/tor/ticket/28615
+
+DETACHED_SIGNATURE_TYPE = 'detached-signature'
+
 
 def get_instance():
   """
@@ -216,6 +225,18 @@ def get_consensus(authority_v3ident = None, microdescriptor = False, **query_arg
   return get_instance().get_consensus(authority_v3ident, microdescriptor, **query_args)
 
 
+def get_detached_signatures(**query_args):
+  """
+  Shorthand for
+  :func:`~stem.descriptor.remote.DescriptorDownloader.get_detached_signatures`
+  on our singleton instance.
+
+  .. versionadded:: 1.8.0
+  """
+
+  return get_instance().get_detached_signatures(**query_args)
+
+
 class Query(object):
   """
   Asynchronous request for descriptor content from a directory authority or
@@ -271,6 +292,7 @@ class Query(object):
   /tor/micro/d/<hash1>-<hash2>                    microdescriptors with the given hashes
   /tor/status-vote/current/consensus              present consensus
   /tor/status-vote/current/consensus-microdesc    present microdescriptor consensus
+  /tor/status-vote/next/consensus-signatures      detached signature, used for making the next consenus
   /tor/keys/all                                   key certificates for the authorities
   /tor/keys/fp/<v3ident1>+<v3ident2>              key certificates for specific authorities
   =============================================== ===========
@@ -479,13 +501,24 @@ class Query(object):
           raise ValueError('BUG: _download_descriptors() finished without either results or an error')
 
         try:
-          results = stem.descriptor.parse_file(
-            io.BytesIO(self.content),
-            self.descriptor_type,
-            validate = self.validate,
-            document_handler = self.document_handler,
-            **self.kwargs
-          )
+          # TODO: special handling until we have an official detatched
+          # signature @type...
+          #
+          #   https://trac.torproject.org/projects/tor/ticket/28615
+
+          if self.descriptor_type.startswith(DETACHED_SIGNATURE_TYPE):
+            results = stem.descriptor.networkstatus._parse_file_detached_sigs(
+              io.BytesIO(self.content),
+              validate = self.validate,
+            )
+          else:
+            results = stem.descriptor.parse_file(
+              io.BytesIO(self.content),
+              self.descriptor_type,
+              validate = self.validate,
+              document_handler = self.document_handler,
+              **self.kwargs
+            )
 
           for desc in results:
             yield desc
@@ -813,6 +846,24 @@ class DescriptorDownloader(object):
 
     return self.query(resource, **query_args)
 
+  def get_detached_signatures(self, **query_args):
+    """
+    Provides the detached signatures that will be used to make the next
+    consensus. Please note that **these are only available during minutes 55-60
+    each hour**. If requested during minutes 0-55 tor will not service these
+    requests, and this will fail with a 404.
+
+    .. versionadded:: 1.8.0
+
+    :param query_args: additional arguments for the
+      :class:`~stem.descriptor.remote.Query` constructor
+
+    :returns: :class:`~stem.descriptor.remote.Query` for the detached
+      signatures
+    """
+
+    return self.query('/tor/status-vote/next/consensus-signatures', **query_args)
+
   def query(self, resource, **query_args):
     """
     Issues a request for the given resource.
@@ -982,6 +1033,8 @@ def _guess_descriptor_type(resource):
     return 'extra-info 1.0'
   elif resource.startswith('/tor/micro/'):
     return 'microdescriptor 1.0'
+  elif resource.startswith('/tor/status-vote/next/consensus-signatures'):
+    return '%s 1.0' % DETACHED_SIGNATURE_TYPE
   elif resource.startswith('/tor/status-vote/current/consensus-microdesc'):
     return 'network-status-microdesc-consensus-3 1.0'
   elif resource.startswith('/tor/status-vote/'):
diff --git a/test/settings.cfg b/test/settings.cfg
index 727bfc80..2ecd34ea 100644
--- a/test/settings.cfg
+++ b/test/settings.cfg
@@ -216,6 +216,7 @@ test.unit_tests
 |test.unit.descriptor.microdescriptor.TestMicrodescriptor
 |test.unit.descriptor.router_status_entry.TestRouterStatusEntry
 |test.unit.descriptor.tordnsel.TestTorDNSELDescriptor
+|test.unit.descriptor.networkstatus.detached_signature.TestDetachedSignature
 |test.unit.descriptor.networkstatus.directory_authority.TestDirectoryAuthority
 |test.unit.descriptor.networkstatus.key_certificate.TestKeyCertificate
 |test.unit.descriptor.networkstatus.document_v2.TestNetworkStatusDocument
diff --git a/test/unit/descriptor/data/detached_signatures b/test/unit/descriptor/data/detached_signatures
new file mode 100644
index 00000000..33fb3bae
--- /dev/null
+++ b/test/unit/descriptor/data/detached_signatures
@@ -0,0 +1,171 @@
+consensus-digest 244E0760BB0B1E5418A4A014822F804AFE0CC3D6
+valid-after 2018-11-22 20:00:00
+fresh-until 2018-11-22 21:00:00
+valid-until 2018-11-22 23:00:00
+additional-digest microdesc sha256 EC7F220E415F62394565259F9E44133800F749BFEFB358A3D7F622B8A1728A47
+additional-signature microdesc sha256 0232AF901C31A04EE9848595AF9BB7620D4C5B2E CD1FD971855430880D3C31E0331C5C55800C2F79
+-----BEGIN SIGNATURE-----
+dVUvw+7/I16XCXuj5aFtrk1akRXx+/j1NGY+vUDFkZgNNEVoU4i2DHJl1rnPjF2R
+PMv8bh0kT3R3MisMGa5htEG9M6fETZnGajvGzkKMH9M91lBNCyJ2ZV7Y+bHn9EfH
+xdsnX79y/MOT5xerKfn5/VHHeTuQeg9RCsNFdYFYlQPPiu7LRzH6iZKbjJilLfUk
+TLHcK6GPnGZB4EQAB1s3m29trBH4sU7scVjfd5ypGI/hLLD+fyMlKJvMBDuc3Zp9
+h2ipkL0YSzOV/4W8DsQg+kRrUBgVEr4DwP/sC26ekiNniVPUXzaxrzIIHmkUWhNt
+WNYsdOUeAUxJx+JSyr4kMA==
+-----END SIGNATURE-----
+additional-signature microdesc sha256 14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4 1F4D49989DA1503D5B20EAADB0673C948BA73B49
+-----BEGIN SIGNATURE-----
+Q+qwfKSwbCiJQRPRdI31h3mXgUQBPzjp7e2eZ656EVDT1PatFGx+7KBgDa7yMNwE
+bzJqdViMhiBVMglp5ZLQ4SBH1QZjgPGns4Zh1pw//yP5DvAbL9b7B7Pa4hjKGFEY
+wVWYdUnFV9XZp+NNes/b2WfbKRifSM1E1Hg3gxuSM16VNZKBsgiABaR3PTYltKs0
+oWF3nIWJSAqZ52PlfysIcU0IHiu/KvUCRB7zhHSTTQi1+k00ljxaiL8/vlcs9fIN
+WED+BbI7ulc8jp7melpsfO5WzbO4VaY+PYQxV6cH+5wdlPCRMmVH0FZHf1O7V2WS
++VjRvqJcnC6CDVdsj9KIvKqEzTI9KiWfSA62W6c5gqJGUiBwsVqPkOCjhVVs1RQk
+py02o9ZDW6crDjqttiGNKgcAzxifcCsoACPLTD6IGUoG7CqpMoyTvpkLdb6BeYSk
+atbVl+Q43EstAVMBLyjYK0NZDstol6lBSN3S6rZLH1sH+4LhucJuxR4/v1ccG4Ut
+-----END SIGNATURE-----
+additional-signature microdesc sha256 23D15D965BC35114467363C165C4F724B64B4F66 A2E5511319AD43DF88EABFB8BB1FFD767D005601
+-----BEGIN SIGNATURE-----
+YEoDpg0vesJ2OSwMNFb7ZkKe17rLNp5T3VhJsyI9U1ggkEuYMIUVLq01aNqFg51O
+yaRlnC6/4eSUDanE4jD7GsIQE5JXJjy8p8NwkY1tLSnFaHjmWJnqJEYxKcN84/NZ
+0x55TZKMpoJKH4g3ECxhXCwChz8ICjsElgyWiOmafMPxLh3cqfDq+rDsAxWShWs6
+qB/E1LU+Ikg5tl+D4xPCdpnODp+eDrjiyIfnVZ1qw68MgBkE000etGwxz63+FctJ
+m26fMx8Jxx3krU/5HXVPqnUEXLvRAV0sdiX0/riK1spSEih6y9oM5pS+4B1wy6Rs
+5PiuB36F+JJr4kRzUq7w5g==
+-----END SIGNATURE-----
+additional-signature microdesc sha256 27102BC123E7AF1D4741AE047E160C91ADC76B21 6FF3CD454038B7FDD7862B2DD778E4F702C31419
+-----BEGIN SIGNATURE-----
+IeLso6GtHdHBLzRUsbeIQ16axr9GD0a1SoyRFwj0ENVCskdD2zZVI5rOmALbim2E
+aooblPJhiCTufpYjBvYJ6idcWTN7xqbr+svRzEkcB9fDRGNr2nSXAFr4WkUs8eHn
+JSsHtXBZ8CNcyKojLMC8yFiyNYDV+Brt5ZbVUs+phvGp36PzldzQBd1qTxQ3tcMV
+2mh1ToRHjSXGjMxBxlMIjhCvOBMNm8EUdWdbZDLZ7T4Xf07k4/yb+qj1cxPJ3lHQ
+PsZBifov7uNrQLMRx/dR5UxFXJCJt9tQxn9jqoF81cOBhQOvd3RygaCtkos46rOo
+mtgFUiyVZ5ABnQG0sCDmcQ==
+-----END SIGNATURE-----
+additional-signature microdesc sha256 49015F787433103580E3B66A1707A00E60F2D15B 9D41020F5BF16D1EC9C00ED1D04178FF116067C2
+-----BEGIN SIGNATURE-----
+J5OinKYS5AWQt9ccw0jkflAHohajq4Zgp99ENN2sLD0UlaDwY3e0FbSRQklsoh8a
+H7wHBfyj1+NC7I/OsaOLOdG84bLzfGNPF/IrDWiNezZBqH8aKW2nYotIDn2xrmw8
+98mSxDgj4gPpbJhzblidM5TM7kFeZDt5iHFwg16FcwnxvrnOcxuHMv7FvkbJtKy1
+4hUTc+TeFREcOYqRrbzUaz1s01yJr1o9WtjcEnnBRR2bRiu2SllwmUbpZJcgjsFN
++aPMWmCXdx6ZUL3mLVHnk6VGzzxHELt8H8NwNgvs1p8p0ZkwpIJNp5PoiEWSaqFA
+LLYd0cv0a+jiOPlKBZHIrA==
+-----END SIGNATURE-----
+additional-signature microdesc sha256 D586D18309DED4CD6D57C18FDB97EFA96D330566 8A45BACC94A6023A90C24FBCD10520C1741828F7
+-----BEGIN SIGNATURE-----
+Dqk4md793wmZjsEIs72pQ6BKbbnkMkxcfQYBmzVmyRHdNm9FLoHHnID4gRrSBOu3
+mI2pJ+X/clAffJ4+Uoezlr3W7dAi+BRuN+EjeLw3S6rLyoMUvew3QI4okmkJ0SzG
+aYmreIfVc6zzeCicuRS9Yv26+Aplpr0sCCMVKzdnFacqyD0qc8LRwpqt6kqp2t1d
+0QkvDknLjf9+eNTdor+fZL9veXJ70TEssG9jzJ21QldkUkENb76m5EIvt3OcgjNe
+Uy4KEDd4mqiUTwuJDz024uyzq7TWrj+9CrkRkD4P0S6ZByspmZTOV/iCmf07jFhd
+Fx3LLszYeq8FinhSCKnQAQ==
+-----END SIGNATURE-----
+additional-signature microdesc sha256 E8A9C45EDE6D711294FADF8E7951F4DE6CA56B58 109A865D7DBE58367C120353CBE9947EE263695A
+-----BEGIN SIGNATURE-----
+d5mJGTGLgmTfg4pxAQKZyx09GKenr9ccxg3XSauqYZ/EkBEvCcJeCxCgp3QxFANz
+vm7HZOw+BdQHJ0AL35pI1EZuTLwA/WszcxbZW1YX4MeJp9PJGtLZ6SNjVHb/dfsS
+ya80+IdYFlMrCtZWZOB3nGKuTtmB2UynbIqY6h8VRxMtndlMXQxWGM4TWJcI/upb
+KjX9lYIU4CpXzGYK88UX+LQa9BmyqoMt37TQGbPZz+UOP4avJpqfehvmH0Ut+rV3
+czXnBSs3OdQNa/y/XlrOrlV7gmdTBqJNZWmhiqycjz2FpXkDHqBf769MVu9DKwK+
+Gv7fZNbYcomRc/EbU71uog==
+-----END SIGNATURE-----
+additional-signature microdesc sha256 ED03BB616EB2F60BEC80151114BB25CEF515B226 E1249D5F87EAD43CD4A48DF9CFCFE810BEEE5287
+-----BEGIN SIGNATURE-----
+AYoO2ZwgblIQuzdFlCcBapMHHC7wbvsjY7Lyy67zPzIHXcF2Xui+XlUpIFFGzjR6
+YnpUL+zPolHus08ouShizFc4a6586Og5oBBw9gIf9Ek0XI/Ov8+PBd1f1uZSDnLS
+nvPVqA7OvLwo+m03T9OY5nFF6HqKxnH9GzECCRBCj0nmSvu6rEvJcS4TbQQPCzb3
+N+FTYKo/u5KaoyyIZYaPZt4QN0uAnTGKPtFn/Qoil6JPwEOmb5Vy2Os6LJm4PjxJ
+4CA1Lmj3kkVHo/BJ/SH92+SK1RhuEikzSDC+jReIa65tmzYIky6VTtVqgMsXTx+S
+QRUtUBzBSIf1TzrI8vec+w==
+-----END SIGNATURE-----
+additional-signature microdesc sha256 EFCBE720AB3A82B99F9E953CD5BF50F7EEFC7B97 517095062288D1B5C7BD6517A677C586D996818B
+-----BEGIN SIGNATURE-----
+cCEvL/OU1L6tvb8ebJFQJksWjaA3UAfhrA726K0nc5JGKDa5PRFcObn8n4sq31nJ
+y1byQae0xysBOdB3IKmF5tV+jvDLI/0bUIub9679saV1xeCYKk1GkQQPQ3QsMfGr
+gObk1zOuZZXnJvBMnJleHErvllUuXs0ApnLuqt1G284igIiqw55HBbh75hguMISg
+azPb/HWcCxF3YDmEq2w+iUU46JR3Hpwy0cAOagk0pDkCk/rnCR34quIwxciE0TGa
+EH8RwDvpmoG0uU5In/2J2XbKT6H6M1GLgqGqe4kg8yrUsUY2+m0U45c7iu8CHdE3
+S8cRULN6W13GtVkVNdJz0w==
+-----END SIGNATURE-----
+directory-signature 0232AF901C31A04EE9848595AF9BB7620D4C5B2E CD1FD971855430880D3C31E0331C5C55800C2F79
+-----BEGIN SIGNATURE-----
+usPMz1JctgKdWTuo0lo2wBzfpX9evtxG9GYmzEeeUGZgrSiRBfk8r3am9MVWfpoP
+NZYJJazmBq6bkbFLGTAeaqhBFaAoAq/ZubwOpFFCf1rDhsHZgdY1hwU/p1Oz38T6
+MZzwLMSmILWedCmcijkCPFN9j5bJ/5sg2ls1zEIa0z3JzHl3UbzXQa0G0Fsyvedu
+cY202F6VJpJee6WUq/15PovULtoxUTh9FPnOSEUyUtkUJ8aGm5KNuvY7qYf3Z6Ar
+BlTtZuRRhDWdEYLq0QMfhkJHToUuZsL8eNMasG/9OQnnxI5oA+nsjsqbAO2DGMiX
+f6v9XU382cMo4N6yu4OmuA==
+-----END SIGNATURE-----
+directory-signature 14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4 1F4D49989DA1503D5B20EAADB0673C948BA73B49
+-----BEGIN SIGNATURE-----
+5XmrTwv+jyeMFeVZUzqYMDn9XqG7D1nd/Z2H5l2FHOYtEYs+FuLsI0w4T1HL8dyN
+x8abjhvJTzfk280wUzgsdg1vbZzhZUMIlyOoGl+5PFyREDvjGlsOmMELdGCtBpyD
+5Vof2Rm4dR4iJTgqx42c219nlmKPVcqg0yu4lFu6nGCtABA9bn8i2ULlokufx9zp
+vPgqj0lIdWpK7jqXUmQ3W9urV+Bxv+3EHq/9M+z/tY/mpbyUlIKOKy9ypIEs/dQ0
+5wsr0E68aGkrG2t5b3yTXYjYsRpg6j1IOaoXC1zb9tBsc9r09jXQFZyZlg4krlng
+5NRrtugzHmPZWosdece2vCp+t84vOP+9Kw+aZ9OTbbBDRrzdPCTfVVOU0ZxCjVth
+Skj92cuj+czAifPkW1h8cWt+twSo+xeZBlMzcPP2XcmlU+pOIPN57hp7tVTFxGm3
+B5IsILBdF6oKURy3sGrFv0QW7hHvGLmZMuom3ME0s1IXDsrR8YjQOkdS6FlOFHoT
+-----END SIGNATURE-----
+directory-signature 23D15D965BC35114467363C165C4F724B64B4F66 A2E5511319AD43DF88EABFB8BB1FFD767D005601
+-----BEGIN SIGNATURE-----
+QLL+wjRougl+WZzSfDSjMBo5zDVThG8qEeDNi0E+cwnGt4J2GeCd/sPzyfnnJ2nM
+k2MFDFG018scR1oLH7G2c2SD09Wpzl50HXKsRPWgHe9UO7zAY5O4Jq17l4nzwbR4
+ynrucQxYqK19aq0FB9JLWy2FKrZlKY59XZnRiGrQSIXndOxgPY4WGXA5qlft8Bu4
+Y4HSXsFDpc751mPopcHxRp6A7kHQopoNKKIaIkhrRwxoQgkPzQKegMzncFIvQaMJ
+SkYvemmKnuFFmLHYKMjTy0YA5oTkmYgFao15gMeR4nNwuqwxwJ7YLEeCTW+D3avi
+9kxIfImiXH9dU7FOyjc1UQ==
+-----END SIGNATURE-----
+directory-signature 27102BC123E7AF1D4741AE047E160C91ADC76B21 6FF3CD454038B7FDD7862B2DD778E4F702C31419
+-----BEGIN SIGNATURE-----
+l7aE38IqdyKrWYHLD23h1aT44BtlJ30RKTX7m4O3YHqNUZJqxo6SgwODGRsmvXLx
+leNdBznoktZLqWstwF0GAEf8+dL4GfPJk/DMJbOQ488imPyFKRO4D0cfHenXIcoa
+7HF4eV9n6pmNftKDI0bfFgF7hAhoUquHOYp2t4HF3Pc/uqvAVn/I5BxfXr7D0mgc
+qPHb++edTPnKYHuwZT1kkx+xFUvCceNFQ9E2O87rKmKmVv7vDUFvWKfyDtaH0vrH
+ycV6l8ZVsmGvJJeVzafevBngmWe04Tm5tUlZ9yjj0AMOaCPvWUa+vPwrpQnoLN6x
+nJsBY9SmpRXZTY5U49ur8g==
+-----END SIGNATURE-----
+directory-signature 49015F787433103580E3B66A1707A00E60F2D15B 9D41020F5BF16D1EC9C00ED1D04178FF116067C2
+-----BEGIN SIGNATURE-----
+Hd5aSe2k2atci9E1Z00yQXcZPO0vPJkzsVOaVvGBgAEik8SjQFIMPOAv8eQVjtZM
+ccnWShq6R91ZAo+M9DIO0n0jkSGVTc8k0kX/yIg+Wo3hbkIxerdybx/Zwk69IjTi
+rqf7ZkHI+oZMvRiyASfLUSkZ3s3dNLxKS1Yq1TK2HxMaTvtckKyjQDn5fwbERwc2
++KeKd3jtyJ7MIG+4/XXuHrmZQkqDYf3YOhYuYtr5AAL2Tyfvyz607mvVI1qRx4LB
+XnYftyffMIy+7G1Nxh/39VVbwg0U0U0kP87yZbihInJrIC/MopjjItIEXox3qTSK
+upfBcZ3WX2heNwSLj0JQ5Q==
+-----END SIGNATURE-----
+directory-signature D586D18309DED4CD6D57C18FDB97EFA96D330566 8A45BACC94A6023A90C24FBCD10520C1741828F7
+-----BEGIN SIGNATURE-----
+nauj1W/QnbGyXNT9qv0i6SWRQvAxzLOtIgWwfSO20ymz0LektBexEFEpo12/cPCl
+ayhzWaAYoHsoi//3mj32c3iazpRPQtR6KwsNbywn3YCCUSueHLSF62mk9ah/COwY
+epFfmARJF83H8kKtXaBkdEydZ+2nMrX3v96RNwy5NdV03HWyMzT68a45Z6O5ZC4j
+KEWkG9x5j53vKzdUg9IlnO/1jIl7VeocJEar+C7fZtOwSv2lKym012rpg5nYdkz6
+nei0IC72k0819wasbIUNpSBvjf5raIETxDCo8SyQBmzGeEo+PVZlewno92ceHkjv
+xchULS5k0zs8Gpi5KME3rQ==
+-----END SIGNATURE-----
+directory-signature E8A9C45EDE6D711294FADF8E7951F4DE6CA56B58 109A865D7DBE58367C120353CBE9947EE263695A
+-----BEGIN SIGNATURE-----
+OAhbgTb28y+B3+/g00NlqIqeSjM0NArUPJmXQEYRMjMSNj8IepBLpwfeNm3GPtAL
+GSuMoQ4fYK237BzbneQ1ZW2ojlhU3hekoUPqvtk26g0yckG9IZnAxzgRu7ecxu/l
+UOd1ZTdsc6gBPCPGYON3MZMBpkQ4vh97OsYQFzr3H/4Ua7+fw0Ih5EDydEBiNDMq
+dQ6IrjFk7as9jtYP47W+bHAZKMDY63shm1AnCRsgF1YK8TsUNemhLjSNmnTYESW2
+TnE7tpSqge/ctJcbe2uGrhf6OcWOd6CerFf1fV3BKYCmsmmel5wLzxeiBcAYsVL/
+PNZtoGOJnVlNn2edsODF2Q==
+-----END SIGNATURE-----
+directory-signature ED03BB616EB2F60BEC80151114BB25CEF515B226 E1249D5F87EAD43CD4A48DF9CFCFE810BEEE5287
+-----BEGIN SIGNATURE-----
+qz/L0vLAE1AUqi3cIPLqxPoeZE6xyDyG08jiXks/Ym9FDHvq+g809SNg9xuHDeGj
+dxbyJ+3yBPKPF3ksJBX05VfY+Crcl0PhjwFNYmKvRvkz1oJtOLFEQFPsH8YNtvvE
+DhNe654+xDSLBZllGcqSpr45o00fBKdecqwY6d5+nI7iSVfPcxVrZ8rm/clN9xxV
+IlJXah/Ta8KyVG7KwHMZ65e79GS+jTc0S26CQ5GnN6H0j+bxQm17g9eHtTZZgifw
+FBaJIe4BaiDkKOD2ExzhNPr/8eZeZBai6plx+a+/VBlCE4DU+kYnQLdEoNacNRjS
+u59VVCi0LGGlYnvq7eaS8A==
+-----END SIGNATURE-----
+directory-signature EFCBE720AB3A82B99F9E953CD5BF50F7EEFC7B97 517095062288D1B5C7BD6517A677C586D996818B
+-----BEGIN SIGNATURE-----
+kOzod5WbXilqTb2CK6fAVg350ZAQzzCc5g0tHawPG6qHWKQKEJqb5Y3tMq8ZPo80
+V8uWX1UqFsHlPEriNZld3ThWittORkF0wxjRlraRwzYcjWSePKcXNqOpz5K/Zd3o
+jCxO4QRu9Xl+LB6/DpIFpyTAADFhQeMWBg9XQY40oDVps2OvjOYAQu4Jw/zbloP8
+KhGSroBsLxITsp/mVrUWTYHb9bf3Alv9Wiu53EB0vrNZZjH2/iKIsk5fJ0xVzM61
+u5qXlLYHrSvxM4suJ6GuOTOW8gsQ/oqVZAqOXQeZ1yFgceezzMnYRgVq+GMTJ/dw
+d4+RGV+xfxa4yRfY+hwauw==
+-----END SIGNATURE-----
diff --git a/test/unit/descriptor/networkstatus/detached_signature.py b/test/unit/descriptor/networkstatus/detached_signature.py
new file mode 100644
index 00000000..18d3c177
--- /dev/null
+++ b/test/unit/descriptor/networkstatus/detached_signature.py
@@ -0,0 +1,206 @@
+"""
+Unit tests for the DetachedSignature of stem.descriptor.networkstatus.
+"""
+
+import datetime
+import unittest
+
+from test.unit.descriptor import get_resource
+
+from stem.descriptor.networkstatus import (
+  DetachedSignature,
+  DocumentDigest,
+  DocumentSignature,
+  _parse_file_detached_sigs,
+)
+
+BLOCK1 = """
+-----BEGIN SIGNATURE-----
+dVUvw+7/I16XCXuj5aFtrk1akRXx+/j1NGY+vUDFkZgNNEVoU4i2DHJl1rnPjF2R
+PMv8bh0kT3R3MisMGa5htEG9M6fETZnGajvGzkKMH9M91lBNCyJ2ZV7Y+bHn9EfH
+xdsnX79y/MOT5xerKfn5/VHHeTuQeg9RCsNFdYFYlQPPiu7LRzH6iZKbjJilLfUk
+TLHcK6GPnGZB4EQAB1s3m29trBH4sU7scVjfd5ypGI/hLLD+fyMlKJvMBDuc3Zp9
+h2ipkL0YSzOV/4W8DsQg+kRrUBgVEr4DwP/sC26ekiNniVPUXzaxrzIIHmkUWhNt
+WNYsdOUeAUxJx+JSyr4kMA==
+-----END SIGNATURE-----
+""".strip()
+
+BLOCK2 = """
+-----BEGIN SIGNATURE-----
+Q+qwfKSwbCiJQRPRdI31h3mXgUQBPzjp7e2eZ656EVDT1PatFGx+7KBgDa7yMNwE
+bzJqdViMhiBVMglp5ZLQ4SBH1QZjgPGns4Zh1pw//yP5DvAbL9b7B7Pa4hjKGFEY
+wVWYdUnFV9XZp+NNes/b2WfbKRifSM1E1Hg3gxuSM16VNZKBsgiABaR3PTYltKs0
+oWF3nIWJSAqZ52PlfysIcU0IHiu/KvUCRB7zhHSTTQi1+k00ljxaiL8/vlcs9fIN
+WED+BbI7ulc8jp7melpsfO5WzbO4VaY+PYQxV6cH+5wdlPCRMmVH0FZHf1O7V2WS
++VjRvqJcnC6CDVdsj9KIvKqEzTI9KiWfSA62W6c5gqJGUiBwsVqPkOCjhVVs1RQk
+py02o9ZDW6crDjqttiGNKgcAzxifcCsoACPLTD6IGUoG7CqpMoyTvpkLdb6BeYSk
+atbVl+Q43EstAVMBLyjYK0NZDstol6lBSN3S6rZLH1sH+4LhucJuxR4/v1ccG4Ut
+-----END SIGNATURE-----
+""".strip()
+
+BLOCK3 = """
+-----BEGIN SIGNATURE-----
+YEoDpg0vesJ2OSwMNFb7ZkKe17rLNp5T3VhJsyI9U1ggkEuYMIUVLq01aNqFg51O
+yaRlnC6/4eSUDanE4jD7GsIQE5JXJjy8p8NwkY1tLSnFaHjmWJnqJEYxKcN84/NZ
+0x55TZKMpoJKH4g3ECxhXCwChz8ICjsElgyWiOmafMPxLh3cqfDq+rDsAxWShWs6
+qB/E1LU+Ikg5tl+D4xPCdpnODp+eDrjiyIfnVZ1qw68MgBkE000etGwxz63+FctJ
+m26fMx8Jxx3krU/5HXVPqnUEXLvRAV0sdiX0/riK1spSEih6y9oM5pS+4B1wy6Rs
+5PiuB36F+JJr4kRzUq7w5g==
+-----END SIGNATURE-----
+""".strip()
+
+BLOCK4 = """
+-----BEGIN SIGNATURE-----
+usPMz1JctgKdWTuo0lo2wBzfpX9evtxG9GYmzEeeUGZgrSiRBfk8r3am9MVWfpoP
+NZYJJazmBq6bkbFLGTAeaqhBFaAoAq/ZubwOpFFCf1rDhsHZgdY1hwU/p1Oz38T6
+MZzwLMSmILWedCmcijkCPFN9j5bJ/5sg2ls1zEIa0z3JzHl3UbzXQa0G0Fsyvedu
+cY202F6VJpJee6WUq/15PovULtoxUTh9FPnOSEUyUtkUJ8aGm5KNuvY7qYf3Z6Ar
+BlTtZuRRhDWdEYLq0QMfhkJHToUuZsL8eNMasG/9OQnnxI5oA+nsjsqbAO2DGMiX
+f6v9XU382cMo4N6yu4OmuA==
+-----END SIGNATURE-----
+""".strip()
+
+BLOCK5 = """
+-----BEGIN SIGNATURE-----
+5XmrTwv+jyeMFeVZUzqYMDn9XqG7D1nd/Z2H5l2FHOYtEYs+FuLsI0w4T1HL8dyN
+x8abjhvJTzfk280wUzgsdg1vbZzhZUMIlyOoGl+5PFyREDvjGlsOmMELdGCtBpyD
+5Vof2Rm4dR4iJTgqx42c219nlmKPVcqg0yu4lFu6nGCtABA9bn8i2ULlokufx9zp
+vPgqj0lIdWpK7jqXUmQ3W9urV+Bxv+3EHq/9M+z/tY/mpbyUlIKOKy9ypIEs/dQ0
+5wsr0E68aGkrG2t5b3yTXYjYsRpg6j1IOaoXC1zb9tBsc9r09jXQFZyZlg4krlng
+5NRrtugzHmPZWosdece2vCp+t84vOP+9Kw+aZ9OTbbBDRrzdPCTfVVOU0ZxCjVth
+Skj92cuj+czAifPkW1h8cWt+twSo+xeZBlMzcPP2XcmlU+pOIPN57hp7tVTFxGm3
+B5IsILBdF6oKURy3sGrFv0QW7hHvGLmZMuom3ME0s1IXDsrR8YjQOkdS6FlOFHoT
+-----END SIGNATURE-----
+""".strip()
+
+BLOCK6 = """
+-----BEGIN SIGNATURE-----
+QLL+wjRougl+WZzSfDSjMBo5zDVThG8qEeDNi0E+cwnGt4J2GeCd/sPzyfnnJ2nM
+k2MFDFG018scR1oLH7G2c2SD09Wpzl50HXKsRPWgHe9UO7zAY5O4Jq17l4nzwbR4
+ynrucQxYqK19aq0FB9JLWy2FKrZlKY59XZnRiGrQSIXndOxgPY4WGXA5qlft8Bu4
+Y4HSXsFDpc751mPopcHxRp6A7kHQopoNKKIaIkhrRwxoQgkPzQKegMzncFIvQaMJ
+SkYvemmKnuFFmLHYKMjTy0YA5oTkmYgFao15gMeR4nNwuqwxwJ7YLEeCTW+D3avi
+9kxIfImiXH9dU7FOyjc1UQ==
+-----END SIGNATURE-----
+""".strip()
+
+
+class TestDetachedSignature(unittest.TestCase):
+  def test_minimal(self):
+    """
+    Parses a minimal detached signature.
+    """
+
+    sig = DetachedSignature.create()
+
+    self.assertEqual('6D3CC0EFA408F228410A4A8145E1B0BB0670E442', sig.consensus_digest)
+    self.assertTrue(sig.valid_after is not None)
+    self.assertTrue(sig.fresh_until is not None)
+    self.assertTrue(sig.valid_until is not None)
+    self.assertEqual([], sig.additional_digests)
+    self.assertEqual([], sig.additional_signatures)
+    self.assertEqual([], sig.signatures)
+    self.assertEqual([], sig.get_unrecognized_lines())
+
+  def test_real_detached_signatures(self):
+    """
+    Checks that actual detached signatures can be properly parsed.
+    """
+
+    expected_additional_sigs = [
+      DocumentSignature('sha256', '0232AF901C31A04EE9848595AF9BB7620D4C5B2E', 'CD1FD971855430880D3C31E0331C5C55800C2F79', BLOCK1, flavor = 'microdesc'),
+      DocumentSignature('sha256', '14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4', '1F4D49989DA1503D5B20EAADB0673C948BA73B49', BLOCK2, flavor = 'microdesc'),
+      DocumentSignature('sha256', '23D15D965BC35114467363C165C4F724B64B4F66', 'A2E5511319AD43DF88EABFB8BB1FFD767D005601', BLOCK3, flavor = 'microdesc'),
+    ]
+
+    expected_sigs = [
+      DocumentSignature('sha1', '0232AF901C31A04EE9848595AF9BB7620D4C5B2E', 'CD1FD971855430880D3C31E0331C5C55800C2F79', BLOCK4),
+      DocumentSignature('sha1', '14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4', '1F4D49989DA1503D5B20EAADB0673C948BA73B49', BLOCK5),
+      DocumentSignature('sha1', '23D15D965BC35114467363C165C4F724B64B4F66', 'A2E5511319AD43DF88EABFB8BB1FFD767D005601', BLOCK6),
+    ]
+
+    with open(get_resource('detached_signatures'), 'rb') as sig_file:
+      sig = next(_parse_file_detached_sigs(sig_file, 'dir-key-certificate-3 1.0'))
+      self.assertEqual('244E0760BB0B1E5418A4A014822F804AFE0CC3D6', sig.consensus_digest)
+      self.assertEqual(datetime.datetime(2018, 11, 22, 20, 0), sig.valid_after)
+      self.assertEqual(datetime.datetime(2018, 11, 22, 21, 0), sig.fresh_until)
+      self.assertEqual(datetime.datetime(2018, 11, 22, 23, 0), sig.valid_until)
+      self.assertEqual([DocumentDigest('microdesc', 'sha256', 'EC7F220E415F62394565259F9E44133800F749BFEFB358A3D7F622B8A1728A47')], sig.additional_digests)
+      self.assertEqual(9, len(sig.additional_signatures))
+      self.assertEqual(expected_additional_sigs, sig.additional_signatures[:3])
+      self.assertEqual(9, len(sig.signatures))
+      self.assertEqual(expected_sigs, sig.signatures[:3])
+      self.assertEqual([], sig.get_unrecognized_lines())
+
+  def test_unrecognized_line(self):
+    """
+    Includes unrecognized content in the descriptor.
+    """
+
+    sig = DetachedSignature.create({'pepperjack': 'is oh so tasty!'})
+    self.assertEqual(['pepperjack is oh so tasty!'], sig.get_unrecognized_lines())
+
+  def test_missing_fields(self):
+    """
+    Parse a detached signature where a mandatory field is missing.
+    """
+
+    mandatory_fields = (
+      'consensus-digest',
+      'valid-after',
+      'fresh-until',
+      'valid-until',
+    )
+
+    for excluded_field in mandatory_fields:
+      content = DetachedSignature.content(exclude = (excluded_field,))
+      self.assertRaises(ValueError, DetachedSignature, content, True)
+
+      sig = DetachedSignature(content, False)
+
+      if excluded_field == 'consensus-digest':
+        self.assertEqual(None, sig.consensus_digest)
+      else:
+        self.assertEqual(40, len(sig.consensus_digest))
+
+  def test_blank_lines(self):
+    """
+    Includes blank lines, which should be ignored.
+    """
+
+    sig = DetachedSignature.create({'consensus-digest': '6D3CC0EFA408F228410A4A8145E1B0BB0670E442\n\n\n'})
+    self.assertEqual('6D3CC0EFA408F228410A4A8145E1B0BB0670E442', sig.consensus_digest)
+
+  def test_time_fields(self):
+    """
+    Parses invalid published, valid-after, fresh-until, and valid-until fields.
+    All are simply datetime values.
+    """
+
+    expected = datetime.datetime(2012, 9, 2, 22, 0, 0)
+    test_value = '2012-09-02 22:00:00'
+
+    sig = DetachedSignature.create({
+      'valid-after': test_value,
+      'fresh-until': test_value,
+      'valid-until': test_value,
+    })
+
+    self.assertEqual(expected, sig.valid_after)
+    self.assertEqual(expected, sig.fresh_until)
+    self.assertEqual(expected, sig.valid_until)
+
+    test_values = (
+      '',
+      '   ',
+      '2012-12-12',
+      '2012-12-12 01:01:',
+      '2012-12-12 01:a1:01',
+    )
+
+    for test_value in test_values:
+      content = DetachedSignature.content({'valid-after': test_value})
+      self.assertRaises(ValueError, DetachedSignature, content, True)
+
+      sig = DetachedSignature(content, False)
+      self.assertEqual(None, sig.valid_after)



More information about the tor-commits mailing list