1# Copyright 2017-2019, Damian Johnson and The Tor Project 2# See LICENSE for licensing information 3 4""" 5Parsing for `Tor Ed25519 certificates 6<https://gitweb.torproject.org/torspec.git/tree/cert-spec.txt>`_, which are 7used to for a variety of purposes... 8 9 * validating the key used to sign server descriptors 10 * validating the key used to sign hidden service v3 descriptors 11 * signing and encrypting hidden service v3 indroductory points 12 13.. versionadded:: 1.6.0 14 15**Module Overview:** 16 17:: 18 19 Ed25519Certificate - Ed25519 signing key certificate 20 | +- Ed25519CertificateV1 - version 1 Ed25519 certificate 21 | |- is_expired - checks if certificate is presently expired 22 | |- signing_key - certificate signing key 23 | +- validate - validates a descriptor's signature 24 | 25 |- from_base64 - decodes a base64 encoded certificate 26 |- to_base64 - base64 encoding of this certificate 27 | 28 |- unpack - decodes a byte encoded certificate 29 +- pack - byte encoding of this certificate 30 31 Ed25519Extension - extension included within an Ed25519Certificate 32 33.. data:: CertType (enum) 34 35 Purpose of Ed25519 certificate. For more information see... 36 37 * `cert-spec.txt <https://gitweb.torproject.org/torspec.git/tree/cert-spec.txt>`_ section A.1 38 * `rend-spec-v3.txt <https://gitweb.torproject.org/torspec.git/tree/rend-spec-v3.txt>`_ appendix E 39 40 .. deprecated:: 1.8.0 41 Replaced with :data:`stem.client.datatype.CertType` 42 43 ======================== =========== 44 CertType Description 45 ======================== =========== 46 **SIGNING** signing key with an identity key 47 **LINK_CERT** TLS link certificate signed with ed25519 signing key 48 **AUTH** authentication key signed with ed25519 signing key 49 **HS_V3_DESC_SIGNING** hidden service v3 short-term descriptor signing key 50 **HS_V3_INTRO_AUTH** hidden service v3 introductory point authentication key 51 **HS_V3_INTRO_ENCRYPT** hidden service v3 introductory point encryption key 52 ======================== =========== 53 54.. data:: ExtensionType (enum) 55 56 Recognized exception types. 57 58 ==================== =========== 59 ExtensionType Description 60 ==================== =========== 61 **HAS_SIGNING_KEY** includes key used to sign the certificate 62 ==================== =========== 63 64.. data:: ExtensionFlag (enum) 65 66 Flags that can be assigned to Ed25519 certificate extensions. 67 68 ====================== =========== 69 ExtensionFlag Description 70 ====================== =========== 71 **AFFECTS_VALIDATION** extension affects whether the certificate is valid 72 **UNKNOWN** extension includes flags not yet recognized by stem 73 ====================== =========== 74""" 75 76import base64 77import binascii 78import datetime 79import hashlib 80import re 81 82import stem.descriptor.hidden_service 83import stem.descriptor.server_descriptor 84import stem.prereq 85import stem.util 86import stem.util.enum 87import stem.util.str_tools 88 89from stem.client.datatype import Field, Size, split 90 91# TODO: Importing under an alternate name until we can deprecate our redundant 92# CertType enum in Stem 2.x. 93 94from stem.client.datatype import CertType as ClientCertType 95 96ED25519_KEY_LENGTH = 32 97ED25519_HEADER_LENGTH = 40 98ED25519_SIGNATURE_LENGTH = 64 99 100SIG_PREFIX_SERVER_DESC = b'Tor router descriptor signature v1' 101SIG_PREFIX_HS_V3 = b'Tor onion service descriptor sig v3' 102 103DEFAULT_EXPIRATION_HOURS = 54 # HSv3 certificate expiration of tor 104 105CertType = stem.util.enum.UppercaseEnum( 106 'SIGNING', 107 'LINK_CERT', 108 'AUTH', 109 'HS_V3_DESC_SIGNING', 110 'HS_V3_INTRO_AUTH', 111 'HS_V3_INTRO_ENCRYPT', 112) 113 114ExtensionType = stem.util.enum.Enum(('HAS_SIGNING_KEY', 4),) 115ExtensionFlag = stem.util.enum.UppercaseEnum('AFFECTS_VALIDATION', 'UNKNOWN') 116 117 118class Ed25519Extension(Field): 119 """ 120 Extension within an Ed25519 certificate. 121 122 :var stem.descriptor.certificate.ExtensionType type: extension type 123 :var list flags: extension attribute flags 124 :var int flag_int: integer encoding of the extension attribute flags 125 :var bytes data: data the extension concerns 126 """ 127 128 def __init__(self, ext_type, flag_val, data): 129 self.type = ext_type 130 self.flags = [] 131 self.flag_int = flag_val if flag_val else 0 132 self.data = data 133 134 if flag_val and flag_val % 2 == 1: 135 self.flags.append(ExtensionFlag.AFFECTS_VALIDATION) 136 flag_val -= 1 137 138 if flag_val: 139 self.flags.append(ExtensionFlag.UNKNOWN) 140 141 if ext_type == ExtensionType.HAS_SIGNING_KEY and len(data) != 32: 142 raise ValueError('Ed25519 HAS_SIGNING_KEY extension must be 32 bytes, but was %i.' % len(data)) 143 144 def pack(self): 145 encoded = bytearray() 146 encoded += Size.SHORT.pack(len(self.data)) 147 encoded += Size.CHAR.pack(self.type) 148 encoded += Size.CHAR.pack(self.flag_int) 149 encoded += self.data 150 return bytes(encoded) 151 152 @staticmethod 153 def pop(content): 154 if len(content) < 4: 155 raise ValueError('Ed25519 extension is missing header fields') 156 157 data_size, content = Size.SHORT.pop(content) 158 ext_type, content = Size.CHAR.pop(content) 159 flags, content = Size.CHAR.pop(content) 160 data, content = split(content, data_size) 161 162 if len(data) != data_size: 163 raise ValueError("Ed25519 extension is truncated. It should have %i bytes of data but there's only %i." % (data_size, len(data))) 164 165 return Ed25519Extension(ext_type, flags, data), content 166 167 def __hash__(self): 168 return stem.util._hash_attr(self, 'type', 'flag_int', 'data', cache = True) 169 170 171class Ed25519Certificate(object): 172 """ 173 Base class for an Ed25519 certificate. 174 175 :var int version: certificate format version 176 :var unicode encoded: base64 encoded ed25519 certificate 177 """ 178 179 def __init__(self, version): 180 self.version = version 181 self.encoded = None # TODO: remove in stem 2.x 182 183 @staticmethod 184 def unpack(content): 185 """ 186 Parses a byte encoded ED25519 certificate. 187 188 :param bytes content: encoded certificate 189 190 :returns: :class:`~stem.descriptor.certificate.Ed25519Certificate` subclsss 191 for the given certificate 192 193 :raises: **ValueError** if certificate is malformed 194 """ 195 196 version = Size.CHAR.pop(content)[0] 197 198 if version == 1: 199 return Ed25519CertificateV1.unpack(content) 200 else: 201 raise ValueError('Ed25519 certificate is version %i. Parser presently only supports version 1.' % version) 202 203 @staticmethod 204 def from_base64(content): 205 """ 206 Parses a base64 encoded ED25519 certificate. 207 208 :param str content: base64 encoded certificate 209 210 :returns: :class:`~stem.descriptor.certificate.Ed25519Certificate` subclsss 211 for the given certificate 212 213 :raises: **ValueError** if content is malformed 214 """ 215 216 content = stem.util.str_tools._to_unicode(content) 217 218 if content.startswith('-----BEGIN ED25519 CERT-----\n') and content.endswith('\n-----END ED25519 CERT-----'): 219 content = content[29:-27] 220 221 try: 222 decoded = base64.b64decode(content) 223 224 if not decoded: 225 raise TypeError('empty') 226 227 instance = Ed25519Certificate.unpack(decoded) 228 instance.encoded = content 229 return instance 230 except (TypeError, binascii.Error) as exc: 231 raise ValueError("Ed25519 certificate wasn't propoerly base64 encoded (%s):\n%s" % (exc, content)) 232 233 def pack(self): 234 """ 235 Encoded byte representation of our certificate. 236 237 :returns: **bytes** for our encoded certificate representation 238 """ 239 240 raise NotImplementedError('Certificate encoding has not been implemented for %s' % type(self).__name__) 241 242 def to_base64(self, pem = False): 243 """ 244 Base64 encoded certificate data. 245 246 :param bool pem: include `PEM header/footer 247 <https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail>`_, for more 248 information see `RFC 7468 <https://tools.ietf.org/html/rfc7468>`_ 249 250 :returns: **unicode** for our encoded certificate representation 251 """ 252 253 encoded = b'\n'.join(stem.util.str_tools._split_by_length(base64.b64encode(self.pack()), 64)) 254 255 if pem: 256 encoded = b'-----BEGIN ED25519 CERT-----\n%s\n-----END ED25519 CERT-----' % encoded 257 258 return stem.util.str_tools._to_unicode(encoded) 259 260 @staticmethod 261 def _from_descriptor(keyword, attribute): 262 def _parse(descriptor, entries): 263 value, block_type, block_contents = entries[keyword][0] 264 265 if not block_contents or block_type != 'ED25519 CERT': 266 raise ValueError("'%s' should be followed by a ED25519 CERT block, but was a %s" % (keyword, block_type)) 267 268 setattr(descriptor, attribute, Ed25519Certificate.from_base64(block_contents)) 269 270 return _parse 271 272 def __str__(self): 273 return self.to_base64(pem = True) 274 275 @staticmethod 276 def parse(content): 277 return Ed25519Certificate.from_base64(content) # TODO: drop this alias in stem 2.x 278 279 280class Ed25519CertificateV1(Ed25519Certificate): 281 """ 282 Version 1 Ed25519 certificate, which are used for signing tor server 283 descriptors. 284 285 :var stem.client.datatype.CertType type: certificate purpose 286 :var int type_int: integer value of the certificate purpose 287 :var datetime expiration: expiration of the certificate 288 :var int key_type: format of the key 289 :var bytes key: key content 290 :var list extensions: :class:`~stem.descriptor.certificate.Ed25519Extension` in this certificate 291 :var bytes signature: certificate signature 292 293 :param bytes signature: pre-calculated certificate signature 294 :param cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey signing_key: certificate signing key 295 """ 296 297 def __init__(self, cert_type = None, expiration = None, key_type = None, key = None, extensions = None, signature = None, signing_key = None): 298 super(Ed25519CertificateV1, self).__init__(1) 299 300 if cert_type is None: 301 raise ValueError('Certificate type is required') 302 elif key is None: 303 raise ValueError('Certificate key is required') 304 305 self.type, self.type_int = ClientCertType.get(cert_type) 306 self.expiration = expiration if expiration else datetime.datetime.utcnow() + datetime.timedelta(hours = DEFAULT_EXPIRATION_HOURS) 307 self.key_type = key_type if key_type else 1 308 self.key = stem.util._pubkey_bytes(key) 309 self.extensions = extensions if extensions else [] 310 self.signature = signature 311 312 if signing_key: 313 calculated_sig = signing_key.sign(self.pack()) 314 315 # if caller provides both signing key *and* signature then ensure they match 316 317 if self.signature and self.signature != calculated_sig: 318 raise ValueError("Signature calculated from its key (%s) mismatches '%s'" % (calculated_sig, self.signature)) 319 320 self.signature = calculated_sig 321 322 if self.type in (ClientCertType.LINK, ClientCertType.IDENTITY, ClientCertType.AUTHENTICATE): 323 raise ValueError('Ed25519 certificate cannot have a type of %i. This is reserved for CERTS cells.' % self.type_int) 324 elif self.type == ClientCertType.ED25519_IDENTITY: 325 raise ValueError('Ed25519 certificate cannot have a type of 7. This is reserved for RSA identity cross-certification.') 326 elif self.type == ClientCertType.UNKNOWN: 327 raise ValueError('Ed25519 certificate type %i is unrecognized' % self.type_int) 328 329 def pack(self): 330 encoded = bytearray() 331 encoded += Size.CHAR.pack(self.version) 332 encoded += Size.CHAR.pack(self.type_int) 333 encoded += Size.LONG.pack(int(stem.util.datetime_to_unix(self.expiration) / 3600)) 334 encoded += Size.CHAR.pack(self.key_type) 335 encoded += self.key 336 encoded += Size.CHAR.pack(len(self.extensions)) 337 338 for extension in self.extensions: 339 encoded += extension.pack() 340 341 if self.signature: 342 encoded += self.signature 343 344 return bytes(encoded) 345 346 @staticmethod 347 def unpack(content): 348 if len(content) < ED25519_HEADER_LENGTH + ED25519_SIGNATURE_LENGTH: 349 raise ValueError('Ed25519 certificate was %i bytes, but should be at least %i' % (len(content), ED25519_HEADER_LENGTH + ED25519_SIGNATURE_LENGTH)) 350 351 header, signature = split(content, len(content) - ED25519_SIGNATURE_LENGTH) 352 353 version, header = Size.CHAR.pop(header) 354 cert_type, header = Size.CHAR.pop(header) 355 expiration_hours, header = Size.LONG.pop(header) 356 key_type, header = Size.CHAR.pop(header) 357 key, header = split(header, ED25519_KEY_LENGTH) 358 extension_count, extension_data = Size.CHAR.pop(header) 359 360 if version != 1: 361 raise ValueError('Ed25519 v1 parser cannot read version %i certificates' % version) 362 363 extensions = [] 364 365 for i in range(extension_count): 366 extension, extension_data = Ed25519Extension.pop(extension_data) 367 extensions.append(extension) 368 369 if extension_data: 370 raise ValueError('Ed25519 certificate had %i bytes of unused extension data' % len(extension_data)) 371 372 return Ed25519CertificateV1(cert_type, datetime.datetime.utcfromtimestamp(expiration_hours * 3600), key_type, key, extensions, signature) 373 374 def is_expired(self): 375 """ 376 Checks if this certificate is presently expired or not. 377 378 :returns: **True** if the certificate has expired, **False** otherwise 379 """ 380 381 return datetime.datetime.now() > self.expiration 382 383 def signing_key(self): 384 """ 385 Provides this certificate's signing key. 386 387 .. versionadded:: 1.8.0 388 389 :returns: **bytes** with the first signing key on the certificate, None if 390 not present 391 """ 392 393 for extension in self.extensions: 394 if extension.type == ExtensionType.HAS_SIGNING_KEY: 395 return extension.data 396 397 return None 398 399 def validate(self, descriptor): 400 """ 401 Validate our descriptor content matches its ed25519 signature. Supported 402 descriptor types include... 403 404 * :class:`~stem.descriptor.server_descriptor.RelayDescriptor` 405 * :class:`~stem.descriptor.hidden_service.HiddenServiceDescriptorV3` 406 407 :param stem.descriptor.__init__.Descriptor descriptor: descriptor to validate 408 409 :raises: 410 * **ValueError** if signing key or descriptor are invalid 411 * **TypeError** if descriptor type is unsupported 412 * **ImportError** if cryptography module or ed25519 support unavailable 413 """ 414 415 if not stem.prereq.is_crypto_available(ed25519 = True): 416 raise ImportError('Certificate validation requires the cryptography module and ed25519 support') 417 418 if isinstance(descriptor, stem.descriptor.server_descriptor.RelayDescriptor): 419 signed_content = hashlib.sha256(Ed25519CertificateV1._signed_content(descriptor)).digest() 420 signature = stem.util.str_tools._decode_b64(descriptor.ed25519_signature) 421 422 self._validate_server_desc_signing_key(descriptor) 423 elif isinstance(descriptor, stem.descriptor.hidden_service.HiddenServiceDescriptorV3): 424 signed_content = Ed25519CertificateV1._signed_content(descriptor) 425 signature = stem.util.str_tools._decode_b64(descriptor.signature) 426 else: 427 raise TypeError('Certificate validation only supported for server and hidden service descriptors, not %s' % type(descriptor).__name__) 428 429 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey 430 from cryptography.exceptions import InvalidSignature 431 432 try: 433 key = Ed25519PublicKey.from_public_bytes(self.key) 434 key.verify(signature, signed_content) 435 except InvalidSignature: 436 raise ValueError('Descriptor Ed25519 certificate signature invalid (signature forged or corrupt)') 437 438 @staticmethod 439 def _signed_content(descriptor): 440 """ 441 Provides this descriptor's signing constant, appended with the portion of 442 the descriptor that's signed. 443 """ 444 445 if isinstance(descriptor, stem.descriptor.server_descriptor.RelayDescriptor): 446 prefix = SIG_PREFIX_SERVER_DESC 447 regex = b'(.+router-sig-ed25519 )' 448 elif isinstance(descriptor, stem.descriptor.hidden_service.HiddenServiceDescriptorV3): 449 prefix = SIG_PREFIX_HS_V3 450 regex = b'(.+)signature ' 451 else: 452 raise ValueError('BUG: %s type unexpected' % type(descriptor).__name__) 453 454 match = re.search(regex, descriptor.get_bytes(), re.DOTALL) 455 456 if not match: 457 raise ValueError('Malformed descriptor missing signature line') 458 459 return prefix + match.group(1) 460 461 def _validate_server_desc_signing_key(self, descriptor): 462 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey 463 from cryptography.exceptions import InvalidSignature 464 465 if descriptor.ed25519_master_key: 466 signing_key = base64.b64decode(stem.util.str_tools._to_bytes(descriptor.ed25519_master_key) + b'=') 467 else: 468 signing_key = self.signing_key() 469 470 if not signing_key: 471 raise ValueError('Server descriptor missing an ed25519 signing key') 472 473 try: 474 key = Ed25519PublicKey.from_public_bytes(signing_key) 475 key.verify(self.signature, base64.b64decode(stem.util.str_tools._to_bytes(self.encoded))[:-ED25519_SIGNATURE_LENGTH]) 476 except InvalidSignature: 477 raise ValueError('Ed25519KeyCertificate signing key is invalid (signature forged or corrupt)') 478