commit 3ce6631670cf3a0ffb5d44736bd426c3eaa82b5d Author: Patrick O'Doherty p@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 + +@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.
tor-commits@lists.torproject.org