[tor-commits] [stem/master] Add Ed25519 certificate extension support

atagar at torproject.org atagar at torproject.org
Thu Mar 30 04:18:02 UTC 2017


commit 3ce6631670cf3a0ffb5d44736bd426c3eaa82b5d
Author: Patrick O'Doherty <p at trickod.com>
Date:   Sun Sep 18 17:24:02 2016 -0700

    Add Ed25519 certificate extension support
    
    Parse the identity-ed25519 certificate block and validate the extension
    contents in accordance with prop #220. Validates the router-sig-ed25519
    signature against the provided certified key.
    
    Start of work to verify onion-key-crosscert blocks
---
 requirements.txt                          |   1 +
 stem/descriptor/__init__.py               |  12 +-
 stem/descriptor/certificate.py            | 198 ++++++++++++++++++++++++++++++
 stem/descriptor/server_descriptor.py      |  29 +++++
 stem/prereq.py                            |  21 ++++
 test/unit/descriptor/server_descriptor.py |  12 ++
 6 files changed, 272 insertions(+), 1 deletion(-)

diff --git a/requirements.txt b/requirements.txt
index 6dc054c..3cd7160 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,4 @@ pyflakes
 pycodestyle
 tox
 cryptography
+pynacl
diff --git a/stem/descriptor/__init__.py b/stem/descriptor/__init__.py
index 6e8c5a4..4e90889 100644
--- a/stem/descriptor/__init__.py
+++ b/stem/descriptor/__init__.py
@@ -636,7 +636,17 @@ class Descriptor(object):
       raise ValueError("Digest is for the range ending with '%s' but that isn't in our descriptor" % end)
 
     digest_content = raw_descriptor[start_index:end_index + len(end)]
-    digest_hash = hashlib.sha1(stem.util.str_tools._to_bytes(digest_content))
+    return self._digest_for_bytes(digest_content)
+
+  def _digest_for_bytes(self, bytes_to_sign):
+    """
+    Provides a digest of the provided bytes
+
+    :param bytes bytes_to_sign: the bytes for which we should generate a digest
+
+    :returns: the digest string encoded in uppercase hex
+    """
+    digest_hash = hashlib.sha1(bytes_to_sign)
     return stem.util.str_tools._to_unicode(digest_hash.hexdigest().upper())
 
   def __getattr__(self, name):
