From cd0a38016cbb689bdf7df2378d21a78f77ffc72b Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Fri, 30 Oct 2026 22:26:25 -0400 Subject: [PATCH 1/4] feat(application): add an SSLv2 dissector - SSLv2 (draft-hickman-netscape-ssl-00) subclasses SSL and joins SSL.id(). Its module and class are named SSLv2. - TLS.read tries TLS first. SSLv2 is taken only when TLS rejects the record and the payload is a complete CLIENT-HELLO or SERVER-HELLO that passes the hello length probe; anything else stays Raw. - The TLS object that adopts an SSLv2 parse reports its 2- or 3-octet header as length, so packet splits as a direct SSLv2 parse does. - TLS.from_data rebuilds an adopted SSLv2 record as SSLv2, so a port-443 hello round-trips byte-exact through TCP.from_data. - Add pcapkit.const.sslv2 (HandshakeType, Version) and bump the const census pins by +2 over #2441's. Part of #2457. --- docs/source/ext.rst | 6 +- docs/source/pcapkit/const/index.rst | 1 + docs/source/pcapkit/const/sslv2.rst | 52 ++ .../pcapkit/protocols/application/index.rst | 1 + .../pcapkit/protocols/application/ssl.rst | 2 + .../pcapkit/protocols/application/sslv2.rst | 120 +++ docs/source/pcapkit/protocols/index.rst | 3 +- pcapkit/__init__.py | 2 +- pcapkit/all.py | 2 +- pcapkit/const/__init__.py | 3 + pcapkit/const/sslv2/__init__.py | 27 + pcapkit/const/sslv2/handshake_type.py | 98 +++ pcapkit/const/sslv2/version.py | 79 ++ pcapkit/protocols/__init__.py | 2 +- pcapkit/protocols/application/__init__.py | 3 +- pcapkit/protocols/application/ssl.py | 34 +- pcapkit/protocols/application/sslv2.py | 477 ++++++++++++ pcapkit/protocols/application/tls.py | 104 ++- pcapkit/protocols/data/__init__.py | 3 + .../protocols/data/application/__init__.py | 8 + pcapkit/protocols/data/application/sslv2.py | 94 +++ pcapkit/protocols/schema/__init__.py | 2 + .../protocols/schema/application/__init__.py | 12 + pcapkit/protocols/schema/application/sslv2.py | 304 ++++++++ tests/const/test_const_enum_builtin_parity.py | 10 +- tests/const/test_const_enum_get.py | 4 +- tests/const/test_const_enum_lookup.py | 2 +- tests/const/test_const_registry_protocol.py | 4 +- tests/const/test_const_sslv2_unit.py | 86 +++ .../test_rfc_constants_one_home_1739_unit.py | 4 + .../application/test_ssl_tls_dtls_unit.py | 137 +++- .../protocols/application/test_sslv2_unit.py | 686 ++++++++++++++++++ 32 files changed, 2343 insertions(+), 29 deletions(-) create mode 100644 docs/source/pcapkit/const/sslv2.rst create mode 100644 docs/source/pcapkit/protocols/application/sslv2.rst create mode 100644 pcapkit/const/sslv2/__init__.py create mode 100644 pcapkit/const/sslv2/handshake_type.py create mode 100644 pcapkit/const/sslv2/version.py create mode 100644 pcapkit/protocols/application/sslv2.py create mode 100644 pcapkit/protocols/data/application/sslv2.py create mode 100644 pcapkit/protocols/schema/application/sslv2.py create mode 100644 tests/const/test_const_sslv2_unit.py create mode 100644 tests/protocols/application/test_sslv2_unit.py diff --git a/docs/source/ext.rst b/docs/source/ext.rst index a9d385ca47..45a52cd8e0 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -99,10 +99,12 @@ The protocol classes :mod:`pcapkit` ships: | | | :class:`pcapkit.protocols.application.ssl.SSL` | + + +-----------------------+-------------------------------------------------------------+ | | | :class:`pcapkit.protocols.application.tls.TLS` | -+ + SSL Family +-----------------------+-------------------------------------------------------------+ -| | | :class:`pcapkit.protocols.application.tls.SSLv3` | ++ + +-----------------------+-------------------------------------------------------------+ +| | SSL Family | :class:`pcapkit.protocols.application.tls.SSLv3` | + + +-----------------------+-------------------------------------------------------------+ | | | :class:`pcapkit.protocols.application.dtls.DTLS` | ++ + +-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.application.sslv2.SSLv2` | +------------------------------------------------------------------+----------------+-----------------------+-------------------------------------------------------------+ | | | :class:`pcapkit.protocols.misc.pcap.header.Header` | + + PCAP Format +-----------------------+-------------------------------------------------------------+ diff --git a/docs/source/pcapkit/const/index.rst b/docs/source/pcapkit/const/index.rst index 5d006a9825..b97eca1249 100644 --- a/docs/source/pcapkit/const/index.rst +++ b/docs/source/pcapkit/const/index.rst @@ -104,6 +104,7 @@ Application Layer http ngap ospf + sslv2 tls Miscellaneous diff --git a/docs/source/pcapkit/const/sslv2.rst b/docs/source/pcapkit/const/sslv2.rst new file mode 100644 index 0000000000..bef2576038 --- /dev/null +++ b/docs/source/pcapkit/const/sslv2.rst @@ -0,0 +1,52 @@ +========================================================================= +:class:`~pcapkit.protocols.application.sslv2.SSLv2` Constant Enumerations +========================================================================= + +.. module:: pcapkit.const.sslv2 + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.application.sslv2.SSLv2` implementations. Available +enumerations include: + +.. list-table:: + + * - :class:`SSLv2_HandshakeType ` + - SSLv2 Handshake Message Type [*]_ + * - :class:`SSLv2_Version ` + - SSLv2 Protocol Version [*]_ + +.. versionadded:: 1.6.0 + +SSLv2 Handshake Message Type +============================= + +.. module:: pcapkit.const.sslv2.handshake_type + +This module contains the constant enumeration for **SSLv2 Handshake Message +Type**, hand-curated rather than generated by a +:class:`~pcapkit.vendor.default.Vendor` crawler: the expired SSLv2 draft is +not a live registry a crawler could re-fetch. + +.. autoclass:: pcapkit.const.sslv2.handshake_type.HandshakeType + :members: + :undoc-members: + :show-inheritance: + +SSLv2 Protocol Version +======================= + +.. module:: pcapkit.const.sslv2.version + +This module contains the constant enumeration for **SSLv2 Protocol +Version**, hand-curated for the same reason +:class:`~pcapkit.const.sslv2.handshake_type.HandshakeType` is. + +.. autoclass:: pcapkit.const.sslv2.version.Version + :members: + :undoc-members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 diff --git a/docs/source/pcapkit/protocols/application/index.rst b/docs/source/pcapkit/protocols/application/index.rst index bc7d33f37b..3ffb8fc841 100644 --- a/docs/source/pcapkit/protocols/application/index.rst +++ b/docs/source/pcapkit/protocols/application/index.rst @@ -23,6 +23,7 @@ function rather than encapsulation, see ospf rarp ssl + sslv2 tls dtls diff --git a/docs/source/pcapkit/protocols/application/ssl.rst b/docs/source/pcapkit/protocols/application/ssl.rst index c44e49b423..e2654da645 100644 --- a/docs/source/pcapkit/protocols/application/ssl.rst +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -8,6 +8,8 @@ SSL - Secure Sockets Layer which is the umbrella base class for the Secure Sockets Layer protocol family [*]_ -- the SSL versions, Transport Layer Security and Datagram TLS: +* :class:`~pcapkit.protocols.application.sslv2.SSLv2` -- SSL 2.0, whose + record layer shares no wire format with the others; * :class:`~pcapkit.protocols.application.tls.TLS` -- SSLv3 and TLS 1.0 through TLS 1.3, with :class:`~pcapkit.protocols.application.tls.SSLv3` as its thin subclass for explicit construction; diff --git a/docs/source/pcapkit/protocols/application/sslv2.rst b/docs/source/pcapkit/protocols/application/sslv2.rst new file mode 100644 index 0000000000..161d7040a4 --- /dev/null +++ b/docs/source/pcapkit/protocols/application/sslv2.rst @@ -0,0 +1,120 @@ +SSLv2 - Secure Sockets Layer version 2 +======================================= + +.. module:: pcapkit.protocols.application.sslv2 + +:mod:`pcapkit.protocols.application.sslv2` contains +:class:`~pcapkit.protocols.application.sslv2.SSLv2` only, +which implements an extractor for Secure Sockets Layer +version 2 (SSLv2) [*]_, whose record header is 2 or 3 octets, chosen by the +high bit of its first octet (``sslv2.indicator``): + +.. code-block:: text + + 2-octet form (high bit set): + + 0 1 2 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |1| sslv2.indicator/length_low (15) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |sslv2.msg_type | handshake message body ... + + 3-octet form (high bit clear): + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |0|E| sslv2.indicator/length_low (14) | sslv2.padding (8) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |sslv2.msg_type | handshake message body ... + +``E`` is the ``IS-ESCAPE`` flag, meaningful only in the 3-octet form. See +:func:`~pcapkit.protocols.schema.application.sslv2.sslv2_record_length` for the +exact bit arithmetic. + +SSLv2 is wire-*incompatible* with SSLv3 and TLS: it carries no content-type +byte and no ``0x03xx``-style record version, and its handshake message type +is the first octet of the record's data rather than a separate record-layer +field. This package parses the ``CLIENT-HELLO`` and ``SERVER-HELLO`` +handshake message bodies; every other +:class:`~pcapkit.const.sslv2.handshake_type.HandshakeType`, and a hello-tagged +body whose declared sub-field lengths do not fit the record (e.g. +ciphertext), is kept as raw octets, header and message type decoded. + +.. versionadded:: 1.6.0 + +.. raw:: html + +
+ +.. autoclass:: pcapkit.protocols.application.sslv2.SSLv2 + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: length + + .. automethod:: read + .. automethod:: make + + .. automethod:: id + + .. automethod:: _make_data + .. automethod:: _make_sslv2_client_hello + .. automethod:: _make_sslv2_server_hello + +Header Schemas +-------------- + +.. module:: pcapkit.protocols.schema.application.sslv2 + +.. autoclass:: pcapkit.protocols.schema.application.sslv2.SSLv2 + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.application.sslv2.HandshakeBody + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.application.sslv2.UnknownHandshakeBody + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.application.sslv2.ClientHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.schema.application.sslv2.ServerHello + :members: + :show-inheritance: + +Auxiliary Functions +~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: pcapkit.protocols.schema.application.sslv2.sslv2_record_length +.. autofunction:: pcapkit.protocols.schema.application.sslv2.sslv2_hello_probe_length +.. autofunction:: pcapkit.protocols.schema.application.sslv2.sslv2_hello_probe_is_plausible +.. autofunction:: pcapkit.protocols.schema.application.sslv2.sslv2_body_selector + +Data Models +----------- + +.. module:: pcapkit.protocols.data.application.sslv2 + +.. autoclass:: pcapkit.protocols.data.application.sslv2.SSLv2 + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.sslv2.ClientHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.sslv2.ServerHello + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index 4c4fdbb691..bd0c6c38f1 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -57,7 +57,7 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: end subgraph ssl [SSL Family] - SSL --> TLS & DTLS + SSL --> TLS & DTLS & SSLv2 TLS --> SSLv3 end end @@ -163,6 +163,7 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: click RARP "application/rarp.html#pcapkit.protocols.application.rarp.RARP" click DRARP "application/rarp.html#pcapkit.protocols.application.rarp.DRARP" click SSL "application/ssl.html#pcapkit.protocols.application.ssl.SSL" + click SSLv2 "application/sslv2.html#pcapkit.protocols.application.sslv2.SSLv2" click TLS "application/tls.html#pcapkit.protocols.application.tls.TLS" click SSLv3 "application/tls.html#pcapkit.protocols.application.tls.SSLv3" click DTLS "application/dtls.html#pcapkit.protocols.application.dtls.DTLS" diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index b60c1e8e61..1f94a6e94f 100644 --- a/pcapkit/__init__.py +++ b/pcapkit/__init__.py @@ -122,7 +122,7 @@ 'FTP', 'FTP_DATA', # Application Layer 'HTTP', 'HTTPv1', 'HTTPv2', 'NGAP', 'OSPF', - 'RARP', 'DRARP', 'SSL', 'SSLv3', 'TLS', 'DTLS', + 'RARP', 'DRARP', 'SSL', 'SSLv2', 'SSLv3', 'TLS', 'DTLS', 'Data', # Protocol Data 'Schema', # Protocol Schema diff --git a/pcapkit/all.py b/pcapkit/all.py index a5a782c8b3..2d292bdd6a 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -127,7 +127,7 @@ 'TCP', 'UDP', 'SCTP', # Transport Layer 'FTP', 'FTP_DATA', # Application Layer 'HTTP', 'HTTPv1', 'HTTPv2', 'NGAP', 'OSPF', 'RARP', 'DRARP', - 'SSL', 'SSLv3', 'TLS', 'DTLS', + 'SSL', 'SSLv2', 'SSLv3', 'TLS', 'DTLS', 'Schema', 'schema', # Protocol Schema 'Data', 'data', # Protocol Data diff --git a/pcapkit/const/__init__.py b/pcapkit/const/__init__.py index 0cb93ffda2..f72124612c 100644 --- a/pcapkit/const/__init__.py +++ b/pcapkit/const/__init__.py @@ -28,6 +28,7 @@ from pcapkit.const.ngap import * from pcapkit.const.ospf import * from pcapkit.const.sctp import * +from pcapkit.const.sslv2 import * from pcapkit.const.tcp import * from pcapkit.const.tls import * from pcapkit.const.vlan import * @@ -80,6 +81,8 @@ 'OSPF_Authentication', 'OSPF_Packet', # SCTP 'SCTP_Chunk', 'SCTP_Parameter', 'SCTP_CauseCode', 'SCTP_PayloadProtocolIdentifier', + # SSLv2 + 'SSLv2_HandshakeType', 'SSLv2_Version', # TCP 'TCP_Checksum', 'TCP_Option', 'TCP_MPTCPOption', 'TCP_Flags', # TLS/DTLS diff --git a/pcapkit/const/sslv2/__init__.py b/pcapkit/const/sslv2/__init__.py new file mode 100644 index 0000000000..f4bed0456e --- /dev/null +++ b/pcapkit/const/sslv2/__init__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +r""":class:`~pcapkit.protocols.application.sslv2.SSLv2` Constant Enumerations +============================================================================= + +.. module:: pcapkit.const.sslv2 + +This module contains all constant enumerations of +:class:`~pcapkit.protocols.application.sslv2.SSLv2` implementations. Available +enumerations include: + +.. list-table:: + + * - :class:`SSLv2_HandshakeType ` + - SSLv2 Handshake Message Type [*]_ + * - :class:`SSLv2_Version ` + - SSLv2 Protocol Version [*]_ + +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 + +""" + +from pcapkit.const.sslv2.handshake_type import HandshakeType as SSLv2_HandshakeType +from pcapkit.const.sslv2.version import Version as SSLv2_Version + +__all__ = ['SSLv2_HandshakeType', 'SSLv2_Version'] diff --git a/pcapkit/const/sslv2/handshake_type.py b/pcapkit/const/sslv2/handshake_type.py new file mode 100644 index 0000000000..2d316bffb8 --- /dev/null +++ b/pcapkit/const/sslv2/handshake_type.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""SSLv2 Handshake Message Type +================================== + +.. module:: pcapkit.const.sslv2.handshake_type + +This module contains the constant enumeration for **SSLv2 Handshake Message +Type**, hand-curated rather than generated by a +:class:`~pcapkit.vendor.default.Vendor` crawler: the ``MSG-*`` codes below +are the complete, closed set defined by the expired Netscape SSLv2 +draft [*]_, which is not an IANA (or any other) live registry a crawler +could re-fetch -- there is nothing upstream of this file to go stale against. + +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 + +""" + +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__ = ['HandshakeType'] + + +class HandshakeType(EnumRegistry, IntEnum): + """[HandshakeType] SSLv2 Handshake Message Type""" + + #: ``MSG-ERROR`` + ERROR = 0 + + #: ``MSG-CLIENT-HELLO`` + CLIENT_HELLO = 1 + + #: ``MSG-CLIENT-MASTER-KEY`` + CLIENT_MASTER_KEY = 2 + + #: ``MSG-CLIENT-FINISHED`` + CLIENT_FINISHED = 3 + + #: ``MSG-SERVER-HELLO`` + SERVER_HELLO = 4 + + #: ``MSG-SERVER-VERIFY`` + SERVER_VERIFY = 5 + + #: ``MSG-SERVER-FINISHED`` + SERVER_FINISHED = 6 + + #: ``MSG-REQUEST-CERTIFICATE`` + REQUEST_CERTIFICATE = 7 + + #: ``MSG-CLIENT-CERTIFICATE`` + CLIENT_CERTIFICATE = 8 + + @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 0x00 <= value <= 0xFF): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'HandshakeType': + """Lookup function used when value is not found. + + A wire byte the draft does not define, ``9`` through ``255``, + resolves to an unregistered ``Unassigned`` member rather than + raising, as other registries in :mod:`pcapkit.const` do. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0x00 <= value <= 0xFF): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + return cls._unregistered_member(value, 'Unassigned') diff --git a/pcapkit/const/sslv2/version.py b/pcapkit/const/sslv2/version.py new file mode 100644 index 0000000000..68f633af16 --- /dev/null +++ b/pcapkit/const/sslv2/version.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""SSLv2 Protocol Version +============================ + +.. module:: pcapkit.const.sslv2.version + +This module contains the constant enumeration for **SSLv2 Protocol +Version**, hand-curated rather than generated by a +:class:`~pcapkit.vendor.default.Vendor` crawler, for the same reason +:class:`pcapkit.const.sslv2.handshake_type.HandshakeType` is: the value below +comes from the expired Netscape SSLv2 draft [*]_, not a live registry. + +SSLv2 carries no record-layer version field; ``CLIENT-VERSION`` and +``SERVER-VERSION`` are handshake-body fields. See :meth:`Version._missing_` +for values other than ``SSLv2``. + +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 + +""" + +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__ = ['Version'] + + +class Version(EnumRegistry, IntEnum): + """[Version] SSLv2 Protocol Version""" + + #: ``SSL-2.0`` + SSLv2 = 0x0002 + + @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 0x0000 <= value <= 0xFFFF): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'Version': + """Lookup function used when value is not found. + + A ``CLIENT-VERSION``/``SERVER-VERSION`` other than ``0x0002`` is not + necessarily malformed: per :rfc:`6101#appendix-E`, an SSLv3-capable + client sends an SSLv2-framed ``CLIENT-HELLO`` whose version reads + ``768`` (``0x0300``). So any unrecognised value resolves to an + unregistered member rather than raising. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0x0000 <= value <= 0xFFFF): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + return cls._unregistered_member(value, 'Unassigned') diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 225b11131b..9dc6a88b09 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -69,7 +69,7 @@ 'NGAP', 'OSPF', 'RARP', 'DRARP', - 'SSL', 'SSLv3', 'TLS', 'DTLS', + 'SSL', 'SSLv2', 'SSLv3', 'TLS', 'DTLS', ] #: dict[str, ~typing.Type[~pcapkit.protocols.protocol.ProtocolBase]]: Protocol registry. diff --git a/pcapkit/protocols/application/__init__.py b/pcapkit/protocols/application/__init__.py index 44fa8ec5a2..f24bc233de 100644 --- a/pcapkit/protocols/application/__init__.py +++ b/pcapkit/protocols/application/__init__.py @@ -28,6 +28,7 @@ from pcapkit.protocols.application.ngap import NGAP from pcapkit.protocols.application.ospf import OSPF from pcapkit.protocols.application.rarp import RARP, DRARP +from pcapkit.protocols.application.sslv2 import SSLv2 from pcapkit.protocols.application.tls import TLS, SSLv3 from pcapkit.protocols.application.dtls import DTLS @@ -45,5 +46,5 @@ 'NGAP', 'OSPF', 'RARP', 'DRARP', - 'SSL', 'SSLv3', 'TLS', 'DTLS', + 'SSL', 'SSLv2', 'SSLv3', 'TLS', 'DTLS', ] diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py index 17bb980ab8..868a4f97ad 100644 --- a/pcapkit/protocols/application/ssl.py +++ b/pcapkit/protocols/application/ssl.py @@ -34,6 +34,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.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 @@ -41,6 +42,7 @@ 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 ServerHello as Data_ServerHello +from pcapkit.protocols.misc.null import NoPayload from pcapkit.protocols.protocol import _PT, _ST from pcapkit.utilities.exceptions import ProtocolError @@ -179,6 +181,7 @@ def _tls_handshake_tiles(fragment: 'bytes') -> 'bool': class SSL(Application[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract-method """Umbrella base class for the SSL protocol family: the SSL versions, TLS and DTLS. + - SSL 2.0 (:class:`~pcapkit.protocols.application.sslv2.SSLv2`) [draft-hickman-netscape-ssl-00] - Transport Layer Security, SSLv3 through TLS 1.3 (:class:`~pcapkit.protocols.application.tls.TLS`) [:rfc:`6101`, :rfc:`5246`, :rfc:`9846`] - Datagram TLS, DTLS 1.0 through DTLS 1.3 @@ -191,19 +194,44 @@ class SSL(Application[_PT, _ST], Generic[_PT, _ST]): # pylint: disable=abstract ########################################################################## @classmethod - def id(cls) -> 'tuple[Literal["TLS"], Literal["DTLS"]]': + def id(cls) -> 'tuple[Literal["SSLv2"], Literal["TLS"], Literal["DTLS"]]': """Index ID of the protocol. Returns: - Index ID of the protocol. + Index ID of the protocol: the direct concrete members of the family. + SSLv3 is not listed, as it is a subclass of + :class:`~pcapkit.protocols.application.tls.TLS`. """ - return ('TLS', 'DTLS') + return ('SSLv2', 'TLS', 'DTLS') ########################################################################## # Utilities. ########################################################################## + def _adopt_dispatched(self, obj: 'Any') -> 'Any': + """Adopt the parse of a sibling tier this dispatcher delegated to. + + Copies the delegate's :attr:`alias` and :attr:`name`, and any next + layer it already decoded, so the dispatcher reports the tier that + actually parsed the record. + + Args: + obj: The delegate's own, already-parsed instance. + + Returns: + ``obj``'s parsed data model, which becomes this dispatcher's own + :attr:`~pcapkit.protocols.protocol.ProtocolBase.info`. + + """ + self._alias = obj.alias # pylint: disable=attribute-defined-outside-init + self._name = obj.name # pylint: disable=attribute-defined-outside-init + next_ = getattr(obj, '_next', None) + if next_ is not None and not isinstance(next_, NoPayload): + self._next = next_ # pylint: disable=attribute-defined-outside-init + self._protos = ProtoChain(self.__class__, self.alias, basis=next_.protochain) # pylint: disable=attribute-defined-outside-init + return obj.info + def _validate_tls_record(self, schema: 'Any', *, available: 'int', dtls: 'bool') -> 'None': """Reject octets that cannot be a genuine TLS/DTLS record. diff --git a/pcapkit/protocols/application/sslv2.py b/pcapkit/protocols/application/sslv2.py new file mode 100644 index 0000000000..6898535216 --- /dev/null +++ b/pcapkit/protocols/application/sslv2.py @@ -0,0 +1,477 @@ +# -*- coding: utf-8 -*- +"""SSLv2 - Secure Sockets Layer version 2 +============================================ + +.. module:: pcapkit.protocols.application.sslv2 + +:mod:`pcapkit.protocols.application.sslv2` contains +:class:`~pcapkit.protocols.application.sslv2.SSLv2` only, +which implements an extractor for Secure Sockets Layer +version 2 (SSLv2) [*]_, as defined by the (long expired) +Netscape draft that introduced it. + +SSLv2 is wire-*incompatible* with SSLv3 and TLS: it carries no +content-type byte and no ``0x03xx``-style record version, its record header +is 2 or 3 octets rather than a fixed 5, and its handshake message type is +the first octet of the record's data rather than a separate record-layer +field. See :func:`~pcapkit.protocols.schema.application.sslv2.sslv2_record_length` +for the header's exact shape. + +SSLv1 has no module here, nor does it need one: it was never publicly +released, so there is no wire format to dissect. + +Decoding ``CLIENT-HELLO``/``SERVER-HELLO`` bodies assumes the record is +genuinely cleartext. Per the draft's §5.4, most other message types are sent +*encrypted* once session keys exist, and this package implements no +decryption -- so a ``CLIENT-HELLO``/``SERVER-HELLO``-tagged record that is +actually ciphertext (e.g. a replayed or malformed capture) is only partly +caught. Before decoding, the body's own declared sub-field lengths are +checked against the record's ``RECORD-LENGTH`` (see +:func:`~pcapkit.protocols.schema.application.sslv2.sslv2_hello_probe_is_plausible`): +when they do not add up, the body is kept as raw octets instead. When they +happen to add up, the ciphertext still decodes as if it were the hello's own +fields, rather than failing cleanly. ``CIPHER-SPECS-DATA`` itself is kept as +an opaque concatenation of 3-octet ``CIPHER-KIND`` entries in every case; +none of the draft's own constraints on it, or on e.g. ``session_id`` being +``0`` or ``16`` octets, are validated here. + +.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING, cast + +from pcapkit.const.sslv2.handshake_type import HandshakeType as Enum_HandshakeType +from pcapkit.const.sslv2.version import Version as Enum_Version +from pcapkit.protocols.application.ssl import SSL +from pcapkit.protocols.data.application.sslv2 import ClientHello as Data_ClientHello +from pcapkit.protocols.data.application.sslv2 import ServerHello as Data_ServerHello +from pcapkit.protocols.data.application.sslv2 import SSLv2 as Data_SSLv2 +from pcapkit.protocols.schema.application.sslv2 import ClientHello as Schema_ClientHello +from pcapkit.protocols.schema.application.sslv2 import ServerHello as Schema_ServerHello +from pcapkit.protocols.schema.application.sslv2 import SSLv2 as Schema_SSLv2 +from pcapkit.protocols.schema.application.sslv2 import \ + UnknownHandshakeBody as Schema_UnknownHandshakeBody +from pcapkit.utilities.exceptions import ProtocolError + +if TYPE_CHECKING: + from enum import IntEnum as StdlibEnum + from typing import Any, Optional, Type + + from aenum import IntEnum as AenumEnum + from typing_extensions import Literal + + from pcapkit.protocols.protocol import ProtocolBase + from pcapkit.protocols.schema.schema import Schema + +__all__ = ['SSLv2'] + + +class SSLv2(SSL[Data_SSLv2, Schema_SSLv2], + schema=Schema_SSLv2, data=Data_SSLv2): + """This class implements Secure Sockets Layer version 2. + + Note: + This class is not bound to TCP port 443 in + :attr:`TCP.__proto__ `: + SSLv3 and TLS reuse that port. On 443 it is reached only as + :class:`~pcapkit.protocols.application.tls.TLS`'s fallback, after + TLS has rejected the record. Callers that know a stream is SSLv2 + construct this class directly. + + """ + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Secure Sockets Layer version 2"]': + """Name of current protocol.""" + return 'Secure Sockets Layer version 2' + + @property + def alias(self) -> 'Literal["SSLv2"]': + """Acronym of current protocol.""" + return 'SSLv2' + + @property + def length(self) -> 'int': + """Header length of current protocol -- ``2`` or ``3`` octets, + depending on the record's own header form.""" + return self._info.header_len + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["SSLv2"]]': # type: ignore[override] + """Index ID of the protocol.""" + return ('SSLv2',) + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_SSLv2': + """Read Secure Sockets Layer version 2. + + Structure of an SSLv2 record, 2-octet header form: + + .. code-block:: text + + +-+-----------------------------+---------------+ + |1| RECORD-LENGTH (15) | MSG-TYPE (8) | + +-+-----------------------------+---------------+ + | Body (*) ... + +-----------------------------------------------+ + + 3-octet header form: + + .. code-block:: text + + +-+-+---------------------------+---------------+---------------+ + |0|E| RECORD-LENGTH (14) | PADDING (8) | MSG-TYPE (8) | + +-+-+---------------------------+---------------+---------------+ + | Body (*) ... + +---------------------------------------------------------------+ + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + """ + if length is None: + length = len(self) + schema = self.__header__ + + two_byte_header = bool(schema.indicator & 0x80) + if two_byte_header: + header_len = 2 + is_escape = False + padding = 0 + record_length = ((schema.indicator & 0x7F) << 8) | schema.length_low + else: + header_len = 3 + is_escape = bool(schema.indicator & 0x40) + padding = schema.padding + record_length = ((schema.indicator & 0x3F) << 8) | schema.length_low + + if record_length < 1: + # NOTE: ``RECORD-LENGTH`` counts ``MSG-TYPE``, so ``0`` is malformed; the + # ``MSG-TYPE`` octet already read belongs to the next record. + raise ProtocolError(f'SSLv2: record length ({record_length}) is too short to ' + f'include a MSG-TYPE octet') + + body_schema = schema.body + if isinstance(body_schema, Schema_ClientHello): + body = Data_ClientHello( + client_version=body_schema.client_version, + cipher_specs=body_schema.cipher_specs, + session_id=body_schema.session_id, + challenge=body_schema.challenge, + ) # type: bytes | Data_ClientHello | Data_ServerHello + elif isinstance(body_schema, Schema_ServerHello): + # NOTE: wire bytes always fit; this refuses a programmatically built + # schema, e.g. ``make(body=Schema_ServerHello(session_id_hit=256))``. + if not 0 <= body_schema.session_id_hit <= 255: + raise ProtocolError(f'SSLv2: SESSION-ID-HIT ({body_schema.session_id_hit}) out ' + f'of range for a single octet (0-255)') + body = Data_ServerHello( + # NOTE: kept as the raw octet, not normalised to a strict + # bool -- see ``Data_ServerHello.session_id_hit``. + session_id_hit=body_schema.session_id_hit, + certificate_type=body_schema.certificate_type, + server_version=body_schema.server_version, + certificate=body_schema.certificate, + cipher_specs=body_schema.cipher_specs, + connection_id=body_schema.connection_id, + ) + else: + # NOTE: non-hello types, and hello-tagged records failing the length + # probe (likely ciphertext), are kept as raw octets. + body = cast('Schema_UnknownHandshakeBody', body_schema).data + + sslv2 = Data_SSLv2( + header_len=header_len, + two_byte_header=two_byte_header, + is_escape=is_escape, + padding=padding, + record_length=record_length, + msg_type=schema.msg_type, + body=body, + ) + return self._decode_next_layer(sslv2, -1, length - header_len - record_length) + + def make(self, + msg_type: 'Enum_HandshakeType | StdlibEnum | AenumEnum | str | int' = Enum_HandshakeType.CLIENT_HELLO, + msg_type_default: 'Optional[int]' = None, + msg_type_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + msg_type_reversed: 'bool' = False, + two_byte_header: 'bool' = True, + is_escape: 'bool' = False, + padding: 'int' = 0, + body: 'Optional[bytes | Data_ClientHello | Data_ServerHello | Schema_ClientHello | Schema_ServerHello | Schema_UnknownHandshakeBody | dict[str, Any]]' = None, # pylint: disable=line-too-long + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_SSLv2': + """Make (construct) packet data. + + Args: + msg_type: Handshake message type. + msg_type_default: Default value for ``msg_type``. + msg_type_namespace: Namespace for ``msg_type``. + msg_type_reversed: Reverse namespace for ``msg_type``. + two_byte_header: Whether to emit the 2-octet header form (the + more common of the two on real traffic) rather than the + 3-octet form. + is_escape: ``IS-ESCAPE`` flag; only meaningful -- and only + written -- for the 3-octet header form. + padding: The header's third, padding octet; only meaningful -- + and only written -- for the 3-octet header form. + body: Handshake message body. A :obj:`dict` or a + :class:`~pcapkit.protocols.data.application.sslv2.ClientHello` + /:class:`~pcapkit.protocols.data.application.sslv2.ServerHello` + data model builds the body according to ``msg_type``, which + must then be ``CLIENT_HELLO`` or ``SERVER_HELLO`` + respectively; omitting it, i.e. :data:`None`, builds an + empty one the same way, as ``{}`` does. A schema instance is + taken as is. Raw :obj:`bytes` are written verbatim. A + message type whose body this package does not parse is + given its body as :obj:`bytes` or as an + :class:`~pcapkit.protocols.schema.application.sslv2.UnknownHandshakeBody`. + payload: Octets after the record, e.g. the next record. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + Raises: + ProtocolError: If ``body`` is a :obj:`dict` and ``msg_type`` is + not ``CLIENT_HELLO``/``SERVER_HELLO``; if ``body`` is a + :class:`~pcapkit.protocols.data.application.sslv2.ClientHello`/ + :class:`~pcapkit.protocols.data.application.sslv2.ServerHello` + data model and ``msg_type`` does not match it (``CLIENT_HELLO`` + for the former, ``SERVER_HELLO`` for the latter); if ``body`` + is of a type this maker does not recognise; if the + resulting record is longer than the chosen header form + allows; or if ``padding`` does not fit the 3-octet header + form's single padding octet (``0``-``255``). + + """ + msg_type_val = cast('Enum_HandshakeType', + self._make_index(msg_type, msg_type_default, namespace=msg_type_namespace, + reversed=msg_type_reversed, pack=False)) + + if body is None: + body = {} + + if isinstance(body, bytes): + body_val = body # type: bytes | Schema_ClientHello | Schema_ServerHello | Schema_UnknownHandshakeBody + elif isinstance(body, (Schema_ClientHello, Schema_ServerHello, Schema_UnknownHandshakeBody)): + body_val = body + elif isinstance(body, Data_ClientHello): + if msg_type_val != Enum_HandshakeType.CLIENT_HELLO: + # NOTE: a mismatched ``msg_type`` would re-parse this body as + # another type's fields, e.g. as a ``ServerHello``. + raise ProtocolError(f'SSLv2: a ClientHello body must be given with ' + f'msg_type=CLIENT_HELLO, not {msg_type_val!s}') + body_val = self._make_sslv2_client_hello( + client_version=body.client_version, + cipher_specs=body.cipher_specs, + session_id=body.session_id, + challenge=body.challenge, + ) + elif isinstance(body, Data_ServerHello): + if msg_type_val != Enum_HandshakeType.SERVER_HELLO: + raise ProtocolError(f'SSLv2: a ServerHello body must be given with ' + f'msg_type=SERVER_HELLO, not {msg_type_val!s}') + body_val = self._make_sslv2_server_hello( + session_id_hit=body.session_id_hit, + certificate_type=body.certificate_type, + server_version=body.server_version, + certificate=body.certificate, + cipher_specs=body.cipher_specs, + connection_id=body.connection_id, + ) + elif isinstance(body, dict): + if msg_type_val == Enum_HandshakeType.CLIENT_HELLO: + body_val = self._make_sslv2_client_hello(**body) + elif msg_type_val == Enum_HandshakeType.SERVER_HELLO: + body_val = self._make_sslv2_server_hello(**body) + else: + raise ProtocolError(f'SSLv2: a {msg_type_val!s} body must be given as bytes, ' + f'not a dict -- this package only builds ' + f'CLIENT-HELLO/SERVER-HELLO bodies from one') + else: + raise ProtocolError(f'SSLv2: invalid body: {body!r}') + + body_bytes_len = len(body_val) if isinstance(body_val, bytes) else len(bytes(body_val)) + # NOTE: ``+ 1`` for ``MSG-TYPE``, which is part of ``RECORD-LENGTH`` + # but not part of ``body`` -- see ``Schema_SSLv2.msg_type``. + record_length = body_bytes_len + 1 + + if two_byte_header: + if not 0 <= record_length <= 0x7FFF: + raise ProtocolError(f'SSLv2: record ({record_length} octet(s)) too long for ' + f"the 2-octet header form's 32767-octet limit") + indicator = 0x80 | (record_length >> 8) + padding_val = 0 + else: + if not 0 <= record_length <= 0x3FFF: + raise ProtocolError(f'SSLv2: record ({record_length} octet(s)) too long for ' + f"the 3-octet header form's 16383-octet limit") + if not 0 <= padding <= 0xFF: + raise ProtocolError(f'SSLv2: padding ({padding}) out of range for the ' + f"3-octet header form's single padding octet (0-255)") + indicator = (0x40 if is_escape else 0x00) | (record_length >> 8) + padding_val = padding + length_low = record_length & 0xFF + + return Schema_SSLv2( + indicator=indicator, + length_low=length_low, + padding=padding_val, + msg_type=msg_type_val, + body=body_val, + payload=payload, + ) + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_SSLv2') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + Only the fields :meth:`make` accepts are passed, so derived fields + such as ``record_length`` raise no + :class:`~pcapkit.utilities.warnings.UnknownFieldWarning`, and + ``payload`` carries any chained next record through a + :meth:`~pcapkit.protocols.protocol.Protocol.from_data` round trip. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'msg_type': data.msg_type, + 'two_byte_header': data.two_byte_header, + 'is_escape': data.is_escape, + 'padding': data.padding, + 'body': data.body, + 'payload': cls._make_payload(data), + } + + def _make_sslv2_client_hello(self, + client_version: 'Enum_Version | StdlibEnum | AenumEnum | str | int' = Enum_Version.SSLv2, # pylint: disable=line-too-long + client_version_default: 'Optional[int]' = None, + client_version_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + client_version_reversed: 'bool' = False, + cipher_specs: 'bytes' = b'', + session_id: 'bytes' = b'', + challenge: 'bytes' = b'\x00' * 16) -> 'Schema_ClientHello': + """Make a ``CLIENT-HELLO`` handshake message body. + + Args: + client_version: Client's maximum supported version. + client_version_default: Default value for ``client_version``. + client_version_namespace: Namespace for ``client_version``. + client_version_reversed: Reverse namespace for ``client_version``. + cipher_specs: Cipher kinds the client supports, as a + concatenation of 3-octet ``CIPHER-KIND`` entries. The draft + (§5.5.1) requires ``CIPHER-SPECS-LENGTH`` to be greater than + zero, i.e. at least one entry -- the default, ``b''``, builds + a ``CLIENT-HELLO`` the draft forbids, so a caller building a + conformant one must supply this. + session_id: Session identifier; ``0`` or ``16`` octets. + challenge: Client's challenge data; ``16``-``32`` octets. + + Returns: + ``CLIENT-HELLO`` message body schema. + + """ + client_version_val = cast('Enum_Version', + self._make_index(client_version, client_version_default, + namespace=client_version_namespace, + reversed=client_version_reversed, pack=False)) + return Schema_ClientHello( + client_version=client_version_val, + cipher_specs_length=len(cipher_specs), + session_id_length=len(session_id), + challenge_length=len(challenge), + cipher_specs=cipher_specs, + session_id=session_id, + challenge=challenge, + ) + + def _make_sslv2_server_hello(self, + session_id_hit: 'int | bool' = 0, + certificate_type: 'int' = 0, + server_version: 'Enum_Version | StdlibEnum | AenumEnum | str | int' = Enum_Version.SSLv2, # pylint: disable=line-too-long + server_version_default: 'Optional[int]' = None, + server_version_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + server_version_reversed: 'bool' = False, + certificate: 'bytes' = b'', + cipher_specs: 'bytes' = b'', + connection_id: 'bytes' = b'\x00' * 16) -> 'Schema_ServerHello': + """Make a ``SERVER-HELLO`` handshake message body. + + Args: + session_id_hit: ``SESSION-ID-HIT`` octet. Per the draft (§5.6.1) + any *non-zero* value means "hit", not specifically ``1`` -- + this is written through verbatim (so a caller reconstructing + from parsed data reproduces the exact octet observed on the + wire), with :data:`True`/:data:`False` taken as + ``1``/``0`` for callers that only care about the boolean + sense. Must fit the single octet the wire format has for it + (``0``-``255``); anything else raises + :class:`~pcapkit.utilities.exceptions.ProtocolError` + rather than wrapping mod 256. + certificate_type: Certificate type. + server_version: Server's selected version. + server_version_default: Default value for ``server_version``. + server_version_namespace: Namespace for ``server_version``. + server_version_reversed: Reverse namespace for ``server_version``. + certificate: Server's certificate. + cipher_specs: Cipher kinds the server supports, as a + concatenation of 3-octet ``CIPHER-KIND`` entries -- empty + when ``session_id_hit`` is non-zero, but the default, + ``b''``, is also what this builds for a non-hit + ``SERVER-HELLO`` unless a caller overrides it, which the + draft does not sanction (the server picks from the client's + offered specs, so it always has at least one to report when + it is not a hit). + connection_id: Connection identifier; ``16``-``32`` octets. + + Returns: + ``SERVER-HELLO`` message body schema. + + """ + server_version_val = cast('Enum_Version', + self._make_index(server_version, server_version_default, + namespace=server_version_namespace, + reversed=server_version_reversed, pack=False)) + session_id_hit_val = int(session_id_hit) + if not 0 <= session_id_hit_val <= 255: + # NOTE: ``UInt8Field`` packs mod 256, so ``256`` would silently + # write as ``0x00``, turning a hit into a miss. + raise ProtocolError(f'SSLv2: SESSION-ID-HIT ({session_id_hit_val}) out of range ' + f'for a single octet (0-255)') + return Schema_ServerHello( + # NOTE: preserved as given, not collapsed to a strict 0/1 -- + # see ``Data_ServerHello.session_id_hit``. + session_id_hit=session_id_hit_val, + certificate_type=certificate_type, + server_version=server_version_val, + certificate_length=len(certificate), + cipher_specs_length=len(cipher_specs), + connection_id_length=len(connection_id), + certificate=certificate, + cipher_specs=cipher_specs, + connection_id=connection_id, + ) diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py index 70ca0ea43d..5df02dedfd 100644 --- a/pcapkit/protocols/application/tls.py +++ b/pcapkit/protocols/application/tls.py @@ -47,8 +47,13 @@ from pcapkit.const.tls.content_type import ContentType as Enum_ContentType from pcapkit.protocols.application.ssl import SSL, TLS13_VERSION +from pcapkit.protocols.application.sslv2 import SSLv2 +from pcapkit.protocols.data.application.sslv2 import ClientHello as SSLv2_ClientHello +from pcapkit.protocols.data.application.sslv2 import ServerHello as SSLv2_ServerHello +from pcapkit.protocols.data.application.sslv2 import SSLv2 as SSLv2_Data from pcapkit.protocols.data.application.tls import TLS as Data_TLS from pcapkit.protocols.schema.application.tls import TLS as Schema_TLS +from pcapkit.utilities.exceptions import ProtocolError if TYPE_CHECKING: from enum import IntEnum as StdlibEnum @@ -103,6 +108,9 @@ class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): #: Tier :meth:`read` identified, e.g. ``'TLS 1.2'``; ``'TLS'`` until then. _alias = 'TLS' # type: Literal["TLS", "SSLv3", "TLS 1.0", "TLS 1.1", "TLS 1.2", "TLS 1.3"] + #: Header length of the SSLv2 record :meth:`read` adopted; ``None`` for TLS's own. + _adopted_length = None # type: Optional[int] + ########################################################################## # Properties. ########################################################################## @@ -118,12 +126,15 @@ def alias(self) -> 'Literal["TLS", "SSLv3", "TLS 1.0", "TLS 1.1", "TLS 1.2", "TL return self._alias @property - def length(self) -> 'Literal[5]': + def length(self) -> 'int': """Header length of current protocol -- the fixed record header, :attr:`~pcapkit.protocols.data.application.tls.TLS.length`'s fragment - excluded. + excluded; or, once :meth:`read` has adopted an SSLv2 record, that + record's own 2- or 3-octet header. """ + if self._adopted_length is not None: + return self._adopted_length return 5 ########################################################################## @@ -142,8 +153,13 @@ def id(cls) -> 'tuple[Literal["TLS"]]': # type: ignore[override] """ return ('TLS',) - def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': - """Read Transport Layer Security (TLS). + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Any': + """Read Transport Layer Security (TLS), or the SSLv2 record port 443 may carry. + + TLS is tried first. Only when :meth:`_read_tls_record` rejects the + record is the payload offered to + :class:`~pcapkit.protocols.application.sslv2.SSLv2`, whose own header + checks are strict, so a genuine TLS record is never taken as SSLv2. Args: length: Length of packet data. @@ -153,11 +169,19 @@ def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': Parsed packet data. Raises: - ProtocolError: If the record cannot be a genuine TLS/SSLv3+ - record -- see :meth:`_read_tls_record`. + ProtocolError: If the payload is neither a TLS record (see + :meth:`_read_tls_record`) nor an SSLv2 record. """ - return self._read_tls_record(length, **kwargs) + if length is None: + length = len(self) + try: + return self._read_tls_record(length, **kwargs) + except ProtocolError: + parsed = self._read_sslv2_fallback(length, **kwargs) + if parsed is None: + raise + return parsed def make(self, content_type: 'Enum_ContentType | StdlibEnum | AenumEnum | str | int' = Enum_ContentType.application_data, # pylint: disable=line-too-long @@ -209,6 +233,28 @@ def make(self, payload=payload, ) + @classmethod + def from_data(cls, data: 'Any', **kwargs: 'Any') -> 'Any': + """Create protocol instance from data. + + An SSLv2 record that :meth:`read` adopted from port 443 is rebuilt as + the SSLv2 record it was parsed from, since TLS's record layout cannot + carry its 2- or 3-octet header. + + Args: + data: Protocol data. + **kwargs: As :meth:`Protocol.from_data + `. + + Returns: + Protocol instance: an :class:`~pcapkit.protocols.application.sslv2.SSLv2` + for an SSLv2 record, otherwise a TLS instance. + + """ + if isinstance(data, SSLv2_Data): + return SSLv2.from_data(data, **kwargs) + return super().from_data(data, **kwargs) + ########################################################################## # Data models. ########################################################################## @@ -301,6 +347,50 @@ def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> ' remaining = available - (self.length + schema.length) return self._decode_next_layer(tls, -1, remaining) + def _read_sslv2_fallback(self, length: 'int', **kwargs: 'Any') -> 'Optional[Any]': + """Parse the payload as SSLv2 once TLS has rejected it. + + Only a complete CLIENT-HELLO or SERVER-HELLO is accepted: the whole + record must sit in this segment, and its body must pass the hello + length probe. Anything else stays :class:`~pcapkit.protocols.misc.raw.Raw`. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + The SSLv2 data model, adopted as this dispatcher's own, or + :data:`None` if the payload is not a complete SSLv2 hello. + + """ + try: + obj = SSLv2(self._data, length, **kwargs) + except ProtocolError: + return None + info = obj.info + if info.header_len + info.record_length > length: + return None + if not isinstance(info.body, (SSLv2_ClientHello, SSLv2_ServerHello)): + return None + return self._adopt_dispatched(obj) + + def _adopt_dispatched(self, obj: 'Any') -> 'Any': + """Adopt the SSLv2 parse, including its 2- or 3-octet header length. + + :attr:`length` and :attr:`packet` must describe the header the record + really has, as a direct SSLv2 parse does. + + Args: + obj: The SSLv2 instance, already parsed. + + Returns: + ``obj``'s parsed data model, as :meth:`SSL._adopt_dispatched` returns it. + + """ + self._adopted_length = obj.length # pylint: disable=attribute-defined-outside-init + return super()._adopt_dispatched(obj) + + class SSLv3(TLS, schema=Schema_TLS, data=Data_TLS): """This class implements Secure Sockets Layer version 3. diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index 4b3b0f06fa..8462dbaa2f 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -256,6 +256,9 @@ # Open Shortest Path First 'OSPF', 'OSPF_CryptographicAuthentication', + # Secure Sockets Layer version 2 + 'SSLv2', 'SSLv2_ClientHello', 'SSLv2_ServerHello', + # Hypertext Transfer Protocol (HTTP/1.*) 'HTTPv1', 'HTTPv1_Header', diff --git a/pcapkit/protocols/data/application/__init__.py b/pcapkit/protocols/data/application/__init__.py index e687bd4e1c..341864dc17 100644 --- a/pcapkit/protocols/data/application/__init__.py +++ b/pcapkit/protocols/data/application/__init__.py @@ -54,6 +54,11 @@ from pcapkit.protocols.data.application.ospf import \ CryptographicAuthentication as OSPF_CryptographicAuthentication +# Secure Sockets Layer version 2 +from pcapkit.protocols.data.application.sslv2 import ClientHello as SSLv2_ClientHello +from pcapkit.protocols.data.application.sslv2 import ServerHello as SSLv2_ServerHello +from pcapkit.protocols.data.application.sslv2 import SSLv2 + # Transport Layer Security from pcapkit.protocols.data.application.tls import TLS from pcapkit.protocols.data.application.tls import Alert as TLS_Alert @@ -92,6 +97,9 @@ # Open Shortest Path First 'OSPF', 'OSPF_CryptographicAuthentication', + # Secure Sockets Layer version 2 + 'SSLv2', 'SSLv2_ClientHello', 'SSLv2_ServerHello', + # Transport Layer Security 'TLS', 'TLS_Handshake', 'TLS_ClientHello', 'TLS_ServerHello', 'TLS_Extension', diff --git a/pcapkit/protocols/data/application/sslv2.py b/pcapkit/protocols/data/application/sslv2.py new file mode 100644 index 0000000000..06da81754c --- /dev/null +++ b/pcapkit/protocols/data/application/sslv2.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""data models for SSLv2 protocol""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.data import Data +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.const.sslv2.version import Version + +__all__ = ['SSLv2', 'ClientHello', 'ServerHello'] + + +@info_final +class ClientHello(Data): + """Data model for the SSLv2 ``CLIENT-HELLO`` handshake message.""" + + #: Client's maximum supported version. + client_version: 'Version' + #: Cipher kinds the client supports, as a concatenation of 3-octet + #: ``CIPHER-KIND`` entries. + cipher_specs: 'bytes' + #: Session identifier, ``0`` or ``16`` octets. + session_id: 'bytes' + #: Client's challenge data, ``16``-``32`` octets. + challenge: 'bytes' + + if TYPE_CHECKING: + def __init__(self, client_version: 'Version', cipher_specs: 'bytes', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + session_id: 'bytes', challenge: 'bytes') -> 'None': ... + + +@info_final +class ServerHello(Data): + """Data model for the SSLv2 ``SERVER-HELLO`` handshake message.""" + + #: Raw ``SESSION-ID-HIT`` octet, kept as read rather than normalised to a + #: :obj:`bool`: per `draft-hickman-netscape-ssl-00`_ §5.6.1 any *non-zero* + #: value is a hit. When non-zero, :attr:`certificate_type`, :attr:`certificate` + #: and :attr:`cipher_specs` carry no data. + #: + #: .. _draft-hickman-netscape-ssl-00: https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 + session_id_hit: 'int' + #: Certificate type, zero when :attr:`session_id_hit` is non-zero. + certificate_type: 'int' + #: Server's selected version. + server_version: 'Version' + #: Server's certificate, empty when :attr:`session_id_hit` is non-zero. + certificate: 'bytes' + #: Cipher kinds the server supports, as a concatenation of 3-octet + #: ``CIPHER-KIND`` entries; empty when :attr:`session_id_hit` is non-zero. + cipher_specs: 'bytes' + #: Connection identifier, ``16``-``32`` octets. + connection_id: 'bytes' + + if TYPE_CHECKING: + def __init__(self, session_id_hit: 'int', certificate_type: 'int', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + server_version: 'Version', certificate: 'bytes', cipher_specs: 'bytes', + connection_id: 'bytes') -> 'None': ... + + +@info_final +class SSLv2(Protocol): + """Data model for SSLv2 packet.""" + + #: Header length (``2`` or ``3`` octets), chosen by the high bit of the + #: record header's first octet. + header_len: 'int' + #: Whether the record uses the 2-octet header form. + two_byte_header: 'bool' + #: ``IS-ESCAPE`` flag; always :data:`False` for a 2-octet header, which + #: has no bit for it. + is_escape: 'bool' + #: The header's third, padding octet; ``0`` for a 2-octet header, which + #: has none. + padding: 'int' + #: ``RECORD-LENGTH``, the record's data length, header excluded. + record_length: 'int' + #: Handshake message type. + msg_type: 'HandshakeType' + #: Handshake message body -- a parsed data model for ``CLIENT-HELLO`` and + #: ``SERVER-HELLO``, or raw octets for every other + #: :class:`~pcapkit.const.sslv2.handshake_type.HandshakeType` and for a + #: hello-tagged body whose declared sub-field lengths do not fit the + #: record, e.g. ciphertext. + body: 'ClientHello | ServerHello | bytes' + + if TYPE_CHECKING: + def __init__(self, header_len: 'int', two_byte_header: 'bool', is_escape: 'bool', # pylint: disable=unused-argument,super-init-not-called,multiple-statements + padding: 'int', record_length: 'int', msg_type: 'HandshakeType', + body: 'ClientHello | ServerHello | bytes') -> 'None': ... diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index f10675e583..6b790e6dd1 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -177,6 +177,8 @@ 'FTP', 'NGAP', 'OSPF', 'OSPF_CryptographicAuthentication', + 'SSLv2', 'SSLv2_HandshakeBody', 'SSLv2_UnknownHandshakeBody', + 'SSLv2_ClientHello', 'SSLv2_ServerHello', 'HTTPv1', 'HTTPv2', 'HTTPv2_FrameType', diff --git a/pcapkit/protocols/schema/application/__init__.py b/pcapkit/protocols/schema/application/__init__.py index 8f2780d46f..396f27f418 100644 --- a/pcapkit/protocols/schema/application/__init__.py +++ b/pcapkit/protocols/schema/application/__init__.py @@ -36,6 +36,14 @@ from pcapkit.protocols.schema.application.ospf import \ CryptographicAuthentication as OSPF_CryptographicAuthentication +# Secure Sockets Layer version 2 +from pcapkit.protocols.schema.application.sslv2 import ClientHello as SSLv2_ClientHello +from pcapkit.protocols.schema.application.sslv2 import HandshakeBody as SSLv2_HandshakeBody +from pcapkit.protocols.schema.application.sslv2 import ServerHello as SSLv2_ServerHello +from pcapkit.protocols.schema.application.sslv2 import SSLv2 +from pcapkit.protocols.schema.application.sslv2 import \ + UnknownHandshakeBody as SSLv2_UnknownHandshakeBody + # Transport Layer Security from pcapkit.protocols.schema.application.tls import TLS @@ -62,6 +70,10 @@ # Open Shortest Path First 'OSPF', 'OSPF_CryptographicAuthentication', + # Secure Sockets Layer version 2 + 'SSLv2', 'SSLv2_HandshakeBody', 'SSLv2_UnknownHandshakeBody', + 'SSLv2_ClientHello', 'SSLv2_ServerHello', + # Transport Layer Security 'TLS', ] diff --git a/pcapkit/protocols/schema/application/sslv2.py b/pcapkit/protocols/schema/application/sslv2.py new file mode 100644 index 0000000000..e03b10079a --- /dev/null +++ b/pcapkit/protocols/schema/application/sslv2.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for SSLv2 protocol""" + +import struct +from typing import TYPE_CHECKING, cast + +from pcapkit.const.sslv2.handshake_type import HandshakeType as Enum_HandshakeType +from pcapkit.const.sslv2.version import Version as Enum_Version +from pcapkit.corekit.fields.misc import (ConditionalField, ForwardMatchField, PayloadField, + SchemaField, SwitchField) +from pcapkit.corekit.fields.numbers import EnumField, UInt8Field, UInt16Field +from pcapkit.corekit.fields.strings import BytesField +from pcapkit.protocols.schema.schema import EnumSchema, Schema, schema_final + +__all__ = [ + 'SSLv2', + + 'HandshakeBody', 'UnknownHandshakeBody', + 'ClientHello', 'ServerHello', +] + +if TYPE_CHECKING: + from typing import Any, Optional + + from pcapkit.corekit.fields.field import FieldBase as Field + from pcapkit.protocols.protocol import ProtocolBase + + +def sslv2_record_length(packet: 'dict[str, Any]') -> 'int': + """Compute the SSLv2 ``RECORD-LENGTH`` from the already-parsed header octets. + + The record header carries the length split across two or three octets in + a shape that depends on the high bit of its first octet, per the + `SSLv2 draft`_, §4.1 (*SSL Record Header Format*): + + * if ``indicator & 0x80`` is set, the header is the 2-octet form and + ``RECORD-LENGTH = ((indicator & 0x7f) << 8) | length_low``; + * otherwise, the header is the 3-octet form (a third, ``padding`` octet + follows) and ``RECORD-LENGTH = ((indicator & 0x3f) << 8) | length_low``. + + Args: + packet: Packet data, with ``indicator`` and ``length_low`` already + parsed (:attr:`SSLv2.indicator` and :attr:`SSLv2.length_low`). + + Returns: + The record's declared length, header excluded. + + .. _SSLv2 draft: https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 + + """ + indicator = cast('int', packet['indicator']) + length_low = cast('int', packet['length_low']) + if indicator & 0x80: + return ((indicator & 0x7F) << 8) | length_low + return ((indicator & 0x3F) << 8) | length_low + + +#: Octets of a hello body's own fixed-width sub-header a forward-match probe +#: ever needs to peek -- enough for the longer of the two, +#: ``SERVER-HELLO``'s 10-octet one (``CLIENT-HELLO``'s is 8). +_HELLO_PROBE_LENGTH = 10 + + +def sslv2_hello_probe_length(packet: 'dict[str, Any]') -> 'int': + """Compute :attr:`SSLv2.probe`'s forward-match length. + + Never more than the body actually declares (:func:`sslv2_record_length` + minus the one octet :attr:`SSLv2.msg_type` already consumed), and never + more than ``_HELLO_PROBE_LENGTH``. + + Args: + packet: Packet data, with ``indicator``/``length_low`` already + parsed (see :func:`sslv2_record_length`). + + Returns: + The probe's length, in octets. + + """ + return min(_HELLO_PROBE_LENGTH, max(0, sslv2_record_length(packet) - 1)) + + +def sslv2_hello_probe_is_plausible(msg_type: 'Enum_HandshakeType', probe: 'bytes', + inner_length: 'int') -> 'bool': + """Check a ``CLIENT-HELLO``/``SERVER-HELLO`` body's declared sub-field + lengths against the record's own declared body length. + + A hello-tagged record that is actually ciphertext would otherwise decode + random octets as ``*-LENGTH`` sub-fields that overrun or underrun the + body. :func:`sslv2_body_selector` parses a body failing this check as + :class:`UnknownHandshakeBody`, keeping its raw octets whole. + + Args: + msg_type: The record's handshake message type; only ``CLIENT_HELLO`` + and ``SERVER_HELLO`` are checked, every other type is vacuously + plausible. + probe: The body's first ``min(10, inner_length)`` octets, forward-matched + by :attr:`SSLv2.probe` -- not yet actually consumed. + inner_length: The body's declared length (``RECORD-LENGTH`` minus the + one octet :attr:`SSLv2.msg_type` already consumed). + + Returns: + :data:`True` if the declared sub-field lengths add up to + ``inner_length`` exactly, or if ``msg_type`` is not one this check + applies to. + + """ + if msg_type == Enum_HandshakeType.CLIENT_HELLO: + # ``client_version`` (2) + ``cipher_specs_length`` (2) + + # ``session_id_length`` (2) + ``challenge_length`` (2) = 8. + if len(probe) < 8: + return False + cipher_specs_length, session_id_length, challenge_length = struct.unpack('>HHH', probe[2:8]) + return 8 + cipher_specs_length + session_id_length + challenge_length == inner_length + if msg_type == Enum_HandshakeType.SERVER_HELLO: + # ``session_id_hit`` (1) + ``certificate_type`` (1) + + # ``server_version`` (2) + ``certificate_length`` (2) + + # ``cipher_specs_length`` (2) + ``connection_id_length`` (2) = 10. + if len(probe) < 10: + return False + certificate_length, cipher_specs_length, connection_id_length = struct.unpack('>HHH', probe[4:10]) + return 10 + certificate_length + cipher_specs_length + connection_id_length == inner_length + return True + + +def sslv2_body_selector(packet: 'dict[str, Any]') -> 'Field': + """Selector function for :attr:`SSLv2.body` field. + + Args: + packet: Packet data. + + Returns: + A :class:`~pcapkit.corekit.fields.misc.SchemaField` wrapped + :class:`HandshakeBody` instance, of whichever subclass + :attr:`HandshakeBody.registry + ` maps + ``packet['msg_type']`` to, sized to the record's remaining declared length -- the record's own + ``RECORD-LENGTH``, the one octet :attr:`SSLv2.msg_type` already + consumed excluded. For ``CLIENT-HELLO``/``SERVER-HELLO``, falls back + to :class:`UnknownHandshakeBody` when :func:`sslv2_hello_probe_is_plausible` + rejects :attr:`SSLv2.probe` -- see that function for why. + + Note: + A malformed ``RECORD-LENGTH = 0`` sizes the body to ``0``, not to the + rest of the buffer; :meth:`~pcapkit.protocols.application.sslv2.SSLv2.read` reports it. + + """ + msg_type = cast('Enum_HandshakeType', packet['msg_type']) + length = max(0, sslv2_record_length(packet) - 1) + # ``probe`` is only present when parsing wire octets; a schema built by + # ``make()`` (or rebuilt via ``from_dict()``) has none, and its body is + # already the hello it was built as, so there is nothing to sanity-check. + probe = cast('Optional[bytes]', packet.get('probe')) + if probe is not None and not sslv2_hello_probe_is_plausible(msg_type, probe, length): + schema = UnknownHandshakeBody # type: type[HandshakeBody] + else: + schema = HandshakeBody.registry[msg_type] + return SchemaField(length=length, schema=schema) + + +class HandshakeBody(EnumSchema[Enum_HandshakeType]): + """Header schema for SSLv2 handshake message bodies. + + Unlike :class:`~pcapkit.protocols.schema.transport.tcp.Option` and its + siblings, this base class declares no field of its own for the + discriminator: :attr:`SSLv2.msg_type` already consumes that octet in the + enclosing schema, before :attr:`SSLv2.body` dispatches here, so every + concrete subclass's own first field starts at the body's first octet -- + the same arrangement + :class:`~pcapkit.protocols.schema.application.httpv2.FrameType` uses for + HTTP/2 frame payloads, where ``type`` is consumed by the outer + :class:`~pcapkit.protocols.schema.application.httpv2.HTTP` schema. + + """ + + __default__ = lambda: UnknownHandshakeBody + + +@schema_final +class UnknownHandshakeBody(HandshakeBody): + """Header schema for an SSLv2 handshake message body kept as raw octets: + every :class:`~pcapkit.const.sslv2.handshake_type.HandshakeType` other + than ``CLIENT-HELLO`` and ``SERVER-HELLO``, and a hello-tagged body that + fails :func:`sslv2_hello_probe_is_plausible`, e.g. ciphertext. + + Keeping the undissected body, rather than losing the whole record, + matches :class:`~pcapkit.protocols.schema.application.httpv2.UnassignedFrame`. + + """ + + #: Undissected message body. + data: 'bytes' = BytesField(length=lambda pkt: pkt['__length__']) + + if TYPE_CHECKING: + def __init__(self, data: 'bytes') -> 'None': ... + + +@schema_final +class ClientHello(HandshakeBody, code=Enum_HandshakeType.CLIENT_HELLO): + """Header schema for the SSLv2 ``CLIENT-HELLO`` handshake message.""" + + #: Client's maximum supported version. + client_version: 'Enum_Version' = EnumField(length=2, namespace=Enum_Version) + #: Length of :attr:`cipher_specs`. + cipher_specs_length: 'int' = UInt16Field() + #: Length of :attr:`session_id`. + session_id_length: 'int' = UInt16Field() + #: Length of :attr:`challenge`. + challenge_length: 'int' = UInt16Field() + #: Cipher kinds the client supports, as a concatenation of 3-octet + #: ``CIPHER-KIND`` entries. + cipher_specs: 'bytes' = BytesField(length=lambda pkt: pkt['cipher_specs_length']) + #: Session identifier, ``0`` or ``16`` octets. + session_id: 'bytes' = BytesField(length=lambda pkt: pkt['session_id_length']) + #: Client's challenge data, ``16``-``32`` octets. + challenge: 'bytes' = BytesField(length=lambda pkt: pkt['challenge_length']) + + if TYPE_CHECKING: + def __init__(self, client_version: 'Enum_Version', cipher_specs_length: 'int', + session_id_length: 'int', challenge_length: 'int', cipher_specs: 'bytes', + session_id: 'bytes', challenge: 'bytes') -> 'None': ... + + +@schema_final +class ServerHello(HandshakeBody, code=Enum_HandshakeType.SERVER_HELLO): + """Header schema for the SSLv2 ``SERVER-HELLO`` handshake message.""" + + #: Whether the client's proposed :attr:`ClientHello.session_id` was + #: accepted; when set, :attr:`certificate_type`, :attr:`certificate` and + #: :attr:`cipher_specs` carry no data. + session_id_hit: 'int' = UInt8Field() + #: Certificate type, ``SSL_CT_X509_CERTIFICATE (0x01)`` being the only one + #: the draft defines. + certificate_type: 'int' = UInt8Field() + #: Server's selected version. + server_version: 'Enum_Version' = EnumField(length=2, namespace=Enum_Version) + #: Length of :attr:`certificate`. + certificate_length: 'int' = UInt16Field() + #: Length of :attr:`cipher_specs`. + cipher_specs_length: 'int' = UInt16Field() + #: Length of :attr:`connection_id`. + connection_id_length: 'int' = UInt16Field() + #: Server's certificate. + certificate: 'bytes' = BytesField(length=lambda pkt: pkt['certificate_length']) + #: Cipher kinds the server supports, as a concatenation of 3-octet + #: ``CIPHER-KIND`` entries. + cipher_specs: 'bytes' = BytesField(length=lambda pkt: pkt['cipher_specs_length']) + #: Connection identifier, ``16``-``32`` octets. + connection_id: 'bytes' = BytesField(length=lambda pkt: pkt['connection_id_length']) + + if TYPE_CHECKING: + def __init__(self, session_id_hit: 'int', certificate_type: 'int', + server_version: 'Enum_Version', certificate_length: 'int', + cipher_specs_length: 'int', connection_id_length: 'int', + certificate: 'bytes', cipher_specs: 'bytes', + connection_id: 'bytes') -> 'None': ... + + +@schema_final +class SSLv2(Schema): + """Header schema for SSLv2 packet. + + The record header is two or three octets, chosen by the high bit of its + first octet -- see :func:`sslv2_record_length` -- rather than a fixed + width, so :attr:`padding` is read only for the 3-octet form. + :attr:`msg_type` is the one octet the `SSLv2 draft`_ puts at the front of + every record's data rather than in the header proper, and it is read + here, ahead of :attr:`body`, for the same reason + :attr:`~pcapkit.protocols.schema.application.httpv2.HTTP.type` is read + ahead of :attr:`~pcapkit.protocols.schema.application.httpv2.HTTP.frame`. + + .. _SSLv2 draft: https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 + + """ + + #: First octet of the record header. Its high bit (``0x80``) selects the + #: header form, and (only for the 3-octet form) the next bit (``0x40``) + #: is ``IS-ESCAPE``; see :func:`sslv2_record_length`. + indicator: 'int' = UInt8Field() + #: Second octet of the record header -- the low 8 bits of ``RECORD-LENGTH``. + length_low: 'int' = UInt8Field() + #: Third octet of the record header, present only for the 3-octet form. + padding: 'int' = ConditionalField( + UInt8Field(), + lambda pkt: not (pkt['indicator'] & 0x80), + ) + #: Handshake message type, the first octet of the record's data. + msg_type: 'Enum_HandshakeType' = EnumField(length=1, namespace=Enum_HandshakeType) + #: The body's first few octets, forward-matched (consuming nothing) so + #: :func:`sslv2_body_selector` can sanity-check a ``CLIENT-HELLO``/ + #: ``SERVER-HELLO``'s own declared sub-field lengths before committing + #: to decoding it as one; see :func:`sslv2_hello_probe_is_plausible`. + probe: 'bytes' = ForwardMatchField(BytesField(length=sslv2_hello_probe_length)) + #: Handshake message body. + body: 'HandshakeBody' = SwitchField( + selector=sslv2_body_selector, + ) + #: Octets after the record, e.g. the next record. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, indicator: 'int', length_low: 'int', padding: 'Optional[int]', + msg_type: 'Enum_HandshakeType', body: 'bytes | HandshakeBody', + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index a3940913ee..712b19c018 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -376,12 +376,14 @@ def test_the_sweep_size_is_pinned(self) -> None: # outliers #647 fixed above, and it is outside # tests.const.test_const_enum_get's narrower (IntEnum, IntFlag)-only # sweep. The int count includes GitHub issue #880's ngap.ProcedureCode - # and ngap.ProtocolIE, which are on EnumRegistry. - self.assertEqual(len(ints), 117) + # and ngap.ProtocolIE, which are on EnumRegistry, and GitHub issue + # #2457's sslv2.HandshakeType and sslv2.Version, hand-curated rather than + # generated but the same EnumRegistry-plus-IntEnum shape. + self.assertEqual(len(ints), 118) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) - self.assertEqual(len(self.enums), 133) - self.assertEqual(len({obj.__module__ for obj in self.enums}), 127) + self.assertEqual(len(self.enums), 134) + self.assertEqual(len({obj.__module__ for obj in self.enums}), 128) 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 836475a478..e274b547c2 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), 123) + self.assertEqual(len(self.enums), 124) 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, 120) + self.assertEqual(covered, 121) 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 8b4094b51f..d9b3e732b3 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), 117) + self.assertEqual(len(self.enums), 118) 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 6ee574f08f..9acc1ecf0b 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), 127) - self.assertEqual(len(generated), 110) + self.assertEqual(len(all_files), 128) + self.assertEqual(len(generated), 111) for relpath in generated: with self.subTest(module=relpath): diff --git a/tests/const/test_const_sslv2_unit.py b/tests/const/test_const_sslv2_unit.py new file mode 100644 index 0000000000..b5b1d7f633 --- /dev/null +++ b/tests/const/test_const_sslv2_unit.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +"""Unit tests for :mod:`pcapkit.const.sslv2.handshake_type` and +:mod:`pcapkit.const.sslv2.version` (:issue:`2457`). + +Both registries are hand-curated rather than generated by a +:class:`~pcapkit.vendor.default.Vendor` crawler -- the expired SSLv2 draft is +not a live registry -- but they still follow this package's lookup contract: +a registered member resolves by value, an unregistered one resolves to a +nameless ``Unassigned`` member without being minted into the registry +(:issue:`860`), and a value outside the member type's own range raises. + +""" +from __future__ import annotations + +import unittest + +#: Test tier (#1958): pure-Python lookups over a hand-curated, closed registry, +#: which behave the same on every interpreter. +__tier__ = 'LATEST' + + +class HandshakeTypeTests(unittest.TestCase): + """:class:`pcapkit.const.sslv2.handshake_type.HandshakeType`.""" + + def test_every_draft_code_resolves_by_name_and_value(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + + expected = { + 'ERROR': 0, 'CLIENT_HELLO': 1, 'CLIENT_MASTER_KEY': 2, 'CLIENT_FINISHED': 3, + 'SERVER_HELLO': 4, 'SERVER_VERIFY': 5, 'SERVER_FINISHED': 6, + 'REQUEST_CERTIFICATE': 7, 'CLIENT_CERTIFICATE': 8, + } + for name, value in expected.items(): + with self.subTest(name=name): + member = HandshakeType[name] + self.assertEqual(member.value, value) + self.assertEqual(HandshakeType(value), member) + + def test_unknown_code_resolves_to_an_unregistered_member(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + + before = len(HandshakeType.__members__) # type: ignore[attr-defined] + member = HandshakeType(50) + self.assertEqual(member.value, 50) + self.assertEqual(member.name, 'Unassigned') + # #860: a miss never mints a new registered member. + self.assertEqual(len(HandshakeType.__members__), before) # type: ignore[attr-defined] + + def test_out_of_range_value_raises(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + + for bad in (-1, 256, 'ERROR'): + with self.subTest(value=bad): + with self.assertRaises(ValueError): + HandshakeType(bad) + + +class VersionTests(unittest.TestCase): + """:class:`pcapkit.const.sslv2.version.Version`.""" + + def test_sslv2_resolves(self) -> None: + from pcapkit.const.sslv2.version import Version + + self.assertEqual(Version(0x0002), Version.SSLv2) + self.assertEqual(Version['SSLv2'].value, 0x0002) + + def test_unknown_version_resolves_to_an_unregistered_member(self) -> None: + from pcapkit.const.sslv2.version import Version + + before = len(Version.__members__) # type: ignore[attr-defined] + member = Version(0x0300) + self.assertEqual(member.value, 0x0300) + self.assertEqual(member.name, 'Unassigned') + self.assertEqual(len(Version.__members__), before) # type: ignore[attr-defined] + + def test_out_of_range_value_raises(self) -> None: + from pcapkit.const.sslv2.version import Version + + for bad in (-1, 0x10000, 'SSLv2'): + with self.subTest(value=bad): + with self.assertRaises(ValueError): + Version(bad) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_rfc_constants_one_home_1739_unit.py b/tests/project/test_rfc_constants_one_home_1739_unit.py index 73c50ff8da..9ec06ac376 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -354,6 +354,10 @@ 'the last code point of the Basic Multilingual Plane, not an IP length', ('pcapkit/dumpkit/common.py', 'codepoint > 65535'): 'above the Basic Multilingual Plane, needs a UTF-16 surrogate pair, not an IP length', + ('pcapkit/protocols/application/sslv2.py', "b'\\x00' * 16"): + "the default length of an SSLv2 CLIENT-HELLO challenge or SERVER-HELLO " + "connection ID (the draft's own 16-32 octet minimum), not the PCAP classic " + 'record header length', } #: Sweep hits of a homed value in a module whose own protocol owns no constant diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py index 3614adfbe7..c8bb3723f5 100644 --- a/tests/protocols/application/test_ssl_tls_dtls_unit.py +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -32,10 +32,12 @@ class SSLFamilyUnitTests(unittest.TestCase): def setUp(self) -> None: reimport_once_per_class(self) - def test_ssl_id_names_both_concrete_protocols(self) -> None: + def test_ssl_id_names_the_direct_concrete_members(self) -> None: from pcapkit.protocols.application.ssl import SSL + from pcapkit.protocols.application.sslv2 import SSLv2 - self.assertEqual(SSL.id(), ('TLS', 'DTLS')) + self.assertEqual(SSL.id(), ('SSLv2', 'TLS', 'DTLS')) + self.assertTrue(issubclass(SSLv2, SSL)) def test_tls_properties_and_make_data(self) -> None: from pcapkit.const.tls.content_type import ContentType @@ -906,11 +908,41 @@ def test_every_first_octet_parses_only_as_an_accepted_content_type(self) -> None with self.assertRaises(ProtocolError): TLS(wire, len(wire)) - def test_an_sslv2_client_hello_is_raw(self) -> None: + def test_a_complete_sslv2_client_hello_on_443_is_sslv2(self) -> None: from pcapkit.protocols.transport.tcp import TCP body = b'\x01\x00\x02\x00\x03\x00\x00\x00\x10' + b'\x01\x00\x80' + bytes(16) raw = _TCP_TO_443 + (0x8000 | len(body)).to_bytes(2, 'big') + body + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:SSLv2') + + def test_bytes_after_an_sslv2_hello_on_443_stay_raw(self) -> None: + from pcapkit.protocols.transport.tcp import TCP + + body = b'\x01\x00\x02\x00\x03\x00\x00\x00\x10' + b'\x01\x00\x80' + bytes(16) + raw = _TCP_TO_443 + (0x8000 | len(body)).to_bytes(2, 'big') + body + b'xyz' + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:SSLv2:Raw') + + def test_a_truncated_sslv2_hello_on_443_is_raw(self) -> None: + from pcapkit.protocols.transport.tcp import TCP + + body = b'\x01\x00\x02\x00\x03\x00\x00\x00\x10' + b'\x01\x00\x80' + bytes(16) + raw = _TCP_TO_443 + (0x8000 | len(body)).to_bytes(2, 'big') + body[:-4] + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:Raw') + + def test_a_non_hello_sslv2_record_on_443_is_raw(self) -> None: + from pcapkit.protocols.transport.tcp import TCP + + body = b'\x05' + bytes(8) # MSG-SERVER-VERIFY (5), not a hello + raw = _TCP_TO_443 + (0x8000 | len(body)).to_bytes(2, 'big') + body with warnings.catch_warnings(): warnings.simplefilter('ignore') proto = TCP(raw, len(raw)) @@ -930,6 +962,105 @@ def test_random_segments_are_raw(self) -> None: self.assertEqual(str(proto.protochain), 'TCP:Raw', raw[20:25].hex()) +def _polyglot_segment() -> bytes: + """A genuine TLS 1.0-legacy-version ClientHello, record length 278, with + ``random[0] == 0xF6``, padded to 5638 octets. Read as SSLv2 under the + 3-octet header it is a complete CLIENT-HELLO (RECORD-LENGTH 5635).""" + random = b'\xf6' + bytes(31) + body = (b'\x03\x03' + random + b'\x00' # version, random, no session id + + b'\x00\x02\x13\x01' # one cipher suite + + b'\x01\x00' # null compression + + b'\x00\xe7' + b'\xff\x01' + b'\x00\xe3' + bytes(227)) # renegotiation_info + assert len(body) == 274 + fragment = b'\x01' + len(body).to_bytes(3, 'big') + body # ClientHello handshake + record = b'\x16\x03\x01' + len(fragment).to_bytes(2, 'big') + fragment + return record + bytes(5638 - len(record)) + + +class SSLv2On443Tests(unittest.TestCase): + """SSLv2 reached over TCP 443 after TLS rejects the payload: hellos of + either direction are accepted, a genuine TLS record is never taken as + SSLv2, and the SSLv2 layer reports what a direct parse would.""" + + #: A complete SERVER-HELLO, 2-octet header, RECORD-LENGTH 34 (hand-built, + #: as in ``test_sslv2_unit``). + SERVER_HELLO = bytes.fromhex( + '80220400010002000400030010aaaaaaaa010080' + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + ) + + def setUp(self) -> None: + reimport_once_per_class(self) + + @staticmethod + def _tcp(payload: bytes): + from pcapkit.protocols.transport.tcp import TCP + + raw = _TCP_TO_443 + payload + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + return TCP(raw, len(raw)) + + def test_a_complete_sslv2_server_hello_on_443_is_sslv2(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + + proto = self._tcp(self.SERVER_HELLO) + self.assertEqual(str(proto.protochain), 'TCP:SSLv2') + self.assertEqual(proto._next.info.msg_type, HandshakeType.SERVER_HELLO) + + def test_a_genuine_tls_client_hello_that_is_also_sslv2_stays_tls(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + seg = _polyglot_segment() + # Premise: the same octets are a complete SSLv2 CLIENT-HELLO. + direct = SSLv2(seg, len(seg)) + self.assertEqual(direct.length, 3) + self.assertEqual(direct.info.record_length, 5635) + self.assertEqual(direct.info.msg_type, HandshakeType.CLIENT_HELLO) + + # TLS is tried first, so the record is TLS; trailing padding stays raw. + proto = self._tcp(seg) + self.assertEqual(str(proto.protochain), 'TCP:TLS 1.0:Raw') + + def test_sslv2_on_443_reports_the_direct_parse_length_packet_and_name(self) -> None: + from pcapkit.protocols.application.sslv2 import SSLv2 + + client_hello = b'\x01\x00\x02\x00\x03\x00\x00\x00\x10' + b'\x01\x00\x80' + bytes(16) + # A 3-octet header: indicator 0x00 (not escape), RECORD-LENGTH, padding 0. + three_octet = bytes([0x00, len(client_hello), 0x00]) + client_hello + two_octet = bytes([0x80 | len(client_hello) >> 8, len(client_hello) & 0xFF]) + client_hello + for name, payload in (('server-hello', self.SERVER_HELLO), + ('client-hello-2', two_octet), + ('client-hello-3', three_octet)): + with self.subTest(payload=name): + on_443 = self._tcp(payload)._next + direct = SSLv2(payload, len(payload)) + self.assertEqual(on_443.alias, 'SSLv2') + self.assertEqual(on_443.name, 'Secure Sockets Layer version 2') + self.assertEqual(on_443.length, direct.length) + self.assertEqual(on_443.packet.header, direct.packet.header) + self.assertEqual(on_443.packet.payload, direct.packet.payload) + + def test_sslv2_hellos_on_443_rebuild_byte_exact(self) -> None: + from pcapkit.protocols.transport.tcp import TCP + + client_hello = b'\x01\x00\x02\x00\x03\x00\x00\x00\x10' + b'\x01\x00\x80' + bytes(16) + three_octet = bytes([0x00, len(client_hello), 0x00]) + client_hello + two_octet = bytes([0x80 | len(client_hello) >> 8, len(client_hello) & 0xFF]) + client_hello + for name, payload in (('server-hello', self.SERVER_HELLO), + ('client-hello-2', two_octet), + ('client-hello-3', three_octet)): + with self.subTest(payload=name): + raw = _TCP_TO_443 + payload + proto = self._tcp(payload) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + rebuilt = TCP.from_data(proto.info) + self.assertEqual(bytes(rebuilt), raw) + self.assertEqual(str(rebuilt.protochain), 'TCP:SSLv2') + + class ExportTests(unittest.TestCase): """The family is exported, and registered, the way every sibling application protocol is -- :class:`~pcapkit.protocols.application.ngap.NGAP` diff --git a/tests/protocols/application/test_sslv2_unit.py b/tests/protocols/application/test_sslv2_unit.py new file mode 100644 index 0000000000..3780d62f35 --- /dev/null +++ b/tests/protocols/application/test_sslv2_unit.py @@ -0,0 +1,686 @@ +# -*- coding: utf-8 -*- +"""Unit tests for :mod:`pcapkit.protocols.application.sslv2`. + +SSLv2's record header is 2 or 3 octets depending on the high bit of its first +octet (:issue:`2457`), so every case below is built and read back in both +header forms, for both handshake messages this package parses the body of -- +``CLIENT-HELLO`` and ``SERVER-HELLO`` -- plus one message type it does not +(``CLIENT-FINISHED``), which must still round-trip as raw octets rather than +losing the record. Every case builds its own octets in memory and reads no +capture. + +Round trips are checked as ``bytes(SSLv2.from_data(parsed.info)) == raw``, +which routes through ``_make_data`` and ``make``; ``parsed.data`` is the +constructor's input echoed back, so comparing it to ``raw`` checks nothing. + +""" +from __future__ import annotations + +import importlib.util +import unittest +import warnings +from typing import TYPE_CHECKING + +from tests._support import reimport_once_per_class + +if TYPE_CHECKING: + from typing import Any + +#: Test tier (#1958): hand-picked round-trip cases, not a sweep. +__tier__ = 'ALL' + +RUNTIME_DEPS = ('tbtrim', 'aenum', 'chardet', 'dictdumper') +HAS_RUNTIME = all(importlib.util.find_spec(name) is not None for name in RUNTIME_DEPS) + + +@unittest.skipUnless(HAS_RUNTIME, 'runtime dependencies not installed') +class TestSSLv2RoundTrip(unittest.TestCase): + """Parse-then-rebuild pins for every record shape this package builds.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_client_hello_two_byte_header_round_trips(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + proto = SSLv2( + msg_type=HandshakeType.CLIENT_HELLO, + two_byte_header=True, + body={ + 'cipher_specs': b'\x01\x00\x80' + b'\x02\x00\x80', + 'session_id': b'\x00' * 16, + 'challenge': b'\xab' * 16, + }, + ) + raw = proto.data + + # The 2-octet header form: high bit of the first octet set. + self.assertEqual(raw[0] & 0x80, 0x80) + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.length, 2) + self.assertTrue(parsed.info.two_byte_header) + self.assertEqual(parsed.info.msg_type, HandshakeType.CLIENT_HELLO) + self.assertEqual(parsed.info.body.cipher_specs, b'\x01\x00\x80\x02\x00\x80') + self.assertEqual(parsed.info.body.session_id, b'\x00' * 16) + self.assertEqual(parsed.info.body.challenge, b'\xab' * 16) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_client_hello_three_byte_header_round_trips(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + proto = SSLv2( + msg_type=HandshakeType.CLIENT_HELLO, + two_byte_header=False, + is_escape=False, + padding=0, + body={ + 'cipher_specs': b'\x01\x00\x80', + 'session_id': b'', + 'challenge': b'\xcd' * 32, + }, + ) + raw = proto.data + + # The 3-octet header form: high bit of the first octet clear. + self.assertEqual(raw[0] & 0x80, 0x00) + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.length, 3) + self.assertFalse(parsed.info.two_byte_header) + self.assertFalse(parsed.info.is_escape) + self.assertEqual(parsed.info.padding, 0) + self.assertEqual(parsed.info.body.session_id, b'') + self.assertEqual(parsed.info.body.challenge, b'\xcd' * 32) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_server_hello_two_byte_header_round_trips(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + proto = SSLv2( + msg_type=HandshakeType.SERVER_HELLO, + two_byte_header=True, + body={ + 'session_id_hit': False, + 'certificate_type': 1, + 'certificate': b'\xee' * 10, + 'cipher_specs': b'\x01\x00\x80', + 'connection_id': b'\x11' * 16, + }, + ) + raw = proto.data + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.length, 2) + self.assertEqual(parsed.info.msg_type, HandshakeType.SERVER_HELLO) + self.assertFalse(parsed.info.body.session_id_hit) + self.assertEqual(parsed.info.body.certificate_type, 1) + self.assertEqual(parsed.info.body.certificate, b'\xee' * 10) + self.assertEqual(parsed.info.body.cipher_specs, b'\x01\x00\x80') + self.assertEqual(parsed.info.body.connection_id, b'\x11' * 16) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_server_hello_three_byte_header_session_id_hit_round_trips(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + # A SESSION-ID-HIT SERVER-HELLO carries no certificate or cipher specs. + proto = SSLv2( + msg_type=HandshakeType.SERVER_HELLO, + two_byte_header=False, + is_escape=True, + padding=7, + body={ + 'session_id_hit': True, + 'certificate_type': 0, + 'certificate': b'', + 'cipher_specs': b'', + 'connection_id': b'\x22' * 32, + }, + ) + raw = proto.data + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.length, 3) + self.assertTrue(parsed.info.is_escape) + self.assertEqual(parsed.info.padding, 7) + self.assertTrue(parsed.info.body.session_id_hit) + self.assertEqual(parsed.info.body.certificate, b'') + self.assertEqual(parsed.info.body.cipher_specs, b'') + self.assertEqual(parsed.info.body.connection_id, b'\x22' * 32) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_server_hello_session_id_hit_preserves_non_boolean_octet(self) -> None: + """``SESSION-ID-HIT`` is any *non-zero* octet per the draft (§5.6.1), + not specifically ``0x01``, so the exact octet survives a round trip + rather than being normalised to a :obj:`bool`.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + proto = SSLv2( + msg_type=HandshakeType.SERVER_HELLO, + two_byte_header=True, + body={ + 'session_id_hit': 0xFF, + 'certificate_type': 0, + 'certificate': b'', + 'cipher_specs': b'', + 'connection_id': b'\x33' * 16, + }, + ) + raw = proto.data + + parsed = SSLv2(raw, len(raw)) + # Truthy, as the draft's "any non-zero value" says -- but the point + # of this test is the *exact* octet, not just its truthiness. + self.assertTrue(parsed.info.body.session_id_hit) + self.assertEqual(parsed.info.body.session_id_hit, 0xFF) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_unimplemented_message_type_keeps_raw_body(self) -> None: + """A message type this package does not decode the body of -- here + ``CLIENT-FINISHED`` -- is kept as raw octets rather than dropped.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + proto = SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, body=b'\x01\x02\x03\x04') + raw = proto.data + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.info.msg_type, HandshakeType.CLIENT_FINISHED) + self.assertEqual(parsed.info.body, b'\x01\x02\x03\x04') + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_from_data_keeps_both_records_when_chained(self) -> None: + """A second record chained onto the first survives a ``from_data`` + round trip: ``_make_data`` passes ``payload`` through to ``make``.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + first = SSLv2(msg_type=HandshakeType.CLIENT_HELLO, two_byte_header=True, body={ + 'cipher_specs': b'\x01\x00\x80', 'session_id': b'', 'challenge': b'\xcc' * 16, + }) + second = SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, two_byte_header=True, + body=b'\x01\x02\x03\x04') + combined = bytes(first) + bytes(second) + + parsed = SSLv2(combined, len(combined)) + self.assertEqual(bytes(parsed.payload), bytes(second)) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), combined) + + def test_from_data_raises_no_unknown_field_warning(self) -> None: + """``from_data`` emits no :exc:`UnknownFieldWarning`: ``_make_data`` + drops derived fields such as ``header_len`` and ``record_length``.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.warnings import UnknownFieldWarning + + proto = SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, body=b'\x01\x02\x03\x04') + raw = proto.data + parsed = SSLv2(raw, len(raw)) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + SSLv2.from_data(parsed.info) + self.assertEqual([w for w in caught if issubclass(w.category, UnknownFieldWarning)], []) + + def test_maximum_length_record_parses_and_one_past_it_is_refused(self) -> None: + """Each header form carries exactly its maximum ``RECORD-LENGTH`` + (``0x7FFF`` and ``0x3FFF``), checked against literal header octets; + one octet more is refused rather than wrapped into the flag bits.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + finished = int(HandshakeType.CLIENT_FINISHED) + cases = ( + # (two_byte_header, is_escape, maximum, header octets) + (True, False, 0x7FFF, b'\xff\xff'), + (False, False, 0x3FFF, b'\x3f\xff\x00'), + (False, True, 0x3FFF, b'\x7f\xff\x00'), + ) + for two_byte_header, is_escape, maximum, header in cases: + with self.subTest(two_byte_header=two_byte_header, is_escape=is_escape): + body = bytes(range(256)) * (maximum // 256) + b'\x5a' * (maximum % 256 - 1) + raw = header + bytes([finished]) + body + self.assertEqual(len(raw), len(header) + maximum) + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.length, len(header)) + self.assertEqual(parsed.info.two_byte_header, two_byte_header) + self.assertEqual(parsed.info.is_escape, is_escape) + self.assertEqual(parsed.info.record_length, maximum) + self.assertEqual(parsed.info.body, body) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + form = '2-octet' if two_byte_header else '3-octet' + refused = rf'SSLv2: record \({maximum + 1} octet\(s\)\) too long for the {form}' + with self.assertRaisesRegex(ProtocolError, refused): + SSLv2(msg_type=finished, two_byte_header=two_byte_header, + is_escape=is_escape, body=body + b'\x00') + + def test_zero_record_length_is_refused_rather_than_corrupting_the_next_record(self) -> None: + """A conformant ``RECORD-LENGTH`` is always >= 1 (it counts + ``MSG-TYPE`` itself). A malformed capture declaring ``0`` is refused + rather than reading ``MSG-TYPE`` and the body from the next record.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + second_record = bytes(SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, two_byte_header=True, + body=b'\xaa\xbb')) + # 2-octet header, high bit set, RECORD-LENGTH = 0. + malformed = bytes([0x80, 0x00]) + second_record + + with self.assertRaisesRegex(ProtocolError, r'SSLv2: record length \(0\) is too short'): + SSLv2(malformed, len(malformed)) + + def test_zero_record_length_schema_leaves_the_next_record_as_payload(self) -> None: + """The schema alone, without ``read()``'s check, sizes the body of a + ``RECORD-LENGTH = 0`` record to nothing rather than the rest of the buffer.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.protocols.schema.application.sslv2 import SSLv2 as Schema_SSLv2 + + second_record = bytes(SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, two_byte_header=True, + body=b'\xaa\xbb')) + for header in (b'\x80\x00', b'\x00\x00\x00'): + with self.subTest(header=header): + malformed = header + bytes([HandshakeType.CLIENT_FINISHED]) + second_record + schema = Schema_SSLv2.unpack(malformed) + self.assertEqual(schema.body.data, b'') + self.assertEqual(schema.payload, second_record) + + def test_make_rejects_out_of_range_padding(self) -> None: + """``padding`` is written as a single octet for the 3-octet header + form, so an out-of-range value is refused rather than wrapped mod 256.""" + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + for padding in (256, 300, -1): + with self.subTest(padding=padding): + with self.assertRaisesRegex(ProtocolError, + rf'SSLv2: padding \({padding}\) out of range'): + SSLv2(msg_type=1, two_byte_header=False, padding=padding, body=b'') + self.assertEqual(SSLv2(msg_type=1, two_byte_header=False, padding=255, body=b'').data[2], + 255) + + def test_ids_are_stable(self) -> None: + from pcapkit.protocols.application.sslv2 import SSLv2 + + self.assertEqual(SSLv2.id(), ('SSLv2',)) + + def test_name_property(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + # ``CLIENT-FINISHED`` so the (deliberately empty) raw body does not + # short-read against ``ClientHello``'s fixed fields. + proto = SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, body=b'') + self.assertEqual(proto.name, 'Secure Sockets Layer version 2') + self.assertEqual(proto.alias, 'SSLv2') + + def test_omitted_body_defaults_to_empty_client_hello(self) -> None: + """``body=None`` (the default) builds the same empty ``CLIENT-HELLO`` + body as ``body={}`` would, rather than raising.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + proto = SSLv2() + self.assertEqual(proto.info.msg_type, HandshakeType.CLIENT_HELLO) + self.assertEqual(proto.info.body.cipher_specs, b'') + self.assertEqual(proto.info.body.session_id, b'') + + def test_make_accepts_a_data_model_body(self) -> None: + """A :class:`~pcapkit.protocols.data.application.sslv2.ClientHello`/ + :class:`~pcapkit.protocols.data.application.sslv2.ServerHello` data + model -- e.g. a round trip through :meth:`~pcapkit.protocols.protocol.Protocol.from_data` + -- rebuilds the same body a dict would.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + first = SSLv2(msg_type=HandshakeType.CLIENT_HELLO, body={ + 'cipher_specs': b'\x01\x00\x80', 'session_id': b'', 'challenge': b'\xcc' * 16, + }) + second = SSLv2(msg_type=HandshakeType.CLIENT_HELLO, body=first.info.body) + self.assertEqual(second.data, first.data) + + hello = SSLv2(msg_type=HandshakeType.SERVER_HELLO, body={ + 'certificate': b'\xdd' * 4, 'cipher_specs': b'\x01\x00\x80', 'connection_id': b'\x33' * 16, + }) + rebuilt = SSLv2(msg_type=HandshakeType.SERVER_HELLO, body=hello.info.body) + self.assertEqual(rebuilt.data, hello.data) + + def test_make_accepts_a_prebuilt_schema_body(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.protocols.schema.application.sslv2 import ClientHello as Schema_ClientHello + + schema = Schema_ClientHello( + client_version=2, cipher_specs_length=0, session_id_length=0, challenge_length=16, + cipher_specs=b'', session_id=b'', challenge=b'\xee' * 16, + ) + proto = SSLv2(msg_type=HandshakeType.CLIENT_HELLO, body=schema) + parsed = SSLv2(proto.data, len(proto.data)) + self.assertEqual(parsed.info.body.challenge, b'\xee' * 16) + + def test_dict_body_refused_for_an_unparsed_message_type(self) -> None: + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaisesRegex(ProtocolError, r'SSLv2: a .* body must be given as bytes'): + SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, body={'foo': b'bar'}) + + def test_invalid_body_type_is_refused(self) -> None: + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + with self.assertRaisesRegex(ProtocolError, r'SSLv2: invalid body: '): + SSLv2(msg_type=1, body=12345) + + def test_make_rejects_out_of_range_session_id_hit(self) -> None: + """``SESSION-ID-HIT`` is a single octet, so ``256``, ``300`` and ``-1`` + are refused rather than wrapped mod 256; ``0`` and ``255`` build.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + for bad in (256, 300, -1): + with self.assertRaisesRegex(ProtocolError, r'SSLv2: SESSION-ID-HIT \(' + str(bad) + r'\) out of range'): + SSLv2(msg_type=HandshakeType.SERVER_HELLO, body={ + 'session_id_hit': bad, 'certificate_type': 0, 'certificate': b'', + 'cipher_specs': b'', 'connection_id': b'\x00' * 16, + }) + + for good in (0, 255): + proto = SSLv2(msg_type=HandshakeType.SERVER_HELLO, body={ + 'session_id_hit': good, 'certificate_type': 0, 'certificate': b'', + 'cipher_specs': b'', 'connection_id': b'\x00' * 16, + }) + raw = proto.data + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.info.body.session_id_hit, good) + + def test_make_rejects_mismatched_data_model_body_msg_type(self) -> None: + """A :class:`~pcapkit.protocols.data.application.sslv2.ClientHello`/ + :class:`~pcapkit.protocols.data.application.sslv2.ServerHello` data + model handed to ``make()`` is refused unless ``msg_type`` matches it, + since a mismatched record would re-parse as another type's fields.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + hello = SSLv2(msg_type=HandshakeType.SERVER_HELLO, body={ + 'certificate': b'\xdd' * 4, 'cipher_specs': b'\x01\x00\x80', 'connection_id': b'\x33' * 16, + }) + server_hello_body = hello.info.body + + with self.assertRaisesRegex(ProtocolError, r'ServerHello body must be given with msg_type=SERVER_HELLO'): + SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, body=server_hello_body) + + with self.assertRaisesRegex(ProtocolError, r'ServerHello body must be given with msg_type=SERVER_HELLO'): + SSLv2(msg_type=HandshakeType.CLIENT_HELLO, body=server_hello_body) + + client_hello = SSLv2(msg_type=HandshakeType.CLIENT_HELLO, body={ + 'cipher_specs': b'\x01\x00\x80', 'session_id': b'', 'challenge': b'\xcc' * 16, + }) + client_hello_body = client_hello.info.body + + with self.assertRaisesRegex(ProtocolError, r'ClientHello body must be given with msg_type=CLIENT_HELLO'): + SSLv2(msg_type=HandshakeType.SERVER_HELLO, body=client_hello_body) + + def test_server_hello_hand_built_wire_bytes_parses(self) -> None: + """A hand-written ``SERVER-HELLO``, not derived from ``make()``, parsed + as a known answer: a round trip alone cannot catch a bug shared by + ``make()`` and ``read()``.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + # 2-octet header, RECORD-LENGTH=34: MSG-SERVER-HELLO(4), + # SESSION-ID-HIT=0, CERTIFICATE-TYPE=1, SERVER-VERSION=0x0002, + # CERTIFICATE-LENGTH=4, CIPHER-SPECS-LENGTH=3, CONNECTION-ID-LENGTH=16, + # CERTIFICATE=\xaa*4, CIPHER-SPECS=\x01\x00\x80, CONNECTION-ID=\xbb*16. + raw = bytes.fromhex( + '80220400010002000400030010aaaaaaaa010080' + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + ) + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.length, 2) + self.assertEqual(parsed.info.record_length, 34) + self.assertEqual(parsed.info.msg_type, HandshakeType.SERVER_HELLO) + self.assertFalse(parsed.info.body.session_id_hit) + self.assertEqual(parsed.info.body.certificate_type, 1) + self.assertEqual(parsed.info.body.certificate, b'\xaa' * 4) + self.assertEqual(parsed.info.body.cipher_specs, b'\x01\x00\x80') + self.assertEqual(parsed.info.body.connection_id, b'\xbb' * 16) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_server_hello_with_long_certificate_hand_built_wire_bytes_parses(self) -> None: + """A hand-written ``SERVER-HELLO`` carrying a 256-octet certificate, + so ``RECORD-LENGTH`` (286, ``0x011E``) needs the header's high length + bits, parsed as a known answer in both header forms.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.const.sslv2.version import Version + from pcapkit.protocols.application.sslv2 import SSLv2 + + certificate = bytes(range(256)) + # MSG-SERVER-HELLO(4), SESSION-ID-HIT=0, CERTIFICATE-TYPE=1, + # SERVER-VERSION=0x0002, CERTIFICATE-LENGTH=256, + # CIPHER-SPECS-LENGTH=3, CONNECTION-ID-LENGTH=16, then the three fields. + record = (bytes.fromhex('04' '00' '01' '0002' '0100' '0003' '0010') + + certificate + bytes.fromhex('010080') + b'\xcc' * 16) + self.assertEqual(len(record), 0x011E) + cases = ( + # (header octets, header_len, two_byte_header, is_escape, padding) + (b'\x81\x1e', 2, True, False, 0), + (b'\x41\x1e\x07', 3, False, True, 7), + ) + for header, header_len, two_byte_header, is_escape, padding in cases: + with self.subTest(two_byte_header=two_byte_header): + raw = header + record + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.length, header_len) + self.assertEqual(parsed.info.two_byte_header, two_byte_header) + self.assertEqual(parsed.info.is_escape, is_escape) + self.assertEqual(parsed.info.padding, padding) + self.assertEqual(parsed.info.record_length, 0x011E) + self.assertEqual(parsed.info.msg_type, HandshakeType.SERVER_HELLO) + self.assertEqual(parsed.info.body.session_id_hit, 0) + self.assertEqual(parsed.info.body.certificate_type, 1) + self.assertEqual(parsed.info.body.server_version, Version.SSLv2) + self.assertEqual(parsed.info.body.certificate, certificate) + self.assertEqual(parsed.info.body.cipher_specs, b'\x01\x00\x80') + self.assertEqual(parsed.info.body.connection_id, b'\xcc' * 16) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_minimal_client_hello_hand_built_wire_bytes_parses(self) -> None: + """A ``CLIENT-HELLO`` body of exactly its 8-octet fixed sub-header, + every length zero, still decodes as a ``CLIENT-HELLO``.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.const.sslv2.version import Version + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.protocols.data.application.sslv2 import ClientHello + + # 2-octet header, RECORD-LENGTH=9: MSG-CLIENT-HELLO(1), + # CLIENT-VERSION=0x0002, then three zero lengths. + raw = bytes.fromhex('8009' '01' '0002' '0000' '0000' '0000') + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.info.record_length, 9) + self.assertEqual(parsed.info.msg_type, HandshakeType.CLIENT_HELLO) + self.assertIsInstance(parsed.info.body, ClientHello) + self.assertEqual(parsed.info.body.client_version, Version.SSLv2) + self.assertEqual(parsed.info.body.cipher_specs, b'') + self.assertEqual(parsed.info.body.session_id, b'') + self.assertEqual(parsed.info.body.challenge, b'') + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_ciphertext_tagged_hello_falls_back_to_raw_body(self) -> None: + """A ``SERVER-HELLO``-tagged record whose own declared sub-field + lengths do not add up to its ``RECORD-LENGTH`` -- simulating + ciphertext under a hello tag -- falls back to + :class:`~pcapkit.protocols.schema.application.sslv2.UnknownHandshakeBody`, + keeping the raw body whole.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + # 2-octet header, RECORD-LENGTH=21: MSG-SERVER-HELLO(4), followed by + # 20 octets of "ciphertext" whose first ten -- read as SESSION-ID-HIT, + # CERTIFICATE-TYPE, SERVER-VERSION, CERTIFICATE-LENGTH, + # CIPHER-SPECS-LENGTH, CONNECTION-ID-LENGTH -- declare lengths far + # larger than the 10 octets actually remaining. + garbage_body = bytes(range(20)) + raw = bytes.fromhex('801504') + garbage_body + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.info.record_length, 21) + self.assertEqual(parsed.info.msg_type, HandshakeType.SERVER_HELLO) + self.assertEqual(parsed.info.body, garbage_body) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + # The gate also holds when the parsed schema is rebuilt from its + # dict form, which still carries the forward-matched ``probe``. + from pcapkit.protocols.schema.application.sslv2 import SSLv2 as Schema_SSLv2 + self.assertEqual(bytes(Schema_SSLv2.from_dict(parsed.__header__.to_dict())), raw) + + def test_hello_tagged_body_shorter_than_its_fixed_sub_header_falls_back(self) -> None: + """A hello-tagged body too short to hold even the hello's own + fixed-width sub-header (8 octets for ``CLIENT-HELLO``, 10 for + ``SERVER-HELLO``) cannot be a hello at all, so it is kept raw.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + cases = ( + (HandshakeType.CLIENT_HELLO, 3), (HandshakeType.CLIENT_HELLO, 7), + (HandshakeType.SERVER_HELLO, 3), (HandshakeType.SERVER_HELLO, 9), + ) + for msg_type, size in cases: + with self.subTest(msg_type=msg_type, size=size): + short_body = bytes(range(1, size + 1)) + raw = bytes([0x80, 1 + len(short_body), msg_type]) + short_body + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.info.msg_type, msg_type) + self.assertEqual(parsed.info.body, short_body) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_server_hello_with_trailing_octet_falls_back_to_raw_body(self) -> None: + """A valid ``SERVER-HELLO`` body followed by one more octet inside the + same record overruns its declared sub-field lengths, so it is kept raw.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + hello = SSLv2(msg_type=HandshakeType.SERVER_HELLO, body={ + 'certificate_type': 1, 'certificate': b'\xaa' * 4, + 'cipher_specs': b'\x01\x00\x80', 'connection_id': b'\xbb' * 16, + }) + body = hello.data[3:] + b'\x00' + raw = bytes([0x80, 1 + len(body), HandshakeType.SERVER_HELLO]) + body + + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.info.msg_type, HandshakeType.SERVER_HELLO) + self.assertEqual(parsed.info.body, body) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def _assert_made_schema_round_trips(self, proto: 'Any') -> None: + """``Schema_SSLv2.from_dict(made.__header__.to_dict())`` packs back + to the made record's exact octets, with no unknown-field warning.""" + from pcapkit.protocols.schema.application.sslv2 import SSLv2 as Schema_SSLv2 + from pcapkit.utilities.warnings import UnknownFieldWarning + + made = proto.__header__.to_dict() + self.assertNotIn('probe', made) + with warnings.catch_warnings(): + warnings.simplefilter('error', UnknownFieldWarning) + rebuilt = Schema_SSLv2.from_dict(made) + self.assertEqual(bytes(rebuilt), proto.data) + + def test_made_client_hello_schema_round_trips_through_from_dict(self) -> None: + """A ``make()``-built schema has no forward-matched ``probe``, so + :func:`~pcapkit.protocols.schema.application.sslv2.sslv2_body_selector` + must not treat it as an implausibly short one and pick + ``UnknownHandshakeBody``.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + self._assert_made_schema_round_trips(SSLv2()) + for two_byte_header in (True, False): + with self.subTest(two_byte_header=two_byte_header): + self._assert_made_schema_round_trips(SSLv2( + msg_type=HandshakeType.CLIENT_HELLO, two_byte_header=two_byte_header, body={ + 'cipher_specs': b'\x01\x00\x80', 'session_id': b'\x11' * 16, + 'challenge': b'\xcc' * 16, + }, + )) + + def test_made_server_hello_schema_round_trips_through_from_dict(self) -> None: + """``SERVER-HELLO`` counterpart of the ``CLIENT-HELLO`` case above.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + self._assert_made_schema_round_trips(SSLv2(msg_type=HandshakeType.SERVER_HELLO)) + for two_byte_header in (True, False): + with self.subTest(two_byte_header=two_byte_header): + self._assert_made_schema_round_trips(SSLv2( + msg_type=HandshakeType.SERVER_HELLO, two_byte_header=two_byte_header, body={ + 'session_id_hit': 1, 'certificate_type': 1, 'certificate': b'\xdd' * 4, + 'cipher_specs': b'\x01\x00\x80', 'connection_id': b'\x33' * 16, + }, + )) + + def test_read_rejects_out_of_range_session_id_hit_on_a_prebuilt_schema(self) -> None: + """A prebuilt :class:`~pcapkit.protocols.schema.application.sslv2.ServerHello` + bypasses ``make()``'s own ``SESSION-ID-HIT`` range check; ``read()``'s + guard is what refuses it, where it would otherwise pack as ``0x00`` + and silently turn a HIT into a MISS.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.protocols.schema.application.sslv2 import ServerHello as Schema_ServerHello + from pcapkit.utilities.exceptions import ProtocolError + + schema = Schema_ServerHello( + session_id_hit=256, certificate_type=0, server_version=2, certificate_length=0, + cipher_specs_length=0, connection_id_length=16, certificate=b'', cipher_specs=b'', + connection_id=b'\x00' * 16, + ) + with self.assertRaisesRegex(ProtocolError, r'SSLv2: SESSION-ID-HIT \(256\) out of range'): + SSLv2(msg_type=HandshakeType.SERVER_HELLO, body=schema) + + def test_client_hello_length_mismatch_falls_back_to_raw_body(self) -> None: + """A ``CLIENT-HELLO``-tagged body whose declared sub-field lengths + overrun or underrun its ``RECORD-LENGTH`` is kept as raw octets.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + + # CLIENT-VERSION=0x0002, then CIPHER-SPECS-LENGTH, SESSION-ID-LENGTH, + # CHALLENGE-LENGTH; the 8-octet sub-header is followed by 19 octets. + tail = b'\xcc' * 19 + cases = { + 'overrun': b'\x00\x02\x00\x03\x00\x00\x00\x11' + tail, # 3 + 17 > 19 + 'underrun': b'\x00\x02\x00\x03\x00\x00\x00\x0f' + tail, # 3 + 15 < 19 + } + for name, body in cases.items(): + with self.subTest(case=name): + raw = bytes([0x80, 1 + len(body), HandshakeType.CLIENT_HELLO]) + body + parsed = SSLv2(raw, len(raw)) + self.assertEqual(parsed.info.msg_type, HandshakeType.CLIENT_HELLO) + self.assertEqual(parsed.info.body, body) + self.assertEqual(bytes(SSLv2.from_data(parsed.info)), raw) + + def test_make_itself_rejects_out_of_range_session_id_hit(self) -> None: + """``make()`` refuses an out-of-range ``SESSION-ID-HIT`` itself, called + directly so ``read()``'s own check never runs.""" + from pcapkit.const.sslv2.handshake_type import HandshakeType + from pcapkit.protocols.application.sslv2 import SSLv2 + from pcapkit.utilities.exceptions import ProtocolError + + proto = SSLv2(msg_type=HandshakeType.CLIENT_FINISHED, body=b'') + with self.assertRaisesRegex(ProtocolError, r'SSLv2: SESSION-ID-HIT \(256\) out of range'): + proto.make(msg_type=HandshakeType.SERVER_HELLO, body={'session_id_hit': 256}) + +if __name__ == '__main__': + unittest.main() From c47621a5adbc1e176aaa08370ffec9f01a313acc Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 15:02:24 -0400 Subject: [PATCH 2/4] fix(tests): bump the census pins by the full +2 sslv2 adds sslv2.HandshakeType and sslv2.Version are 2 new const modules, but the branch's pin bumps were all +1. Each pinned count needed the second +1: - test_const_enum_builtin_parity: ints 118->119, enums 134->135, distinct modules 128->129 - test_const_enum_get: enums 124->125, covered 121->122 - test_const_enum_lookup: enums 118->119 - test_const_registry_protocol: all_files 128->129, generated 111->112 Verified: all four files pass clean (128 tests, 1522 subtests). --- 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 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index 712b19c018..fde267215c 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -379,11 +379,11 @@ def test_the_sweep_size_is_pinned(self) -> None: # and ngap.ProtocolIE, which are on EnumRegistry, and GitHub issue # #2457's sslv2.HandshakeType and sslv2.Version, hand-curated rather than # generated but the same EnumRegistry-plus-IntEnum shape. - self.assertEqual(len(ints), 118) + self.assertEqual(len(ints), 119) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) - self.assertEqual(len(self.enums), 134) - self.assertEqual(len({obj.__module__ for obj in self.enums}), 128) + self.assertEqual(len(self.enums), 135) + self.assertEqual(len({obj.__module__ for obj in self.enums}), 129) 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 e274b547c2..f26f485944 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), 124) + self.assertEqual(len(self.enums), 125) 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, 121) + self.assertEqual(covered, 122) 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 d9b3e732b3..d624d9b033 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), 118) + self.assertEqual(len(self.enums), 119) 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 9acc1ecf0b..993e9c1d2b 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), 128) - self.assertEqual(len(generated), 111) + self.assertEqual(len(all_files), 129) + self.assertEqual(len(generated), 112) for relpath in generated: with self.subTest(module=relpath): From e427d96cd0ec4a37ab53ffa09d85e3bb17303f5c Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sat, 31 Oct 2026 15:32:19 -0400 Subject: [PATCH 3/4] fix(vendor): add the SSLv2 crawler, DATA-attribute shape pcapkit.const.sslv2's two modules were hand-curated with no pcapkit.vendor.sslv2 crawler, violating the const-vendor-pairing rule (#2495: "ALL const MUST have its own vendor crawler... use the DATA attribute... no exception"). - Add pcapkit.vendor.sslv2 (handshake_type, version), mirroring the ARPHRD precedent (#2497): each Vendor subclass's request() returns a DATA dict transcribed from the expired SSLv2 draft (draft-hickman-netscape-ssl-00) instead of fetching a URL. - Wire SSLv2_HandshakeType and SSLv2_Version into pcapkit.vendor.__all__. - Regenerate both const modules from the crawlers. Only drift: the docstring (moved to the crawler) and Version.SSLv2's literal (0x0002 -> 2, the crawler's decimal rendering). A second run is a byte-for-byte no-op. - Bump EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES 35->37 for the two new crawlers. - Update docs/source/pcapkit/const/sslv2.rst and the now-stale "hand-curated, no crawler" comments in two tests. Verified: tests/vendor/, tests/const/, the sslv2-specific tests, and tests/project/test_public_api.py all green. --- docs/source/pcapkit/const/sslv2.rst | 10 +- pcapkit/const/sslv2/handshake_type.py | 14 +-- pcapkit/const/sslv2/version.py | 21 +--- pcapkit/vendor/__init__.py | 3 + pcapkit/vendor/sslv2/__init__.py | 24 ++++ pcapkit/vendor/sslv2/handshake_type.py | 106 ++++++++++++++++++ pcapkit/vendor/sslv2/version.py | 106 ++++++++++++++++++ tests/const/test_const_enum_builtin_parity.py | 5 +- tests/const/test_const_sslv2_unit.py | 18 +-- tests/vendor/test_vendor_missing_body_unit.py | 2 +- 10 files changed, 264 insertions(+), 45 deletions(-) create mode 100644 pcapkit/vendor/sslv2/__init__.py create mode 100644 pcapkit/vendor/sslv2/handshake_type.py create mode 100644 pcapkit/vendor/sslv2/version.py diff --git a/docs/source/pcapkit/const/sslv2.rst b/docs/source/pcapkit/const/sslv2.rst index bef2576038..2c9eda017a 100644 --- a/docs/source/pcapkit/const/sslv2.rst +++ b/docs/source/pcapkit/const/sslv2.rst @@ -23,9 +23,10 @@ SSLv2 Handshake Message Type .. module:: pcapkit.const.sslv2.handshake_type This module contains the constant enumeration for **SSLv2 Handshake Message -Type**, hand-curated rather than generated by a -:class:`~pcapkit.vendor.default.Vendor` crawler: the expired SSLv2 draft is -not a live registry a crawler could re-fetch. +Type**, which is automatically generated from +:class:`pcapkit.vendor.sslv2.handshake_type.HandshakeType`. Its source is the +expired SSLv2 draft linked above, not a live registry, so the crawler reads a +``DATA`` attribute instead of fetching a URL. .. autoclass:: pcapkit.const.sslv2.handshake_type.HandshakeType :members: @@ -38,7 +39,8 @@ SSLv2 Protocol Version .. module:: pcapkit.const.sslv2.version This module contains the constant enumeration for **SSLv2 Protocol -Version**, hand-curated for the same reason +Version**, which is automatically generated from +:class:`pcapkit.vendor.sslv2.version.Version`, for the same reason :class:`~pcapkit.const.sslv2.handshake_type.HandshakeType` is. .. autoclass:: pcapkit.const.sslv2.version.Version diff --git a/pcapkit/const/sslv2/handshake_type.py b/pcapkit/const/sslv2/handshake_type.py index 2d316bffb8..4e7965eefc 100644 --- a/pcapkit/const/sslv2/handshake_type.py +++ b/pcapkit/const/sslv2/handshake_type.py @@ -5,14 +5,8 @@ .. module:: pcapkit.const.sslv2.handshake_type -This module contains the constant enumeration for **SSLv2 Handshake Message -Type**, hand-curated rather than generated by a -:class:`~pcapkit.vendor.default.Vendor` crawler: the ``MSG-*`` codes below -are the complete, closed set defined by the expired Netscape SSLv2 -draft [*]_, which is not an IANA (or any other) live registry a crawler -could re-fetch -- there is nothing upstream of this file to go stale against. - -.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 +This module contains the constant enumeration for **SSLv2 Handshake Message Type**, +which is automatically generated from :class:`pcapkit.vendor.sslv2.handshake_type.HandshakeType`. """ @@ -85,10 +79,6 @@ def _validate_value(cls, value: 'Any') -> 'None': def _missing_(cls, value: 'int') -> 'HandshakeType': """Lookup function used when value is not found. - A wire byte the draft does not define, ``9`` through ``255``, - resolves to an unregistered ``Unassigned`` member rather than - raising, as other registries in :mod:`pcapkit.const` do. - Args: value: Value to get enum item. diff --git a/pcapkit/const/sslv2/version.py b/pcapkit/const/sslv2/version.py index 68f633af16..4e4fcd0eb6 100644 --- a/pcapkit/const/sslv2/version.py +++ b/pcapkit/const/sslv2/version.py @@ -5,17 +5,8 @@ .. module:: pcapkit.const.sslv2.version -This module contains the constant enumeration for **SSLv2 Protocol -Version**, hand-curated rather than generated by a -:class:`~pcapkit.vendor.default.Vendor` crawler, for the same reason -:class:`pcapkit.const.sslv2.handshake_type.HandshakeType` is: the value below -comes from the expired Netscape SSLv2 draft [*]_, not a live registry. - -SSLv2 carries no record-layer version field; ``CLIENT-VERSION`` and -``SERVER-VERSION`` are handshake-body fields. See :meth:`Version._missing_` -for values other than ``SSLv2``. - -.. [*] https://datatracker.ietf.org/doc/html/draft-hickman-netscape-ssl-00 +This module contains the constant enumeration for **SSLv2 Protocol Version**, +which is automatically generated from :class:`pcapkit.vendor.sslv2.version.Version`. """ @@ -36,7 +27,7 @@ class Version(EnumRegistry, IntEnum): """[Version] SSLv2 Protocol Version""" #: ``SSL-2.0`` - SSLv2 = 0x0002 + SSLv2 = 2 @classmethod def _validate_value(cls, value: 'Any') -> 'None': @@ -64,12 +55,6 @@ def _validate_value(cls, value: 'Any') -> 'None': def _missing_(cls, value: 'int') -> 'Version': """Lookup function used when value is not found. - A ``CLIENT-VERSION``/``SERVER-VERSION`` other than ``0x0002`` is not - necessarily malformed: per :rfc:`6101#appendix-E`, an SSLv3-capable - client sends an SSLv2-framed ``CLIENT-HELLO`` whose version reads - ``768`` (``0x0300``). So any unrecognised value resolves to an - unregistered member rather than raising. - Args: value: Value to get enum item. diff --git a/pcapkit/vendor/__init__.py b/pcapkit/vendor/__init__.py index 9febaf3742..faa786df2e 100644 --- a/pcapkit/vendor/__init__.py +++ b/pcapkit/vendor/__init__.py @@ -119,6 +119,7 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': from pcapkit.vendor.ngap import * from pcapkit.vendor.ospf import * from pcapkit.vendor.sctp import * + from pcapkit.vendor.sslv2 import * from pcapkit.vendor.tcp import * from pcapkit.vendor.vlan import * @@ -178,6 +179,8 @@ def __getattr__(name: 'str') -> 'typing.NoReturn': # SCTP 'SCTP_Chunk', 'SCTP_Parameter', 'SCTP_CauseCode', 'SCTP_PayloadProtocolIdentifier', + # SSLv2 + 'SSLv2_HandshakeType', 'SSLv2_Version', # TCP 'TCP_Checksum', 'TCP_Option', 'TCP_MPTCPOption', 'TCP_Flags', # VLAN diff --git a/pcapkit/vendor/sslv2/__init__.py b/pcapkit/vendor/sslv2/__init__.py new file mode 100644 index 0000000000..8b33154905 --- /dev/null +++ b/pcapkit/vendor/sslv2/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +""":mod:`pcapkit.const.sslv2` Vendor Crawlers +================================================ + +.. module:: pcapkit.vendor.sslv2 + +This module contains all vendor crawlers of +:class:`~pcapkit.protocols.application.sslv2.SSLv2` implementations. +Available crawlers include: + +.. list-table:: + + * - :class:`SSLv2_HandshakeType ` + - SSLv2 Handshake Message Type + * - :class:`SSLv2_Version ` + - SSLv2 Protocol Version + +""" + +from pcapkit.vendor.sslv2.handshake_type import HandshakeType as SSLv2_HandshakeType +from pcapkit.vendor.sslv2.version import Version as SSLv2_Version + +__all__ = ['SSLv2_HandshakeType', 'SSLv2_Version'] diff --git a/pcapkit/vendor/sslv2/handshake_type.py b/pcapkit/vendor/sslv2/handshake_type.py new file mode 100644 index 0000000000..d28058f8c3 --- /dev/null +++ b/pcapkit/vendor/sslv2/handshake_type.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""SSLv2 Handshake Message Type +================================== + +.. module:: pcapkit.vendor.sslv2.handshake_type + +This module contains the vendor crawler for **SSLv2 Handshake Message Type**, +which is automatically generating :class:`pcapkit.const.sslv2.handshake_type.HandshakeType`. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['HandshakeType'] + +############################################################################### +# NOTE: this crawler does not crawl. Its source is the expired Netscape SSLv2 +# draft, draft-hickman-netscape-ssl-00, which is not an IANA (or any other) +# live registry a crawler could re-fetch -- there is nothing upstream of this +# module to go stale against. Per the const-vendor-pairing convention, a +# hand-maintained registry still gets a crawler, reading +# :data:`~pcapkit.vendor.sslv2.handshake_type.DATA` instead of fetching a URL +# -- the same shape as :mod:`pcapkit.vendor.linux.arphrd`, +# :mod:`pcapkit.vendor.ipx.packet` and :mod:`pcapkit.vendor.ipx.socket`. +############################################################################### + +#: SSLv2 handshake message type registry, transcribed from +#: draft-hickman-netscape-ssl-00 for the complete, closed set of ``MSG-*`` +#: codes the draft defines. +#: +#: Maps a handshake message type to the enumeration name it takes and the +#: ``MSG-*`` constant the comment above it renders. Names go through +#: :meth:`~Vendor.rename`, so the generated member is the +#: :meth:`~Vendor.safe_name` of the first element. +DATA = { + 0: ('ERROR', '``MSG-ERROR``'), + 1: ('CLIENT_HELLO', '``MSG-CLIENT-HELLO``'), + 2: ('CLIENT_MASTER_KEY', '``MSG-CLIENT-MASTER-KEY``'), + 3: ('CLIENT_FINISHED', '``MSG-CLIENT-FINISHED``'), + 4: ('SERVER_HELLO', '``MSG-SERVER-HELLO``'), + 5: ('SERVER_VERIFY', '``MSG-SERVER-VERIFY``'), + 6: ('SERVER_FINISHED', '``MSG-SERVER-FINISHED``'), + 7: ('REQUEST_CERTIFICATE', '``MSG-REQUEST-CERTIFICATE``'), + 8: ('CLIENT_CERTIFICATE', '``MSG-CLIENT-CERTIFICATE``'), +} # type: dict[int, tuple[str, str]] + + +class HandshakeType(Vendor): + """SSLv2 Handshake Message Type""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0x00 <= value <= 0xFF' + + def request(self) -> 'dict[int, tuple[str, str]]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data (:data:`~pcapkit.vendor.sslv2.handshake_type.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, tuple[str, str]]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(self.safe_name(name) for name, _ in data.values()) + + def process(self, data: 'dict[int, tuple[str, str]]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [ + "return cls._unregistered_member(value, 'Unassigned')", + ] + + for code, (name, desc) in data.items(): + pval = str(code) + renm = self.rename(name, pval) + + enum.append(f'#: {self.wrap_comment(desc)}\n {renm} = {pval}') + return enum, miss + + +if __name__ == '__main__': + sys.exit(HandshakeType()) # type: ignore[arg-type] diff --git a/pcapkit/vendor/sslv2/version.py b/pcapkit/vendor/sslv2/version.py new file mode 100644 index 0000000000..1b89260da2 --- /dev/null +++ b/pcapkit/vendor/sslv2/version.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""SSLv2 Protocol Version +============================ + +.. module:: pcapkit.vendor.sslv2.version + +This module contains the vendor crawler for **SSLv2 Protocol Version**, which +is automatically generating :class:`pcapkit.const.sslv2.version.Version`. + +""" + +import collections +import sys +from typing import TYPE_CHECKING + +from pcapkit.vendor.default import Vendor + +if TYPE_CHECKING: + from collections import Counter + +__all__ = ['Version'] + +############################################################################### +# NOTE: this crawler does not crawl. Its source is the expired Netscape SSLv2 +# draft, draft-hickman-netscape-ssl-00, which is not an IANA (or any other) +# live registry a crawler could re-fetch -- there is nothing upstream of this +# module to go stale against. Per the const-vendor-pairing convention, a +# hand-maintained registry still gets a crawler, reading +# :data:`~pcapkit.vendor.sslv2.version.DATA` instead of fetching a URL -- the +# same shape as :mod:`pcapkit.vendor.linux.arphrd`, +# :mod:`pcapkit.vendor.ipx.packet` and :mod:`pcapkit.vendor.ipx.socket`. +# +# SSLv2 carries no record-layer version field; ``CLIENT-VERSION`` and +# ``SERVER-VERSION`` are handshake-body fields, which is why the registry +# below names a single value rather than a numbered range: any value other +# than ``SSL-2.0`` is not necessarily malformed -- per :rfc:`6101#appendix-E`, +# an SSLv3-capable client sends an SSLv2-framed ``CLIENT-HELLO`` whose version +# reads ``768`` (``0x0300``) -- so :meth:`~pcapkit.const.sslv2.version.Version._missing_` +# resolves any other in-range value to an unregistered member instead of +# raising. +############################################################################### + +#: SSLv2 protocol version registry, transcribed from +#: draft-hickman-netscape-ssl-00 for the single value the draft defines. +#: +#: Maps the version value to the enumeration name it takes and the +#: ``SSL-*`` constant the comment above it renders. Names go through +#: :meth:`~Vendor.rename`, so the generated member is the +#: :meth:`~Vendor.safe_name` of the first element. +DATA = { + 2: ('SSLv2', '``SSL-2.0``'), +} # type: dict[int, tuple[str, str]] + + +class Version(Vendor): + """SSLv2 Protocol Version""" + + #: Value limit checker. + FLAG = 'isinstance(value, int) and 0x0000 <= value <= 0xFFFF' + + def request(self) -> 'dict[int, tuple[str, str]]': # type: ignore[override] # pylint: disable=arguments-differ + """Fetch registry data. + + Returns: + Registry data (:data:`~pcapkit.vendor.sslv2.version.DATA`). + + """ + return DATA + + def count(self, data: 'dict[int, tuple[str, str]]') -> 'Counter[str]': # type: ignore[override] + """Count field records. + + Args: + data: Registry data. + + Returns: + Field recordings. + + """ + return collections.Counter(self.safe_name(name) for name, _ in data.values()) + + def process(self, data: 'dict[int, tuple[str, str]]') -> 'tuple[list[str], list[str]]': # type: ignore[override] + """Process registry data. + + Args: + data: Registry data. + + Returns: + Enumeration fields and missing fields. + + """ + enum = [] # type: list[str] + miss = [ + "return cls._unregistered_member(value, 'Unassigned')", + ] + + for code, (name, desc) in data.items(): + pval = str(code) + renm = self.rename(name, pval) + + enum.append(f'#: {self.wrap_comment(desc)}\n {renm} = {pval}') + return enum, miss + + +if __name__ == '__main__': + sys.exit(Version()) # 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 fde267215c..b80efd53c1 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -377,8 +377,9 @@ def test_the_sweep_size_is_pinned(self) -> None: # tests.const.test_const_enum_get's narrower (IntEnum, IntFlag)-only # sweep. The int count includes GitHub issue #880's ngap.ProcedureCode # and ngap.ProtocolIE, which are on EnumRegistry, and GitHub issue - # #2457's sslv2.HandshakeType and sslv2.Version, hand-curated rather than - # generated but the same EnumRegistry-plus-IntEnum shape. + # #2457's sslv2.HandshakeType and sslv2.Version, generated by a + # DATA-attribute crawler rather than a web-scraped one, but the same + # EnumRegistry-plus-IntEnum shape. self.assertEqual(len(ints), 119) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) diff --git a/tests/const/test_const_sslv2_unit.py b/tests/const/test_const_sslv2_unit.py index b5b1d7f633..d6bc90c69b 100644 --- a/tests/const/test_const_sslv2_unit.py +++ b/tests/const/test_const_sslv2_unit.py @@ -2,20 +2,22 @@ """Unit tests for :mod:`pcapkit.const.sslv2.handshake_type` and :mod:`pcapkit.const.sslv2.version` (:issue:`2457`). -Both registries are hand-curated rather than generated by a -:class:`~pcapkit.vendor.default.Vendor` crawler -- the expired SSLv2 draft is -not a live registry -- but they still follow this package's lookup contract: -a registered member resolves by value, an unregistered one resolves to a -nameless ``Unassigned`` member without being minted into the registry -(:issue:`860`), and a value outside the member type's own range raises. +Both registries are generated by a :class:`~pcapkit.vendor.default.Vendor` +crawler (:mod:`pcapkit.vendor.sslv2`) that reads a ``DATA`` attribute instead +of fetching a URL, since their source -- the expired SSLv2 draft -- is not a +live registry a crawler could re-fetch. They still follow this package's +lookup contract: a registered member resolves by value, an unregistered one +resolves to a nameless ``Unassigned`` member without being minted into the +registry (:issue:`860`), and a value outside the member type's own range +raises. """ from __future__ import annotations import unittest -#: Test tier (#1958): pure-Python lookups over a hand-curated, closed registry, -#: which behave the same on every interpreter. +#: Test tier (#1958): pure-Python lookups over a closed registry, which +#: behave the same on every interpreter. __tier__ = 'LATEST' diff --git a/tests/vendor/test_vendor_missing_body_unit.py b/tests/vendor/test_vendor_missing_body_unit.py index da0487a3fe..05d0224ef0 100644 --- a/tests/vendor/test_vendor_missing_body_unit.py +++ b/tests/vendor/test_vendor_missing_body_unit.py @@ -85,7 +85,7 @@ #: or under a different variable name entirely is skipped by construction too, #: same as one built by ``.append()``. Those are covered behaviourally, not #: statically, by :file:`tests/const/test_const_enum_lookup.py`. -EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES = 36 +EXPECTED_CRAWLERS_WITH_CONSTANT_MISS_LINES = 38 def _constant_miss_lines() -> 'dict[str, list[str]]': From 53341213e44c5dd08caa2430e7f366427b81002c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:15:05 +0000 Subject: [PATCH 4/4] Merge remote-tracking branch 'origin/main' into feat/2457-sslv2-dissector Co-authored-by: JarryShaw <15666417+JarryShaw@users.noreply.github.com> --- tests/const/test_const_registry_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/const/test_const_registry_protocol.py b/tests/const/test_const_registry_protocol.py index 8a1c5f1d4c..53ad66ac1a 100644 --- a/tests/const/test_const_registry_protocol.py +++ b/tests/const/test_const_registry_protocol.py @@ -847,7 +847,7 @@ def test_every_generated_const_module_is_accounted_for(self) -> None: if path not in converted_relpaths and path not in EXCLUDED_FROM_INTENUM_CENSUS] self.assertEqual(len(all_files), 130) - self.assertEqual(len(generated), 124) + self.assertEqual(len(generated), 113) for relpath in generated: with self.subTest(module=relpath):