From 0ef7139a61ca8709f6138fdf93c5e17f542f6256 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 1 Nov 2026 11:56:16 -0500 Subject: [PATCH] feat(application): parse TLS handshake extensions (#2514) - Add the IANA TLS ExtensionType registry: pcapkit.vendor.tls.extension_type and the pcapkit.const.tls.extension_type module it generates. - Add SSL.__extension__ with register_extension and the register_tls_extension wrapper; decode server_name, supported_groups, signature_algorithms, ALPN, supported_versions and key_share in ClientHello, ServerHello, HelloRetryRequest and EncryptedExtensions. - Keep unknown extensions, and bodies their decoder rejects, as raw octets, the rejection quiet under CaughtByCaller; decode EncryptedExtensions. - Build extensions from data models, raw octets or (type, kwargs) pairs, byte-exact both ways; add the tls-extension options-generator family. - Add the shared value helpers to Protocol: _read_vector, _read_whole_vector, _read_uints, _make_uint, _make_vector and _make_uints, which the SSL extension readers and makers call. - Document the models, registry and wrapper, and the decoded bodies in the DTLS models, the roadmap and the TLS/DTLS pages; bump the const pins. --- docs/source/contributing/pep.rst | 13 +- docs/source/ext.rst | 4 +- docs/source/pcapkit/const/tls.rst | 16 + docs/source/pcapkit/foundation/registry.rst | 2 + .../pcapkit/protocols/application/dtls.rst | 6 +- .../pcapkit/protocols/application/ssl.rst | 81 +- .../pcapkit/protocols/application/tls.rst | 12 +- docs/source/pcapkit/vendor/tls.rst | 15 + examples/generators/options.py | 61 ++ pcapkit/all.py | 1 + pcapkit/const/__init__.py | 2 +- pcapkit/const/tls/__init__.py | 6 +- pcapkit/const/tls/extension_type.py | 372 +++++++++ pcapkit/foundation/__init__.py | 1 + pcapkit/foundation/analysis/dtls.py | 5 +- pcapkit/foundation/registry/__init__.py | 1 + pcapkit/foundation/registry/protocols.py | 40 + pcapkit/protocols/application/ssl.py | 757 +++++++++++++++++- pcapkit/protocols/application/tls.py | 5 +- pcapkit/protocols/data/__init__.py | 7 +- .../protocols/data/application/__init__.py | 23 +- pcapkit/protocols/data/application/dtls.py | 11 +- pcapkit/protocols/data/application/tls.py | 200 ++++- pcapkit/protocols/protocol.py | 161 +++- pcapkit/vendor/__init__.py | 2 +- pcapkit/vendor/tls/__init__.py | 6 +- pcapkit/vendor/tls/extension_type.py | 151 ++++ tests/const/test_const_enum_builtin_parity.py | 6 +- tests/const/test_const_enum_get.py | 4 +- tests/const/test_const_enum_lookup.py | 2 +- tests/const/test_const_registry_protocol.py | 4 +- .../registry/test_registry_symmetry_unit.py | 1 + ...tls_extension_quiet_rejection_2514_unit.py | 123 +++ .../test_tls_extensions_2514_unit.py | 518 ++++++++++++ .../test_parser_registrar_identity_unit.py | 1 + .../test_protocol_value_helpers_2514_unit.py | 213 +++++ 36 files changed, 2744 insertions(+), 89 deletions(-) create mode 100644 pcapkit/const/tls/extension_type.py create mode 100644 pcapkit/vendor/tls/extension_type.py create mode 100644 tests/protocols/application/test_tls_extension_quiet_rejection_2514_unit.py create mode 100644 tests/protocols/application/test_tls_extensions_2514_unit.py create mode 100644 tests/protocols/test_protocol_value_helpers_2514_unit.py diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index ddaa2b4e47..e90a840f47 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -238,8 +238,12 @@ DTLS's own divergence (the epoch/sequence-number pair in its record header, and the fragment header each handshake message carries) staying in :class:`~pcapkit.protocols.application.dtls.DTLS` alone. ``ClientHello`` and ``ServerHello`` decode down to their cipher suite(s), compression method(s) -and extension list (TYPE+LENGTH framed, extension bodies undecoded); every -other handshake message is message-TYPE-level framing only. Decryption is a +and extension list, and ``EncryptedExtensions`` down to its extension list. An +extension body decodes where +:attr:`SSL.__extension__ ` +registers a decoder for its type, and stays raw octets where none is +registered or the decoder rejects it (:issue:`2514`). Every other handshake +message is message-TYPE-level framing only. Decryption is a separate question and is not needed to make the record structure legible, in the same way :class:`ESP ` parses without keys. @@ -1167,8 +1171,9 @@ group-record list, each a shape of its own. **Done within wave 2 — TLS/SSL with DTLS.** The `DTLS`_ section above lists what is decoded: the shared :class:`~pcapkit.protocols.application.ssl.SSL` -base, TLS, DTLS, handshake message framing, and handshake-fragment -reassembly (:issue:`2440`). +base, TLS, DTLS, handshake message framing, the hello and +``EncryptedExtensions`` extension lists with their registered extension bodies +(:issue:`2514`), and handshake-fragment reassembly (:issue:`2440`). **Done ahead of wave 2 — handlers for the** ``DLT_NULL``, ``DLT_RAW``, ``DLT_LINUX_SLL`` **and** ``DLT_LINUX_SLL2`` **link types.** Each is a diff --git a/docs/source/ext.rst b/docs/source/ext.rst index d4aad021cf..de76db22e3 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -412,7 +412,9 @@ writes them: + +------------------------------------------------------------+-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ | | :class:`~pcapkit.protocols.transport.dccp.DCCP` | :attr:`DCCP.__option__ ` | :func:`~pcapkit.foundation.registry.protocols.register_dccp_option` | +-------------------+------------------------------------------------------------+-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ -| Application Layer | :class:`~pcapkit.protocols.application.httpv2.HTTPv2` | :attr:`HTTPv2.__frame__ ` | :func:`~pcapkit.foundation.registry.protocols.register_http_frame` | +| | :class:`~pcapkit.protocols.application.httpv2.HTTPv2` | :attr:`HTTPv2.__frame__ ` | :func:`~pcapkit.foundation.registry.protocols.register_http_frame` | ++ Application Layer +------------------------------------------------------------+-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ +| | :class:`~pcapkit.protocols.application.ssl.SSL` | :attr:`SSL.__extension__ ` | :func:`~pcapkit.foundation.registry.protocols.register_tls_extension` | +-------------------+------------------------------------------------------------+-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ | | | :attr:`PCAPNG.__block__ ` | :func:`~pcapkit.foundation.registry.protocols.register_pcapng_block` | + + +-----------------------------------------------------------------------------------------------+----------------------------------------------------------------------------+ diff --git a/docs/source/pcapkit/const/tls.rst b/docs/source/pcapkit/const/tls.rst index 5d838df110..f16f8fa0eb 100644 --- a/docs/source/pcapkit/const/tls.rst +++ b/docs/source/pcapkit/const/tls.rst @@ -16,6 +16,8 @@ enumerations include: - TLS/DTLS Content Types [*]_ * - :class:`TLS_HandshakeType ` - TLS/DTLS HandshakeType [*]_ + * - :class:`TLS_ExtensionType ` + - TLS/DTLS ExtensionType Values [*]_ .. versionadded:: 1.6.0 @@ -47,7 +49,21 @@ DTLS-only messages :rfc:`9147#section-5.2` adds to the registry. :undoc-members: :show-inheritance: +TLS/DTLS ExtensionType Values +============================= + +.. module:: pcapkit.const.tls.extension_type + +This module contains the constant enumeration for **TLS/DTLS ExtensionType Values**, +which is automatically generated from :class:`pcapkit.vendor.tls.extension_type.ExtensionType`. + +.. autoclass:: pcapkit.const.tls.extension_type.ExtensionType + :members: + :undoc-members: + :show-inheritance: + .. rubric:: Footnotes .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-5 .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7 +.. [*] https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#tls-extensiontype-values-1 diff --git a/docs/source/pcapkit/foundation/registry.rst b/docs/source/pcapkit/foundation/registry.rst index 4d47b51b0a..2c835568eb 100644 --- a/docs/source/pcapkit/foundation/registry.rst +++ b/docs/source/pcapkit/foundation/registry.rst @@ -146,6 +146,8 @@ Application Layer Registries .. autofunction:: pcapkit.foundation.registry.protocols.register_http_frame +.. autofunction:: pcapkit.foundation.registry.protocols.register_tls_extension + Miscellaneous Protocol Registries ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/pcapkit/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst index 8aa3c61c86..43fef7cd1e 100644 --- a/docs/source/pcapkit/protocols/application/dtls.rst +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -121,9 +121,9 @@ Data Models :members: :show-inheritance: -See :doc:`ssl` for ``ClientHello``, ``ServerHello``, ``Extension``, ``Alert`` -and ``ChangeCipherSpec``, shared verbatim with -:class:`~pcapkit.protocols.application.tls.TLS`. +See :doc:`ssl` for ``ClientHello``, ``ServerHello``, ``EncryptedExtensions``, +``Extension`` and its decoded bodies, ``Alert`` and ``ChangeCipherSpec``, +shared verbatim with :class:`~pcapkit.protocols.application.tls.TLS`. .. rubric:: Footnotes diff --git a/docs/source/pcapkit/protocols/application/ssl.rst b/docs/source/pcapkit/protocols/application/ssl.rst index 19cb864aeb..de40f16338 100644 --- a/docs/source/pcapkit/protocols/application/ssl.rst +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -26,9 +26,9 @@ version and the length, and DTLS adds a fragment header to each handshake message. Each record header is declared in its subclass's own schema, and :class:`~pcapkit.protocols.application.dtls.DTLS` reads the epoch and sequence number. This base class carries the logic the two share -- content-type -dispatch, handshake-message decoding (including the DTLS fragment header), and -the record-level validation that keeps dispatch from accepting bytes that -cannot be a genuine record. +dispatch, handshake-message decoding (including the DTLS fragment header), the +extension registry, and the record-level validation that keeps dispatch from +accepting bytes that cannot be a genuine record. .. versionadded:: 1.6.0 @@ -41,6 +41,23 @@ cannot be a genuine record. :show-inheritance: .. automethod:: id + .. automethod:: register_extension + + .. automethod:: _read_ext_unknown + .. automethod:: _read_ext_server_name + .. automethod:: _read_ext_supported_groups + .. automethod:: _read_ext_signature_algorithms + .. automethod:: _read_ext_alpn + .. automethod:: _read_ext_supported_versions + .. automethod:: _read_ext_key_share + + .. automethod:: _make_ext_unknown + .. automethod:: _make_ext_server_name + .. automethod:: _make_ext_supported_groups + .. automethod:: _make_ext_signature_algorithms + .. automethod:: _make_ext_alpn + .. automethod:: _make_ext_supported_versions + .. automethod:: _make_ext_key_share .. automethod:: _validate_tls_record @@ -58,9 +75,15 @@ cannot be a genuine record. .. automethod:: _read_tls_server_hello .. automethod:: _make_tls_server_hello + .. automethod:: _read_tls_encrypted_extensions + .. automethod:: _make_tls_encrypted_extensions + .. automethod:: _read_tls_extensions .. automethod:: _make_tls_extensions + .. autoattribute:: __extension__ + :no-value: + Data Models ----------- @@ -78,10 +101,62 @@ Data Models :members: :show-inheritance: +.. autoclass:: pcapkit.protocols.data.application.tls.EncryptedExtensions + :members: + :show-inheritance: + .. autoclass:: pcapkit.protocols.data.application.tls.Extension :members: :show-inheritance: +.. autoclass:: pcapkit.protocols.data.application.tls.ExtensionData + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.ServerNameList + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.ServerName + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.NamedGroupList + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.SignatureSchemeList + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.ProtocolNameList + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.SupportedVersionsClientHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.SupportedVersionsServerHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.KeyShareClientHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.KeyShareHelloRetryRequest + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.KeyShareServerHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.KeyShareEntry + :members: + :show-inheritance: + .. autoclass:: pcapkit.protocols.data.application.tls.Alert :members: :show-inheritance: diff --git a/docs/source/pcapkit/protocols/application/tls.rst b/docs/source/pcapkit/protocols/application/tls.rst index 5054e98933..7358334149 100644 --- a/docs/source/pcapkit/protocols/application/tls.rst +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -9,7 +9,7 @@ extractor for the TLS/SSLv3+ record-layer family [*]_ -- SSLv3 (:rfc:`6101`), TLS 1.0 (:rfc:`2246`), TLS 1.1 (:rfc:`4346`), TLS 1.2 (:rfc:`5246`) and TLS 1.3 (:rfc:`9846`, which obsoletes :rfc:`5077`, :rfc:`5246`, :rfc:`6961`, :rfc:`7627`, :rfc:`8422` and :rfc:`8446`) -- at the record layer, plus -handshake message TYPE-level framing. +handshake message framing and the bodies `Decoded Fields`_ lists. Version Identification ---------------------- @@ -75,7 +75,10 @@ Decoded Fields decides which are): * ``ClientHello``/``ServerHello`` down to their cipher suites, compression - methods and extensions (type and length only). + methods and extensions, and ``EncryptedExtensions`` down to its + extensions; an extension's body decodes where + :attr:`SSL.__extension__ ` + registers a decoder for its type. * Every other handshake message at type and length only. * ``Alert`` and ``ChangeCipherSpec`` in full. @@ -150,8 +153,9 @@ Data Model :members: :show-inheritance: -See :doc:`ssl` for ``ClientHello``, ``ServerHello``, ``Handshake``, ``Extension``, -``Alert`` and ``ChangeCipherSpec``, shared verbatim with +See :doc:`ssl` for ``ClientHello``, ``ServerHello``, ``EncryptedExtensions``, +``Handshake``, ``Extension`` and its decoded bodies, ``Alert`` and +``ChangeCipherSpec``, shared verbatim with :class:`~pcapkit.protocols.application.dtls.DTLS`. .. rubric:: Footnotes diff --git a/docs/source/pcapkit/vendor/tls.rst b/docs/source/pcapkit/vendor/tls.rst index abc05c28b6..c254a468a7 100644 --- a/docs/source/pcapkit/vendor/tls.rst +++ b/docs/source/pcapkit/vendor/tls.rst @@ -17,6 +17,8 @@ include: - TLS/DTLS Content Types [*]_ * - :class:`TLS_HandshakeType ` - TLS/DTLS HandshakeType [*]_ + * - :class:`TLS_ExtensionType ` + - TLS/DTLS ExtensionType Values [*]_ .. versionadded:: 1.6.0 @@ -44,7 +46,20 @@ which is automatically generating :class:`pcapkit.const.tls.handshake_type.Hands :members: FLAG, LINK :show-inheritance: +TLS/DTLS ExtensionType Values +============================= + +.. module:: pcapkit.vendor.tls.extension_type + +This module contains the vendor crawler for **TLS/DTLS ExtensionType Values**, +which is automatically generating :class:`pcapkit.const.tls.extension_type.ExtensionType`. + +.. autoclass:: pcapkit.vendor.tls.extension_type.ExtensionType + :members: FLAG, LINK + :show-inheritance: + .. rubric:: Footnotes .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-5 .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7 +.. [*] https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#tls-extensiontype-values-1 diff --git a/examples/generators/options.py b/examples/generators/options.py index 41a7c53ae5..b3848270fd 100644 --- a/examples/generators/options.py +++ b/examples/generators/options.py @@ -1325,6 +1325,63 @@ def _httpv2_rebuild(frame: 'Any') -> 'Any': frame=frame) +############################################################################### +# TLS -- handshake extensions +############################################################################### +# +# Each case is one extension in a ``ClientHello``, built from keyword arguments +# by the extension's ``_make_ext_*``. No capture is written: the ``tcp`` +# envelope is addressed to port 80, which dispatches to HTTP rather than TLS. + + +def _tls_extension_registry() -> 'Any': + from pcapkit.protocols.application.tls import TLS + return TLS.__extension__ + + +def _tls_extension_overrides() -> 'dict[Any, dict[str, Any]]': + from pcapkit.const.tls.extension_type import ExtensionType as Enum_ExtensionType + from pcapkit.protocols.data.application.tls import KeyShareEntry, ServerName + return { + # Each of these defaults to an empty list. + Enum_ExtensionType.server_name: { + 'server_name_list': (ServerName(name_type=0, name=b'example.com'),)}, + Enum_ExtensionType.supported_groups: {'named_group_list': (0x001D, 0x0017)}, + Enum_ExtensionType.signature_algorithms: {'supported_signature_algorithms': (0x0403, 0x0804)}, + Enum_ExtensionType.application_layer_protocol_negotiation: { + 'protocol_name_list': (b'h2', b'http/1.1')}, + Enum_ExtensionType.supported_versions: {'versions': (0x0304, 0x0303)}, + Enum_ExtensionType.key_share: { + 'client_shares': (KeyShareEntry(group=0x001D, key_exchange=bytes(range(32))),)}, + } + + +def _tls_client_hello(extensions: 'Any') -> 'Any': + """A TLS record carrying one ``ClientHello`` with ``extensions``.""" + from pcapkit.const.tls.content_type import ContentType as Enum_ContentType + from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import ClientHello, Handshake + + hello = ClientHello(version=0x0303, random=bytes(32), session_id=b'', cookie=None, + cipher_suites=(0x1301,), compression_methods=(0,), extensions=extensions) + return TLS(content_type=Enum_ContentType.handshake, version=0x0303, + content=(Handshake(msg_type=Enum_HandshakeType.client_hello, length=0, body=hello),)) + + +def _tls_extension_build(code: 'Any', kwargs: 'dict[str, Any]') -> 'Any': + return _tls_client_hello(((code, dict(kwargs)),)) + + +def _tls_parse(octets: 'bytes') -> 'Any': + from pcapkit.protocols.application.tls import TLS + return TLS(octets, len(octets)) + + +def _tls_extension_extract(parsed: 'Any') -> 'Any': + return parsed.info.content[0].body.extensions + + ############################################################################### # PCAP-NG -- blocks, block options, name records and decryption secrets ############################################################################### @@ -1765,6 +1822,10 @@ def _pcapng_secrets_build(code: 'Any', kwargs: 'dict[str, Any]') -> 'Any': Family('icmpv6-type', _icmpv6_registry, _icmpv6_overrides, _icmpv6_build, _icmpv6_parse, _icmp_extract, _icmpv6_rebuild, capture=None, envelope=None), + # No capture -- see the section comment above ``_tls_extension_registry``. + Family('tls-extension', _tls_extension_registry, _tls_extension_overrides, + _tls_extension_build, _tls_parse, _tls_extension_extract, + _tls_client_hello, capture=None, envelope=None), # The PCAP-NG families write no capture -- see the section comment # above ``PCAPNG_TIME`` for why the construction API's defaults do not # make a reproducible one, and what already covers that space instead. diff --git a/pcapkit/all.py b/pcapkit/all.py index 11bf704ae0..3b39c0a982 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -107,6 +107,7 @@ 'register_sctp_chunk', 'register_sctp_parameter', 'register_sctp_cause', 'register_dccp_option', 'register_http_frame', + 'register_tls_extension', 'register_pcapng_block', 'register_pcapng_option', 'register_pcapng_secrets', 'register_pcapng_record', 'register_extractor_engine', diff --git a/pcapkit/const/__init__.py b/pcapkit/const/__init__.py index 1fbe215b30..1df7a63be4 100644 --- a/pcapkit/const/__init__.py +++ b/pcapkit/const/__init__.py @@ -100,7 +100,7 @@ # TCP 'TCP_Checksum', 'TCP_Option', 'TCP_MPTCPOption', 'TCP_Flags', # TLS/DTLS - 'TLS_ContentType', 'TLS_HandshakeType', + 'TLS_ContentType', 'TLS_HandshakeType', 'TLS_ExtensionType', # VLAN 'VLAN_PriorityLevel', # PCAPNG diff --git a/pcapkit/const/tls/__init__.py b/pcapkit/const/tls/__init__.py index 05891fccd9..36bfefdade 100644 --- a/pcapkit/const/tls/__init__.py +++ b/pcapkit/const/tls/__init__.py @@ -17,13 +17,17 @@ - TLS/DTLS Content Types [*]_ * - :class:`TLS_HandshakeType ` - TLS/DTLS HandshakeType [*]_ + * - :class:`TLS_ExtensionType ` + - TLS/DTLS ExtensionType Values [*]_ .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-5 .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7 +.. [*] https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#tls-extensiontype-values-1 """ from pcapkit.const.tls.content_type import ContentType as TLS_ContentType +from pcapkit.const.tls.extension_type import ExtensionType as TLS_ExtensionType from pcapkit.const.tls.handshake_type import HandshakeType as TLS_HandshakeType -__all__ = ['TLS_ContentType', 'TLS_HandshakeType'] +__all__ = ['TLS_ContentType', 'TLS_HandshakeType', 'TLS_ExtensionType'] diff --git a/pcapkit/const/tls/extension_type.py b/pcapkit/const/tls/extension_type.py new file mode 100644 index 0000000000..c378a26ab1 --- /dev/null +++ b/pcapkit/const/tls/extension_type.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""TLS/DTLS ExtensionType Values +=================================== + +.. module:: pcapkit.const.tls.extension_type + +This module contains the constant enumeration for **TLS/DTLS ExtensionType Values**, +which is automatically generated from :class:`pcapkit.vendor.tls.extension_type.ExtensionType`. + +""" + +from typing import TYPE_CHECKING + +from aenum import IntEnum + +from pcapkit.corekit.enum import EnumRegistry +from pcapkit.utilities.exceptions import EnumValueError + +if TYPE_CHECKING: + from typing import Any + +__all__ = ['ExtensionType'] + + +class ExtensionType(EnumRegistry, IntEnum): + """[ExtensionType] TLS/DTLS ExtensionType Values""" + + #: server_name [:rfc:`6066`][:rfc:`9261`] + server_name = 0 + + #: max_fragment_length [:rfc:`6066`][:rfc:`8449`] + max_fragment_length = 1 + + #: client_certificate_url [:rfc:`6066`] + client_certificate_url = 2 + + #: trusted_ca_keys [:rfc:`6066`] + trusted_ca_keys = 3 + + #: truncated_hmac [:rfc:`6066`][IESG Action 2018-08-16][:rfc:`9847`][Tag Size + #: Does Matter: Attacks and Proofs for the TLS Record Protocol] + truncated_hmac = 4 + + #: status_request [:rfc:`6066`][:rfc:`9846`] + status_request = 5 + + #: user_mapping [:rfc:`4681`] + user_mapping = 6 + + #: client_authz [:rfc:`5878`] + client_authz = 7 + + #: server_authz [:rfc:`5878`] + server_authz = 8 + + #: cert_type [:rfc:`6091`] + cert_type = 9 + + #: supported_groups [:rfc:`8422`][:rfc:`7919`][:rfc:`9846`] + supported_groups = 10 + + #: ec_point_formats [:rfc:`8422`] + ec_point_formats = 11 + + #: srp [:rfc:`5054`] + srp = 12 + + #: signature_algorithms [:rfc:`9846`] + signature_algorithms = 13 + + #: use_srtp [:rfc:`5764`] + use_srtp = 14 + + #: heartbeat [:rfc:`6520`] + heartbeat = 15 + + #: application_layer_protocol_negotiation [:rfc:`7301`] + application_layer_protocol_negotiation = 16 + + #: status_request_v2 [:rfc:`6961`] + status_request_v2 = 17 + + #: signed_certificate_timestamp [:rfc:`6962`] + signed_certificate_timestamp = 18 + + #: client_certificate_type [:rfc:`7250`] + client_certificate_type = 19 + + #: server_certificate_type [:rfc:`7250`] + server_certificate_type = 20 + + #: padding [:rfc:`7685`] + padding = 21 + + #: encrypt_then_mac [:rfc:`7366`] + encrypt_then_mac = 22 + + #: extended_main_secret [:rfc:`7627`][:rfc:`9846`] + extended_main_secret = 23 + + #: token_binding [:rfc:`8472`] + token_binding = 24 + + #: cached_info [:rfc:`7924`][:rfc:`9846`] + cached_info = 25 + + #: tls_lts [draft-gutmann-tls-lts-11] + tls_lts = 26 + + #: compress_certificate [:rfc:`8879`] + compress_certificate = 27 + + #: record_size_limit [:rfc:`8449`] + record_size_limit = 28 + + #: pwd_protect [:rfc:`8492`] + pwd_protect = 29 + + #: pwd_clear [:rfc:`8492`] + pwd_clear = 30 + + #: password_salt [:rfc:`8492`] + password_salt = 31 + + #: ticket_pinning [:rfc:`8672`] + ticket_pinning = 32 + + #: tls_cert_with_extern_psk [:rfc:`9973`] + tls_cert_with_extern_psk = 33 + + #: delegated_credential [:rfc:`9345`] + delegated_credential = 34 + + #: session_ticket [:rfc:`5077`][:rfc:`8447`] + session_ticket = 35 + + #: TLMSP [ETSI TS 103 523-2] + TLMSP = 36 + + #: TLMSP_proxying [ETSI TS 103 523-2] + TLMSP_proxying = 37 + + #: TLMSP_delegate [ETSI TS 103 523-2] + TLMSP_delegate = 38 + + #: supported_ekt_ciphers [:rfc:`8870`] + supported_ekt_ciphers = 39 + + #: Reserved [:rfc:`9847`][tls-reg-review mailing list] + Reserved_40 = 40 + + #: pre_shared_key [:rfc:`9846`] + pre_shared_key = 41 + + #: early_data [:rfc:`9846`] + early_data = 42 + + #: supported_versions [:rfc:`9846`] + supported_versions = 43 + + #: cookie [:rfc:`9846`] + cookie = 44 + + #: psk_key_exchange_modes [:rfc:`9846`] + psk_key_exchange_modes = 45 + + #: Reserved [:rfc:`9847`][tls-reg-review mailing list] + Reserved_46 = 46 + + #: certificate_authorities [:rfc:`9846`] + certificate_authorities = 47 + + #: oid_filters [:rfc:`9846`] + oid_filters = 48 + + #: post_handshake_auth [:rfc:`9846`] + post_handshake_auth = 49 + + #: signature_algorithms_cert [:rfc:`9846`] + signature_algorithms_cert = 50 + + #: key_share [:rfc:`9846`][RFC Errata 5483] + key_share = 51 + + #: transparency_info [:rfc:`9162`] + transparency_info = 52 + + #: connection_id (deprecated) [:rfc:`9146`][:rfc:`9847`] + connection_id_deprecated = 53 + + #: connection_id [:rfc:`9146`] + connection_id = 54 + + #: external_id_hash [:rfc:`8844`] + external_id_hash = 55 + + #: external_session_id [:rfc:`8844`] + external_session_id = 56 + + #: quic_transport_parameters [:rfc:`9001`] + quic_transport_parameters = 57 + + #: ticket_request [:rfc:`9149`] + ticket_request = 58 + + #: dnssec_chain [:rfc:`9102`][RFC Errata 6860] + dnssec_chain = 59 + + #: sequence_number_encryption_algorithms + #: [draft-pismenny-tls-dtls-plaintext-sequence-number-01] + sequence_number_encryption_algorithms = 60 + + #: rrc [:rfc:`9853`] + rrc = 61 + + #: tls_flags [draft-ietf-tls-tlsflags-14] + tls_flags = 62 + + #: Reserved [:rfc:`8701`] + Reserved_2570 = 2570 + + #: Reserved [:rfc:`8701`] + Reserved_6682 = 6682 + + #: Reserved [:rfc:`8701`] + Reserved_10794 = 10794 + + #: Reserved [:rfc:`8701`] + Reserved_14906 = 14906 + + #: Reserved [:rfc:`8701`] + Reserved_19018 = 19018 + + #: Reserved [:rfc:`8701`] + Reserved_23130 = 23130 + + #: Reserved [:rfc:`8701`] + Reserved_27242 = 27242 + + #: Reserved [:rfc:`8701`] + Reserved_31354 = 31354 + + #: Reserved [:rfc:`8701`] + Reserved_35466 = 35466 + + #: Reserved [:rfc:`8701`] + Reserved_39578 = 39578 + + #: Reserved [:rfc:`8701`] + Reserved_43690 = 43690 + + #: Reserved [:rfc:`8701`] + Reserved_47802 = 47802 + + #: Reserved [:rfc:`8701`] + Reserved_51914 = 51914 + + #: Reserved [:rfc:`8701`] + Reserved_56026 = 56026 + + #: Reserved [:rfc:`8701`] + Reserved_60138 = 60138 + + #: Reserved [:rfc:`8701`] + Reserved_64250 = 64250 + + #: ech_outer_extensions [:rfc:`9849`] -- Only appears in inner CH. + ech_outer_extensions = 64768 + + #: encrypted_client_hello [:rfc:`9849`] + encrypted_client_hello = 65037 + + #: Reserved for Private Use [:rfc:`9846`] + Reserved_for_Private_Use = 65280 + + #: renegotiation_info [:rfc:`5746`] + renegotiation_info = 65281 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Overrides :meth:`~pcapkit.corekit.enum.EnumLookup._validate_value` + with that check and the message it raises, so + :meth:`~pcapkit.corekit.enum.EnumRegistry.register` refuses exactly the + values :meth:`~pcapkit.corekit.enum.EnumLookup.get` refuses, in the + same words (:issue:`1816`). + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. A + :exc:`ValueError`, so an ``except ValueError`` caller is + unaffected. + + """ + if not (isinstance(value, int) and 0 <= value <= 65535): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'ExtensionType': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0 <= value <= 65535): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + if 63 <= value <= 2569: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 2571 <= value <= 6681: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 6683 <= value <= 10793: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 10795 <= value <= 14905: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 14907 <= value <= 19017: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 19019 <= value <= 23129: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 23131 <= value <= 27241: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 27243 <= value <= 31353: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 31355 <= value <= 35465: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 35467 <= value <= 39577: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 39579 <= value <= 43689: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 43691 <= value <= 47801: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 47803 <= value <= 51913: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 51915 <= value <= 56025: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 56027 <= value <= 60137: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 60139 <= value <= 64249: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 64251 <= value <= 64767: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 64769 <= value <= 65036: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 65038 <= value <= 65279: + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') + if 65282 <= value <= 65535: + #: Reserved for Private Use [:rfc:`9846`] + return cls._unregistered_member(value, 'Reserved_for_Private_Use') + return super()._missing_(value) diff --git a/pcapkit/foundation/__init__.py b/pcapkit/foundation/__init__.py index c7fbfa971e..1ba63a7ccf 100644 --- a/pcapkit/foundation/__init__.py +++ b/pcapkit/foundation/__init__.py @@ -49,6 +49,7 @@ 'register_sctp_chunk', 'register_sctp_parameter', 'register_sctp_cause', 'register_dccp_option', 'register_http_frame', + 'register_tls_extension', 'register_pcapng_block', 'register_pcapng_option', 'register_pcapng_secrets', 'register_pcapng_record', diff --git a/pcapkit/foundation/analysis/dtls.py b/pcapkit/foundation/analysis/dtls.py index 47487ec547..c1fc367f5f 100644 --- a/pcapkit/foundation/analysis/dtls.py +++ b/pcapkit/foundation/analysis/dtls.py @@ -35,8 +35,9 @@ def dtls_handshake(datagram: 'DTLS_Datagram') -> 'Optional[Data_DTLSHandshake]': 0 and ``fragment_length`` its ``length`` (:rfc:`6347#section-4.2.3`), by :meth:`DTLS.read_handshake `. So a - ``ClientHello`` or ``ServerHello`` body decodes as an unfragmented one - does, whatever its length, and any other body stays raw octets. + ``ClientHello``, ``ServerHello`` or ``EncryptedExtensions`` body decodes + as an unfragmented one does, whatever its length, and any other body stays + raw octets. Args: datagram: Reassembled message (:term:`reasm.dtls.datagram`). diff --git a/pcapkit/foundation/registry/__init__.py b/pcapkit/foundation/registry/__init__.py index d98fb14be0..b1869c267d 100644 --- a/pcapkit/foundation/registry/__init__.py +++ b/pcapkit/foundation/registry/__init__.py @@ -48,6 +48,7 @@ 'register_dccp_option', 'register_http_frame', + 'register_tls_extension', 'register_pcapng_block', 'register_pcapng_option', 'register_pcapng_secrets', 'register_pcapng_record', diff --git a/pcapkit/foundation/registry/protocols.py b/pcapkit/foundation/registry/protocols.py index 38f3f5fa9f..4499a04b15 100644 --- a/pcapkit/foundation/registry/protocols.py +++ b/pcapkit/foundation/registry/protocols.py @@ -49,10 +49,12 @@ PayloadProtocolIdentifier as Enum_PayloadProtocolIdentifier from pcapkit.const.tcp.mp_tcp_option import MPTCPOption as TCP_MPTCPOption from pcapkit.const.tcp.option import Option as TCP_Option +from pcapkit.const.tls.extension_type import ExtensionType as TLS_ExtensionType from pcapkit.corekit.module import ModuleDescriptor from pcapkit.corekit.sentinels import MISSING, NULL from pcapkit.protocols import __proto__ as protocol_registry from pcapkit.protocols.application.httpv2 import HTTPv2 +from pcapkit.protocols.application.ssl import SSL from pcapkit.protocols.internet.hip import HIP from pcapkit.protocols.internet.hopopt import HOPOPT from pcapkit.protocols.internet.icmpv4 import ICMPv4 @@ -111,6 +113,8 @@ from pcapkit.corekit.sentinels import NullType from pcapkit.protocols.application.httpv2 import FrameConstructor as HTTP_FrameConstructor from pcapkit.protocols.application.httpv2 import FrameParser as HTTP_FrameParser + from pcapkit.protocols.application.ssl import ExtensionConstructor as TLS_ExtensionConstructor + from pcapkit.protocols.application.ssl import ExtensionParser as TLS_ExtensionParser from pcapkit.protocols.internet.hip import ParameterConstructor as HIP_ParameterConstructor from pcapkit.protocols.internet.hip import ParameterParser as HIP_ParameterParser from pcapkit.protocols.internet.hopopt import OptionConstructor as HOPOPT_OptionConstructor @@ -174,6 +178,7 @@ 'register_dccp_option', 'register_http_frame', + 'register_tls_extension', 'register_pcapng_block', 'register_pcapng_option', 'register_pcapng_secrets', 'register_pcapng_record', @@ -2095,6 +2100,41 @@ def register_http_frame(code: 'HTTP_Frame', meth: 'str | tuple[HTTP_FrameParser, logger.debug('registered HTTP/2 frame parser: %s', code.name) +# NOTE: pcapkit.protocols.application.ssl.SSL.__extension__ +def register_tls_extension(code: 'TLS_ExtensionType', + meth: 'str | tuple[TLS_ExtensionParser, TLS_ExtensionConstructor]') -> 'None': + """Register an extension parser. + + The function will register the given extension parser to the + :data:`pcapkit.protocols.application.ssl.SSL.__extension__` registry, which + :class:`~pcapkit.protocols.application.tls.TLS` and + :class:`~pcapkit.protocols.application.dtls.DTLS` share. + + Args: + code: TLS/DTLS extension type as in + :class:`~pcapkit.const.tls.extension_type.ExtensionType`. + meth: Method name, or a ``(parser, constructor)`` pair of callables, to + parse and construct the ``extension_data``. A name names the + ``_read_ext_*`` and ``_make_ext_*`` methods, and has to name an + existing ``_read_ext_*`` one. + + Raises: + RegistryError: If ``code`` is not an + :class:`~pcapkit.const.tls.extension_type.ExtensionType` member, + e.g. a member of another enum, or ``meth`` is a name with no + matching ``_read_ext_*`` method. + + .. versionadded:: 1.6.0 + + """ + _check_code(code, TLS_ExtensionType, SSL) # type: ignore[type-abstract] + if isinstance(meth, str) and not hasattr(SSL, f'_read_ext_{meth}'): + raise RegistryError('method must be a valid TLS extension parser function') + + SSL.register_extension(code, meth) + logger.debug('registered TLS extension parser: %s', code.name) + + ############################################################################### # Miscellaneous Protocol Registries ############################################################################### diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index 148c89df82..2464abc857 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -22,35 +22,64 @@ :class:`~pcapkit.protocols.schema.application.dtls.DTLS`), and :class:`~pcapkit.protocols.application.dtls.DTLS` reads the epoch and sequence number. This base class carries the logic the two share -- content-type -dispatch and handshake-message decoding -- parameterised by a ``dtls`` flag -where the DTLS handshake fragment header has to be read or written. +dispatch, handshake-message decoding and the extension registry -- parameterised +by a ``dtls`` flag where the DTLS handshake fragment header has to be read or +written. .. [*] https://en.wikipedia.org/wiki/Transport_Layer_Security .. versionadded:: 1.6.0 """ -from typing import TYPE_CHECKING, Generic +import collections +from typing import TYPE_CHECKING, Generic, cast from pcapkit.const.tls.content_type import ContentType as Enum_ContentType +from pcapkit.const.tls.extension_type import ExtensionType as Enum_ExtensionType from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType from pcapkit.corekit.protochain import ProtoChain from pcapkit.protocols.application.application import Application from pcapkit.protocols.data.application.tls import Alert as Data_Alert from pcapkit.protocols.data.application.tls import ChangeCipherSpec as Data_ChangeCipherSpec from pcapkit.protocols.data.application.tls import ClientHello as Data_ClientHello +from pcapkit.protocols.data.application.tls import EncryptedExtensions as Data_EncryptedExtensions from pcapkit.protocols.data.application.tls import Extension as Data_Extension from pcapkit.protocols.data.application.tls import Handshake as Data_Handshake +from pcapkit.protocols.data.application.tls import KeyShareClientHello as Data_KeyShareClientHello +from pcapkit.protocols.data.application.tls import KeyShareEntry as Data_KeyShareEntry +from pcapkit.protocols.data.application.tls import \ + KeyShareHelloRetryRequest as Data_KeyShareHelloRetryRequest +from pcapkit.protocols.data.application.tls import KeyShareServerHello as Data_KeyShareServerHello +from pcapkit.protocols.data.application.tls import NamedGroupList as Data_NamedGroupList +from pcapkit.protocols.data.application.tls import ProtocolNameList as Data_ProtocolNameList from pcapkit.protocols.data.application.tls import ServerHello as Data_ServerHello +from pcapkit.protocols.data.application.tls import ServerName as Data_ServerName +from pcapkit.protocols.data.application.tls import ServerNameList as Data_ServerNameList +from pcapkit.protocols.data.application.tls import SignatureSchemeList as Data_SignatureSchemeList +from pcapkit.protocols.data.application.tls import \ + SupportedVersionsClientHello as Data_SupportedVersionsClientHello +from pcapkit.protocols.data.application.tls import \ + SupportedVersionsServerHello as Data_SupportedVersionsServerHello from pcapkit.protocols.misc.null import NoPayload from pcapkit.protocols.protocol import _PT, _ST -from pcapkit.utilities.exceptions import ProtocolError +from pcapkit.utilities.exceptions import CaughtByCaller, ProtocolError +from pcapkit.utilities.warnings import RegistryWarning, warn if TYPE_CHECKING: - from typing import Any, Optional + from typing import Any, Callable, DefaultDict, Iterable, Optional, Union + from mypy_extensions import DefaultArg, KwArg, NamedArg from typing_extensions import Literal + from pcapkit.protocols.data.application.tls import ExtensionData as Data_ExtensionData + + #: Parser of an ``extension_data`` field registered as a callable. + ExtensionParser = Callable[[Enum_ExtensionType, bytes, NamedArg(Enum_HandshakeType, 'msg_type')], + Union[Data_ExtensionData, bytes]] + #: Constructor of an ``extension_data`` field registered as a callable. + ExtensionConstructor = Callable[[Enum_ExtensionType, DefaultArg(Optional[Data_ExtensionData]), + NamedArg(Enum_HandshakeType, 'msg_type'), KwArg(Any)], bytes] + __all__ = ['SSL'] @@ -80,15 +109,19 @@ Enum_ContentType.return_routability_check, }) -#: The ``supported_versions`` extension type (:rfc:`9846#section-4.3.1`), where -#: TLS 1.3 and DTLS 1.3 negotiate the version their record header does not carry. -_EXT_SUPPORTED_VERSIONS = 43 - #: The ``supported_versions`` value for TLS 1.3 (:rfc:`9846#section-4.3.1`). TLS13_VERSION = 0x0304 #: The ``supported_versions`` value for DTLS 1.3 (:rfc:`9147#section-5.3`). DTLS13_VERSION = 0xFEFC +#: The ``random`` that makes a ``ServerHello`` a ``HelloRetryRequest``: the +#: SHA-256 of ``"HelloRetryRequest"`` (:rfc:`9846#section-4.2.3`). +_HRR_RANDOM = bytes.fromhex('cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c') + +#: The ``host_name`` ``NameType`` of ``server_name``, the only one defined +#: (:rfc:`6066#section-3`). +_HOST_NAME = 0 + def _negotiated_versions(message: 'Any') -> 'tuple[int, ...]': """The versions a ``supported_versions`` extension on ``message`` lists. @@ -104,24 +137,23 @@ def _negotiated_versions(message: 'Any') -> 'tuple[int, ...]': Every version the extension lists, in order: zero or more for a ``ClientHello`` (a list), exactly one for a ``ServerHello`` (a bare selection), or nothing at all when ``message`` carries no - ``supported_versions`` extension -- including when it carries no - ``extensions`` at all. + ``supported_versions`` extension, or one left undecoded. A + ``ClientHello`` list of odd length is left undecoded, so it yields + nothing, not even the whole pairs before its stray octet. """ extensions = getattr(message, 'extensions', None) if not extensions: return () for extension in extensions: - if int(extension.type) != _EXT_SUPPORTED_VERSIONS: + if extension.type != Enum_ExtensionType.supported_versions: continue data = extension.data - # A ``ClientHello`` lists several versions behind a 1-octet length - # prefix; a ``ServerHello`` selects exactly one, with no length - # prefix of its own (:rfc:`9846#section-4.3.1`). - versions = data[1:] if isinstance(message, Data_ClientHello) else data - return tuple( - int.from_bytes(versions[i:i + 2], 'big') for i in range(0, len(versions) - 1, 2) - ) + if isinstance(data, Data_SupportedVersionsClientHello): + return tuple(data.versions) + if isinstance(data, Data_SupportedVersionsServerHello): + return (data.selected_version,) + return () return () @@ -151,6 +183,41 @@ def _take(data: 'bytes', pos: 'int', size: 'int', *, what: 'str', alias: 'str') return chunk +def _read_key_share_entry(proto: 'SSL', data: 'bytes', pos: 'int') -> 'tuple[Data_KeyShareEntry, int]': + """Read one ``KeyShareEntry`` (:rfc:`9846#section-4.3.8`) at ``pos``. + + Args: + proto: Protocol reading the entry, whose alias the error message names. + data: Buffer being read. + pos: Offset of the entry. + + Returns: + The entry, and the offset just past it. + + Raises: + ProtocolError: If the entry runs past the end of ``data``. + + """ + group = int.from_bytes(_take(data, pos, 2, what='key_share.group', alias=proto.alias), 'big') + key_exchange, end = proto._read_vector(data, pos + 2, size=2, # pylint: disable=protected-access + what='key_share.key_exchange') + return Data_KeyShareEntry(group=group, key_exchange=key_exchange), end + + +def _make_key_share_entry(proto: 'SSL', entry: 'Data_KeyShareEntry') -> 'bytes': + """Write one ``KeyShareEntry`` -- the inverse of :func:`_read_key_share_entry`.""" + key_exchange = proto._make_vector(entry.key_exchange, size=2) # pylint: disable=protected-access + return int(entry.group).to_bytes(2, 'big') + key_exchange + + +def _hello_type(random: 'bytes') -> 'Enum_HandshakeType': + """The message a ``ServerHello`` with ``random`` is: a ``HelloRetryRequest`` + for :data:`_HRR_RANDOM`, a ``ServerHello`` otherwise.""" + if random == _HRR_RANDOM: + return cast('Enum_HandshakeType', Enum_HandshakeType.hello_retry_request) + return cast('Enum_HandshakeType', Enum_HandshakeType.server_hello) + + def _tls_handshake_tiles(fragment: 'bytes') -> 'bool': """Whether ``fragment`` is tiled exactly by handshake-message headers. @@ -188,8 +255,58 @@ class SSL(Application[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract - Datagram TLS, DTLS 1.0 through DTLS 1.3 (:class:`~pcapkit.protocols.application.dtls.DTLS`) [:rfc:`4347`, :rfc:`6347`, :rfc:`9147`] + The ``extension_data`` of these extensions decodes, in ``ClientHello``, + ``ServerHello``, ``HelloRetryRequest`` and ``EncryptedExtensions``: + + .. list-table:: + :header-rows: 1 + + * - Extension Type + - Extension Parser + - Extension Constructor + * - :attr:`~pcapkit.const.tls.extension_type.ExtensionType.server_name` + - :meth:`~pcapkit.protocols.application.ssl.SSL._read_ext_server_name` + - :meth:`~pcapkit.protocols.application.ssl.SSL._make_ext_server_name` + * - :attr:`~pcapkit.const.tls.extension_type.ExtensionType.supported_groups` + - :meth:`~pcapkit.protocols.application.ssl.SSL._read_ext_supported_groups` + - :meth:`~pcapkit.protocols.application.ssl.SSL._make_ext_supported_groups` + * - :attr:`~pcapkit.const.tls.extension_type.ExtensionType.signature_algorithms` + - :meth:`~pcapkit.protocols.application.ssl.SSL._read_ext_signature_algorithms` + - :meth:`~pcapkit.protocols.application.ssl.SSL._make_ext_signature_algorithms` + * - :attr:`~pcapkit.const.tls.extension_type.ExtensionType.application_layer_protocol_negotiation` + - :meth:`~pcapkit.protocols.application.ssl.SSL._read_ext_alpn` + - :meth:`~pcapkit.protocols.application.ssl.SSL._make_ext_alpn` + * - :attr:`~pcapkit.const.tls.extension_type.ExtensionType.supported_versions` + - :meth:`~pcapkit.protocols.application.ssl.SSL._read_ext_supported_versions` + - :meth:`~pcapkit.protocols.application.ssl.SSL._make_ext_supported_versions` + * - :attr:`~pcapkit.const.tls.extension_type.ExtensionType.key_share` + - :meth:`~pcapkit.protocols.application.ssl.SSL._read_ext_key_share` + - :meth:`~pcapkit.protocols.application.ssl.SSL._make_ext_key_share` + """ + ########################################################################## + # Defaults. + ########################################################################## + + #: Extension type to method mapping, see :meth:`_read_tls_extensions` and + #: :meth:`_make_tls_extensions`. A :obj:`str` value names the methods + #: ``_read_ext_${name}`` and ``_make_ext_${name}``, falling back to + #: :meth:`_read_ext_unknown` and :meth:`_make_ext_unknown` when the class + #: has no such method, as an unregistered type does; any other value is a + #: ``(parser, constructor)`` pair of callables. + __extension__ = collections.defaultdict( + lambda: 'unknown', + { + Enum_ExtensionType.server_name: 'server_name', # [RFC 6066] + Enum_ExtensionType.supported_groups: 'supported_groups', # [RFC 9846] + Enum_ExtensionType.signature_algorithms: 'signature_algorithms', # [RFC 9846] + Enum_ExtensionType.application_layer_protocol_negotiation: 'alpn', # [RFC 7301] + Enum_ExtensionType.supported_versions: 'supported_versions', # [RFC 9846] + Enum_ExtensionType.key_share: 'key_share', # [RFC 9846] + }, + ) # type: DefaultDict[Enum_ExtensionType | int, str | tuple[ExtensionParser, ExtensionConstructor]] + ########################################################################## # Methods. ########################################################################## @@ -206,10 +323,486 @@ def id(cls) -> 'tuple[Literal["SSLv2"], Literal["TLS"], Literal["DTLS"]]': """ return ('SSLv2', 'TLS', 'DTLS') + @classmethod + def register_extension(cls, code: 'Enum_ExtensionType', + meth: 'str | tuple[ExtensionParser, ExtensionConstructor]') -> 'None': + """Register an extension parser. + + Args: + code: TLS/DTLS extension type. + meth: Name of the ``_read_ext_${name}`` and ``_make_ext_${name}`` + methods, or a ``(parser, constructor)`` pair of callables; see + :attr:`__extension__`. + + Warns: + RegistryWarning: If a different entry is already registered for + ``code``; it is overwritten. Handing back the entry already + stored, or one equal to it, is a silent no-op. The entry + ``code`` shipped with is accepted like any other, so an + override can be undone (:issue:`1362`, :issue:`1363`). + + """ + incumbent = cls.__extension__.get(code) + if incumbent is not None and incumbent == meth: + return + if incumbent is not None: + warn(f'TLS: [Extension {code}] extension already registered, overwriting', RegistryWarning) + cls.__extension__[code] = meth + ########################################################################## # Utilities. ########################################################################## + def _read_ext_unknown(self, ext_type: 'Enum_ExtensionType', data: 'bytes', *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType') -> 'bytes': # pylint: disable=unused-argument + """Read an extension with no decoder registered: its raw octets. + + Args: + ext_type: Extension type. + data: ``extension_data`` octets. + msg_type: Handshake message carrying the extension. + + Returns: + ``data`` unchanged. + + """ + return data + + def _read_ext_server_name(self, ext_type: 'Enum_ExtensionType', data: 'bytes', *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType') -> 'Data_ServerNameList | bytes': # pylint: disable=unused-argument + """Read a ``server_name`` extension. + + Structure of ``ServerNameList`` [:rfc:`6066#section-3`]: + + .. code-block:: text + + struct { + NameType name_type; + select (name_type) { + case host_name: HostName; + } name; + } ServerName; + + enum { + host_name(0), (255) + } NameType; + + opaque HostName<1..2^16-1>; + + struct { + ServerName server_name_list<1..2^16-1> + } ServerNameList; + + Args: + ext_type: Extension type. + data: ``extension_data`` octets. + msg_type: Handshake message carrying the extension. + + Returns: + The decoded list; or ``data`` when empty, as a ``ServerHello``'s + and an ``EncryptedExtensions``' are. + + Raises: + ProtocolError: If ``data`` is malformed, or names a type other + than ``host_name``, whose ``name`` has no defined structure. + + """ + if not data: + return data + + alias = self.alias + body = self._read_whole_vector(data, size=2, what='server_name.server_name_list') + names = [] # type: list[Data_ServerName] + pos = 0 + while pos < len(body): + name_type = _take(body, pos, 1, what='server_name.name_type', alias=alias)[0] + if name_type != _HOST_NAME: + raise ProtocolError(f'{alias}: invalid format: server_name.name_type {name_type} ' + 'is not host_name') + name, pos = self._read_vector(body, pos + 1, size=2, what='server_name.name') + names.append(Data_ServerName(name_type=name_type, name=name)) + return Data_ServerNameList(server_name_list=tuple(names)) + + def _read_ext_supported_groups(self, ext_type: 'Enum_ExtensionType', data: 'bytes', *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType') -> 'Data_NamedGroupList': # pylint: disable=unused-argument + """Read a ``supported_groups`` extension. + + Structure of ``NamedGroupList`` [:rfc:`9846#section-4.3.7`]: + + .. code-block:: text + + struct { + NamedGroup named_group_list<2..2^16-1>; + } NamedGroupList; + + Args: + ext_type: Extension type. + data: ``extension_data`` octets. + msg_type: Handshake message carrying the extension. + + Returns: + The decoded list. + + Raises: + ProtocolError: If ``data`` is malformed. + + """ + what = 'supported_groups.named_group_list' + body = self._read_whole_vector(data, size=2, what=what) + return Data_NamedGroupList(named_group_list=self._read_uints(body, size=2, what=what)) + + def _read_ext_signature_algorithms(self, ext_type: 'Enum_ExtensionType', data: 'bytes', *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType') -> 'Data_SignatureSchemeList': # pylint: disable=unused-argument + """Read a ``signature_algorithms`` extension. + + Structure of ``SignatureSchemeList`` [:rfc:`9846#section-4.3.3`]: + + .. code-block:: text + + struct { + SignatureScheme supported_signature_algorithms<2..2^16-2>; + } SignatureSchemeList; + + Args: + ext_type: Extension type. + data: ``extension_data`` octets. + msg_type: Handshake message carrying the extension. + + Returns: + The decoded list. + + Raises: + ProtocolError: If ``data`` is malformed. + + """ + what = 'signature_algorithms.supported_signature_algorithms' + body = self._read_whole_vector(data, size=2, what=what) + return Data_SignatureSchemeList(supported_signature_algorithms=self._read_uints(body, size=2, what=what)) + + def _read_ext_alpn(self, ext_type: 'Enum_ExtensionType', data: 'bytes', *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType') -> 'Data_ProtocolNameList': # pylint: disable=unused-argument + """Read an ``application_layer_protocol_negotiation`` extension. + + Structure of ``ProtocolNameList`` [:rfc:`7301#section-3.1`]: + + .. code-block:: text + + opaque ProtocolName<1..2^8-1>; + + struct { + ProtocolName protocol_name_list<2..2^16-1> + } ProtocolNameList; + + Args: + ext_type: Extension type. + data: ``extension_data`` octets. + msg_type: Handshake message carrying the extension. + + Returns: + The decoded list. + + Raises: + ProtocolError: If ``data`` is malformed. + + """ + body = self._read_whole_vector(data, size=2, what='alpn.protocol_name_list') + names = [] # type: list[bytes] + pos = 0 + while pos < len(body): + name, pos = self._read_vector(body, pos, size=1, what='alpn.protocol_name') + names.append(name) + return Data_ProtocolNameList(protocol_name_list=tuple(names)) + + def _read_ext_supported_versions(self, ext_type: 'Enum_ExtensionType', data: 'bytes', *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType') -> 'Data_SupportedVersionsClientHello | Data_SupportedVersionsServerHello | bytes': # pylint: disable=line-too-long + """Read a ``supported_versions`` extension. + + Structure of ``SupportedVersions`` [:rfc:`9846#section-4.3.1`]: + + .. code-block:: text + + struct { + select (Handshake.msg_type) { + case client_hello: + ProtocolVersion versions<2..254>; + + case server_hello: /* and HelloRetryRequest */ + ProtocolVersion selected_version; + }; + } SupportedVersions; + + Args: + ext_type: Extension type. + data: ``extension_data`` octets. + msg_type: Handshake message carrying the extension. + + Returns: + The decoded versions for ``msg_type``; or ``data`` for a message + the structure does not cover. + + Raises: + ProtocolError: If ``data`` is malformed. + + """ + alias = self.alias + if msg_type == Enum_HandshakeType.client_hello: + what = 'supported_versions.versions' + body = self._read_whole_vector(data, size=1, what=what) + return Data_SupportedVersionsClientHello(versions=self._read_uints(body, size=2, what=what)) + if msg_type in (Enum_HandshakeType.server_hello, Enum_HandshakeType.hello_retry_request): + if len(data) != 2: + raise ProtocolError(f'{alias}: invalid format: supported_versions.selected_version ' + 'must be exactly two octets') + return Data_SupportedVersionsServerHello(selected_version=int.from_bytes(data, 'big')) + return data + + def _read_ext_key_share(self, ext_type: 'Enum_ExtensionType', data: 'bytes', *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType') -> 'Data_KeyShareClientHello | Data_KeyShareHelloRetryRequest | Data_KeyShareServerHello | bytes': # pylint: disable=line-too-long + """Read a ``key_share`` extension. + + Structure of ``key_share`` [:rfc:`9846#section-4.3.8`]: + + .. code-block:: text + + struct { + NamedGroup group; + opaque key_exchange<1..2^16-1>; + } KeyShareEntry; + + struct { + KeyShareEntry client_shares<0..2^16-1>; + } KeyShareClientHello; + + struct { + NamedGroup selected_group; + } KeyShareHelloRetryRequest; + + struct { + KeyShareEntry server_share; + } KeyShareServerHello; + + Args: + ext_type: Extension type. + data: ``extension_data`` octets. + msg_type: Handshake message carrying the extension. + + Returns: + The decoded structure for ``msg_type``; or ``data`` for a message + the structure does not cover. + + Raises: + ProtocolError: If ``data`` is malformed. + + """ + alias = self.alias + if msg_type == Enum_HandshakeType.client_hello: + body = self._read_whole_vector(data, size=2, what='key_share.client_shares') + shares = [] # type: list[Data_KeyShareEntry] + pos = 0 + while pos < len(body): + entry, pos = _read_key_share_entry(self, body, pos) + shares.append(entry) + return Data_KeyShareClientHello(client_shares=tuple(shares)) + if msg_type == Enum_HandshakeType.hello_retry_request: + if len(data) != 2: + raise ProtocolError(f'{alias}: invalid format: key_share.selected_group ' + 'must be exactly two octets') + return Data_KeyShareHelloRetryRequest(selected_group=int.from_bytes(data, 'big')) + if msg_type == Enum_HandshakeType.server_hello: + entry, end = _read_key_share_entry(self, data, 0) + if end != len(data): + raise ProtocolError(f'{alias}: invalid format: key_share.server_share carries excess octets') + return Data_KeyShareServerHello(server_share=entry) + return data + + def _make_ext_unknown(self, ext_type: 'Enum_ExtensionType', data: 'Optional[Data_ExtensionData]' = None, *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType', **kwargs: 'Any') -> 'bytes': # pylint: disable=unused-argument + """Make an extension with no constructor registered. + + Args: + ext_type: Extension type. + data: Decoded ``extension_data``, which this type has no + structure to write. + msg_type: Handshake message carrying the extension. + **kwargs: Arbitrary keyword arguments. + + Returns: + Empty ``extension_data`` when ``data`` is :data:`None`. + + Raises: + ProtocolError: If ``data`` is given. + + """ + if data is not None: + raise ProtocolError(f'{self.alias}: [Extension {ext_type}] no constructor for ' + f'{type(data).__name__}; pass the raw octets instead') + return b'' + + def _make_ext_server_name(self, ext_type: 'Enum_ExtensionType', data: 'Optional[Data_ServerNameList]' = None, *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType', # pylint: disable=unused-argument + server_name_list: 'Iterable[Data_ServerName]' = (), **kwargs: 'Any') -> 'bytes': + """Make a ``server_name`` extension. + + Args: + ext_type: Extension type. + data: Decoded ``extension_data``. + msg_type: Handshake message carrying the extension. + server_name_list: Server names, if ``data`` is not given. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed ``extension_data``. + + """ + names = data.server_name_list if data is not None else server_name_list + return self._make_vector(b''.join(bytes([int(name.name_type)]) + self._make_vector(name.name, size=2) + for name in names), size=2) + + def _make_ext_supported_groups(self, ext_type: 'Enum_ExtensionType', data: 'Optional[Data_NamedGroupList]' = None, *, # pylint: disable=unused-argument,line-too-long + msg_type: 'Enum_HandshakeType', # pylint: disable=unused-argument + named_group_list: 'Iterable[int]' = (), **kwargs: 'Any') -> 'bytes': + """Make a ``supported_groups`` extension. + + Args: + ext_type: Extension type. + data: Decoded ``extension_data``. + msg_type: Handshake message carrying the extension. + named_group_list: ``NamedGroup`` values, if ``data`` is not given. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed ``extension_data``. + + """ + groups = data.named_group_list if data is not None else named_group_list + return self._make_vector(self._make_uints(groups, size=2), size=2) + + def _make_ext_signature_algorithms(self, ext_type: 'Enum_ExtensionType', data: 'Optional[Data_SignatureSchemeList]' = None, *, # pylint: disable=unused-argument,line-too-long + msg_type: 'Enum_HandshakeType', # pylint: disable=unused-argument + supported_signature_algorithms: 'Iterable[int]' = (), + **kwargs: 'Any') -> 'bytes': + """Make a ``signature_algorithms`` extension. + + Args: + ext_type: Extension type. + data: Decoded ``extension_data``. + msg_type: Handshake message carrying the extension. + supported_signature_algorithms: ``SignatureScheme`` values, if + ``data`` is not given. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed ``extension_data``. + + """ + schemes = data.supported_signature_algorithms if data is not None else supported_signature_algorithms + return self._make_vector(self._make_uints(schemes, size=2), size=2) + + def _make_ext_alpn(self, ext_type: 'Enum_ExtensionType', data: 'Optional[Data_ProtocolNameList]' = None, *, # pylint: disable=unused-argument + msg_type: 'Enum_HandshakeType', # pylint: disable=unused-argument + protocol_name_list: 'Iterable[bytes]' = (), **kwargs: 'Any') -> 'bytes': + """Make an ``application_layer_protocol_negotiation`` extension. + + Args: + ext_type: Extension type. + data: Decoded ``extension_data``. + msg_type: Handshake message carrying the extension. + protocol_name_list: ``ProtocolName`` octet strings, if ``data`` is + not given. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed ``extension_data``. + + """ + names = data.protocol_name_list if data is not None else protocol_name_list + return self._make_vector(b''.join(self._make_vector(name, size=1) for name in names), size=2) + + def _make_ext_supported_versions(self, ext_type: 'Enum_ExtensionType', # pylint: disable=unused-argument + data: 'Optional[Data_SupportedVersionsClientHello | Data_SupportedVersionsServerHello]' = None, *, # pylint: disable=line-too-long + msg_type: 'Enum_HandshakeType', versions: 'Iterable[int]' = (), + selected_version: 'Optional[int]' = None, **kwargs: 'Any') -> 'bytes': + """Make a ``supported_versions`` extension. + + ``data`` decides the structure when given, ``msg_type`` otherwise. + + Args: + ext_type: Extension type. + data: Decoded ``extension_data``. + msg_type: Handshake message carrying the extension. + versions: ``ProtocolVersion`` values for a ``ClientHello``, if + ``data`` is not given. + selected_version: ``ProtocolVersion`` selected by a + ``ServerHello`` or ``HelloRetryRequest``, if ``data`` is not + given. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed ``extension_data``. + + Raises: + ProtocolError: If neither ``data`` nor ``msg_type`` names a + structure, or ``selected_version`` is missing. + + """ + if data is None: + if msg_type == Enum_HandshakeType.client_hello: + data = Data_SupportedVersionsClientHello(versions=tuple(versions)) + elif selected_version is not None and msg_type in (Enum_HandshakeType.server_hello, + Enum_HandshakeType.hello_retry_request): + data = Data_SupportedVersionsServerHello(selected_version=selected_version) + if isinstance(data, Data_SupportedVersionsClientHello): + return self._make_vector(self._make_uints(data.versions, size=2), size=1) + if isinstance(data, Data_SupportedVersionsServerHello): + return int(data.selected_version).to_bytes(2, 'big') + raise ProtocolError(f'{self.alias}: [Extension {ext_type}] no supported_versions structure ' + f'for {msg_type!r}') + + def _make_ext_key_share(self, ext_type: 'Enum_ExtensionType', # pylint: disable=unused-argument + data: 'Optional[Data_KeyShareClientHello | Data_KeyShareHelloRetryRequest | Data_KeyShareServerHello]' = None, *, # pylint: disable=line-too-long + msg_type: 'Enum_HandshakeType', client_shares: 'Iterable[Data_KeyShareEntry]' = (), + selected_group: 'Optional[int]' = None, + server_share: 'Optional[Data_KeyShareEntry]' = None, **kwargs: 'Any') -> 'bytes': + """Make a ``key_share`` extension. + + ``data`` decides the structure when given, ``msg_type`` otherwise. + + Args: + ext_type: Extension type. + data: Decoded ``extension_data``. + msg_type: Handshake message carrying the extension. + client_shares: Key shares for a ``ClientHello``, if ``data`` is + not given. + selected_group: ``NamedGroup`` for a ``HelloRetryRequest``, if + ``data`` is not given. + server_share: Key share for a ``ServerHello``, if ``data`` is + not given. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed ``extension_data``. + + Raises: + ProtocolError: If neither ``data`` nor ``msg_type`` names a + structure, or the argument it needs is missing. + + """ + if data is None: + if msg_type == Enum_HandshakeType.client_hello: + data = Data_KeyShareClientHello(client_shares=tuple(client_shares)) + elif msg_type == Enum_HandshakeType.hello_retry_request and selected_group is not None: + data = Data_KeyShareHelloRetryRequest(selected_group=selected_group) + elif msg_type == Enum_HandshakeType.server_hello and server_share is not None: + data = Data_KeyShareServerHello(server_share=server_share) + if isinstance(data, Data_KeyShareClientHello): + return self._make_vector(b''.join(_make_key_share_entry(self, entry) for entry in data.client_shares), + size=2) + if isinstance(data, Data_KeyShareHelloRetryRequest): + return int(data.selected_group).to_bytes(2, 'big') + if isinstance(data, Data_KeyShareServerHello): + return _make_key_share_entry(self, data.server_share) + raise ProtocolError(f'{self.alias}: [Extension {ext_type}] no key_share structure for {msg_type!r}') + def _adopt_dispatched(self, obj: 'Any') -> 'Any': """Adopt the parse of a sibling tier this dispatcher delegated to. @@ -424,9 +1017,10 @@ def _read_tls_handshake_messages(self, fragment: 'bytes', *, ``fragment``. Notes: - Only an unfragmented ``ClientHello``/``ServerHello`` body is - decoded; other bodies stay undecoded (:issue:`2437`). A fragmented - DTLS message is decoded once reassembled, by + Only an unfragmented ``ClientHello``, ``ServerHello`` or + ``EncryptedExtensions`` body is decoded; other bodies stay + undecoded (:issue:`2437`). A fragmented DTLS message is decoded + once reassembled, by :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` (:issue:`2440`). """ @@ -464,6 +1058,8 @@ def _read_tls_handshake_messages(self, fragment: 'bytes', *, body = self._read_tls_client_hello(body_bytes, dtls=dtls) # type: Any elif not is_fragmented and msg_type == Enum_HandshakeType.server_hello: body = self._read_tls_server_hello(body_bytes) + elif not is_fragmented and msg_type == Enum_HandshakeType.encrypted_extensions: + body = self._read_tls_encrypted_extensions(body_bytes) else: body = body_bytes @@ -511,6 +1107,8 @@ def _make_tls_handshake_messages(self, messages: 'tuple[Any, ...]', *, body_bytes = self._make_tls_client_hello(body, dtls=dtls) elif isinstance(body, Data_ServerHello): body_bytes = self._make_tls_server_hello(body) + elif isinstance(body, Data_EncryptedExtensions): + body_bytes = self._make_tls_encrypted_extensions(body) else: body_bytes = bytes(body) @@ -634,7 +1232,8 @@ def _read_tls_client_hello(self, body: 'bytes', *, dtls: 'bool') -> 'Data_Client pos += 2 extensions_raw = _take(body, pos, ext_total_len, what='client_hello.extensions', alias=alias) pos += ext_total_len - extensions = self._read_tls_extensions(extensions_raw) + extensions = self._read_tls_extensions( + extensions_raw, msg_type=cast('Enum_HandshakeType', Enum_HandshakeType.client_hello)) if pos != len(body): raise ProtocolError(f'{alias}: invalid format: client_hello carries excess octets') @@ -646,7 +1245,8 @@ def _read_tls_client_hello(self, body: 'bytes', *, dtls: 'bool') -> 'Data_Client ) def _read_tls_server_hello(self, body: 'bytes') -> 'Data_ServerHello': - """Decode a ``ServerHello`` handshake message body. + """Decode a ``ServerHello`` handshake message body, or a + ``HelloRetryRequest``'s, whose ``random`` is :data:`_HRR_RANDOM`. Structure of ``ServerHello`` [:rfc:`9846#section-4.2.3`]: @@ -693,7 +1293,7 @@ def _read_tls_server_hello(self, body: 'bytes') -> 'Data_ServerHello': pos += 2 extensions_raw = _take(body, pos, ext_total_len, what='server_hello.extensions', alias=alias) pos += ext_total_len - extensions = self._read_tls_extensions(extensions_raw) + extensions = self._read_tls_extensions(extensions_raw, msg_type=_hello_type(random)) if pos != len(body): raise ProtocolError(f'{alias}: invalid format: server_hello carries excess octets') @@ -703,8 +1303,33 @@ def _read_tls_server_hello(self, body: 'bytes') -> 'Data_ServerHello': compression_method=compression_method, extensions=extensions, ) - def _read_tls_extensions(self, data: 'bytes') -> 'tuple[Data_Extension, ...]': - """Decode an extension list -- TYPE and LENGTH framing only. + def _read_tls_encrypted_extensions(self, body: 'bytes') -> 'Data_EncryptedExtensions': + """Decode an ``EncryptedExtensions`` handshake message body. + + Structure of ``EncryptedExtensions`` [:rfc:`9846#section-4.4.1`]: + + .. code-block:: text + + struct { + Extension extensions<0..2^16-1>; + } EncryptedExtensions; + + Args: + body: Message body. + + Returns: + Decoded ``EncryptedExtensions``. + + Raises: + ProtocolError: If ``body`` is malformed. + + """ + extensions_raw = self._read_whole_vector(body, size=2, what='encrypted_extensions.extensions') + msg_type = cast('Enum_HandshakeType', Enum_HandshakeType.encrypted_extensions) + return Data_EncryptedExtensions(extensions=self._read_tls_extensions(extensions_raw, msg_type=msg_type)) + + def _read_tls_extensions(self, data: 'bytes', *, msg_type: 'Enum_HandshakeType') -> 'tuple[Data_Extension, ...]': + """Decode an extension list. Structure of one extension [:rfc:`9846#section-4.3`]: @@ -715,11 +1340,19 @@ def _read_tls_extensions(self, data: 'bytes') -> 'tuple[Data_Extension, ...]': opaque extension_data<0..2^16-1>; } Extension; - An extension's body is kept undecoded (:issue:`2437`). + Each ``extension_data`` is read by the parser :attr:`__extension__` + registers for its type. The framing delimits it, so one its parser + rejects with a :exc:`~pcapkit.utilities.exceptions.ProtocolError` is + kept as its raw octets rather than failing the message. The parser runs + in a :class:`~pcapkit.utilities.exceptions.CaughtByCaller` block, so + that error is quiet: nothing is logged and no exception hook installed. Args: data: The extension list, i.e. one or more concatenated extensions, with no further length prefix of its own. + msg_type: Handshake message carrying the list, which the structure + of some extensions depends on; ``hello_retry_request`` for a + ``ServerHello`` that is a ``HelloRetryRequest``. Returns: The decoded extensions, in order. @@ -734,12 +1367,25 @@ def _read_tls_extensions(self, data: 'bytes') -> 'tuple[Data_Extension, ...]': total = len(data) while pos < total: header = _take(data, pos, 4, what='extension header', alias=self.alias) - ext_type = int.from_bytes(header[0:2], 'big') + ext_type = Enum_ExtensionType.get(int.from_bytes(header[0:2], 'big')) ext_len = int.from_bytes(header[2:4], 'big') pos += 4 ext_data = _take(data, pos, ext_len, what='extension data', alias=self.alias) pos += ext_len - extensions.append(Data_Extension(type=ext_type, data=ext_data)) + + name = self._lookup_registry(self.__extension__, ext_type) + if isinstance(name, str): + meth = cast('ExtensionParser', getattr(self, f'_read_ext_{name}', self._read_ext_unknown)) + else: + meth = name[0] + try: + # NOTE: a rejected body is kept raw below, so its ProtocolError is + # quiet, whoever raised it, as EnumLookup.get's misses are (#1845). + with CaughtByCaller(ProtocolError): + ext_body = meth(ext_type, ext_data, msg_type=msg_type) # type: Data_ExtensionData | bytes + except ProtocolError: + ext_body = ext_data + extensions.append(Data_Extension(type=ext_type, data=ext_body)) return tuple(extensions) def _make_tls_content(self, content_type: 'Enum_ContentType', content: 'Any', *, @@ -795,7 +1441,8 @@ def _make_tls_client_hello(self, data: 'Data_ClientHello', *, dtls: 'bool') -> ' out += bytes([len(compression_methods)]) + compression_methods if data.extensions is not None: - extensions_raw = self._make_tls_extensions(data.extensions) + extensions_raw = self._make_tls_extensions( + data.extensions, msg_type=cast('Enum_HandshakeType', Enum_HandshakeType.client_hello)) out += len(extensions_raw).to_bytes(2, 'big') + extensions_raw return bytes(out) @@ -818,15 +1465,36 @@ def _make_tls_server_hello(self, data: 'Data_ServerHello') -> 'bytes': out += bytes([int(data.compression_method)]) if data.extensions is not None: - extensions_raw = self._make_tls_extensions(data.extensions) + extensions_raw = self._make_tls_extensions(data.extensions, msg_type=_hello_type(data.random)) out += len(extensions_raw).to_bytes(2, 'big') + extensions_raw return bytes(out) - def _make_tls_extensions(self, extensions: 'tuple[Data_Extension, ...]') -> 'bytes': + def _make_tls_encrypted_extensions(self, data: 'Data_EncryptedExtensions') -> 'bytes': + """Encode an ``EncryptedExtensions`` handshake message body -- the + inverse of :meth:`_read_tls_encrypted_extensions`. + + Args: + data: Decoded ``EncryptedExtensions``. + + Returns: + Encoded message body. + + """ + msg_type = cast('Enum_HandshakeType', Enum_HandshakeType.encrypted_extensions) + return self._make_vector(self._make_tls_extensions(data.extensions, msg_type=msg_type), size=2) + + def _make_tls_extensions(self, extensions: 'Iterable[Data_Extension | tuple[Enum_ExtensionType | int, dict[str, Any]]]', # pylint: disable=line-too-long + *, msg_type: 'Enum_HandshakeType') -> 'bytes': """Encode an extension list -- the inverse of :meth:`_read_tls_extensions`. + Raw ``extension_data`` octets are written as they are; anything else + goes to the constructor :attr:`__extension__` registers for the type. + Args: - extensions: Decoded extensions. + extensions: Decoded extensions, or ``(type, kwargs)`` pairs for the + registered constructor to build from. + msg_type: Handshake message carrying the list, as for + :meth:`_read_tls_extensions`. Returns: Encoded extension list, with no further length prefix of its own. @@ -834,7 +1502,22 @@ def _make_tls_extensions(self, extensions: 'tuple[Data_Extension, ...]') -> 'byt """ out = bytearray() for extension in extensions: - out += int(extension.type).to_bytes(2, 'big') - out += len(extension.data).to_bytes(2, 'big') - out += extension.data + if isinstance(extension, tuple): + code, kwargs = extension + ext_type = Enum_ExtensionType.get(code) + data = None # type: Optional[Data_ExtensionData | bytes] + else: + ext_type, data, kwargs = extension.type, extension.data, {} + + if isinstance(data, bytes): + ext_data = data + else: + name = self._lookup_registry(self.__extension__, ext_type) + if isinstance(name, str): + meth = cast('ExtensionConstructor', getattr(self, f'_make_ext_{name}', self._make_ext_unknown)) + else: + meth = name[1] + ext_data = meth(ext_type, data, msg_type=msg_type, **kwargs) + + out += int(ext_type).to_bytes(2, 'big') + self._make_vector(ext_data, size=2) return bytes(out) diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index 4e24cca544..cbc71f4e65 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -25,7 +25,10 @@ decides which are): * ``ClientHello``/``ServerHello`` down to their cipher suites, compression - methods and extensions (type and length only). + methods and extensions, and ``EncryptedExtensions`` down to its + extensions; an extension's body decodes where + :attr:`SSL.__extension__ ` + registers a decoder for its type. * Every other handshake message at type and length only. * ``Alert`` and ``ChangeCipherSpec`` in full. diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index b97db2d370..2f23aae8e0 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -310,7 +310,12 @@ # Transport Layer Security 'TLS', - 'TLS_Handshake', 'TLS_ClientHello', 'TLS_ServerHello', 'TLS_Extension', + 'TLS_Handshake', 'TLS_ClientHello', 'TLS_ServerHello', 'TLS_EncryptedExtensions', + 'TLS_Extension', 'TLS_ExtensionData', + 'TLS_ServerName', 'TLS_ServerNameList', 'TLS_NamedGroupList', 'TLS_SignatureSchemeList', + 'TLS_ProtocolNameList', 'TLS_SupportedVersionsClientHello', 'TLS_SupportedVersionsServerHello', + 'TLS_KeyShareEntry', 'TLS_KeyShareClientHello', 'TLS_KeyShareHelloRetryRequest', + 'TLS_KeyShareServerHello', 'TLS_Alert', 'TLS_ChangeCipherSpec', # No Payload diff --git a/pcapkit/protocols/data/application/__init__.py b/pcapkit/protocols/data/application/__init__.py index e8c949fa5f..c915b7af41 100644 --- a/pcapkit/protocols/data/application/__init__.py +++ b/pcapkit/protocols/data/application/__init__.py @@ -64,9 +64,25 @@ from pcapkit.protocols.data.application.tls import Alert as TLS_Alert from pcapkit.protocols.data.application.tls import ChangeCipherSpec as TLS_ChangeCipherSpec from pcapkit.protocols.data.application.tls import ClientHello as TLS_ClientHello +from pcapkit.protocols.data.application.tls import EncryptedExtensions as TLS_EncryptedExtensions from pcapkit.protocols.data.application.tls import Extension as TLS_Extension +from pcapkit.protocols.data.application.tls import ExtensionData as TLS_ExtensionData from pcapkit.protocols.data.application.tls import Handshake as TLS_Handshake +from pcapkit.protocols.data.application.tls import KeyShareClientHello as TLS_KeyShareClientHello +from pcapkit.protocols.data.application.tls import KeyShareEntry as TLS_KeyShareEntry +from pcapkit.protocols.data.application.tls import \ + KeyShareHelloRetryRequest as TLS_KeyShareHelloRetryRequest +from pcapkit.protocols.data.application.tls import KeyShareServerHello as TLS_KeyShareServerHello +from pcapkit.protocols.data.application.tls import NamedGroupList as TLS_NamedGroupList +from pcapkit.protocols.data.application.tls import ProtocolNameList as TLS_ProtocolNameList from pcapkit.protocols.data.application.tls import ServerHello as TLS_ServerHello +from pcapkit.protocols.data.application.tls import ServerName as TLS_ServerName +from pcapkit.protocols.data.application.tls import ServerNameList as TLS_ServerNameList +from pcapkit.protocols.data.application.tls import SignatureSchemeList as TLS_SignatureSchemeList +from pcapkit.protocols.data.application.tls import \ + SupportedVersionsClientHello as TLS_SupportedVersionsClientHello +from pcapkit.protocols.data.application.tls import \ + SupportedVersionsServerHello as TLS_SupportedVersionsServerHello __all__ = [ # Datagram Transport Layer Security @@ -102,6 +118,11 @@ # Transport Layer Security 'TLS', - 'TLS_Handshake', 'TLS_ClientHello', 'TLS_ServerHello', 'TLS_Extension', + 'TLS_Handshake', 'TLS_ClientHello', 'TLS_ServerHello', 'TLS_EncryptedExtensions', + 'TLS_Extension', 'TLS_ExtensionData', + 'TLS_ServerName', 'TLS_ServerNameList', 'TLS_NamedGroupList', 'TLS_SignatureSchemeList', + 'TLS_ProtocolNameList', 'TLS_SupportedVersionsClientHello', 'TLS_SupportedVersionsServerHello', + 'TLS_KeyShareEntry', 'TLS_KeyShareClientHello', 'TLS_KeyShareHelloRetryRequest', + 'TLS_KeyShareServerHello', 'TLS_Alert', 'TLS_ChangeCipherSpec', ] diff --git a/pcapkit/protocols/data/application/dtls.py b/pcapkit/protocols/data/application/dtls.py index c5c9bb0101..3b04bce4aa 100644 --- a/pcapkit/protocols/data/application/dtls.py +++ b/pcapkit/protocols/data/application/dtls.py @@ -12,7 +12,7 @@ from pcapkit.const.tls.content_type import ContentType as Enum_ContentType from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType - from pcapkit.protocols.data.application.tls import ClientHello, ServerHello + from pcapkit.protocols.data.application.tls import ClientHello, EncryptedExtensions, ServerHello __all__ = [ 'DTLS', @@ -32,8 +32,9 @@ class DTLSHandshake(Handshake): (:rfc:`6347#section-4.2.2`), so that a handshake message that does not fit in one datagram can be split across several. - :attr:`body` decodes to a :class:`~pcapkit.protocols.data.application.tls.ClientHello` - or :class:`~pcapkit.protocols.data.application.tls.ServerHello` when + :attr:`body` decodes to a :class:`~pcapkit.protocols.data.application.tls.ClientHello`, + :class:`~pcapkit.protocols.data.application.tls.ServerHello` or + :class:`~pcapkit.protocols.data.application.tls.EncryptedExtensions` when :attr:`is_fragmented` is :data:`False`. A fragmented message's :attr:`body` is the fragment's raw octets; :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` decodes the @@ -47,7 +48,7 @@ class DTLSHandshake(Handshake): #: Total message length (24-bit), fragment headers and bodies excluded. length: 'int' #: Message body -- see the class docstring for when this decodes. - body: 'Union[ClientHello, ServerHello, bytes]' + body: 'Union[ClientHello, ServerHello, EncryptedExtensions, bytes]' #: Message sequence number. message_seq: 'int' #: Fragment offset (24-bit) into the reassembled message. @@ -59,7 +60,7 @@ class DTLSHandshake(Handshake): is_fragmented: 'bool' if TYPE_CHECKING: - def __init__(self, msg_type: 'Enum_HandshakeType', length: 'int', body: 'Union[ClientHello, ServerHello, bytes]', message_seq: 'int', fragment_offset: 'int', fragment_length: 'int', is_fragmented: 'bool') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + def __init__(self, msg_type: 'Enum_HandshakeType', length: 'int', body: 'Union[ClientHello, ServerHello, EncryptedExtensions, bytes]', message_seq: 'int', fragment_offset: 'int', fragment_length: 'int', is_fragmented: 'bool') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long @info_final diff --git a/pcapkit/protocols/data/application/tls.py b/pcapkit/protocols/data/application/tls.py index 68f6648e09..3f0b9c6007 100644 --- a/pcapkit/protocols/data/application/tls.py +++ b/pcapkit/protocols/data/application/tls.py @@ -11,32 +11,185 @@ from typing import Any, Optional, Union from pcapkit.const.tls.content_type import ContentType as Enum_ContentType + from pcapkit.const.tls.extension_type import ExtensionType as Enum_ExtensionType from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType __all__ = [ 'TLS', - 'Handshake', 'ClientHello', 'ServerHello', 'Extension', + 'Handshake', 'ClientHello', 'ServerHello', 'EncryptedExtensions', + + 'Extension', 'ExtensionData', + 'ServerName', 'ServerNameList', 'NamedGroupList', 'SignatureSchemeList', + 'ProtocolNameList', 'SupportedVersionsClientHello', 'SupportedVersionsServerHello', + 'KeyShareEntry', 'KeyShareClientHello', 'KeyShareHelloRetryRequest', 'KeyShareServerHello', + 'Alert', 'ChangeCipherSpec', ] +class ExtensionData(Data): + """Data model for a decoded TLS/DTLS ``extension_data`` field. + + Each subclass is the structure one + :class:`~pcapkit.const.tls.extension_type.ExtensionType` carries, named + as its specification names it. + + """ + + @info_final class Extension(Data): - """Data model for a TLS/DTLS extension list entry. + """Data model for a TLS/DTLS extension list entry (:rfc:`9846#section-4.3`). - Only the TYPE and LENGTH framing is decoded (:issue:`2437`); an - extension's own body is kept as :attr:`data`, undecoded. + :attr:`data` holds the decoded ``extension_data`` where + :attr:`SSL.__extension__ ` + registers a decoder for :attr:`type`, and the raw octets otherwise -- + including a body its decoder cannot read exactly, or one in a message + whose structure for it the decoder does not know. """ #: Extension type. - type: 'int' - #: Extension data, undecoded. - data: 'bytes' + type: 'Enum_ExtensionType' + #: Extension data: decoded, or the raw octets. + data: 'Union[ExtensionData, bytes]' + + if TYPE_CHECKING: + def __init__(self, type: 'Enum_ExtensionType', data: 'Union[ExtensionData, bytes]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin,line-too-long + + +@info_final +class ServerName(Data): + """Data model for one ``ServerName`` of a ``server_name`` extension (:rfc:`6066#section-3`).""" + + #: Name type; ``0`` is ``host_name``, the only one defined. + name_type: 'int' + #: Name, i.e. the ``HostName`` octets. + name: 'bytes' + + if TYPE_CHECKING: + def __init__(self, name_type: 'int', name: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class ServerNameList(ExtensionData): + """Data model for the ``server_name`` extension (:rfc:`6066#section-3`). + + A ``ServerHello`` or ``EncryptedExtensions`` carries the extension with + empty ``extension_data``, which stays ``b''``. + + """ + + #: Server names. + server_name_list: 'tuple[ServerName, ...]' if TYPE_CHECKING: - def __init__(self, type: 'int', data: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin + def __init__(self, server_name_list: 'tuple[ServerName, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class NamedGroupList(ExtensionData): + """Data model for the ``supported_groups`` extension (:rfc:`9846#section-4.3.7`).""" + + #: ``NamedGroup`` values, most preferred first. + named_group_list: 'tuple[int, ...]' + + if TYPE_CHECKING: + def __init__(self, named_group_list: 'tuple[int, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class SignatureSchemeList(ExtensionData): + """Data model for the ``signature_algorithms`` extension (:rfc:`9846#section-4.3.3`).""" + + #: ``SignatureScheme`` values, most preferred first. + supported_signature_algorithms: 'tuple[int, ...]' + + if TYPE_CHECKING: + def __init__(self, supported_signature_algorithms: 'tuple[int, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class ProtocolNameList(ExtensionData): + """Data model for the ``application_layer_protocol_negotiation`` extension + (:rfc:`7301#section-3.1`).""" + + #: ``ProtocolName`` octet strings, e.g. ``b'h2'``. + protocol_name_list: 'tuple[bytes, ...]' + + if TYPE_CHECKING: + def __init__(self, protocol_name_list: 'tuple[bytes, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class SupportedVersionsClientHello(ExtensionData): + """Data model for a ``ClientHello``'s ``supported_versions`` extension + (:rfc:`9846#section-4.3.1`).""" + + #: ``ProtocolVersion`` values, most preferred first. + versions: 'tuple[int, ...]' + + if TYPE_CHECKING: + def __init__(self, versions: 'tuple[int, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class SupportedVersionsServerHello(ExtensionData): + """Data model for a ``ServerHello``'s or ``HelloRetryRequest``'s + ``supported_versions`` extension (:rfc:`9846#section-4.3.1`).""" + + #: The ``ProtocolVersion`` selected. + selected_version: 'int' + + if TYPE_CHECKING: + def __init__(self, selected_version: 'int') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class KeyShareEntry(Data): + """Data model for one ``KeyShareEntry`` of a ``key_share`` extension (:rfc:`9846#section-4.3.8`).""" + + #: ``NamedGroup`` of the key. + group: 'int' + #: Key exchange information. + key_exchange: 'bytes' + + if TYPE_CHECKING: + def __init__(self, group: 'int', key_exchange: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class KeyShareClientHello(ExtensionData): + """Data model for a ``ClientHello``'s ``key_share`` extension (:rfc:`9846#section-4.3.8`).""" + + #: Key shares offered, most preferred first. + client_shares: 'tuple[KeyShareEntry, ...]' + + if TYPE_CHECKING: + def __init__(self, client_shares: 'tuple[KeyShareEntry, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class KeyShareHelloRetryRequest(ExtensionData): + """Data model for a ``HelloRetryRequest``'s ``key_share`` extension (:rfc:`9846#section-4.3.8`).""" + + #: ``NamedGroup`` the server asks a retried ``ClientHello`` to share. + selected_group: 'int' + + if TYPE_CHECKING: + def __init__(self, selected_group: 'int') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class KeyShareServerHello(ExtensionData): + """Data model for a ``ServerHello``'s ``key_share`` extension (:rfc:`9846#section-4.3.8`).""" + + #: The server's key share. + server_share: 'KeyShareEntry' + + if TYPE_CHECKING: + def __init__(self, server_share: 'KeyShareEntry') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements @info_final @@ -69,7 +222,13 @@ def __init__(self, version: 'int', random: 'bytes', session_id: 'bytes', cookie: @info_final class ServerHello(Data): - """Data model for a TLS/DTLS ``ServerHello`` handshake message body.""" + """Data model for a TLS/DTLS ``ServerHello`` handshake message body. + + A ``HelloRetryRequest`` shares the structure and is told apart by its + :attr:`random` (:rfc:`9846#section-4.2.3`); its extensions decode in the + shapes a ``HelloRetryRequest`` gives them. + + """ #: Server (legacy) version. version: 'int' @@ -90,12 +249,25 @@ class ServerHello(Data): def __init__(self, version: 'int', random: 'bytes', session_id: 'bytes', cipher_suite: 'int', compression_method: 'int', extensions: 'Optional[tuple[Extension, ...]]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long +@info_final +class EncryptedExtensions(Data): + """Data model for a TLS/DTLS ``EncryptedExtensions`` handshake message body + (:rfc:`9846#section-4.4.1`).""" + + #: Extensions. + extensions: 'tuple[Extension, ...]' + + if TYPE_CHECKING: + def __init__(self, extensions: 'tuple[Extension, ...]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + class Handshake(Data): """Data model for a TLS/DTLS handshake message. - :attr:`body` holds a :class:`ClientHello` or :class:`ServerHello` for - those two message types, and the raw, undecoded message body for every - other :class:`~pcapkit.const.tls.handshake_type.HandshakeType` -- + :attr:`body` holds a :class:`ClientHello`, :class:`ServerHello` or + :class:`EncryptedExtensions` for those message types, and the raw, + undecoded message body for every other + :class:`~pcapkit.const.tls.handshake_type.HandshakeType` -- message-TYPE-level framing only (:issue:`2437`). """ @@ -105,10 +277,10 @@ class Handshake(Data): #: Message length (24-bit), the 4-octet handshake header excluded. length: 'int' #: Message body. - body: 'Union[ClientHello, ServerHello, bytes]' + body: 'Union[ClientHello, ServerHello, EncryptedExtensions, bytes]' if TYPE_CHECKING: - def __init__(self, msg_type: 'Enum_HandshakeType', length: 'int', body: 'Union[ClientHello, ServerHello, bytes]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + def __init__(self, msg_type: 'Enum_HandshakeType', length: 'int', body: 'Union[ClientHello, ServerHello, EncryptedExtensions, bytes]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long @info_final diff --git a/pcapkit/protocols/protocol.py b/pcapkit/protocols/protocol.py index e89f64aa5f..219fb8d34f 100644 --- a/pcapkit/protocols/protocol.py +++ b/pcapkit/protocols/protocol.py @@ -69,7 +69,7 @@ if TYPE_CHECKING: from enum import IntEnum as StdlibEnum from types import FrameType - from typing import IO, Any, Callable, DefaultDict, Optional, Type + from typing import IO, Any, Callable, DefaultDict, Iterable, Optional, Type from aenum import IntEnum as AenumEnum from typing_extensions import Literal, Self @@ -1878,6 +1878,90 @@ def _read_binary(self, size: 'int' = 1) -> 'str': raise StructError(f'{self.__class__.__name__}: unpack failed') return ''.join(bin(byte)[2:].zfill(8) for byte in mem) + def _read_vector(self, data: 'bytes', pos: 'int' = 0, *, size: 'int' = 1, + what: 'str') -> 'tuple[bytes, int]': + """Read a length-prefixed vector out of ``data``. + + The vector is a ``size``-octet unsigned big-endian length, then that + many octets, as the variable-length vectors of TLS's presentation + language are (:rfc:`9846#section-3.4`). + + Args: + data: buffer being read + pos: offset of the length prefix in ``data`` + size: octets in the length prefix + what: name of the vector, for the error message + + Returns: + The vector's octets, and the offset just past them. + + Raises: + ProtocolError: If the length prefix, or the octets it counts, run + past the end of ``data``. The message names :attr:`alias`. + + .. versionadded:: 1.6.0 + + """ + prefix = data[pos:pos + size] + if len(prefix) != size: + raise ProtocolError(f'{self.alias}: invalid format: {what} length runs past the end of the buffer') + start = pos + size + end = start + int.from_bytes(prefix, 'big') + octets = data[start:end] + if len(octets) != end - start: + raise ProtocolError(f'{self.alias}: invalid format: {what} runs past the end of the buffer') + return octets, end + + def _read_whole_vector(self, data: 'bytes', *, size: 'int' = 1, what: 'str') -> 'bytes': + """Read the one length-prefixed vector that ``data`` holds. + + The vector is read as :meth:`_read_vector` reads it from offset zero, + and has to end where ``data`` does. + + Args: + data: buffer holding the vector and nothing else + size: octets in the length prefix + what: name of the vector, for the error message + + Returns: + The vector's octets. + + Raises: + ProtocolError: If the vector runs past the end of ``data``, or + octets follow it. The message names :attr:`alias`. + + .. versionadded:: 1.6.0 + + """ + octets, end = self._read_vector(data, 0, size=size, what=what) + if end != len(data): + raise ProtocolError(f'{self.alias}: invalid format: {what} carries excess octets') + return octets + + def _read_uints(self, data: 'bytes', *, size: 'int' = 1, what: 'str') -> 'tuple[int, ...]': + """Read ``data`` as a run of ``size``-octet unsigned big-endian integers. + + Args: + data: buffer holding the integers and nothing else + size: octets in each integer + what: name of the list, for the error message + + Returns: + The integers, in order. + + Raises: + ProtocolError: If the length of ``data`` is not a multiple of + ``size``, whose trailing octets would otherwise be padded out + when the list is made again. The message names :attr:`alias`. + + .. versionadded:: 1.6.0 + + """ + if len(data) % size: + raise ProtocolError(f'{self.alias}: invalid format: {what} length {len(data)} ' + f'is not a multiple of {size}') + return tuple(int.from_bytes(data[index:index + size], 'big') for index in range(0, len(data), size)) + @overload # pragma: no cover def _read_packet(self, length: 'Optional[int]' = ..., *, header: 'None' = ...) -> 'bytes': ... @overload # pragma: no cover @@ -1960,6 +2044,81 @@ def _make_pack(cls, integer: 'int', *, size: 'int' = 1, raise StructError(f'{cls.__name__}: pack failed') from error return buf + def _make_uint(self, value: 'Any', *, bits: 'int', name: 'str') -> 'int': + """Check that ``value`` is an unsigned integer that fits in ``bits`` bits. + + A :obj:`bool` passes only for a one-bit field, so that a flag given + for a wider field is refused rather than read as ``0`` or ``1``. + + Args: + value: the value given for the field + bits: width of the field, in bits + name: name of the field, for the error message + + Returns: + ``value`` as a plain :class:`int`. + + Raises: + ProtocolError: If ``value`` is not an :class:`int`, is a + :obj:`bool` for a field wider than one bit, is negative, or + does not fit in ``bits`` bits. The message names + :attr:`alias`. + + .. versionadded:: 1.6.0 + + """ + if (not isinstance(value, int) or (isinstance(value, bool) and bits != 1) + or not 0 <= value < (1 << bits)): + raise ProtocolError(f'{self.alias}: invalid {name}: {value!r}') + return int(value) + + @classmethod + def _make_vector(cls, octets: 'bytes', *, size: 'int' = 1) -> 'bytes': + """Write ``octets`` as a length-prefixed vector. + + The inverse of :meth:`_read_vector`: a ``size``-octet unsigned + big-endian length, packed by :meth:`_make_pack`, then ``octets``. + + Args: + octets: the vector's octets + size: octets in the length prefix + + Returns: + The length prefix followed by ``octets``. + + Raises: + StructError: If the length of ``octets`` does not fit in ``size`` + octets. + + .. versionadded:: 1.6.0 + + """ + return cls._make_pack(len(octets), size=size) + octets + + @classmethod + def _make_uints(cls, values: 'Iterable[int]', *, size: 'int' = 1) -> 'bytes': + """Write ``values`` as a run of ``size``-octet unsigned big-endian integers. + + The inverse of :meth:`_read_uints`, each value packed by + :meth:`_make_pack`. + + Args: + values: the integers, or anything :class:`int` converts to one, + such as an enumeration member + size: octets in each integer + + Returns: + The packed integers, concatenated in order. + + Raises: + StructError: If a value is negative, or does not fit in ``size`` + octets. + + .. versionadded:: 1.6.0 + + """ + return b''.join(cls._make_pack(int(value), size=size) for value in values) + @overload # pragma: no cover @classmethod def _make_index(cls, name: 'int | StdlibEnum | AenumEnum', *, pack: 'Literal[False]' = ...) -> 'int': ... diff --git a/pcapkit/vendor/__init__.py b/pcapkit/vendor/__init__.py index 38e94ba818..bc2652c7f3 100644 --- a/pcapkit/vendor/__init__.py +++ b/pcapkit/vendor/__init__.py @@ -199,7 +199,7 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': # TCP 'TCP_Checksum', 'TCP_Option', 'TCP_MPTCPOption', 'TCP_Flags', # TLS - 'TLS_ContentType', 'TLS_HandshakeType', + 'TLS_ContentType', 'TLS_HandshakeType', 'TLS_ExtensionType', # VLAN 'VLAN_PriorityLevel', # PCAPNG diff --git a/pcapkit/vendor/tls/__init__.py b/pcapkit/vendor/tls/__init__.py index a7f5d0be97..6897dae0b6 100644 --- a/pcapkit/vendor/tls/__init__.py +++ b/pcapkit/vendor/tls/__init__.py @@ -14,13 +14,17 @@ - TLS/DTLS Content Types [*]_ * - :class:`TLS_HandshakeType ` - TLS/DTLS HandshakeType [*]_ + * - :class:`TLS_ExtensionType ` + - TLS/DTLS ExtensionType Values [*]_ .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-5 .. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7 +.. [*] https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#tls-extensiontype-values-1 """ from pcapkit.vendor.tls.content_type import ContentType as TLS_ContentType +from pcapkit.vendor.tls.extension_type import ExtensionType as TLS_ExtensionType from pcapkit.vendor.tls.handshake_type import HandshakeType as TLS_HandshakeType -__all__ = ['TLS_ContentType', 'TLS_HandshakeType'] +__all__ = ['TLS_ContentType', 'TLS_HandshakeType', 'TLS_ExtensionType'] diff --git a/pcapkit/vendor/tls/extension_type.py b/pcapkit/vendor/tls/extension_type.py new file mode 100644 index 0000000000..81401b3259 --- /dev/null +++ b/pcapkit/vendor/tls/extension_type.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +"""TLS/DTLS ExtensionType Values +================================== + +.. module:: pcapkit.vendor.tls.extension_type + +This module contains the vendor crawler for **TLS/DTLS ExtensionType Values**, +which is automatically generating +:class:`pcapkit.const.tls.extension_type.ExtensionType`. + +The registry is IANA's ``TLS ExtensionType Values`` table, a seven-column CSV -- +``Value, Extension Name, TLS 1.3, DTLS-Only, Recommended, Reference, Comment`` +-- rather than the three-column ``Value, Description, Reference`` shape +:meth:`~pcapkit.vendor.default.Vendor.process` assumes, so this crawler reads +the ``Reference`` column at index ``5`` and the ``Comment`` column at index +``6``, and otherwise follows the same rendering the base class uses elsewhere. + +The ``Extension Name`` cells are the wire names :rfc:`9846#section-4.3` and the +extension RFCs spell, and keep the registrar's own case (``TLMSP``). Two kinds +of parenthetical need care, since :meth:`~pcapkit.vendor.default.Vendor.safe_name` +drops a parenthetical outright: + +* ``(renamed from "...")`` records a former name, which is dropped, so + ``supported_groups`` and ``session_ticket`` keep their current names; +* ``(deprecated)`` is what tells value ``53`` apart from the ``connection_id`` + at ``54`` that replaced it (:rfc:`9146`), so it is kept as a + ``_deprecated`` suffix rather than leaving the two to collide. + +The single-value ``Reserved`` rows -- the GREASE values :rfc:`8701` reserves, +and the code points :rfc:`9847` retired -- become members suffixed with their +value, as :meth:`~pcapkit.vendor.default.Vendor.rename` does for every +``Reserved`` row; the ``Unassigned`` and ``Reserved for Private Use`` ranges +resolve to the bare label. + +""" + +import collections +import csv +import re +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['ExtensionType'] + +#: A ``(renamed from "...")`` parenthetical, which records a former name. +RENAMED = re.compile(r'\s*\(renamed from [^)]*\)') + +#: Any other parenthetical, e.g. ``(deprecated)``, kept as a name suffix. +QUALIFIER = re.compile(r'\s*\(([^)]*)\)') + + +class ExtensionType(Vendor): + """TLS/DTLS ExtensionType Values""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0 <= value <= 65535' + #: Link to registry. + LINK = 'https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values-1.csv' + + def clean_name(self, raw: 'str') -> 'str': + """Turn an IANA ``Extension Name`` cell into a member identifier. + + Drops a ``(renamed from "...")`` parenthetical and turns any other + into a suffix, as the module docstring describes. + + Args: + raw: Verbatim ``Extension Name`` cell. + + Returns: + The identifier :meth:`~pcapkit.vendor.default.Vendor.rename` + should render, before duplicate-suffixing. + + """ + return QUALIFIER.sub(r'_\1', RENAMED.sub('', raw)) + + def count(self, data: 'list[str]') -> 'Counter[str]': + """Count field records. + + Args: + data: CSV data. + + Returns: + Field recordings. + + """ + reader = csv.reader(data) + next(reader) # header + return collections.Counter( + self.safe_name(self.clean_name(item[1])) + for item in reader if len(item[0].split('-')) != 2 + ) + + def process(self, data: 'list[str]') -> 'tuple[list[str], list[str]]': + """Process CSV data. + + Args: + data: CSV data. + + Returns: + Enumeration fields and missing fields. + + """ + reader = csv.reader(data) + next(reader) # header + + enum = [] # type: list[str] + miss = [] # type: list[str] + for item in reader: + name = item[1] + rfcs = item[5] + comment = item[6].strip() + + temp = [] # type: list[str] + for rfc in self.split_references(rfcs): + if 'RFC' in rfc and re.match(r'\d+', rfc[3:]): + temp_split = rfc[3:].split(', ', maxsplit=1) + if len(temp_split) > 1: + temp.append(f'[:rfc:`{temp_split[0]}#{temp_split[1].lower()}`]'.replace(' ', '-')) + else: + temp.append(f'[:rfc:`{temp_split[0]}`]') + else: + temp.append(f'[{rfc}]'.replace('_', ' ')) + body = '%s %s' % (RENAMED.sub('', name), ''.join(temp) if rfcs else '') # pylint: disable=consider-using-f-string + if comment: + body = '%s -- %s' % (body.strip(), comment) # pylint: disable=consider-using-f-string + desc = self.wrap_comment(re.sub(r'\r*\n\s*', ' ', body, flags=re.MULTILINE)) + + try: + code, _ = item[0], int(item[0]) + renm = self.rename(self.clean_name(name), code) + + pres = f'{renm} = {code}' + sufs = f'#: {desc}' + + enum.append(f'{sufs}\n {pres}') + except ValueError: + start, stop = item[0].split('-') + + miss.append(f'if {start} <= value <= {stop}:') + miss.append(f' #: {desc}') + miss.append(f" return cls._unregistered_member(value, '{self.safe_name(name)}')") + return enum, miss + + +if __name__ == '__main__': + sys.exit(ExtensionType()) # type: ignore[arg-type] diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index 1ff453b01e..a6712e751e 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -378,11 +378,11 @@ def test_the_sweep_size_is_pinned(self) -> None: # sweep. The int count includes GitHub issue #880's ngap.ProcedureCode # and ngap.ProtocolIE, which are on EnumRegistry, as are the ICMP, # ICMPv6, IGMP and DCCP registry families. - self.assertEqual(len(ints), 135) + self.assertEqual(len(ints), 136) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) - self.assertEqual(len(self.enums), 151) - self.assertEqual(len({obj.__module__ for obj in self.enums}), 145) + self.assertEqual(len(self.enums), 152) + self.assertEqual(len({obj.__module__ for obj in self.enums}), 146) def test_every_registry_rejects_a_negative_value(self) -> None: """The registry-wide form of GitHub issue #647. diff --git a/tests/const/test_const_enum_get.py b/tests/const/test_const_enum_get.py index aadec644bc..893179dc7a 100644 --- a/tests/const/test_const_enum_get.py +++ b/tests/const/test_const_enum_get.py @@ -297,7 +297,7 @@ def test_the_sweep_size_is_pinned(self) -> None: # pcapkit.protocols.application.ngap; neither needs an exception-set # entry, since both take the base's own get(key, default). names = {_qualname(obj) for obj in self.enums} - self.assertEqual(len(self.enums), 141) + self.assertEqual(len(self.enums), 142) for expected in (EXPECTED_TO_RESOLVE_ANYTHING, EXPECTED_WITHOUT_AN_INTEGER_DEFAULT, EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK): self.assertTrue(expected.issubset(names), @@ -337,7 +337,7 @@ def test_every_integer_path_consults_the_default(self) -> None: # registry excused above: ``EXPECTED_TO_RESOLVE_ANYTHING`` and # ``EXPECTED_WITHOUT_A_CACHEABLE_FALLBACK``, with # ``EXPECTED_WITHOUT_AN_INTEGER_DEFAULT`` empty. - self.assertEqual(covered, 138) + self.assertEqual(covered, 139) def test_the_always_resolving_registries_have_nothing_to_fall_back_to(self) -> None: """The two registries excused from the sweep, and why. diff --git a/tests/const/test_const_enum_lookup.py b/tests/const/test_const_enum_lookup.py index ebd5ea32f4..851c2f3ca9 100644 --- a/tests/const/test_const_enum_lookup.py +++ b/tests/const/test_const_enum_lookup.py @@ -200,7 +200,7 @@ def test_every_registry_enum_was_discovered(self) -> None: # ``_iter_const_int_flags``'s, and so are GitHub issue #880's # ngap.ProcedureCode and ngap.ProtocolIE, which are on EnumRegistry. names = {f'{obj.__module__}.{obj.__qualname__}' for obj in self.enums} - self.assertEqual(len(self.enums), 135) + self.assertEqual(len(self.enums), 136) self.assertTrue(EXPECTED_TO_REJECT_ZERO.issubset(names), f'expected reject-list entries missing from the sweep: ' f'{EXPECTED_TO_REJECT_ZERO - names}') diff --git a/tests/const/test_const_registry_protocol.py b/tests/const/test_const_registry_protocol.py index 0a9bf484eb..8444d30767 100644 --- a/tests/const/test_const_registry_protocol.py +++ b/tests/const/test_const_registry_protocol.py @@ -846,8 +846,8 @@ def test_every_generated_const_module_is_accounted_for(self) -> None: generated = [path for path in all_files if path not in converted_relpaths and path not in EXCLUDED_FROM_INTENUM_CENSUS] - self.assertEqual(len(all_files), 145) - self.assertEqual(len(generated), 128) + self.assertEqual(len(all_files), 146) + self.assertEqual(len(generated), 129) for relpath in generated: with self.subTest(module=relpath): source = (REPO_ROOT / relpath).read_text() diff --git a/tests/foundation/registry/test_registry_symmetry_unit.py b/tests/foundation/registry/test_registry_symmetry_unit.py index 3d9955116e..be421dd1a6 100644 --- a/tests/foundation/registry/test_registry_symmetry_unit.py +++ b/tests/foundation/registry/test_registry_symmetry_unit.py @@ -454,6 +454,7 @@ def test_every_public_registrar_is_covered_or_excused(self) -> None: 'register_reassembly_tcp_callback': 'append-only, nothing to undo with', 'register_traceflow_tcp_callback': 'append-only, nothing to undo with', 'register_icmp_type': 'no schema argument; override and restore are in test_icmp_type_registrar_unit', + 'register_tls_extension': 'no schema argument; override and restore are in test_tls_extensions_2514_unit', 'register_ipx': 'IPX.__proto__ ships no entry, so an override has nothing to be restored to', 'register_netlink': 'Netlink.__proto__ ships no entry, so there is none to restore', 'register_dccp': 'DCCP.__proto__ ships no entry, so there is none to restore', diff --git a/tests/protocols/application/test_tls_extension_quiet_rejection_2514_unit.py b/tests/protocols/application/test_tls_extension_quiet_rejection_2514_unit.py new file mode 100644 index 0000000000..d64ee26b60 --- /dev/null +++ b/tests/protocols/application/test_tls_extension_quiet_rejection_2514_unit.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +"""A TLS extension body its decoder rejects is kept raw, quietly (#2514). + +``SSL._read_tls_extensions`` catches the decoder's ``ProtocolError`` and keeps +the octets, so the error is control flow: it logs nothing and leaves +``sys.excepthook`` and ``threading.excepthook`` alone. That holds for a shipped +decoder, which raises an ordinary loud error, and for a registered one. + +""" +from __future__ import annotations + +import sys +import threading +import unittest +import warnings +from typing import TYPE_CHECKING + +from tests._support import reimport_once_per_class +from tests.utilities._harness import capture + +if TYPE_CHECKING: + from typing import Any + +#: Test tier (#1958): checks that the exception hooks are left alone. +__tier__ = 'ALL' + + +def _ext(code: int, data: bytes) -> bytes: + return code.to_bytes(2, 'big') + len(data).to_bytes(2, 'big') + data + + +def _vec(data: bytes, width: int = 2) -> bytes: + return len(data).to_bytes(width, 'big') + data + + +def _client_hello(*extensions: bytes) -> bytes: + body = (b'\x03\x03' + bytes(32) + b'\x00' + _vec(b'\x13\x01') + _vec(b'\x00', 1) + + _vec(b''.join(extensions))) + fragment = b'\x01' + len(body).to_bytes(3, 'big') + body + return b'\x16\x03\x03' + len(fragment).to_bytes(2, 'big') + fragment + + +class QuietRejectionTests(unittest.TestCase): + """A rejected body keeps its octets, logs nothing and installs no hook.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + saved_sys, saved_threading = sys.excepthook, threading.excepthook + + def _restore() -> None: + sys.excepthook = saved_sys + threading.excepthook = saved_threading + + self.addCleanup(_restore) + + def _sys_sentinel(*args: 'Any') -> None: + saved_sys(*args) + + def _threading_sentinel(args: 'Any') -> None: + saved_threading(args) + + self.sys_sentinel, self.threading_sentinel = _sys_sentinel, _threading_sentinel + sys.excepthook, threading.excepthook = _sys_sentinel, _threading_sentinel + + def _parse_quietly(self, wire: bytes) -> 'tuple': + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities import exceptions + from pcapkit.utilities.logging import logger + + with capture(logger) as package, capture(exceptions.logger) as module: + parsed = TLS(wire, len(wire)) + self.assertEqual(package.messages, []) + self.assertEqual(module.messages, []) + self.assertIs(sys.excepthook, self.sys_sentinel) + self.assertIs(threading.excepthook, self.threading_sentinel) + self.assertEqual(bytes(TLS.from_data(parsed.info)).hex(), wire.hex()) + return parsed.info.content[0].body.extensions + + def test_a_shipped_decoders_rejection_is_quiet(self) -> None: + for name, extension in { + 'alpn list length mismatch': _ext(16, b'\x00\x09\x02h2'), + 'supported_groups odd length': _ext(10, _vec(b'\x00\x1d\x00')), + 'supported_versions odd length': _ext(43, _vec(b'\x03\x04\x03', 1)), + }.items(): + with self.subTest(case=name): + ext, = self._parse_quietly(_client_hello(extension)) + self.assertEqual(ext.data, extension[4:]) + + def test_a_registered_decoders_loud_rejection_is_quiet(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.foundation.registry.protocols import register_tls_extension + from pcapkit.protocols.application.ssl import SSL + from pcapkit.utilities import exceptions + from pcapkit.utilities.exceptions import ProtocolError + from pcapkit.utilities.logging import logger + + def parser(ext_type: object, data: bytes, *, msg_type: object) -> bytes: # pylint: disable=unused-argument + raise ProtocolError('padding: rejected') + + def constructor(ext_type: object, data: object = None, *, msg_type: object, # pylint: disable=unused-argument + **kwargs: object) -> bytes: + return b'' + + # The same error, constructed outside the parse, is loud. + with capture(logger) as package, capture(exceptions.logger) as module: + ProtocolError('padding: rejected') + self.assertIn(('CRITICAL', 'ProtocolError: padding: rejected'), package.messages + module.messages) + sys.excepthook, threading.excepthook = self.sys_sentinel, self.threading_sentinel + + shipped = dict(SSL.__extension__) + try: + with warnings.catch_warnings(): + warnings.simplefilter('error') + register_tls_extension(ExtensionType.padding, (parser, constructor)) + ext, = self._parse_quietly(_client_hello(_ext(21, bytes(3)))) + self.assertEqual(ext.data, bytes(3)) + finally: + SSL.__extension__.clear() + SSL.__extension__.update(shipped) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_tls_extensions_2514_unit.py b/tests/protocols/application/test_tls_extensions_2514_unit.py new file mode 100644 index 0000000000..95c5d0fbc1 --- /dev/null +++ b/tests/protocols/application/test_tls_extensions_2514_unit.py @@ -0,0 +1,518 @@ +# -*- coding: utf-8 -*- +"""TLS/DTLS handshake extensions decode, and rebuild byte for byte (#2514). + +``SSL.__extension__`` registers a decoder for ``server_name``, +``supported_groups``, ``signature_algorithms``, ALPN, ``supported_versions`` +and ``key_share``; every other extension keeps its raw octets. Each case is +checked against the field values of its specification, and both ways round: +parse then ``from_data``, and ``make`` from the parsed content. + +""" +from __future__ import annotations + +import unittest +import warnings + +from tests._support import reimport_once_per_class + +#: ``random`` of a ``HelloRetryRequest`` (RFC 9846 section 4.2.3). +HRR_RANDOM = bytes.fromhex('cf21ad74e59a6111be1d8c021e65b891c2a211167abb8c5e079e09e2c8a8339c') + + +def _ext(code: int, data: bytes) -> bytes: + return code.to_bytes(2, 'big') + len(data).to_bytes(2, 'big') + data + + +def _vec(data: bytes, width: int = 2) -> bytes: + return len(data).to_bytes(width, 'big') + data + + +def _record(fragment: bytes, content_type: int = 22) -> bytes: + return bytes([content_type]) + b'\x03\x03' + len(fragment).to_bytes(2, 'big') + fragment + + +def _handshake(msg_type: int, body: bytes) -> bytes: + return bytes([msg_type]) + len(body).to_bytes(3, 'big') + body + + +def _client_hello(*extensions: bytes) -> bytes: + return _record(_handshake(1, b'\x03\x03' + bytes(range(32)) + b'\x00' + _vec(b'\x13\x01') + + _vec(b'\x00', 1) + _vec(b''.join(extensions)))) + + +def _server_hello(*extensions: bytes, random: bytes = bytes(range(32, 64))) -> bytes: + return _record(_handshake(2, b'\x03\x03' + random + b'\x00' + b'\x13\x01' + b'\x00' + + _vec(b''.join(extensions)))) + + +def _encrypted_extensions(*extensions: bytes) -> bytes: + return _record(_handshake(8, _vec(b''.join(extensions)))) + + +#: ``server_name`` with one ``host_name`` (RFC 6066 section 3). +SNI = _ext(0, _vec(b'\x00' + _vec(b'example.com'))) +#: ``supported_groups``: x25519, secp256r1 (RFC 9846 section 4.3.7). +GROUPS = _ext(10, _vec(b'\x00\x1d\x00\x17')) +#: ``signature_algorithms``: ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256. +SIGALGS = _ext(13, _vec(b'\x04\x03\x08\x04')) +#: ALPN: ``h2``, ``http/1.1`` (RFC 7301 section 3.1). +ALPN = _ext(16, _vec(_vec(b'h2', 1) + _vec(b'http/1.1', 1))) +#: ``supported_versions`` in a ``ClientHello``: TLS 1.3, TLS 1.2. +VERSIONS = _ext(43, _vec(b'\x03\x04\x03\x03', 1)) +#: ``key_share`` in a ``ClientHello``: one x25519 share. +KEY_SHARE = _ext(51, _vec(b'\x00\x1d' + _vec(bytes(range(32))))) + + +class ExtensionDecodingTests(unittest.TestCase): + """Each registered extension decodes to its specification's fields.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def _extensions(self, wire: bytes) -> 'tuple': + from pcapkit.protocols.application.tls import TLS + + parsed = TLS(wire, len(wire)) + self.assertEqual(bytes(TLS.from_data(parsed.info)).hex(), wire.hex()) + maker = object.__new__(TLS) + made = maker.make(content_type=parsed.info.content_type, version=parsed.info.version, + content=parsed.info.content) + self.assertEqual(bytes(made).hex(), wire.hex()) + return parsed.info.content[0].body.extensions + + def test_client_hello_extensions(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.protocols.data.application.tls import (KeyShareClientHello, KeyShareEntry, + NamedGroupList, ProtocolNameList, + ServerName, ServerNameList, + SignatureSchemeList, + SupportedVersionsClientHello) + + sni, groups, sigalgs, alpn, versions, key_share = self._extensions( + _client_hello(SNI, GROUPS, SIGALGS, ALPN, VERSIONS, KEY_SHARE)) + self.assertEqual([ext.type for ext in (sni, groups, sigalgs, alpn, versions, key_share)], [ + ExtensionType.server_name, ExtensionType.supported_groups, ExtensionType.signature_algorithms, + ExtensionType.application_layer_protocol_negotiation, ExtensionType.supported_versions, + ExtensionType.key_share]) + self.assertEqual(sni.data, ServerNameList(server_name_list=(ServerName(name_type=0, name=b'example.com'),))) + self.assertEqual(groups.data, NamedGroupList(named_group_list=(0x001D, 0x0017))) + self.assertEqual(sigalgs.data, SignatureSchemeList(supported_signature_algorithms=(0x0403, 0x0804))) + self.assertEqual(alpn.data, ProtocolNameList(protocol_name_list=(b'h2', b'http/1.1'))) + self.assertEqual(versions.data, SupportedVersionsClientHello(versions=(0x0304, 0x0303))) + self.assertEqual(key_share.data, KeyShareClientHello( + client_shares=(KeyShareEntry(group=0x001D, key_exchange=bytes(range(32))),))) + for ext, model in zip((sni, groups, sigalgs, alpn, versions, key_share), + (ServerNameList, NamedGroupList, SignatureSchemeList, ProtocolNameList, + SupportedVersionsClientHello, KeyShareClientHello)): + self.assertIsInstance(ext.data, model) + + def test_empty_key_share_in_a_client_hello(self) -> None: + """A client asking for a ``HelloRetryRequest`` sends no shares (RFC 9846 section 4.3.8).""" + from pcapkit.protocols.data.application.tls import KeyShareClientHello + + key_share, = self._extensions(_client_hello(_ext(51, _vec(b'')))) + self.assertEqual(key_share.data, KeyShareClientHello(client_shares=())) + + def test_server_hello_extensions(self) -> None: + from pcapkit.protocols.data.application.tls import (KeyShareEntry, KeyShareServerHello, + ProtocolNameList, + SupportedVersionsServerHello) + + versions, key_share, sni, alpn = self._extensions(_server_hello( + _ext(43, b'\x03\x04'), _ext(51, b'\x00\x1d' + _vec(b'\xaa' * 32)), _ext(0, b''), + _ext(16, _vec(_vec(b'h2', 1))))) + self.assertEqual(versions.data, SupportedVersionsServerHello(selected_version=0x0304)) + self.assertEqual(key_share.data, KeyShareServerHello( + server_share=KeyShareEntry(group=0x001D, key_exchange=b'\xaa' * 32))) + self.assertEqual(sni.data, b'') + self.assertEqual(alpn.data, ProtocolNameList(protocol_name_list=(b'h2',))) + + def test_hello_retry_request_extensions(self) -> None: + """A ``ServerHello`` carrying the ``HelloRetryRequest`` random takes that message's shapes.""" + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import (KeyShareHelloRetryRequest, + SupportedVersionsServerHello) + + wire = _server_hello(_ext(43, b'\x03\x04'), _ext(51, b'\x00\x17'), _ext(44, _vec(b'cookie')), + random=HRR_RANDOM) + versions, key_share, cookie = self._extensions(wire) + self.assertEqual(versions.data, SupportedVersionsServerHello(selected_version=0x0304)) + self.assertEqual(key_share.data, KeyShareHelloRetryRequest(selected_group=0x0017)) + self.assertEqual(cookie.data, _vec(b'cookie')) + self.assertEqual(TLS(wire, len(wire)).alias, 'TLS 1.3') + + # The same two octets in a plain ServerHello are no KeyShareEntry. + key_share, = self._extensions(_server_hello(_ext(51, b'\x00\x17'))) + self.assertEqual(key_share.data, b'\x00\x17') + + def test_encrypted_extensions(self) -> None: + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import (EncryptedExtensions, NamedGroupList, + ProtocolNameList) + + wire = _encrypted_extensions(_ext(0, b''), GROUPS, _ext(16, _vec(_vec(b'h2', 1)))) + body = TLS(wire, len(wire)).info.content[0].body + self.assertIsInstance(body, EncryptedExtensions) + sni, groups, alpn = self._extensions(wire) + self.assertEqual(sni.data, b'') + self.assertEqual(groups.data, NamedGroupList(named_group_list=(0x001D, 0x0017))) + self.assertEqual(alpn.data, ProtocolNameList(protocol_name_list=(b'h2',))) + + empty = _encrypted_extensions() + self.assertEqual(self._extensions(empty), ()) + + def test_dtls_client_hello_extensions(self) -> None: + """DTLS shares the registry, so its ``ClientHello`` decodes the same.""" + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.data.application.tls import SupportedVersionsClientHello + + body = (b'\xfe\xfd' + bytes(32) + b'\x00' + b'\x00' + _vec(b'\x13\x01') + _vec(b'\x00', 1) + + _vec(_ext(43, _vec(b'\xfe\xfc', 1)) + ALPN)) + message = (b'\x01' + len(body).to_bytes(3, 'big') + bytes(2) + bytes(3) + + len(body).to_bytes(3, 'big') + body) + wire = b'\x16\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + _vec(message) + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'DTLS 1.3') + versions, alpn = parsed.info.content[0].body.extensions + self.assertEqual(versions.data, SupportedVersionsClientHello(versions=(0xFEFC,))) + self.assertEqual(alpn.data.protocol_name_list, (b'h2', b'http/1.1')) + self.assertEqual(bytes(DTLS.from_data(parsed.info)).hex(), wire.hex()) + + +class UnknownExtensionTests(unittest.TestCase): + """An extension with no decoder keeps its octets, under its registry member.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_unregistered_types_stay_raw(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.protocols.application.tls import TLS + + cases = ( + (21, bytes(7), 'padding'), + (0x0A0A, b'', 'Reserved_2570'), # GREASE (RFC 8701) + (0x1234, b'\x01\x02', 'Unassigned'), + (0xFF01, b'\x00', 'renegotiation_info'), + (0xFF05, b'\xde\xad', 'Reserved_for_Private_Use'), + ) + wire = _client_hello(*(_ext(code, data) for code, data, _ in cases)) + extensions = TLS(wire, len(wire)).info.content[0].body.extensions + for ext, (code, data, name) in zip(extensions, cases): + with self.subTest(code=code): + self.assertIsInstance(ext.type, ExtensionType) + self.assertEqual((int(ext.type), ext.type.name), (code, name)) + self.assertEqual(ext.data, data) + self.assertEqual(bytes(TLS.from_data(TLS(wire, len(wire)).info)).hex(), wire.hex()) + + +class MalformedExtensionTests(unittest.TestCase): + """A body its decoder cannot read exactly keeps its octets; broken framing still raises.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_malformed_bodies_stay_raw_and_rebuild(self) -> None: + """The rest of the list still decodes, here a ``supported_versions`` after it.""" + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import (SupportedVersionsClientHello, + SupportedVersionsServerHello) + + cases = { + 'server_name list length too long': (_client_hello, _ext(0, b'\x00\x10\x00\x00\x01a')), + 'server_name list length too short': (_client_hello, _ext(0, b'\x00\x03\x00\x00\x01ab')), + 'server_name of an undefined name type': (_client_hello, _ext(0, _vec(b'\x01' + _vec(b'x')))), + 'server_name name past its list': (_client_hello, _ext(0, _vec(b'\x00\x00\x05abc'))), + 'supported_groups odd length': (_client_hello, _ext(10, _vec(b'\x00\x1d\x00'))), + 'supported_groups trailing octets': (_client_hello, _ext(10, _vec(b'\x00\x1d') + b'\x00')), + 'supported_groups no length prefix': (_client_hello, _ext(10, b'\x00')), + 'signature_algorithms odd length': (_client_hello, _ext(13, _vec(b'\x04'))), + 'signature_algorithms list length mismatch': (_client_hello, _ext(13, b'\xab\xcd')), + 'alpn name past its list': (_client_hello, _ext(16, _vec(b'\x05h2'))), + 'alpn list length mismatch': (_client_hello, _ext(16, b'\x00\x09\x02h2')), + 'supported_versions odd length': (_client_hello, _ext(43, _vec(b'\x03\x04\x03', 1))), + 'supported_versions length mismatch': (_client_hello, _ext(43, b'\x04\x03\x04')), + 'supported_versions selected_version too long': (_server_hello, _ext(43, b'\x03\x04\x00')), + 'key_share entry past its list': (_client_hello, _ext(51, _vec(b'\x00\x1d\x00\x20\x01'))), + 'key_share server_share trailing octets': (_server_hello, _ext(51, b'\x00\x1d\x00\x01\xaa\x00')), + 'key_share selected_group too long': ( + lambda *exts: _server_hello(*exts, random=HRR_RANDOM), _ext(51, b'\x00\x17\x00')), + } + for name, (hello, extension) in cases.items(): + with self.subTest(case=name): + client = hello is _client_hello + wire = hello(extension, VERSIONS if client else _ext(43, b'\x03\x04')) + parsed = TLS(wire, len(wire)) + ext, versions = parsed.info.content[0].body.extensions + self.assertEqual(ext.data, extension[4:]) + self.assertIsInstance(versions.data, SupportedVersionsClientHello if client + else SupportedVersionsServerHello) + self.assertEqual(bytes(TLS.from_data(parsed.info)).hex(), wire.hex()) + + def test_a_structure_the_message_does_not_have_stays_raw(self) -> None: + from pcapkit.protocols.application.tls import TLS + + for extension in (_ext(43, b'\x03\x04'), _ext(51, b'\x00\x17')): + with self.subTest(extension=extension.hex()): + wire = _encrypted_extensions(extension) + parsed = TLS(wire, len(wire)) + ext, = parsed.info.content[0].body.extensions + self.assertEqual(ext.data, extension[4:]) + self.assertEqual(bytes(TLS.from_data(parsed.info)).hex(), wire.hex()) + + def test_a_malformed_extension_leaves_version_detection_alone(self) -> None: + from pcapkit.protocols.application.tls import TLS + + wire = _client_hello(_ext(16, b'\x00\x09\x02h2'), VERSIONS) + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'TLS 1.3') + alpn, versions = parsed.info.content[0].body.extensions + self.assertEqual(alpn.data, b'\x00\x09\x02h2') + self.assertEqual(versions.data.versions, (0x0304, 0x0303)) + + def test_broken_framing_raises(self) -> None: + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + for name, wire in { + 'extension past its list': _client_hello(b'\x00\x0a\x00\xff'), + 'encrypted_extensions list length mismatch': _record(_handshake(8, b'\x00\x05' + GROUPS)), + 'encrypted_extensions without its length': _record(_handshake(8, b'\x00')), + }.items(): + with self.subTest(case=name), self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + +class ExtensionConstructionTests(unittest.TestCase): + """``make`` builds an extension from a data model, raw octets or keyword arguments.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def _hello(extensions: 'tuple', *, random: bytes = bytes(range(32)), server: bool = False) -> bytes: + from pcapkit.const.tls.content_type import ContentType + from pcapkit.const.tls.handshake_type import HandshakeType + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import ClientHello, Handshake, ServerHello + + if server: + body = ServerHello(version=0x0303, random=random, session_id=b'', cipher_suite=0x1301, + compression_method=0, extensions=extensions) # type: object + msg_type = HandshakeType.server_hello + else: + body = ClientHello(version=0x0303, random=random, session_id=b'', cookie=None, + cipher_suites=(0x1301,), compression_methods=(0,), extensions=extensions) + msg_type = HandshakeType.client_hello + maker = object.__new__(TLS) + return bytes(maker.make(content_type=ContentType.handshake, version=0x0303, + content=(Handshake(msg_type=msg_type, length=0, body=body),))) + + def test_client_hello_from_keyword_arguments(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.protocols.data.application.tls import KeyShareEntry, ServerName + + made = self._hello(( + (ExtensionType.server_name, {'server_name_list': (ServerName(name_type=0, name=b'example.com'),)}), + (ExtensionType.supported_groups, {'named_group_list': (0x001D, 0x0017)}), + (13, {'supported_signature_algorithms': (0x0403, 0x0804)}), + (ExtensionType.application_layer_protocol_negotiation, {'protocol_name_list': (b'h2', b'http/1.1')}), + (ExtensionType.supported_versions, {'versions': (0x0304, 0x0303)}), + (ExtensionType.key_share, {'client_shares': (KeyShareEntry(group=0x001D, key_exchange=bytes(range(32))),)}), + (ExtensionType.padding, {}), + )) + self.assertEqual(made.hex(), _client_hello(SNI, GROUPS, SIGALGS, ALPN, VERSIONS, KEY_SHARE, + _ext(21, b'')).hex()) + + def test_server_and_retry_hellos_from_keyword_arguments(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.protocols.data.application.tls import KeyShareEntry + + share = KeyShareEntry(group=0x001D, key_exchange=b'\xaa') + made = self._hello(((ExtensionType.supported_versions, {'selected_version': 0x0304}), + (ExtensionType.key_share, {'server_share': share})), + random=bytes(range(32, 64)), server=True) + self.assertEqual(made.hex(), _server_hello(_ext(43, b'\x03\x04'), _ext(51, b'\x00\x1d\x00\x01\xaa')).hex()) + + made = self._hello(((ExtensionType.supported_versions, {'selected_version': 0x0304}), + (ExtensionType.key_share, {'selected_group': 0x0017})), + random=HRR_RANDOM, server=True) + self.assertEqual(made.hex(), _server_hello(_ext(43, b'\x03\x04'), _ext(51, b'\x00\x17'), + random=HRR_RANDOM).hex()) + + def test_raw_octets_are_written_as_they_are(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.protocols.data.application.tls import Extension + + made = self._hello((Extension(type=ExtensionType.supported_groups, data=b'\xab\xcd'),)) + self.assertEqual(made.hex(), _client_hello(_ext(10, b'\xab\xcd')).hex()) + + def test_a_structure_with_nothing_to_build_from_raises(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.protocols.data.application.tls import Extension, NamedGroupList + from pcapkit.utilities.exceptions import ProtocolError + + for name, extensions, server in ( + ('selected_version missing', ((ExtensionType.supported_versions, {}),), True), + ('server_share missing', ((ExtensionType.key_share, {}),), True), + ('a model under a type with no constructor', ( + Extension(type=ExtensionType.padding, data=NamedGroupList(named_group_list=())),), False), + ): + with self.subTest(case=name), self.assertRaises(ProtocolError): + self._hello(extensions, server=server) + + +class ExtensionRegistryTests(unittest.TestCase): + """``register_tls_extension`` writes ``SSL.__extension__``, which TLS and DTLS read.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_callable_pair_overrides_and_the_shipped_entry_restores(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.const.tls.handshake_type import HandshakeType + from pcapkit.foundation.registry.protocols import register_tls_extension + from pcapkit.protocols.application.ssl import SSL + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import ProtocolNameList + from pcapkit.utilities.warnings import RegistryWarning + + calls = [] + + def parser(ext_type: object, data: bytes, *, msg_type: object) -> object: + calls.append(('read', ext_type, msg_type)) + return ProtocolNameList(protocol_name_list=(data,)) + + def constructor(ext_type: object, data: object = None, *, msg_type: object, + **kwargs: object) -> bytes: # pylint: disable=unused-argument + calls.append(('make', ext_type, msg_type)) + return data.protocol_name_list[0] # type: ignore[attr-defined] + + shipped = dict(SSL.__extension__) + try: + with self.assertWarns(RegistryWarning): + register_tls_extension(ExtensionType.supported_groups, (parser, constructor)) + wire = _client_hello(GROUPS) + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.info.content[0].body.extensions[0].data, + ProtocolNameList(protocol_name_list=(GROUPS[4:],))) + self.assertEqual(bytes(TLS.from_data(parsed.info)).hex(), wire.hex()) + for kind in ('read', 'make'): + self.assertIn((kind, ExtensionType.supported_groups, HandshakeType.client_hello), calls) + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', RegistryWarning) + register_tls_extension(ExtensionType.supported_groups, 'supported_groups') + self.assertEqual(dict(SSL.__extension__), shipped) + self.assertIs(TLS.__extension__, SSL.__extension__) + finally: + SSL.__extension__.clear() + SSL.__extension__.update(shipped) + + def test_a_type_with_no_shipped_entry(self) -> None: + """``padding`` (RFC 7685 section 3) has no decoder until one is registered, silently.""" + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.const.tls.handshake_type import HandshakeType + from pcapkit.corekit.infoclass import info_final + from pcapkit.foundation.registry.protocols import register_tls_extension + from pcapkit.protocols.application.ssl import SSL + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import ExtensionData + from pcapkit.utilities.exceptions import ProtocolError + + @info_final + class Padding(ExtensionData): + """``extension_data`` of ``padding``: zero octets, counted.""" + + length: 'int' + + def parser(ext_type: object, data: bytes, *, msg_type: object) -> object: # pylint: disable=unused-argument + if data.strip(b'\x00'): + raise ProtocolError('padding: non-zero octet') + return Padding(length=len(data)) + + def constructor(ext_type: object, data: object = None, *, msg_type: object, + length: int = 0, **kwargs: object) -> bytes: # pylint: disable=unused-argument + self.assertEqual(msg_type, HandshakeType.client_hello) + return bytes(data.length if data is not None else length) # type: ignore[attr-defined] + + wire = _client_hello(_ext(21, bytes(5)), _ext(21, b'\x00\x01'), VERSIONS) + self.assertNotIn(ExtensionType.padding, SSL.__extension__) + shipped = dict(SSL.__extension__) + try: + with warnings.catch_warnings(): + warnings.simplefilter('error') + register_tls_extension(ExtensionType.padding, (parser, constructor)) + parsed = TLS(wire, len(wire)) + padding, rejected, versions = parsed.info.content[0].body.extensions + self.assertEqual(padding.data, Padding(length=5)) + self.assertEqual(rejected.data, b'\x00\x01') + self.assertEqual(versions.data.versions, (0x0304, 0x0303)) + self.assertEqual(bytes(TLS.from_data(parsed.info)).hex(), wire.hex()) + self.assertEqual(ExtensionConstructionTests._hello(((ExtensionType.padding, {'length': 3}),)).hex(), + _client_hello(_ext(21, bytes(3))).hex()) + finally: + SSL.__extension__.clear() + SSL.__extension__.update(shipped) + padding, _, _ = TLS(wire, len(wire)).info.content[0].body.extensions + self.assertEqual(padding.data, bytes(5)) + + def test_handing_back_the_shipped_entry_is_a_silent_no_op(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.foundation.registry.protocols import register_tls_extension + from pcapkit.protocols.application.ssl import SSL + + shipped = dict(SSL.__extension__) + with warnings.catch_warnings(): + warnings.simplefilter('error') + register_tls_extension(ExtensionType.key_share, 'key_share') + self.assertEqual(dict(SSL.__extension__), shipped) + + def test_bad_registrations_change_nothing(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + from pcapkit.const.tls.handshake_type import HandshakeType + from pcapkit.foundation.registry.protocols import register_tls_extension + from pcapkit.protocols.application.ssl import SSL + from pcapkit.utilities.exceptions import RegistryError + + shipped = dict(SSL.__extension__) + for code, meth, pattern in ( + (10, 'supported_groups', '^SSL: code must be '), + (HandshakeType.client_hello, 'supported_groups', '^SSL: code must be '), + (ExtensionType.padding, 'no_such_parser', 'TLS extension parser'), + ): + with self.subTest(code=code, meth=meth), self.assertRaisesRegex(RegistryError, pattern): + register_tls_extension(code, meth) # type: ignore[arg-type] + self.assertEqual(dict(SSL.__extension__), shipped) + + +class ExtensionTypeRegistryTests(unittest.TestCase): + """``ExtensionType``, as its crawler renders IANA's registry.""" + + def test_names_and_ranges(self) -> None: + from pcapkit.const.tls.extension_type import ExtensionType + + for value, name in ((0, 'server_name'), (10, 'supported_groups'), (35, 'session_ticket'), + (36, 'TLMSP'), (40, 'Reserved_40'), (53, 'connection_id_deprecated'), + (54, 'connection_id'), (0x0A0A, 'Reserved_2570'), + (0xFF00, 'Reserved_for_Private_Use'), (0xFF01, 'renegotiation_info'), + (63, 'Unassigned'), (0xFFFF, 'Reserved_for_Private_Use')): + with self.subTest(value=value): + self.assertEqual(ExtensionType(value).name, name) + with self.assertRaises(ValueError): + ExtensionType(0x10000) + + def test_crawler_cleans_the_parentheticals(self) -> None: + from pcapkit.vendor.tls.extension_type import ExtensionType + + crawler = object.__new__(ExtensionType) + for raw, name in (('supported_groups (renamed from "elliptic_curves")', 'supported_groups'), + ('session_ticket (renamed from "SessionTicket TLS")', 'session_ticket'), + ('connection_id (deprecated)', 'connection_id_deprecated'), + ('TLMSP', 'TLMSP')): + with self.subTest(raw=raw): + self.assertEqual(crawler.clean_name(raw), name) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/test_parser_registrar_identity_unit.py b/tests/protocols/test_parser_registrar_identity_unit.py index 88dea7c175..ed70437bdb 100644 --- a/tests/protocols/test_parser_registrar_identity_unit.py +++ b/tests/protocols/test_parser_registrar_identity_unit.py @@ -39,6 +39,7 @@ ('pcapkit.protocols.transport.sctp', 'SCTP', 'register_cause', '__cause__'), ('pcapkit.protocols.transport.dccp', 'DCCP', 'register_option', '__option__'), ('pcapkit.protocols.application.httpv2', 'HTTPv2', 'register_frame', '__frame__'), + ('pcapkit.protocols.application.ssl', 'SSL', 'register_extension', '__extension__'), ) diff --git a/tests/protocols/test_protocol_value_helpers_2514_unit.py b/tests/protocols/test_protocol_value_helpers_2514_unit.py new file mode 100644 index 0000000000..7e228873f8 --- /dev/null +++ b/tests/protocols/test_protocol_value_helpers_2514_unit.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +"""The value helpers ``Protocol`` shares with its subclasses (GitHub issue #2514). + +The TLS extension parser on PR #2595 brought module-level vector and integer-list +helpers, and the owner asked there for them to live on the base ``Protocol`` +instead, so that any protocol can use them; PR #2607's 802.11 header brought the +``_make_uint`` check, and the owner asked for the same there. Each helper is +checked here on its own, apart from TLS: + +* ``_read_vector`` and ``_read_whole_vector`` -- a length-prefixed vector, with + prefix widths of one, two and three octets, refusing a truncated prefix, a + length past the end of the buffer and, for the whole-buffer form, trailing + octets; +* ``_read_uints`` -- a run of fixed-width unsigned integers, refusing a buffer + that is not a whole number of them; +* ``_make_vector`` and ``_make_uints`` -- their inverses, refusing what does not + fit the width; +* ``_make_uint`` -- the check that a value given for a field fits its width. + +The errors the readers and ``_make_uint`` raise name the instance's ``alias``, +which is per instance -- a TLS record's is its tier, such as ``TLS 1.3`` -- so +that is pinned with an alias that differs from the class name. + +""" + +from __future__ import annotations + +import enum +import unittest +from typing import TYPE_CHECKING +from unittest import mock + +from tests._support import reimport_once_per_class + +if TYPE_CHECKING: + from typing import Callable + + from pcapkit.protocols.misc.raw import Raw + +#: Test tier (#1958): the per-helper cases of the protocol base, beside the +#: ``ALL``-tier smoke slice in ``test_protocol_base_unit``. +__tier__ = 'LATEST' + +#: An alias that is not the class name, to tell ``self.alias`` from ``cls.__name__``. +ALIAS = 'Sample 1.0' + + +class _HelperTestCase(unittest.TestCase): + """A fresh :mod:`pcapkit` per class, and a protocol instance to call the helpers on.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + from pcapkit.protocols.misc.raw import Raw + + self.proto = Raw(b'') # type: Raw + + def assertRefused(self, pattern: 'str', func: 'Callable[..., object]', *args: 'object', + **kwargs: 'object') -> None: + """``func`` raises :exc:`ProtocolError` naming :data:`ALIAS` and matching ``pattern``.""" + from pcapkit.utilities.exceptions import ProtocolError + + with mock.patch.object(type(self.proto), 'alias', new_callable=mock.PropertyMock, return_value=ALIAS): + with self.assertRaisesRegex(ProtocolError, f'^{ALIAS}: {pattern}'): + func(*args, **kwargs) + + +class ReadVectorTests(_HelperTestCase): + """``_read_vector`` returns the vector's octets and the offset just past them.""" + + def test_each_prefix_width(self) -> None: + for size, prefix in ((1, b'\x03'), (2, b'\x00\x03'), (3, b'\x00\x00\x03')): + with self.subTest(size=size): + data = b'\xee' + prefix + b'abc' + b'\xff' + self.assertEqual(self.proto._read_vector(data, 1, size=size, what='v'), (b'abc', 1 + size + 3)) + + def test_an_empty_vector_and_the_default_offset(self) -> None: + self.assertEqual(self.proto._read_vector(b'\x00rest', what='v'), (b'', 1)) + + def test_a_truncated_prefix_is_refused(self) -> None: + for size in (1, 2, 3): + with self.subTest(size=size): + self.assertRefused('invalid format: v length runs past the end of the buffer', + self.proto._read_vector, b'\x00' * size, 1, size=size, what='v') + + def test_a_length_past_the_end_is_refused(self) -> None: + for size, prefix in ((1, b'\x04'), (2, b'\x00\x04'), (3, b'\x00\x00\x04')): + with self.subTest(size=size): + self.assertRefused('invalid format: v runs past the end of the buffer', + self.proto._read_vector, prefix + b'abc', size=size, what='v') + + +class ReadWholeVectorTests(_HelperTestCase): + """``_read_whole_vector`` reads the one vector a buffer holds, and nothing after it.""" + + def test_each_prefix_width(self) -> None: + for size, prefix in ((1, b'\x02'), (2, b'\x00\x02'), (3, b'\x00\x00\x02')): + with self.subTest(size=size): + self.assertEqual(self.proto._read_whole_vector(prefix + b'ab', size=size, what='v'), b'ab') + + def test_a_truncated_prefix_is_refused(self) -> None: + for size in (2, 3): + with self.subTest(size=size): + self.assertRefused('invalid format: v length runs past the end of the buffer', + self.proto._read_whole_vector, b'\x00' * (size - 1), size=size, what='v') + + def test_a_length_past_the_end_is_refused(self) -> None: + for size, prefix in ((1, b'\x03'), (2, b'\x00\x03'), (3, b'\x00\x00\x03')): + with self.subTest(size=size): + self.assertRefused('invalid format: v runs past the end of the buffer', + self.proto._read_whole_vector, prefix + b'ab', size=size, what='v') + + def test_trailing_octets_are_refused(self) -> None: + for size, prefix in ((1, b'\x01'), (2, b'\x00\x01'), (3, b'\x00\x00\x01')): + with self.subTest(size=size): + self.assertRefused('invalid format: v carries excess octets', + self.proto._read_whole_vector, prefix + b'ab', size=size, what='v') + + +class ReadUintsTests(_HelperTestCase): + """``_read_uints`` reads a buffer as big-endian unsigned integers of one width.""" + + def test_each_width(self) -> None: + data = bytes(range(1, 7)) + for size, expected in ((1, (1, 2, 3, 4, 5, 6)), (2, (0x0102, 0x0304, 0x0506)), + (3, (0x010203, 0x040506))): + with self.subTest(size=size): + self.assertEqual(self.proto._read_uints(data, size=size, what='list'), expected) + + def test_an_empty_list(self) -> None: + self.assertEqual(self.proto._read_uints(b'', size=2, what='list'), ()) + + def test_a_length_that_is_not_a_whole_number_of_integers_is_refused(self) -> None: + for size, data in ((2, b'\x00\x01\x02'), (3, b'\x00\x01\x02\x03')): + with self.subTest(size=size): + self.assertRefused(f'invalid format: list length {len(data)} is not a multiple of {size}$', + self.proto._read_uints, data, size=size, what='list') + + +class MakeVectorTests(_HelperTestCase): + """``_make_vector`` writes the prefix ``_read_vector`` reads.""" + + def test_each_prefix_width(self) -> None: + for size, prefix in ((1, b'\x03'), (2, b'\x00\x03'), (3, b'\x00\x00\x03')): + with self.subTest(size=size): + made = type(self.proto)._make_vector(b'abc', size=size) + self.assertEqual(made, prefix + b'abc') + self.assertEqual(self.proto._read_whole_vector(made, size=size, what='v'), b'abc') + + def test_a_vector_too_long_for_its_prefix_is_refused(self) -> None: + from pcapkit.utilities.exceptions import StructError + + with self.assertRaisesRegex(StructError, '^Raw: pack failed$'): + type(self.proto)._make_vector(bytes(256), size=1) + + +class MakeUintsTests(_HelperTestCase): + """``_make_uints`` writes the integers ``_read_uints`` reads.""" + + def test_each_width(self) -> None: + class Code(enum.IntEnum): + five = 5 + + for size, expected in ((1, b'\x01\x05'), (2, b'\x00\x01\x00\x05'), (3, b'\x00\x00\x01\x00\x00\x05')): + with self.subTest(size=size): + made = type(self.proto)._make_uints((1, Code.five), size=size) + self.assertEqual(made, expected) + self.assertEqual(self.proto._read_uints(made, size=size, what='list'), (1, 5)) + + def test_a_value_outside_the_width_is_refused(self) -> None: + from pcapkit.utilities.exceptions import StructError + + for size, value in ((1, 256), (2, 0x10000), (3, 0x1000000), (2, -1)): + with self.subTest(size=size, value=value), self.assertRaises(StructError): + type(self.proto)._make_uints((value,), size=size) + + +class MakeUintTests(_HelperTestCase): + """``_make_uint`` passes a value that fits its field, as a plain :class:`int`.""" + + def test_a_value_that_fits_is_returned_as_an_int(self) -> None: + for bits, value in ((1, 1), (2, 3), (4, 0), (16, 0xFFFF)): + with self.subTest(bits=bits, value=value): + self.assertEqual(self.proto._make_uint(value, bits=bits, name='field'), value) + + def test_a_bool_is_accepted_for_a_one_bit_field(self) -> None: + for flag in (False, True): + with self.subTest(flag=flag): + made = self.proto._make_uint(flag, bits=1, name='flag') + self.assertEqual(made, int(flag)) + self.assertIs(type(made), int) + + def test_a_bool_is_refused_for_a_wider_field(self) -> None: + for bits in (2, 8): + with self.subTest(bits=bits): + self.assertRefused(r'invalid field: True$', self.proto._make_uint, True, bits=bits, name='field') + + def test_a_negative_value_is_refused(self) -> None: + self.assertRefused(r'invalid field: -1$', self.proto._make_uint, -1, bits=4, name='field') + + def test_a_value_one_past_the_width_is_refused(self) -> None: + for bits in (1, 4, 16): + with self.subTest(bits=bits): + self.assertRefused(rf'invalid field: {2 ** bits}$', + self.proto._make_uint, 2 ** bits, bits=bits, name='field') + + def test_a_non_int_is_refused(self) -> None: + for value, shown in (('1', "'1'"), (1.0, r'1\.0'), (None, 'None')): + with self.subTest(value=value): + self.assertRefused(f'invalid field: {shown}$', self.proto._make_uint, value, bits=4, name='field') + + +if __name__ == '__main__': + unittest.main()