diff --git a/stem/descriptor/certificate.py b/stem/descriptor/certificate.py
new file mode 100644
index 0000000..e26992f
--- /dev/null
+++ b/stem/descriptor/certificate.py
@@ -0,0 +1,198 @@
+# Copyright 2016, Patrick O'Doherty and The Tor Project
+# See LICENSE for licensing information
+
+"""
+Parsing for the Tor server descriptor Ed25519 Certificates, which is used to
+validate the Ed25519 key used to sign the relay descriptor.
+
+Certificates can optionally contain CertificateExtension objects depending on their type and purpose. Currently Ed25519KeyCertificate certificates will contain one SignedWithEd25519KeyCertificateExtensio
+
+
+**Module Overview:**
+
+::
+
+  Certificate - Tor Certificate
+    |- Ed25519KeyCertificate - Certificate for Ed25519 signing key
+    +- +- verify_descriptor_signature - verify a relay descriptor against a signature
+
+
+  CertificateExtension - Certificate extension
+  +- - SignedWithEd25519KeyCertificateExtension - Ed25519 signing key extension
+"""
+
+import base64
+import hashlib
+import time
+from collections import OrderedDict
+
+import stem.util.str_tools
+
+import nacl.signing
+from nacl.exceptions import BadSignatureError
+
+
+SIGNATURE_LENGTH = 64
+STANDARD_ATTRIBUTES_LENGTH = 40
+CERTIFICATE_FLAGS_LENGTH = 4
+ED25519_ROUTER_SIGNATURE_PREFIX = 'Tor router descriptor signature v1'
+
+
+def _bytes_to_long(b):
+  return long(b.encode('hex'), 16)
+
+
+def _parse_long_offset(offset, length):
+  def _parse(raw_contents):
+    return _bytes_to_long(raw_contents[offset:(offset + length)])
+
+  return _parse
+
+
+def _parse_offset(offset, length):
+  def _parse(raw_contents):
+    return raw_contents[offset:(offset + length)]
+
+  return _parse
+
+
+def _parse_certificate(raw_contents, master_key_bytes, validate = False):
+  version, cert_type = raw_contents[0:2]
+
+  if version == '\x01':
+    if cert_type == '\x04':
+      return Ed25519KeyCertificate(raw_contents, master_key_bytes, validate = validate)
+    elif cert_type == '\x05':
+      # TLS link certificated signed with ed25519 signing key
+      pass
+    elif cert_type == '\x06':
+      # Ed25519 authentication signed with ed25519 signing key
+      pass
+    else:
+      raise ValueError("Unknown Certificate type %s" % cert_type.encode('hex'))
+  else:
+    raise ValueError("Unknown Certificate version %s" % version.encode('hex'))
+
+
+def _parse_extensions(raw_contents):
+  n_extensions = _bytes_to_long(raw_contents[39:40])
+  if n_extensions == 0:
+    return []
+
+  extensions = []
+  extension_bytes = raw_contents[STANDARD_ATTRIBUTES_LENGTH:-SIGNATURE_LENGTH]
+  while len(extension_bytes) > 0:
+    ext_length = _bytes_to_long(extension_bytes[0:2])
+    ext_type, ext_flags = extension_bytes[2:CERTIFICATE_FLAGS_LENGTH]
+    try:
+      ext_data = extension_bytes[CERTIFICATE_FLAGS_LENGTH:(CERTIFICATE_FLAGS_LENGTH + ext_length)]
+    except:
+      raise ValueError('Certificate contained truncated extension')
+
+    if ext_type == SignedWithEd25519KeyCertificateExtension.TYPE:
+      extension = SignedWithEd25519KeyCertificateExtension(ext_type, ext_flags, ext_data)
+    else:
+      raise ValueError('Invalid certificate extension type: %s' % ext_type.encode('hex'))
+
+    extensions.append(extension)
+    extension_bytes = extension_bytes[CERTIFICATE_FLAGS_LENGTH + ext_length:]
+
+  if len(extensions) != n_extensions:
+    raise ValueError('n_extensions was %d but parsed %d' % (n_extensions, len(extensions)))
+
+  return extensions
+
+
+def _parse_signature(cert):
+  return cert[-SIGNATURE_LENGTH:]
+
+
+class Certificate(object):
+  """
+  See proposal #220 <https://gitweb.torproject.org/torspec.git/tree/proposals/220-ecc-id-keys.txt>
+  """
+
+  ATTRIBUTES = {
+    'version': _parse_offset(0, 1),
+    'cert_type': _parse_offset(1, 1),
+    'expiration_date': _parse_long_offset(2, 4),
+    'cert_key_type': _parse_offset(6, 1),
+    'certified_key': _parse_offset(7, 32),
+    'n_extensions': _parse_long_offset(39, 1),
+    'extensions': _parse_extensions,
+    'signature': _parse_signature
+  }
+
+  def __init__(self, raw_contents, identity_key, validate = False):
+    self.certificate_bytes = raw_contents
+    self.identity_key = identity_key
+
+    self.__set_certificate_entries(raw_contents)
+
+  def __set_certificate_entries(self, raw_contents):
+    entries = OrderedDict()
+    for key, func in Certificate.ATTRIBUTES.iteritems():
+      try:
+        entries[key] = func(raw_contents)
+      except IndexError:
+        raise ValueError('Unable to get bytes for %s from certificate' % key)
+
+    for key, value in entries.iteritems():
+      setattr(self, key, value)
+
+
+class Ed25519KeyCertificate(Certificate):
+  def __init__(self, raw_contents, identity_key, validate = False):
+    super(Ed25519KeyCertificate, self).__init__(raw_contents, identity_key, validate = False)
+
+    if validate:
+      if len(self.extensions) == 0:
+        raise ValueError('Ed25519KeyCertificate missing SignedWithEd25519KeyCertificateExtension extension')
+
+      self._verify_signature()
+
+      if (self.expiration_date * 3600) < int(time.time()):
+        raise ValueError('Expired Ed25519KeyCertificate')
+
+  def verify_descriptor_signature(self, descriptor, signature):
+    missing_padding = len(signature) % 4
+    signature_bytes = base64.b64decode(stem.util.str_tools._to_bytes(signature) + b'=' * missing_padding)
+    verify_key = nacl.signing.VerifyKey(self.certified_key)
+
+    signed_part = descriptor[:descriptor.index('router-sig-ed25519 ') + len('router-sig-ed25519 ')]
+    descriptor_with_prefix = ED25519_ROUTER_SIGNATURE_PREFIX + signed_part
+    descriptor_sha256_digest = hashlib.sha256(descriptor_with_prefix).digest()
+    verify_key.verify(descriptor_sha256_digest, signature_bytes)
+
+  def _verify_signature(self):
+    if self.identity_key:
+      verify_key = nacl.signing.VerifyKey(base64.b64decode(self.identity_key + '='))
+    else:
+      verify_key = nacl.singing.VerifyKey(self.extensions[0].ext_data)
+
+    try:
+      verify_key.verify(self.certificate_bytes[:-SIGNATURE_LENGTH], self.signature)
+    except BadSignatureError:
+      raise ValueError('Ed25519KeyCertificate signature invalid')
+
+
+class CertificateExtension(object):
+  KNOWN_TYPES = ['\x04']
+
+  def __init__(self, ext_type, ext_flags, ext_data):
+    self.ext_type = ext_type
+    self.ext_flags = ext_flags
+    self.ext_data = ext_data
+
+  def is_known_type(self):
+    return self.ext_type in CertificateExtension.KNOWN_TYPES
+
+  def affects_validation(self):
+    return self.ext_flags == '\x01'
+
+
+class SignedWithEd25519KeyCertificateExtension(CertificateExtension):
+  TYPE = '\x04'
+
+  def __init__(self, ext_type, ext_flags, ext_data):
+    super(SignedWithEd25519KeyCertificateExtension, self).__init__(ext_type, ext_flags, ext_data)
diff --git a/stem/descriptor/server_descriptor.py b/stem/descriptor/server_descriptor.py
index 3aa5fb0..c5bcaad 100644
--- a/stem/descriptor/server_descriptor.py
+++ b/stem/descriptor/server_descriptor.py
@@ -62,6 +62,8 @@ from stem.descriptor import (
   _parse_key_block,
 )
 
