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