+from stem.descriptor.certificate import _parse_certificate
+
 try:
   # added in python 3.2
   from functools import lru_cache
@@ -662,6 +664,14 @@ class ServerDescriptor(Descriptor):
     if expected_last_keyword and expected_last_keyword != list(entries.keys())[-1]:
       raise ValueError("Descriptor must end with a '%s' entry" % expected_last_keyword)
 
+    if 'identity-ed25519' in entries.keys():
+      if not 'router-sig-ed25519' in entries.keys():
+        raise ValueError("Descriptor must have router-sig-ed25519 entry to accompany identity-ed25519")
+
+      if 'router-sig-ed25519' != list(entries.keys())[-2]:
+        if 'router-sig-ed25519' != list(entries.keys())[-1]:
+          raise ValueError("Descriptor must end with a 'router-sig-ed25519' entry")
+
     if not self.exit_policy:
       raise ValueError("Descriptor must have at least one 'accept' or 'reject' entry")
 
@@ -750,6 +760,25 @@ class RelayDescriptor(ServerDescriptor):
         if signed_digest != self.digest():
           raise ValueError('Decrypted digest does not match local digest (calculated: %s, local: %s)' % (signed_digest, self.digest()))
 
+      if stem.prereq.is_nacl_available() and self.ed25519_certificate:
+        self.certificate = _parse_certificate(_bytes_for_block(self.ed25519_certificate),
+                                              self.ed25519_master_key,
+                                              validate)
+
+        if self.ed25519_master_key is not None:
+          if self.certificate.identity_key != self.ed25519_master_key:
+            raise ValueError("master-key-ed25519 does not match ed25519 certificate identity key")
+
+        self.certificate.verify_descriptor_signature(stem.util.str_tools._to_unicode(raw_contents),
+                                                     self.ed25519_signature)
+
+        onion_key_bytes = _bytes_for_block(self.onion_key)
+        from Crypto.Util import asn1
+        seq = asn1.DerSequence()
+        seq.decode(onion_key_bytes)
+        self._digest_for_signature(self.onion_key, self.onion_key_crosscert)
+
+
   @lru_cache()
   def digest(self):
     """
diff --git a/stem/prereq.py b/stem/prereq.py
index 585b619..e8769a3 100644
--- a/stem/prereq.py
+++ b/stem/prereq.py
@@ -15,6 +15,7 @@ Checks for stem dependencies. We require python 2.6 or greater (including the
   check_requirements - checks for minimum requirements for running stem
   is_python_3 - checks if python 3.0 or later is available
   is_crypto_available - checks if the cryptography module is available
+  is_nacl_available - checks if the pynacl module is available
 """
 
 import inspect
@@ -27,6 +28,7 @@ except ImportError:
   from stem.util.lru_cache import lru_cache
 
 CRYPTO_UNAVAILABLE = "Unable to import the cryptography module. Because of this we'll be unable to verify descriptor signature integrity. You can get cryptography from: https://pypi.python.org/pypi/cryptography"
+NACL_UNAVAILABLE = "Unable to import the pynacl module. Because of this we'll be unable to verify descriptor ed25519 certificate integrity. You can get pynacl from https://github.com/pyca/pynacl/"
 
 
 def check_requirements():
@@ -146,3 +148,22 @@ def is_mock_available():
     return True
   except ImportError:
     return False
+
+ at lru_cache()
+def is_nacl_available():
+  """
+  Checks if the pynacl functions we use are available. This is used for
+  verifying ed25519 certificates in relay descriptor signatures.
+
+  :returns: **True** if we can use pynacl and **False** otherwise
+  """
+
+  from stem.util import log
+
+  try:
+    from nacl import encoding
+    from nacl import signing
+    return True
+  except ImportError:
+    log.log_once('stem.prereq.is_nacl_available', log.INFO, NACL_UNAVAILABLE)
+    return False
diff --git a/test/unit/descriptor/server_descriptor.py b/test/unit/descriptor/server_descriptor.py
index ba8271d..c4e9b67 100644
--- a/test/unit/descriptor/server_descriptor.py
+++ b/test/unit/descriptor/server_descriptor.py
@@ -3,6 +3,7 @@ Unit tests for stem.descriptor.server_descriptor.
 """
 
 import datetime
+import time
 import io
 import pickle
 import tarfile
@@ -245,6 +246,7 @@ Qlx9HNCqCY877ztFRC624ja2ql6A2hBcuoYMbkHjcQ4=
 
     self.assertTrue(isinstance(str(desc), str))
 
+  @patch('time.time', Mock(return_value = time.mktime(datetime.date(2010, 1, 1).timetuple())))
   def test_with_ed25519(self):
     """
     Parses a descriptor with a ed25519 identity key, as added by proposal 228
@@ -299,6 +301,16 @@ Qlx9HNCqCY877ztFRC624ja2ql6A2hBcuoYMbkHjcQ4=
     self.assertEqual('B5E441051D139CCD84BC765D130B01E44DAC29AD', desc.digest())
     self.assertEqual([], desc.get_unrecognized_lines())
 
+  @patch('time.time', Mock(return_value = time.mktime(datetime.date(2020, 1, 1).timetuple())))
+  def test_with_ed25519_expired_cert(self):
+    """
+    Parses a server descriptor with an expired ed25519 certificate
+    """
+    desc_text = open(get_resource('bridge_descriptor_with_ed25519'), 'rb').read()
+    desc_iter = stem.descriptor.server_descriptor._parse_file(io.BytesIO(desc_text), validate = True)
+    self.assertRaises(ValueError, list, desc_iter)
+
+
   def test_bridge_with_ed25519(self):
     """
     Parses a bridge descriptor with ed25519.





More information about the tor-commits mailing list