diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 0decc56a15..e65acf9f76 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -43,14 +43,18 @@ the ones outstanding are, * Link Layer: DSL, EAPOL, FDDI, ISDN, LINUX_SLL, LINUX_SLL2, NDP, PPP * Internet Layer: ECN, ICMP, ICMPv6, IGMP, Shim6 * Transport Layer: DCCP, RSVP -* Application Layer: BGP, DHCP, DHCPv6, DNS, DTLS, IMAP, LDAP, MQTT, NNTP, - NTP, ONC/RPC, POP, QUIC, RIP, RTP, SIP, SMTP, SNMP, SSH, Telnet, TLS/SSL, +* Application Layer: BGP, DHCP, DHCPv6, DNS, IMAP, LDAP, MQTT, NNTP, + NTP, ONC/RPC, POP, QUIC, RIP, RTP, SIP, SMTP, SNMP, SSH, Telnet, XMPP Several of these come in pairs that want a shared abstract base rather than two independent implementations, in the way :class:`~pcapkit.protocols.internet.ip.IP` already covers its family: ICMP with -ICMPv6, TLS/SSL with DTLS, and ``LINUX_SLL`` with ``LINUX_SLL2``. +ICMPv6, and ``LINUX_SLL`` with ``LINUX_SLL2``. **Done** for TLS/SSL with +DTLS: :class:`~pcapkit.protocols.application.ssl.SSL` is the shared +record-layer base of :class:`~pcapkit.protocols.application.tls.TLS` and +:class:`~pcapkit.protocols.application.dtls.DTLS` -- see the `DTLS`_ section +below. :class:`~pcapkit.protocols.link.vlan.VLAN` is the closer precedent for a pair whose *layout* is identical -- it holds the whole of the tag, and :class:`~pcapkit.protocols.link.c_tag.C_Tag` and @@ -62,7 +66,7 @@ whose *layout* is identical -- it holds the whole of the tag, and ``NotImplemented`` folder. **Those files are not a roadmap.** They are scratch reminders, so a stub's presence does not mean a protocol is planned or claimed, and its absence does not mean the protocol is unwanted: - ``LINUX_SLL``, ``LINUX_SLL2``, ``QUIC`` and **DTLS** have no stub and are on the + ``LINUX_SLL``, ``LINUX_SLL2`` and ``QUIC`` have no stub and are on the list above, while **NGAP** has none either and is implemented. Take this page as the record and ignore the folder. @@ -222,35 +226,41 @@ looks like an ordinary field and is not: DTLS ~~~~ -**Not started**, and it has no stub in the tree -- of the TLS/SSL and DTLS pair, -only TLS/SSL does. It earns its own entry because the registries -have moved ahead of it, and a growing part of SCTP's surface names a protocol -that does not exist: - -* the DTLS chunk, type 65 of :class:`~pcapkit.const.sctp.chunk.Chunk`, and the - DTLS Key Management chunk parameter, ``0x8006`` of - :class:`~pcapkit.const.sctp.parameter.Parameter` -- both fall through to the - generic handlers, so they parse as opaque; -* its error causes, 100 to 103 of - :class:`~pcapkit.const.sctp.cause_code.CauseCode`; -* payload protocol identifiers -- 47 (Diameter over DTLS/SCTP), 66 to 69 - (NGAP, XnAP, F1AP and E1AP, each over DTLS over SCTP) and 4242 (DTLS chunk - key management). - -That last group is the one that bites. NGAP over DTLS over SCTP is PPID 66, and -even with an NGAP dissector registered against it the bytes arriving are a DTLS -record rather than an NGAP PDU, so they can only degrade to -:class:`~pcapkit.protocols.misc.raw.Raw`. DTLS therefore blocks NGAP over DTLS, +**Done** for the record layer and handshake message framing: +:class:`~pcapkit.protocols.application.tls.TLS` (SSLv3, :rfc:`6101`; TLS +1.0, :rfc:`2246`; TLS 1.1, :rfc:`4346`; TLS 1.2, :rfc:`5246`; TLS 1.3, +:rfc:`9846`) and :class:`~pcapkit.protocols.application.dtls.DTLS` (DTLS +1.0, :rfc:`4347`; DTLS 1.2, :rfc:`6347`; DTLS 1.3, :rfc:`9147`) share +:class:`~pcapkit.protocols.application.ssl.SSL` as their record-layer base -- +content type, legacy version, length, and content-type dispatch -- with +DTLS's own divergence (the epoch/sequence-number pair in its record header, +and the fragment header each handshake message carries) staying in +:class:`~pcapkit.protocols.application.dtls.DTLS` alone. ``ClientHello`` and +``ServerHello`` decode down to their cipher suite(s), compression method(s) +and extension list (TYPE+LENGTH framed, extension bodies undecoded); every +other handshake message is message-TYPE-level framing only. Decryption is a +separate question and is not needed to make the record structure legible, in +the same way :class:`ESP ` parses +without keys. + +**Not done: reassembling a handshake message DTLS has fragmented across +several records** (:rfc:`6347#section-4.2.3`, :issue:`2440`). That needs a +:class:`~pcapkit.foundation.reassembly.reassembly.Reassembly` subclass keyed +on the UDP 5-tuple plus epoch. A fragmented message's body is kept as that one +fragment's raw octets -- see +:attr:`DTLSHandshake.is_fragmented ` +-- rather than decoded from a slice of the message. An unfragmented message is +unaffected. + +NGAP over DTLS over SCTP (PPID 66, +:class:`~pcapkit.const.sctp.payload_protocol_identifier.PayloadProtocolIdentifier`), asked for in `discussion #251 -`__, independently of -whether NGAP itself is implemented. - -A record-layer dissector is enough to unblock that -- content type, version, -epoch, sequence number, length, and the fragment offset and length DTLS adds to -handshake messages. Decryption is a separate question and is not needed to make -the record structure legible, in the same way :class:`ESP -` parses without keys. DTLS over UDP is the -other half of the same work and wants the same dissector. +`__, is **not** +decoded: :attr:`SCTP.__proto__ ` +maps PPID 66 to :class:`~pcapkit.protocols.application.ngap.NGAP` rather than +through :class:`~pcapkit.protocols.application.dtls.DTLS`, so those bytes +degrade to :class:`~pcapkit.protocols.misc.raw.Raw`. Rewiring that dispatch +needs a decision on what an undecrypted DTLS-wrapped NGAP PDU reports. Registered, but Not Dissected ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -307,10 +317,13 @@ Some wanted only a table entry, because the dissector was already there: *name* rather than a parse -- which is the right answer for a data channel carrying an arbitrary file. * **Done.** HTTP is additionally bound on 8080 (IANA ``http-alt``, "HTTP - Alternate (see port 80)") on both TCP and UDP. **8443 is deliberately left - unbound**: IANA registers it as ``pcsync-https``, not as an HTTP alternate, - and de-facto 8443 traffic is TLS-wrapped, which pcapkit cannot parse -- see - the ``tls`` stub above. Binding it would feed a TLS record to an HTTP parser. + Alternate (see port 80)") on both TCP and UDP. TCP 443 (IANA ``https``) is + bound to :class:`~pcapkit.protocols.application.tls.TLS` and UDP 5684 (IANA + ``coaps``) to :class:`~pcapkit.protocols.application.dtls.DTLS`. **8443 is deliberately left unbound**: IANA registers it as + ``pcsync-https``, not as an HTTP alternate, and binding it to HTTP would + feed a TLS record to an HTTP parser; binding it to TLS instead would be a + guess this registry does not make for a service IANA does not actually + name there. * **Done.** The service VLAN tag identifier (S-Tag), ``0x88A8``, is bound to :class:`~pcapkit.protocols.link.s_tag.S_Tag`, and the customer tag ``0x8100`` to :class:`~pcapkit.protocols.link.c_tag.C_Tag`. Both subclass the abstract @@ -1078,6 +1091,11 @@ pair alone would answer it twice. ``LINUX_SLL`` matters out of proportion to its size: every ``tcpdump -i any`` capture uses it, and neither variant has even a stub. +**Done within wave 2 — TLS/SSL with DTLS.** The `DTLS`_ section above lists +what is decoded (the shared :class:`~pcapkit.protocols.application.ssl.SSL` +base, TLS, DTLS, and handshake message framing) and what is not (DTLS +handshake-fragment reassembly, :issue:`2440`). + **Done ahead of wave 2 — handlers for the** ``DLT_NULL`` **and** ``DLT_RAW`` **link types.** Both are genuine DLTs that a capture can legitimately be rooted in — ``DLT_NULL`` (0) is BSD's loopback encapsulation, ``DLT_RAW`` (101) an IP diff --git a/docs/source/ext.rst b/docs/source/ext.rst index 817964ce48..151dfd046f 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -89,6 +89,14 @@ The protocol classes :mod:`pcapkit` ships: | | | :class:`pcapkit.protocols.application.rarp.RARP` | + + RARP Family +-----------------------+-------------------------------------------------------------+ | | | :class:`pcapkit.protocols.application.rarp.DRARP` | ++ +----------------+-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.application.ssl.SSL` | ++ + +-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.application.tls.TLS` | ++ + SSL Family +-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.application.tls.SSLv3` | ++ + +-----------------------+-------------------------------------------------------------+ +| | | :class:`pcapkit.protocols.application.dtls.DTLS` | +------------------------------------------------------------------+----------------+-----------------------+-------------------------------------------------------------+ | | | :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 6f6ff59478..5d006a9825 100644 --- a/docs/source/pcapkit/const/index.rst +++ b/docs/source/pcapkit/const/index.rst @@ -104,6 +104,7 @@ Application Layer http ngap ospf + tls Miscellaneous ------------- diff --git a/docs/source/pcapkit/const/tls.rst b/docs/source/pcapkit/const/tls.rst new file mode 100644 index 0000000000..01f8e46405 --- /dev/null +++ b/docs/source/pcapkit/const/tls.rst @@ -0,0 +1,53 @@ +===================================================================== +:class:`~pcapkit.protocols.application.ssl.SSL` Constant Enumerations +===================================================================== + +.. module:: pcapkit.const.tls + +This module contains the constant enumerations shared by the record-layer +implementations under the :class:`~pcapkit.protocols.application.ssl.SSL` +umbrella -- :class:`~pcapkit.protocols.application.tls.TLS` (SSLv3 through +TLS 1.3) and :class:`~pcapkit.protocols.application.dtls.DTLS`. Available +enumerations include: + +.. list-table:: + + * - :class:`TLS_ContentType ` + - TLS/DTLS Content Types [*]_ + * - :class:`TLS_HandshakeType ` + - TLS/DTLS HandshakeType [*]_ + +.. versionadded:: 1.6.0 + +TLS/DTLS Content Types +====================== + +.. module:: pcapkit.const.tls.content_type + +This module contains the constant enumeration for **TLS/DTLS Content Types**, +hand-curated from IANA's ``TLS ContentType`` registry rather than generated by +a :class:`~pcapkit.vendor.default.Vendor` crawler. + +.. autoclass:: pcapkit.const.tls.content_type.ContentType + :members: + :undoc-members: + :show-inheritance: + +TLS/DTLS HandshakeType +====================== + +.. module:: pcapkit.const.tls.handshake_type + +This module contains the constant enumeration for **TLS/DTLS HandshakeType**, +hand-curated from IANA's ``TLS HandshakeType`` registry, including the +DTLS-only messages :rfc:`9147#section-5.2` adds to it. + +.. autoclass:: pcapkit.const.tls.handshake_type.HandshakeType + :members: + :undoc-members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-5 +.. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7 diff --git a/docs/source/pcapkit/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst new file mode 100644 index 0000000000..ff5c75334c --- /dev/null +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -0,0 +1,112 @@ +DTLS - Datagram Transport Layer Security +============================================== + +.. module:: pcapkit.protocols.application.dtls + +:mod:`pcapkit.protocols.application.dtls` contains +:class:`~pcapkit.protocols.application.dtls.DTLS` only, which implements an +extractor for Datagram Transport Layer Security (DTLS) [*]_ -- DTLS 1.0 +(:rfc:`4347`), DTLS 1.2 (:rfc:`6347`) and DTLS 1.3's legacy-format records +(:rfc:`9147`) -- at the record layer, plus the epoch/sequence-number fields and +the handshake-message fragment header that set DTLS apart from +:class:`~pcapkit.protocols.application.tls.TLS`, whose reliable, ordered +transport needs neither. + +Version Identification +---------------------- + +As with :class:`~pcapkit.protocols.application.tls.TLS`, one class parses +every version and :meth:`~pcapkit.protocols.application.dtls.DTLS.read` sets +:attr:`~pcapkit.protocols.application.dtls.DTLS.alias` from the record; +:meth:`~pcapkit.protocols.application.dtls.DTLS.id` stays ``('DTLS',)``. + +.. list-table:: + :header-rows: 1 + + * - Legacy record version + - :attr:`~pcapkit.protocols.application.dtls.DTLS.alias` + * - ``0xFEFF`` + - ``DTLS 1.0`` + * - ``0xFEFD`` + - ``DTLS 1.2`` + * - ``0xFEFD``, or ``0xFEFF`` on an initial ``ClientHello``, whose + ``supported_versions`` extension lists ``0xFEFC`` + - ``DTLS 1.3`` + * - any other ``0xFExx`` + - ``DTLS`` + +DTLS 1.3 never puts its own ``0xFEFC`` on the record layer +(:rfc:`9147#section-4`), so it is told apart only by the +``supported_versions`` peek. A version the table does not name keeps the +generic ``DTLS`` alias rather than claiming a specific one. + +Known Limitations +----------------- + +**A handshake message split across DTLS records is not reassembled** +(:rfc:`6347#section-4.2.3`, :issue:`2440`). Such a message is flagged +:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.is_fragmented` +and its :attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` +kept as raw octets. + +**DTLS 1.3's unified record header is not decoded** (:issue:`2467`). It has +no content-type octet (:rfc:`9147#section-4`), so it degrades to +:class:`~pcapkit.protocols.misc.raw.Raw`. DTLS 1.3's cleartext records use +the legacy ``DTLSPlaintext`` header and are decoded. + +**Connection ID records are not decoded** (``tls12_cid``, :rfc:`9146`): the +connection ID's length is not carried in the record, so they degrade to +:class:`~pcapkit.protocols.misc.raw.Raw`. + +.. versionadded:: 1.6.0 + +.. raw:: html + +
+ +.. autoclass:: pcapkit.protocols.application.dtls.DTLS + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: length + + .. automethod:: id + + .. automethod:: read + .. automethod:: make + + .. automethod:: __length_hint__ + + .. automethod:: _make_data + +Header Schema +------------- + +.. module:: pcapkit.protocols.schema.application.dtls + +.. autoclass:: pcapkit.protocols.schema.application.dtls.DTLS + :members: + :show-inheritance: + +Data Models +----------- + +.. module:: pcapkit.protocols.data.application.dtls + +.. autoclass:: pcapkit.protocols.data.application.dtls.DTLS + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.dtls.DTLSHandshake + :members: + :show-inheritance: + +See :doc:`ssl` for ``ClientHello``, ``ServerHello``, ``Extension``, ``Alert`` +and ``ChangeCipherSpec``, shared verbatim with +:class:`~pcapkit.protocols.application.tls.TLS`. + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/wiki/Datagram_Transport_Layer_Security diff --git a/docs/source/pcapkit/protocols/application/index.rst b/docs/source/pcapkit/protocols/application/index.rst index 1c22c570d7..bc7d33f37b 100644 --- a/docs/source/pcapkit/protocols/application/index.rst +++ b/docs/source/pcapkit/protocols/application/index.rst @@ -22,11 +22,14 @@ function rather than encapsulation, see ngap ospf rarp + ssl + tls + dtls .. todo:: Implement BGP, DHCP, DHCPv6, DNS, IMAP, LDAP, MQTT, NNTP, NTP, ONC:RPC, - POP, RIP, RTP, SIP, SMTP, SNMP, SSH, TELNET, TLS/SSL, XMPP. + POP, RIP, RTP, SIP, SMTP, SNMP, SSH, TELNET, XMPP. Protocol Registry ----------------- diff --git a/docs/source/pcapkit/protocols/application/ngap.rst b/docs/source/pcapkit/protocols/application/ngap.rst index a71f4197fc..65a499447d 100644 --- a/docs/source/pcapkit/protocols/application/ngap.rst +++ b/docs/source/pcapkit/protocols/application/ngap.rst @@ -91,8 +91,11 @@ Known Limitations ----------------- * **PPID 66 payloads are not decoded.** NGAP over DTLS over SCTP wraps the - ``NGAP-PDU`` in a DTLS record, and :mod:`pcapkit` implements no DTLS, so the - bytes reaching :meth:`NGAP.read` are not an APER encoding. The PPID is + ``NGAP-PDU`` in a DTLS record, and :attr:`SCTP.__proto__ + ` maps PPID 66 to + :class:`~pcapkit.protocols.application.ngap.NGAP` rather than + through :class:`~pcapkit.protocols.application.dtls.DTLS`, so the bytes + reaching :meth:`NGAP.read` are not an APER encoding. The PPID is registered so that it is *named* rather than anonymous; the payload itself degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. * **The specification version is that of the installed decoder, not this diff --git a/docs/source/pcapkit/protocols/application/ssl.rst b/docs/source/pcapkit/protocols/application/ssl.rst new file mode 100644 index 0000000000..c44e49b423 --- /dev/null +++ b/docs/source/pcapkit/protocols/application/ssl.rst @@ -0,0 +1,93 @@ +SSL - Secure Sockets Layer +=============================== + +.. module:: pcapkit.protocols.application.ssl + +:mod:`pcapkit.protocols.application.ssl` contains +:class:`~pcapkit.protocols.application.ssl.SSL` only, +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.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; +* :class:`~pcapkit.protocols.application.dtls.DTLS` -- DTLS 1.0, 1.2 and 1.3. + +:meth:`SSL.id ` lists the direct +concrete subclasses, so a lookup keyed on ``SSL`` matches any of them. + +The TLS record header (:rfc:`9846#section-5.1`) is a +:class:`~pcapkit.const.tls.content_type.ContentType`, a legacy record version +and a length. The DTLS ``DTLSPlaintext`` header (:rfc:`9147#section-4`) has +the same fields plus an epoch and a 48-bit sequence number *between* the +version and the length, and DTLS adds a fragment header to each handshake +message. Each record header is declared in its subclass's own schema, and +:class:`~pcapkit.protocols.application.dtls.DTLS` reads the epoch and sequence +number. This base class carries the logic the two share -- content-type +dispatch, handshake-message decoding (including the DTLS fragment header), and +the record-level validation that keeps dispatch from accepting bytes that +cannot be a genuine record. + +.. versionadded:: 1.6.0 + +.. raw:: html + +
+ +.. autoclass:: pcapkit.protocols.application.ssl.SSL + :no-members: + :show-inheritance: + + .. automethod:: id + + .. automethod:: _validate_tls_record + + .. automethod:: _tls_content_is_cleartext + + .. automethod:: _read_tls_content + .. automethod:: _make_tls_content + + .. automethod:: _read_tls_handshake_messages + .. automethod:: _make_tls_handshake_messages + + .. automethod:: _read_tls_client_hello + .. automethod:: _make_tls_client_hello + + .. automethod:: _read_tls_server_hello + .. automethod:: _make_tls_server_hello + + .. automethod:: _read_tls_extensions + .. automethod:: _make_tls_extensions + +Data Models +----------- + +.. module:: pcapkit.protocols.data.application.tls + +.. autoclass:: pcapkit.protocols.data.application.tls.Handshake + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.ClientHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.ServerHello + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.Extension + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.Alert + :members: + :show-inheritance: + +.. autoclass:: pcapkit.protocols.data.application.tls.ChangeCipherSpec + :members: + :show-inheritance: + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/wiki/Transport_Layer_Security diff --git a/docs/source/pcapkit/protocols/application/tls.rst b/docs/source/pcapkit/protocols/application/tls.rst new file mode 100644 index 0000000000..4162411c7b --- /dev/null +++ b/docs/source/pcapkit/protocols/application/tls.rst @@ -0,0 +1,146 @@ +TLS/SSL - Transport Layer Security +======================================== + +.. module:: pcapkit.protocols.application.tls + +:mod:`pcapkit.protocols.application.tls` contains +:class:`~pcapkit.protocols.application.tls.TLS` and +:class:`~pcapkit.protocols.application.tls.SSLv3`, which implement an +extractor for the TLS/SSLv3+ record-layer family [*]_ -- SSLv3 (:rfc:`6101`), +TLS 1.0 (:rfc:`2246`), TLS 1.1 (:rfc:`4346`), TLS 1.2 (:rfc:`5246`) and TLS +1.3 (:rfc:`9846`, which obsoletes :rfc:`5077`, :rfc:`5246`, :rfc:`6961`, +:rfc:`7627`, :rfc:`8422` and :rfc:`8446`) -- at the record layer, plus +handshake message TYPE-level framing. + +Version Identification +---------------------- + +The record layer is wire-identical across the family, so one +:class:`~pcapkit.protocols.application.tls.TLS` class parses all of it and +reports the version it identified through +:attr:`~pcapkit.protocols.application.tls.TLS.alias` and +:attr:`~pcapkit.protocols.application.tls.TLS.name`, set by +:meth:`~pcapkit.protocols.application.tls.TLS.read` -- the same shape +:class:`~pcapkit.protocols.link.arp.ARP` uses for RARP/DRARP/InARP. +:meth:`~pcapkit.protocols.application.tls.TLS.id` stays ``('TLS',)`` +whatever the version. + +.. list-table:: + :header-rows: 1 + + * - Legacy record version + - :attr:`~pcapkit.protocols.application.tls.TLS.alias` + - :attr:`~pcapkit.protocols.application.tls.TLS.name` + * - ``0x0300`` + - ``SSLv3`` + - ``Secure Sockets Layer version 3`` + * - ``0x0301`` + - ``TLS 1.0`` + - ``Transport Layer Security`` + * - ``0x0302`` + - ``TLS 1.1`` + - ``Transport Layer Security`` + * - ``0x0303`` + - ``TLS 1.2`` + - ``Transport Layer Security`` + * - ``0x0301`` or ``0x0303``, on a ``ClientHello`` or ``ServerHello`` + whose ``supported_versions`` extension lists ``0x0304`` + - ``TLS 1.3`` + - ``Transport Layer Security`` + * - any other ``0x03xx`` + - ``TLS`` + - ``Transport Layer Security`` + +TLS 1.3 never puts its own ``0x0304`` on the record layer +(:rfc:`9846#section-5.1`), so it is told apart only by the +``supported_versions`` peek. A version the table does not name keeps the +generic ``TLS`` alias rather than claiming a specific one. + +Each record is branded by its own legacy version, not by the connection's +negotiated one. So in a TLS 1.3 connection, every record after the hellos +carries ``0x0303`` and reads ``TLS 1.2``; and a TLS 1.2 ``ClientHello`` sent +at record version ``0x0301`` reads ``TLS 1.0``. + +:class:`~pcapkit.protocols.application.tls.SSLv3` is for explicit +construction only, mirroring :class:`~pcapkit.protocols.link.arp.InARP`: it +narrows :meth:`~pcapkit.protocols.application.tls.SSLv3.id` to ``('SSLv3',)`` +and inherits :meth:`~pcapkit.protocols.application.tls.TLS.read` unchanged, +so the alias still follows the wire. Dispatch never constructs one. + +Decoded Fields +-------------- + +* The record header, always: it is never encrypted. +* A **cleartext** ``handshake``, ``alert`` or ``change_cipher_spec`` record + (:meth:`SSL._tls_content_is_cleartext ` + decides which are): + + * ``ClientHello``/``ServerHello`` down to their cipher suites, compression + methods and extensions (type and length only). + * Every other handshake message at type and length only. + * ``Alert`` and ``ChangeCipherSpec`` in full. + +* Everything else -- the same types once encrypted, ``application_data`` + and every other content type -- as opaque :obj:`bytes`. + +Known Limitation +---------------- + +**A record split across two TCP segments is not reassembled**, and decodes as +:class:`~pcapkit.protocols.misc.raw.Raw`. A record may be up to 16 KiB +against a typical 1460-octet MSS, so this is routine. + +.. versionadded:: 1.6.0 + +.. raw:: html + +
+ +.. autoclass:: pcapkit.protocols.application.tls.TLS + :no-members: + :show-inheritance: + + .. autoproperty:: name + .. autoproperty:: alias + .. autoproperty:: length + + .. automethod:: id + + .. automethod:: read + .. automethod:: make + + .. automethod:: __length_hint__ + + .. automethod:: _make_data + +.. autoclass:: pcapkit.protocols.application.tls.SSLv3 + :no-members: + :show-inheritance: + + .. automethod:: id + +Header Schema +------------- + +.. module:: pcapkit.protocols.schema.application.tls + +.. autoclass:: pcapkit.protocols.schema.application.tls.TLS + :members: + :show-inheritance: + +Data Model +---------- + +.. currentmodule:: pcapkit.protocols.data.application.tls + +.. autoclass:: pcapkit.protocols.data.application.tls.TLS + :members: + :show-inheritance: + +See :doc:`ssl` for ``ClientHello``, ``ServerHello``, ``Handshake``, ``Extension``, +``Alert`` and ``ChangeCipherSpec``, shared verbatim with +:class:`~pcapkit.protocols.application.dtls.DTLS`. + +.. rubric:: Footnotes + +.. [*] https://en.wikipedia.org/wiki/Transport_Layer_Security diff --git a/docs/source/pcapkit/protocols/index.rst b/docs/source/pcapkit/protocols/index.rst index bbf9c04857..280c830988 100644 --- a/docs/source/pcapkit/protocols/index.rst +++ b/docs/source/pcapkit/protocols/index.rst @@ -42,7 +42,7 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: end subgraph application [Application Layer] - Application --> HTTP & FTP & FTP_DATA & NGAP & OSPF & RARP + Application --> HTTP & FTP & FTP_DATA & NGAP & OSPF & RARP & SSL subgraph http [HTTP Family] HTTP --> h1["HTTP/1.*"] & h2["HTTP/2"] @@ -55,6 +55,11 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: subgraph rarp [RARP Family] RARP --> DRARP end + + subgraph ssl [SSL Family] + SSL --> TLS & DTLS + TLS --> SSLv3 + end end subgraph link [Link Layer] @@ -150,6 +155,10 @@ from given information. The class hierarchy of :mod:`pcapkit.protocols`: click OSPF "application/ospf.html#pcapkit.protocols.application.ospf.OSPF" 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 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" click Raw "misc/raw.html#pcapkit.protocols.misc.raw.Raw" click NoPayload "misc/null.html#pcapkit.protocols.misc.null.NoPayload" diff --git a/examples/generators/dispatch.py b/examples/generators/dispatch.py index ac1d0af029..cd379258d8 100644 --- a/examples/generators/dispatch.py +++ b/examples/generators/dispatch.py @@ -58,10 +58,11 @@ defect: building a well-formed PER-encoded ``NGAP-PDU`` is out of scope for a minimal dispatch probe, so both use the same placeholder payload :meth:`~tests.protocols.transport.test_sctp_unit.SCTPUnitTests.test_ppid_dispatch_hook` -already does, and for the same documented reason -- one PPID is DTLS-wrapped, -and pcapkit implements no DTLS; the other degrades identically -because the payload is not an aligned PER encoding, exactly as that test's own -docstring records. +already does, and for the same documented reason -- one PPID is DTLS-wrapped +and :attr:`SCTP.__proto__ ` +maps it to NGAP rather than through DTLS; the other degrades +identically because the payload is not an aligned PER encoding, exactly as +that test's own docstring records. This module constructs its own octets and reads no capture under :file:`examples/captures/`, so -- like its sibling -- it belongs to the unit @@ -423,10 +424,22 @@ def _internet_build(code: 'int') -> 'Any': # TCP / UDP -- port. ############################################################################### +#: A minimal, genuine TLS record: ``application_data`` (23), legacy version +#: 3.3, and a zero-length fragment -- content type and legacy version both in +#: range, and the (zero) declared length fits the (zero) remaining buffer, so +#: :meth:`SSL._validate_tls_record ` +#: accepts it rather than degrading it to :class:`Raw`. +_TLS_RECORD = b'\x17\x03\x03\x00\x00' + +#: The DTLS equivalent of :data:`_TLS_RECORD`: ``application_data``, legacy +#: version 0xFEFD, epoch 0, sequence number 1, zero-length fragment. +_DTLS_RECORD = b'\x17\xfe\xfd\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00' + _TCP_PORT_PAYLOAD = { 20: b'binary-file-contents', 21: b'USER anonymous\r\n', 80: b'GET / HTTP/1.1\r\nHost: probe.invalid\r\n\r\n', + 443: _TLS_RECORD, 8080: b'GET / HTTP/1.1\r\nHost: probe.invalid\r\n\r\n', } @@ -437,6 +450,7 @@ def _internet_build(code: 'int') -> 'Any': _UDP_PORT_PAYLOAD = { 80: b'GET / HTTP/1.1\r\nHost: probe.invalid\r\n\r\n', 1701: _L2TP_DATA, + 5684: _DTLS_RECORD, 8080: b'GET / HTTP/1.1\r\nHost: probe.invalid\r\n\r\n', } @@ -662,11 +676,13 @@ def _link_enum() -> 'Any': # The version-identifying proxy (#682), as for ``udp/80`` and ``udp/8080`` # below. 'tcp/80': ('pcapkit.protocols.application.http', 'HTTP'), + 'tcp/443': ('pcapkit.protocols.application.tls', 'TLS'), 'tcp/8080': ('pcapkit.protocols.application.http', 'HTTP'), # -- UDP.__proto__ (port) -------------------------------------------------- 'udp/80': ('pcapkit.protocols.application.http', 'HTTP'), 'udp/1701': ('pcapkit.protocols.link.l2tpv2', 'L2TPv2'), + 'udp/5684': ('pcapkit.protocols.application.dtls', 'DTLS'), 'udp/8080': ('pcapkit.protocols.application.http', 'HTTP'), # -- SCTP.__proto__ (payload protocol identifier) -------------------------- @@ -913,14 +929,15 @@ def outcomes(families: 'Optional[tuple[Family, ...]]' = None) -> 'list[Outcome]' 'placeholder payload is not an aligned PER NGAP-PDU; building ' 'one is out of scope for a minimal dispatch probe, not a ' 'library defect'), - # PPID 66 is NGAP wrapped in a DTLS record, and pcapkit implements no - # DTLS -- registered so the PPID is *named*, per the registry's own - # comment in pcapkit/protocols/transport/sctp.py, not because it decodes. - # The same placeholder payload fails the same way, for the same reason. + # PPID 66 is NGAP wrapped in a DTLS record, and the registry maps it to + # NGAP rather than through DTLS -- registered so the PPID + # is *named*, per the registry's own comment in + # pcapkit/protocols/transport/sctp.py, not because it decodes. The same + # placeholder payload fails the same way, for the same reason. 'sctp/PayloadProtocolIdentifier_3GPP_NGAP_over_DTLS_over_SCTP': ( ('malformed NGAP-PDU', 'needs the optional "pycrate" dependency'), - 'pcapkit implements no DTLS, and the same placeholder payload ' - 'is not a DTLS record either'), + 'PPID 66 is not routed through DTLS, and the same placeholder ' + 'payload is not a DTLS record either'), } diff --git a/pcapkit/__init__.py b/pcapkit/__init__.py index 81b3023e9f..ffb7913ba2 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', + 'RARP', 'DRARP', 'SSL', 'SSLv3', 'TLS', 'DTLS', 'Data', # Protocol Data 'Schema', # Protocol Schema diff --git a/pcapkit/all.py b/pcapkit/all.py index 2fe64341d0..c195938cec 100644 --- a/pcapkit/all.py +++ b/pcapkit/all.py @@ -127,6 +127,7 @@ 'TCP', 'UDP', 'SCTP', # Transport Layer 'FTP', 'FTP_DATA', # Application Layer 'HTTP', 'HTTPv1', 'HTTPv2', 'NGAP', 'OSPF', 'RARP', 'DRARP', + 'SSL', 'SSLv3', 'TLS', 'DTLS', 'Schema', 'schema', # Protocol Schema 'Data', 'data', # Protocol Data diff --git a/pcapkit/const/__init__.py b/pcapkit/const/__init__.py index f1aeee3fc5..0cb93ffda2 100644 --- a/pcapkit/const/__init__.py +++ b/pcapkit/const/__init__.py @@ -29,6 +29,7 @@ from pcapkit.const.ospf import * from pcapkit.const.sctp import * from pcapkit.const.tcp import * +from pcapkit.const.tls import * from pcapkit.const.vlan import * __all__ = [ @@ -81,6 +82,8 @@ 'SCTP_Chunk', 'SCTP_Parameter', 'SCTP_CauseCode', 'SCTP_PayloadProtocolIdentifier', # TCP 'TCP_Checksum', 'TCP_Option', 'TCP_MPTCPOption', 'TCP_Flags', + # TLS/DTLS + 'TLS_ContentType', 'TLS_HandshakeType', # VLAN 'VLAN_PriorityLevel', # PCAPNG diff --git a/pcapkit/const/tls/__init__.py b/pcapkit/const/tls/__init__.py new file mode 100644 index 0000000000..05891fccd9 --- /dev/null +++ b/pcapkit/const/tls/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# pylint: disable=unused-import +""":class:`~pcapkit.protocols.application.ssl.SSL` Constant Enumerations +========================================================================== + +.. module:: pcapkit.const.tls + +This module contains the constant enumerations shared by the record-layer +implementations under the :class:`~pcapkit.protocols.application.ssl.SSL` +umbrella -- :class:`~pcapkit.protocols.application.tls.TLS` (SSLv3 through +TLS 1.3) and :class:`~pcapkit.protocols.application.dtls.DTLS`. Available +enumerations include: + +.. list-table:: + + * - :class:`TLS_ContentType ` + - TLS/DTLS Content Types [*]_ + * - :class:`TLS_HandshakeType ` + - TLS/DTLS HandshakeType [*]_ + +.. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-5 +.. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7 + +""" + +from pcapkit.const.tls.content_type import ContentType as TLS_ContentType +from pcapkit.const.tls.handshake_type import HandshakeType as TLS_HandshakeType + +__all__ = ['TLS_ContentType', 'TLS_HandshakeType'] diff --git a/pcapkit/const/tls/content_type.py b/pcapkit/const/tls/content_type.py new file mode 100644 index 0000000000..2adb95e502 --- /dev/null +++ b/pcapkit/const/tls/content_type.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""TLS/DTLS Content Types +============================ + +.. module:: pcapkit.const.tls.content_type + +This module contains the constant enumeration for **TLS/DTLS Content Types**, +as assigned by IANA in the ``TLS ContentType`` registry [*]_. The record-layer +header -- :rfc:`9846#section-5.1` for TLS, :rfc:`9147#section-4`'s +``DTLSPlaintext`` for DTLS -- opens with this type in both. + +Every value IANA assigns is named below. Everything else in the 0-255 range it +lists as Unassigned (0-19 and 28-31, plus 64-255, which :rfc:`9443` reserves +for multiplexing with other protocols on the same port) or as Reserved (32-63, +:rfc:`9147`), and :meth:`ContentType._missing_` resolves those to an +``Unassigned`` member without growing the registry. + +.. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-5 + +""" + +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__ = ['ContentType'] + + +class ContentType(EnumRegistry, IntEnum): + """[ContentType] TLS/DTLS Content Types""" + + #: Invalid -- a sentinel :rfc:`9846#section-5.1` names so that a record + #: carrying it can be rejected, never a value sent on the wire; IANA keeps + #: 0 inside its Unassigned 0-19 band rather than assigning it [:rfc:`9846`] + invalid = 0 + + #: ChangeCipherSpec [:rfc:`9846`] + change_cipher_spec = 20 + + #: Alert [:rfc:`9846`] + alert = 21 + + #: Handshake [:rfc:`9846`] + handshake = 22 + + #: Application Data [:rfc:`9846`] + application_data = 23 + + #: Heartbeat [:rfc:`6520`] + heartbeat = 24 + + #: ``tls12_cid`` [:rfc:`9146`] + tls12_cid = 25 + + #: ACK -- DTLS 1.3 only [:rfc:`9147`] + ack = 26 + + #: ``return_routability_check`` -- DTLS connection-ID path validation + #: [:rfc:`9853`] + return_routability_check = 27 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. + + """ + if not (isinstance(value, int) and 0 <= value <= 255): + raise EnumValueError('%r is not a valid %s' % (value, cls.__name__)) + + @classmethod + def _missing_(cls, value: 'int') -> 'ContentType': + """Lookup function used when value is not found. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0 <= value <= 255): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') diff --git a/pcapkit/const/tls/handshake_type.py b/pcapkit/const/tls/handshake_type.py new file mode 100644 index 0000000000..223c87613d --- /dev/null +++ b/pcapkit/const/tls/handshake_type.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,consider-using-f-string +"""TLS/DTLS HandshakeType +============================ + +.. module:: pcapkit.const.tls.handshake_type + +This module contains the constant enumeration for **TLS/DTLS HandshakeType**, +as assigned by IANA in the ``TLS HandshakeType`` registry [*]_, including the +DTLS-only connection-ID messages :rfc:`9147#section-5.2` adds to that same +registry. Every value IANA assigns is named below; the ranges it lists as +Unassigned fall through to :meth:`_missing_` like any other unassigned value. + +Each member's name is the message name the protocol uses on the wire. IANA +suffixes ``_RESERVED`` onto the ones TLS 1.3 retired -- it keeps the code point +reserved rather than reassigning it -- which is a statement about the registry +rather than a different message name, so the suffix is not carried into the +member name and each such member instead records IANA's own comment on why the +assignment is retired. + +.. [*] https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-7 + +""" + +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] TLS/DTLS HandshakeType""" + + #: ``hello_request`` -- TLS 1.2 and earlier only [:rfc:`5246`] + hello_request = 0 + + #: ``client_hello`` [:rfc:`9846`] + client_hello = 1 + + #: ``server_hello`` [:rfc:`9846`] + server_hello = 2 + + #: ``hello_verify_request`` -- DTLS 1.2 and earlier only; DTLS 1.3 moved + #: its return-routability role to the ``cookie`` extension + #: (:rfc:`9147#section-5.1`) [:rfc:`6347`] + hello_verify_request = 3 + + #: ``new_session_ticket`` [:rfc:`9846`] + new_session_ticket = 4 + + #: ``end_of_early_data`` [:rfc:`9846`] + end_of_early_data = 5 + + #: ``hello_retry_request`` -- historical; the functionality moved to an + #: extension, and TLS 1.3 carries it on a ``server_hello`` instead + #: (:rfc:`9846#section-4.2.4`) [:rfc:`9846`] + hello_retry_request = 6 + + #: ``encrypted_extensions`` [:rfc:`9846`] + encrypted_extensions = 8 + + #: ``request_connection_id`` -- DTLS 1.3 only [:rfc:`9147`] + request_connection_id = 9 + + #: ``new_connection_id`` -- DTLS 1.3 only [:rfc:`9147`] + new_connection_id = 10 + + #: ``certificate`` [:rfc:`9846`] + certificate = 11 + + #: ``server_key_exchange`` -- TLS 1.2 and earlier only [:rfc:`5246`] + server_key_exchange = 12 + + #: ``certificate_request`` [:rfc:`9846`] + certificate_request = 13 + + #: ``server_hello_done`` -- TLS 1.2 and earlier only [:rfc:`5246`] + server_hello_done = 14 + + #: ``certificate_verify`` [:rfc:`9846`] + certificate_verify = 15 + + #: ``client_key_exchange`` -- TLS 1.2 and earlier only [:rfc:`5246`] + client_key_exchange = 16 + + #: ``client_certificate_request`` -- TLS 1.2 and earlier only, per IANA's + #: own comment on this assignment [:rfc:`9261`] + client_certificate_request = 17 + + #: ``finished`` [:rfc:`9846`] + finished = 20 + + #: ``certificate_url`` -- TLS 1.2 and earlier only [:rfc:`6066`] + certificate_url = 21 + + #: ``certificate_status`` -- TLS 1.2 and earlier only [:rfc:`6066`] + certificate_status = 22 + + #: ``supplemental_data`` -- TLS 1.2 and earlier only [:rfc:`4680`] + supplemental_data = 23 + + #: ``key_update`` [:rfc:`9846`] + key_update = 24 + + #: ``compressed_certificate`` [:rfc:`8879`] + compressed_certificate = 25 + + #: ``ekt_key`` [:rfc:`8870`] + ekt_key = 26 + + #: ``message_hash`` [:rfc:`9846`] + message_hash = 254 + + @classmethod + def _validate_value(cls, value: 'Any') -> 'None': + """Reject ``value`` unless it passes the range check :meth:`_missing_` makes. + + Args: + value: Value about to be looked up or minted. + + Raises: + EnumValueError: If ``value`` fails that check. + + """ + if not (isinstance(value, int) and 0 <= value <= 255): + 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. + + Args: + value: Value to get enum item. + + """ + if not (isinstance(value, int) and 0 <= value <= 255): + raise ValueError('%r is not a valid %s' % (value, cls.__name__)) + #: Unassigned + return cls._unregistered_member(value, 'Unassigned') diff --git a/pcapkit/protocols/__init__.py b/pcapkit/protocols/__init__.py index 544ddb65ff..b2665e3e6d 100644 --- a/pcapkit/protocols/__init__.py +++ b/pcapkit/protocols/__init__.py @@ -68,6 +68,7 @@ 'NGAP', 'OSPF', 'RARP', 'DRARP', + 'SSL', 'SSLv3', 'TLS', 'DTLS', ] #: dict[str, ~typing.Type[~pcapkit.protocols.protocol.ProtocolBase]]: Protocol registry. diff --git a/pcapkit/protocols/application/NotImplemented/tls.py b/pcapkit/protocols/application/NotImplemented/tls.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pcapkit/protocols/application/__init__.py b/pcapkit/protocols/application/__init__.py index 2416e88cc9..44fa8ec5a2 100644 --- a/pcapkit/protocols/application/__init__.py +++ b/pcapkit/protocols/application/__init__.py @@ -11,7 +11,7 @@ """ # TODO: Implements BGP, DHCP, DHCPv6, DNS, IMAP, LDAP, MQTT, # NNTP, NTP, ONC:RPC, POP, RIP, RTP, SIP, SMTP, SNMP, -# SSH, TELNET, TLS/SSL, XMPP. +# SSH, TELNET, XMPP. # # NB: NGAP's decoder below is an optional # dependency; see pcapkit.protocols.application.ngap. Importing the module @@ -28,9 +28,12 @@ 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.tls import TLS, SSLv3 +from pcapkit.protocols.application.dtls import DTLS # Base Classes from pcapkit.protocols.application.http import HTTP +from pcapkit.protocols.application.ssl import SSL # Application Layer Protocol Numbers from pcapkit.const.reg.apptype import AppType as APPTYPE @@ -42,4 +45,5 @@ 'NGAP', 'OSPF', 'RARP', 'DRARP', + 'SSL', 'SSLv3', 'TLS', 'DTLS', ] diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py new file mode 100644 index 0000000000..21555b084f --- /dev/null +++ b/pcapkit/protocols/application/dtls.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +"""DTLS - Datagram Transport Layer Security +============================================== + +.. module:: pcapkit.protocols.application.dtls + +:mod:`pcapkit.protocols.application.dtls` contains +:class:`~pcapkit.protocols.application.dtls.DTLS` only, which implements an +extractor for the DTLS record-layer family -- DTLS 1.0 (:rfc:`4347`), DTLS +1.2 (:rfc:`6347`), and DTLS 1.3's legacy-format records (:rfc:`9147`). +:attr:`UDP.__proto__ ` binds +it at port 5684. As with :class:`~pcapkit.protocols.application.tls.TLS`, +:meth:`~DTLS.read` sets :attr:`~DTLS.alias` from the record's legacy version. + +Known Limitations +----------------- + +**A handshake message split across DTLS records is not reassembled** +(:rfc:`6347#section-4.2.3`, :issue:`2440`). Such a message is flagged +:attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.is_fragmented` +and its :attr:`~pcapkit.protocols.data.application.dtls.DTLSHandshake.body` +kept as raw octets. + +**DTLS 1.3's unified record header is not decoded** (:issue:`2467`). It has +no content-type octet (:rfc:`9147#section-4`), so it degrades to +:class:`~pcapkit.protocols.misc.raw.Raw`. DTLS 1.3's cleartext records use +the legacy ``DTLSPlaintext`` header and are decoded. + +**Connection ID records are not decoded** (``tls12_cid``, :rfc:`9146`): the +connection ID's length is not carried in the record, so they degrade to +:class:`~pcapkit.protocols.misc.raw.Raw`. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING, cast + +from pcapkit.const.tls.content_type import ContentType as Enum_ContentType +from pcapkit.protocols.application.ssl import DTLS13_VERSION, SSL +from pcapkit.protocols.data.application.dtls import DTLS as Data_DTLS +from pcapkit.protocols.schema.application.dtls import DTLS as Schema_DTLS + +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__ = ['DTLS'] + +#: Legacy record version DTLS 1.0 carries (:rfc:`4347#section-4.1`). +_DTLSV1_0_VERSION = 0xFEFF +#: Legacy record version DTLS 1.2 carries, and the value DTLS 1.3 freezes its +#: own legacy-format records at (:rfc:`9147#section-4`). +_DTLSV1_2_VERSION = 0xFEFD +#: Legacy record versions a DTLS 1.3 record may carry: DTLS 1.2's always, and +#: DTLS 1.0's on an initial ``ClientHello`` (:rfc:`9147#section-4`). +_DTLS13_LEGACY_VERSIONS = frozenset({_DTLSV1_0_VERSION, _DTLSV1_2_VERSION}) + + +class DTLS(SSL[Data_DTLS, Schema_DTLS], schema=Schema_DTLS, data=Data_DTLS): + """This class implements all protocols in the DTLS record-layer family. + + - Datagram Transport Layer Security 1.0 [:rfc:`4347`] + - Datagram Transport Layer Security 1.2 [:rfc:`6347`] + - Datagram Transport Layer Security 1.3, legacy-format records only [:rfc:`9147`] + + Note: + The subpackage does not track the dispatch tier: DTLS is dispatched + from :attr:`UDP.__proto__ ` + at port 5684 (``coaps``, :rfc:`7252`) only, though IANA registers + other DTLS-borne services; bind more with + :func:`pcapkit.foundation.registry.protocols.register_udp`. + + """ + + #: Tier :meth:`read` identified, e.g. ``'DTLS 1.2'``; ``'DTLS'`` until then. + _alias = 'DTLS' # type: Literal["DTLS", "DTLS 1.0", "DTLS 1.2", "DTLS 1.3"] + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Datagram Transport Layer Security"]': + """Name of current protocol.""" + return 'Datagram Transport Layer Security' + + @property + def alias(self) -> 'Literal["DTLS", "DTLS 1.0", "DTLS 1.2", "DTLS 1.3"]': + """Acronym of current protocol -- the concrete tier :meth:`read` identified.""" + return self._alias + + @property + def length(self) -> 'Literal[13]': + """Header length of current protocol -- the fixed record header, + :attr:`~pcapkit.protocols.data.application.dtls.DTLS.length`'s + fragment excluded. + + """ + return 13 + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["DTLS"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. Fixed, regardless of which tier + :meth:`read` identifies the record as -- see + :meth:`TLS.id ` for why. + + """ + return ('DTLS',) + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': + """Read Datagram Transport Layer Security (DTLS). + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If the record cannot be a genuine DTLS record -- + see :meth:`_read_dtls_record`. + + """ + return self._read_dtls_record(length, **kwargs) + + def make(self, + content_type: 'Enum_ContentType | StdlibEnum | AenumEnum | str | int' = Enum_ContentType.application_data, # pylint: disable=line-too-long + content_type_default: 'Optional[int]' = None, + content_type_namespace: 'Optional[dict[str, int] | dict[int, str] | Type[StdlibEnum] | Type[AenumEnum]]' = None, # pylint: disable=line-too-long + content_type_reversed: 'bool' = False, + version: 'int' = 0xFEFD, + epoch: 'int' = 0, + sequence_number: 'int' = 0, + content: 'Any' = b'', + data: 'Optional[bytes]' = None, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_DTLS': + """Make (construct) packet data, for every tier: ``version`` is written as is. + + Args: + content_type: Record content type. + content_type_default: Default value for ``content_type``. + content_type_namespace: Namespace for ``content_type``. + content_type_reversed: Reverse namespace for ``content_type``. + version: Legacy record version (``major << 8 | minor``), e.g. + ``0xFEFD`` for DTLS 1.2 and (the legacy value DTLS 1.3 keeps + here) DTLS 1.3. + epoch: Epoch. + sequence_number: Sequence number within ``epoch`` (48-bit). + content: Record content: a :obj:`tuple` of + :class:`~pcapkit.protocols.data.application.dtls.DTLSHandshake` + for ``handshake``, an + :class:`~pcapkit.protocols.data.application.tls.Alert`, a + :class:`~pcapkit.protocols.data.application.tls.ChangeCipherSpec`, + or raw :obj:`bytes`, as :meth:`read` would decode it. + data: Pre-encoded record fragment. When given, it is used + verbatim in place of ``content`` and ``length`` is computed + from it. + payload: Octets after the record, e.g. a further record + coalesced in the same buffer. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + """ + content_type_ = cast('Enum_ContentType', self._make_index( + content_type, content_type_default, namespace=content_type_namespace, + reversed=content_type_reversed, pack=False)) + + fragment = data if data is not None else self._make_tls_content(content_type_, content, dtls=True) + return Schema_DTLS( + content_type=content_type_, + version=version, + epoch=epoch, + sequence_number=sequence_number, + length=len(fragment), + fragment=fragment, + payload=payload, + ) + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[13]': + """Return an estimated length for the object.""" + return 13 + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_DTLS') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'content_type': data.content_type, + 'version': data.version, + 'epoch': data.epoch, + 'sequence_number': data.sequence_number, + 'content': data.content, + 'payload': cls._make_payload(data), + } + + def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS': # pylint: disable=unused-argument + """Read a DTLS record, identifying its concrete tier as it goes. + + Structure of DTLS record layer [:rfc:`9147#section-4`]: + + .. code-block:: text + + struct { + ContentType type; + ProtocolVersion legacy_record_version; + uint16 epoch = 0; + uint48 sequence_number; + uint16 length; + opaque fragment[DTLSPlaintext.length]; + } DTLSPlaintext; + + The legacy version names the tier, except that DTLS 1.3 reuses + ``0xFEFD`` (or ``0xFEFF`` on an initial ``ClientHello``) and is found + by peeking ``supported_versions`` (:meth:`SSL._tls_negotiates + `). An unknown + ``0xFExx`` keeps the generic ``'DTLS'`` alias. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If the record header cannot be a genuine DTLS + record -- an unrecognised content type, a legacy version + outside DTLS's range, or a declared ``length`` that runs past + the end of the buffer -- or if the record fragment is + malformed for its content type. + + """ + schema = self.__header__ + available = length if length is not None else len(self) + self._validate_tls_record(schema, available=available, dtls=True) + + # NOTE: DTLS 1.3 is checked first, at 0xFEFF too: an initial ClientHello may carry + # {254, 255} (RFC 9147, section 4). Unknown versions keep the generic alias. + if schema.version in _DTLS13_LEGACY_VERSIONS and self._tls_negotiates(schema, DTLS13_VERSION, dtls=True): + self._alias = 'DTLS 1.3' + elif schema.version == _DTLSV1_0_VERSION: + self._alias = 'DTLS 1.0' + elif schema.version == _DTLSV1_2_VERSION: + self._alias = 'DTLS 1.2' + else: + self._alias = 'DTLS' + + content = self._read_tls_content(schema.content_type, schema.fragment, + dtls=True, epoch=schema.epoch) + dtls = Data_DTLS( + content_type=schema.content_type, + version=schema.version, + epoch=schema.epoch, + sequence_number=schema.sequence_number, + length=schema.length, + content=content, + ) + + remaining = available - (self.length + schema.length) + return self._decode_next_layer(dtls, -1, remaining) diff --git a/pcapkit/protocols/application/ngap.py b/pcapkit/protocols/application/ngap.py index 356a0ecd3a..4f196fee97 100644 --- a/pcapkit/protocols/application/ngap.py +++ b/pcapkit/protocols/application/ngap.py @@ -76,8 +76,11 @@ ----------------- * **PPID 66 payloads are not decoded.** NGAP over DTLS over SCTP wraps the - ``NGAP-PDU`` in a DTLS record, and :mod:`pcapkit` implements no DTLS, so the - bytes reaching :meth:`NGAP.read` are not an APER encoding. The PPID is + ``NGAP-PDU`` in a DTLS record, and :attr:`SCTP.__proto__ + ` maps PPID 66 to + :class:`~pcapkit.protocols.application.ngap.NGAP` rather than + through :class:`~pcapkit.protocols.application.dtls.DTLS`, so the bytes + reaching :meth:`NGAP.read` are not an APER encoding. The PPID is registered so that it is *named* rather than anonymous; the payload itself degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. * **The specification version is that of the installed decoder, not this diff --git a/pcapkit/protocols/application/ssl.py b/pcapkit/protocols/application/ssl.py new file mode 100644 index 0000000000..bf82524d3e --- /dev/null +++ b/pcapkit/protocols/application/ssl.py @@ -0,0 +1,789 @@ +# -*- coding: utf-8 -*- +"""SSL - Secure Sockets Layer +=============================== + +.. module:: pcapkit.protocols.application.ssl + +:mod:`pcapkit.protocols.application.ssl` contains +:class:`~pcapkit.protocols.application.ssl.SSL` only, +which is the umbrella base class for the Secure Sockets Layer protocol +family [*]_ -- the SSL versions, Transport Layer Security +(:class:`~pcapkit.protocols.application.tls.TLS`, which also covers SSLv3 +through :class:`~pcapkit.protocols.application.tls.SSLv3`) and Datagram TLS +(:class:`~pcapkit.protocols.application.dtls.DTLS`). + +The TLS record header (:rfc:`9846#section-5.1`) is a +:class:`~pcapkit.const.tls.content_type.ContentType`, a legacy record version +and a length. The DTLS ``DTLSPlaintext`` header (:rfc:`9147#section-4`) has +the same fields plus an epoch and a 48-bit sequence number *between* the +version and the length, and DTLS adds a fragment header to each handshake +message. Each record header is declared in its subclass's own schema +(:class:`~pcapkit.protocols.schema.application.tls.TLS` and +:class:`~pcapkit.protocols.schema.application.dtls.DTLS`), and +:class:`~pcapkit.protocols.application.dtls.DTLS` reads the epoch and sequence +number. This base class carries the logic the two share -- content-type +dispatch and handshake-message decoding -- parameterised by a ``dtls`` flag +where the DTLS handshake fragment header has to be read or written. + +.. [*] https://en.wikipedia.org/wiki/Transport_Layer_Security + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING, Generic + +from pcapkit.const.tls.content_type import ContentType as Enum_ContentType +from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType +from pcapkit.protocols.application.application import Application +from pcapkit.protocols.data.application.tls import Alert as Data_Alert +from pcapkit.protocols.data.application.tls import ChangeCipherSpec as Data_ChangeCipherSpec +from pcapkit.protocols.data.application.tls import ClientHello as Data_ClientHello +from pcapkit.protocols.data.application.tls import 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.protocol import _PT, _ST +from pcapkit.utilities.exceptions import ProtocolError + +if TYPE_CHECKING: + from typing import Any, Optional + + from typing_extensions import Literal + +__all__ = ['SSL'] + + +#: Legacy record-version major octet TLS records carry (SSLv3 through the +#: TLS 1.3 wire value, all ``0x03xx``). +_TLS_VERSION_MAJOR = 0x03 +#: Legacy record-version major octet DTLS records carry (``0xFExx`` -- +#: DTLS encodes its legacy version as the one's complement of TLS's). +_DTLS_VERSION_MAJOR = 0xFE + +#: The content types a genuine record may carry. An allowlist rather than +#: every :class:`~pcapkit.const.tls.content_type.ContentType` member: the +#: registry names ``invalid(0)``, a sentinel :rfc:`9846#section-5.1` rejects, +#: and mints a member for any unassigned value it is asked for. +#: +#: ``tls12_cid`` (25, :rfc:`9146#section-4`) is left out: its connection ID +#: precedes ``length`` and has a negotiated length the record does not carry, +#: so reading ``length`` at the fixed offset would fabricate a fragment. +_WIRE_CONTENT_TYPES = frozenset({ + Enum_ContentType.change_cipher_spec, + Enum_ContentType.alert, + Enum_ContentType.handshake, + Enum_ContentType.application_data, + Enum_ContentType.heartbeat, + Enum_ContentType.ack, + Enum_ContentType.return_routability_check, +}) + +#: The ``supported_versions`` extension type (:rfc:`9846#section-4.3.1`), where +#: TLS 1.3 and DTLS 1.3 negotiate the version their record header does not carry. +_EXT_SUPPORTED_VERSIONS = 43 + +#: The ``supported_versions`` value for TLS 1.3 (:rfc:`9846#section-4.3.1`). +TLS13_VERSION = 0x0304 +#: The ``supported_versions`` value for DTLS 1.3 (:rfc:`9147#section-5.3`). +DTLS13_VERSION = 0xFEFC + + +def _negotiated_versions(message: 'Any') -> 'tuple[int, ...]': + """The versions a ``supported_versions`` extension on ``message`` lists. + + Args: + message: A decoded handshake message's body -- typically + :class:`~pcapkit.protocols.data.application.tls.ClientHello` or + :class:`~pcapkit.protocols.data.application.tls.ServerHello`. + Any other type (raw :obj:`bytes`, or a message with no + ``extensions`` attribute) yields nothing. + + Returns: + Every version the extension lists, in order: zero or more for a + ``ClientHello`` (a list), exactly one for a ``ServerHello`` (a bare + selection), or nothing at all when ``message`` carries no + ``supported_versions`` extension -- including when it carries no + ``extensions`` at all. + + """ + extensions = getattr(message, 'extensions', None) + if not extensions: + return () + for extension in extensions: + if int(extension.type) != _EXT_SUPPORTED_VERSIONS: + continue + data = extension.data + # A ``ClientHello`` lists several versions behind a 1-octet length + # prefix; a ``ServerHello`` selects exactly one, with no length + # prefix of its own (:rfc:`9846#section-4.3.1`). + versions = data[1:] if isinstance(message, Data_ClientHello) else data + return tuple( + int.from_bytes(versions[i:i + 2], 'big') for i in range(0, len(versions) - 1, 2) + ) + return () + + +def _take(data: 'bytes', pos: 'int', size: 'int', *, what: 'str', alias: 'str') -> 'bytes': + """Slice ``size`` octets out of ``data`` at ``pos``, refusing a short read. + + Plain slicing silently returns fewer octets, which would let a truncated + message parse short rather than fail. + + Args: + data: Buffer being read. + pos: Offset to start at. + size: Number of octets to take. + what: Name of the field being read, for the error message. + alias: Protocol alias, for the error message. + + Returns: + The requested slice. + + Raises: + ProtocolError: If fewer than ``size`` octets remain from ``pos``. + + """ + chunk = data[pos:pos + size] + if len(chunk) != size: + raise ProtocolError(f'{alias}: invalid format: {what} runs past the end of the buffer') + return chunk + + +def _tls_handshake_tiles(fragment: 'bytes') -> 'bool': + """Whether ``fragment`` is tiled exactly by handshake-message headers. + + Once keys are derived a ``handshake`` record carries ciphertext, and its + header does not say so. A cleartext fragment is a whole number of + ``msg_type``/``length``-framed messages; ciphertext almost never is. + + Args: + fragment: Record fragment, i.e. the candidate handshake messages. + + Returns: + Whether the walk lands exactly on the end of ``fragment``. + + Notes: + A heuristic: ciphertext that happens to tile is decoded, and a + message split across records is not. Keeping a cleartext message + opaque loses nothing; decoding ciphertext would fabricate messages. + + """ + pos = 0 + total = len(fragment) + while pos < total: + if total - pos < 4: + return False + pos += 4 + int.from_bytes(fragment[pos + 1:pos + 4], 'big') + return pos == total + + +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. + + - 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 + (:class:`~pcapkit.protocols.application.dtls.DTLS`) [:rfc:`4347`, :rfc:`6347`, :rfc:`9147`] + + """ + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["TLS"], Literal["DTLS"]]': + """Index ID of the protocol. + + Returns: + Index ID of the protocol. + + """ + return ('TLS', 'DTLS') + + ########################################################################## + # Utilities. + ########################################################################## + + def _validate_tls_record(self, schema: 'Any', *, available: 'int', dtls: 'bool') -> 'None': + """Reject octets that cannot be a genuine TLS/DTLS record. + + Dispatch hands this class every payload on its port, most of them + mid-stream continuations with no record header. Without this check + any octets decode as a record: the content-type registry mints a + member for unknown values, and a ``length`` past the buffer is + zero-padded by :meth:`FieldBase.unpack + `. + + Args: + schema: The just-parsed record header. + available: Octets actually available to this layer -- the record + header plus its fragment, and anything coalesced after it. + dtls: Whether this is a DTLS record, for the legacy-version range + it checks. + + Raises: + ProtocolError: If ``schema`` cannot be a genuine record, so that + :func:`~pcapkit.corekit.decorators.beholder` degrades + dispatch to :class:`~pcapkit.protocols.misc.raw.Raw` instead + of accepting a fabricated one. + + Note: + A record split across TCP segments is rejected too: this layer + holds only a fragment of it, and reassembly is + :class:`~pcapkit.foundation.reassembly.tcp.TCP`'s job. + + """ + # NOTE: ``make()`` leaves ``content_type`` a bare :obj:`int`, not the enum member a + # parse yields, so resolve it through the registry to cover both. + # NOTE: checked against ``_WIRE_CONTENT_TYPES``, not by name: ``invalid(0)`` is + # named yet never a record's content type. + content_type = Enum_ContentType.get(int(schema.content_type)) + if content_type not in _WIRE_CONTENT_TYPES: + raise ProtocolError( + f'{self.alias}: invalid format: unrecognised content type {int(content_type)}') + + major = schema.version >> 8 + expected_major = _DTLS_VERSION_MAJOR if dtls else _TLS_VERSION_MAJOR + if major != expected_major: + raise ProtocolError( + f'{self.alias}: invalid format: legacy record version {schema.version:#06x} is ' + f'not a {"DTLS" if dtls else "TLS"} one') + + if self.length + schema.length > available: + raise ProtocolError( + f'{self.alias}: invalid format: record length {schema.length} runs past ' + 'the end of the buffer') + + def _tls_negotiates(self, schema: 'Any', target: 'int', *, dtls: 'bool') -> 'bool': + """Whether this record's first handshake message negotiates ``target``. + + A best-effort peek ahead of the real parse: any decoding failure here + is reported as "no", leaving the real parse to raise. + + Args: + schema: The just-parsed record header. + target: The negotiated-version value to look for + (:data:`TLS13_VERSION` or :data:`DTLS13_VERSION`). + dtls: Whether this is a DTLS record. + + Returns: + Whether the record's fragment decodes as cleartext handshake + messages whose first one carries a ``supported_versions`` + extension listing ``target``. + + """ + try: + content = self._read_tls_content(schema.content_type, schema.fragment, + dtls=dtls, epoch=getattr(schema, 'epoch', 0)) + except ProtocolError: + return False + if not isinstance(content, tuple) or not content: + return False + body = getattr(content[0], 'body', None) + return target in _negotiated_versions(body) + + def _tls_content_is_cleartext(self, content_type: 'Enum_ContentType', fragment: 'bytes', *, + dtls: 'bool', epoch: 'int') -> 'bool': + """Whether a record's fragment is cleartext to decode structurally. + + ``handshake``, ``alert`` and ``change_cipher_spec`` carry ciphertext + once keys are derived, and the record header does not say so. + Decoding ciphertext would fabricate content, or fail and lose the + cleartext header to :class:`~pcapkit.protocols.misc.raw.Raw`. + + * **DTLS**: epoch 0 is cleartext, any later epoch encrypted + (:rfc:`9147#section-4.2`). + * **TLS**: ``handshake`` must tile (:func:`_tls_handshake_tiles`); + ``alert`` must be exactly 2 octets and ``change_cipher_spec`` + exactly 1, which encryption overhead never yields. + + Any other content type is reported as not cleartext. + + Args: + content_type: Record content type. + fragment: Record fragment, undecoded. + dtls: Whether this is a DTLS record. + epoch: The DTLS record's epoch; unused when ``dtls`` is false. + + Returns: + Whether to decode ``fragment`` structurally for its content type. + + """ + if dtls: + return not epoch # i.e. epoch 0, the cleartext initial handshake + if content_type == Enum_ContentType.handshake: + return _tls_handshake_tiles(fragment) + if content_type == Enum_ContentType.alert: + return len(fragment) == 2 + if content_type == Enum_ContentType.change_cipher_spec: + return len(fragment) == 1 + return False + + def _read_tls_content(self, content_type: 'Enum_ContentType', fragment: 'bytes', *, + dtls: 'bool', epoch: 'int' = 0) -> 'Any': + """Decode a record fragment by its content type. + + Args: + content_type: Record content type. + fragment: Record fragment, undecoded. + dtls: Whether this is a DTLS record, i.e. whether a handshake + message carries DTLS's fragment header. + epoch: The DTLS record's epoch, which says whether its fragment is + encrypted; ignored when ``dtls`` is false, since a TLS record + header carries no equivalent. + + Returns: + A :obj:`tuple` of handshake messages for + :attr:`~pcapkit.const.tls.content_type.ContentType.handshake`, an + :class:`~pcapkit.protocols.data.application.tls.Alert` for + :attr:`~pcapkit.const.tls.content_type.ContentType.alert`, a + :class:`~pcapkit.protocols.data.application.tls.ChangeCipherSpec` + for :attr:`~pcapkit.const.tls.content_type.ContentType.change_cipher_spec`, + or ``fragment`` unchanged for every other content type -- + application data, heartbeat, ACK, and any content type this + version does not name. + + Raises: + ProtocolError: If ``fragment`` is malformed for ``content_type``. + + Notes: + ``handshake``, ``alert`` and ``change_cipher_spec`` are kept as + :obj:`bytes` when :meth:`_tls_content_is_cleartext` says they are + encrypted. + + """ + if content_type == Enum_ContentType.handshake: + if not self._tls_content_is_cleartext(content_type, fragment, dtls=dtls, epoch=epoch): + return fragment + return self._read_tls_handshake_messages(fragment, dtls=dtls) + if content_type == Enum_ContentType.alert: + if not self._tls_content_is_cleartext(content_type, fragment, dtls=dtls, epoch=epoch): + return fragment + if len(fragment) != 2: + raise ProtocolError(f'{self.alias}: invalid format: alert record must be exactly two octets') + level, description = _take(fragment, 0, 2, what='alert', alias=self.alias) + return Data_Alert(level=level, description=description) + if content_type == Enum_ContentType.change_cipher_spec: + if not self._tls_content_is_cleartext(content_type, fragment, dtls=dtls, epoch=epoch): + return fragment + if len(fragment) != 1: + raise ProtocolError(f'{self.alias}: invalid format: change_cipher_spec ' + 'record must be exactly one octet') + return Data_ChangeCipherSpec(msg=fragment[0]) + return fragment + + def _make_tls_content(self, content_type: 'Enum_ContentType', content: 'Any', *, + dtls: 'bool') -> 'bytes': + """Encode a record fragment by its content type -- the inverse of + :meth:`_read_tls_content`. + + Args: + content_type: Record content type. + content: Decoded record content, or already-encoded :obj:`bytes`. + dtls: Whether this is a DTLS record. + + Returns: + Encoded record fragment. + + """ + if isinstance(content, bytes): + return content + if content_type == Enum_ContentType.handshake: + return self._make_tls_handshake_messages(content, dtls=dtls) + if content_type == Enum_ContentType.alert: + return bytes([int(content.level), int(content.description)]) + if content_type == Enum_ContentType.change_cipher_spec: + return bytes([int(content.msg)]) + return bytes(content) + + def _read_tls_handshake_messages(self, fragment: 'bytes', *, + dtls: 'bool') -> 'tuple[Any, ...]': + """Decode a ``handshake`` record fragment into its handshake messages. + + A record may coalesce several handshake messages. + + Args: + fragment: Record fragment, i.e. one or more concatenated + handshake messages. + dtls: Whether each message carries DTLS's fragment header + (``message_seq``, ``fragment_offset``, ``fragment_length``; + :rfc:`6347#section-4.2.2`) after the 4-octet + ``msg_type``/``length`` header shared with TLS. + + Returns: + The decoded handshake messages, in order. + + Raises: + ProtocolError: If a message's header or body runs past the end of + ``fragment``. + + Notes: + Only an unfragmented ``ClientHello``/``ServerHello`` body is + decoded; a fragmented DTLS message is not reassembled + (:issue:`2440`), and other bodies stay undecoded (:issue:`2437`). + + """ + from pcapkit.protocols.data.application.dtls import \ + DTLSHandshake as Data_DTLSHandshake # pylint: disable=import-outside-toplevel + + messages = [] # type: list[Any] + pos = 0 + total = len(fragment) + while pos < total: + header = _take(fragment, pos, 4, what='handshake header', alias=self.alias) + msg_type = Enum_HandshakeType.get(header[0]) + length = int.from_bytes(header[1:4], 'big') + pos += 4 + + is_fragmented = False + # NOTE: 0 rather than ``None``: unused when ``dtls`` is false, and + # :class:`DTLSHandshake` declares these as plain ``int``. + message_seq = fragment_offset = fragment_length = 0 + if dtls: + frag_header = _take(fragment, pos, 8, what='DTLS fragment header', alias=self.alias) + pos += 8 + message_seq = int.from_bytes(frag_header[0:2], 'big') + fragment_offset = int.from_bytes(frag_header[2:5], 'big') + fragment_length = int.from_bytes(frag_header[5:8], 'big') + is_fragmented = bool(fragment_offset) or fragment_length != length + body_length = fragment_length + else: + body_length = length + + body_bytes = _take(fragment, pos, body_length, what='handshake body', alias=self.alias) + pos += body_length + + if not is_fragmented and msg_type == Enum_HandshakeType.client_hello: + body = self._read_tls_client_hello(body_bytes, dtls=dtls) # type: Any + elif not is_fragmented and msg_type == Enum_HandshakeType.server_hello: + body = self._read_tls_server_hello(body_bytes) + else: + body = body_bytes + + if dtls: + messages.append(Data_DTLSHandshake( + msg_type=msg_type, length=length, body=body, + message_seq=message_seq, fragment_offset=fragment_offset, + fragment_length=fragment_length, is_fragmented=is_fragmented, + )) + else: + messages.append(Data_Handshake(msg_type=msg_type, length=length, body=body)) + return tuple(messages) + + def _make_tls_handshake_messages(self, messages: 'tuple[Any, ...]', *, + dtls: 'bool') -> 'bytes': + """Encode handshake messages back into a record fragment -- the + inverse of :meth:`_read_tls_handshake_messages`. + + Args: + messages: Handshake messages, as returned by + :meth:`_read_tls_handshake_messages`, or already-encoded + :obj:`bytes` per message. + dtls: Whether to write DTLS's fragment header after each + message's ``msg_type``/``length`` header. + + Returns: + Encoded record fragment. + + """ + out = bytearray() + for message in messages: + if isinstance(message, bytes): + out += message + continue + + body = message.body + if isinstance(body, Data_ClientHello): + body_bytes = self._make_tls_client_hello(body, dtls=dtls) + elif isinstance(body, Data_ServerHello): + body_bytes = self._make_tls_server_hello(body) + else: + body_bytes = bytes(body) + + # NOTE: the message's ``length`` is trusted only for a fragmented DTLS message; + # otherwise it is recomputed from ``body_bytes``, so a rebuild stays exact. + is_fragmented = dtls and message.is_fragmented + length = message.length if is_fragmented else len(body_bytes) + + out += bytes([int(message.msg_type)]) + length.to_bytes(3, 'big') + if dtls: + fragment_length = message.fragment_length if is_fragmented else len(body_bytes) + out += (message.message_seq.to_bytes(2, 'big') + + message.fragment_offset.to_bytes(3, 'big') + + fragment_length.to_bytes(3, 'big')) + out += body_bytes + return bytes(out) + + def _read_tls_client_hello(self, body: 'bytes', *, dtls: 'bool') -> 'Data_ClientHello': + """Decode a ``ClientHello`` handshake message body. + + Structure of TLS's ``ClientHello`` [:rfc:`9846#section-4.2.2`]: + + .. code-block:: text + + uint16 ProtocolVersion; + opaque Random[32]; + + uint8 CipherSuite[2]; /* Cryptographic suite selector */ + + struct { + ProtocolVersion legacy_version = 0x0303; /* TLS v1.2 */ + Random random; + opaque legacy_session_id<0..32>; + CipherSuite cipher_suites<2..2^16-2>; + opaque legacy_compression_methods<1..2^8-1>; + Extension extensions<7..2^16-1>; + } ClientHello; + + DTLS inserts a ``cookie`` after ``legacy_session_id`` + [:rfc:`6347#section-4.2.1`, :rfc:`9147#section-5.3`]: + + .. code-block:: text + + struct { + ProtocolVersion legacy_version = { 254,253 }; // DTLSv1.2 + Random random; + opaque legacy_session_id<0..32>; + opaque legacy_cookie<0..2^8-1>; // DTLS + CipherSuite cipher_suites<2..2^16-2>; + opaque legacy_compression_methods<1..2^8-1>; + Extension extensions<8..2^16-1>; + } ClientHello; + + Args: + body: Message body. + dtls: Whether this is a DTLS ``ClientHello``, i.e. whether + ``cookie`` is present. + + Returns: + Decoded ``ClientHello``. + + Raises: + ProtocolError: If ``body`` is malformed. + + """ + alias = self.alias + version = int.from_bytes(_take(body, 0, 2, what='client_hello.version', alias=alias), 'big') + random = _take(body, 2, 32, what='client_hello.random', alias=alias) + pos = 34 + + session_id_len = _take(body, pos, 1, what='client_hello.session_id length', alias=alias)[0] + pos += 1 + session_id = _take(body, pos, session_id_len, what='client_hello.session_id', alias=alias) + pos += session_id_len + + cookie = None # type: Optional[bytes] + if dtls: + cookie_len = _take(body, pos, 1, what='client_hello.cookie length', alias=alias)[0] + pos += 1 + cookie = _take(body, pos, cookie_len, what='client_hello.cookie', alias=alias) + pos += cookie_len + + cipher_suites_len = int.from_bytes( + _take(body, pos, 2, what='client_hello.cipher_suites length', alias=alias), 'big') + pos += 2 + # NOTE: a ``CipherSuite`` is 2 octets (:rfc:`9846#section-4.2.2`): an odd length + # would decode a lone octet and re-pad it on rebuild, so it is refused. + if cipher_suites_len % 2: + raise ProtocolError( + f'{alias}: invalid format: client_hello.cipher_suites length ' + f'{cipher_suites_len} is not a multiple of 2') + cipher_suites_raw = _take(body, pos, cipher_suites_len, what='client_hello.cipher_suites', alias=alias) + pos += cipher_suites_len + cipher_suites = tuple( + int.from_bytes(cipher_suites_raw[i:i + 2], 'big') for i in range(0, cipher_suites_len, 2) + ) + + compression_len = _take(body, pos, 1, what='client_hello.compression_methods length', alias=alias)[0] + pos += 1 + compression_methods = tuple( + _take(body, pos, compression_len, what='client_hello.compression_methods', alias=alias)) + pos += compression_len + + extensions = None # type: Optional[tuple[Data_Extension, ...]] + if pos < len(body): + ext_total_len = int.from_bytes( + _take(body, pos, 2, what='client_hello.extensions length', alias=alias), 'big') + pos += 2 + extensions_raw = _take(body, pos, ext_total_len, what='client_hello.extensions', alias=alias) + pos += ext_total_len + extensions = self._read_tls_extensions(extensions_raw) + + if pos != len(body): + raise ProtocolError(f'{alias}: invalid format: client_hello carries excess octets') + + return Data_ClientHello( + version=version, random=random, session_id=session_id, cookie=cookie, + cipher_suites=cipher_suites, compression_methods=compression_methods, + extensions=extensions, + ) + + def _make_tls_client_hello(self, data: 'Data_ClientHello', *, dtls: 'bool') -> 'bytes': + """Encode a ``ClientHello`` handshake message body -- the inverse of + :meth:`_read_tls_client_hello`. + + Args: + data: Decoded ``ClientHello``. + dtls: Whether this is a DTLS ``ClientHello``, i.e. whether to + write the ``cookie`` field. + + Returns: + Encoded message body. + + """ + out = bytearray() + out += int(data.version).to_bytes(2, 'big') + out += data.random + out += bytes([len(data.session_id)]) + data.session_id + + if dtls: + cookie = data.cookie if data.cookie is not None else b'' + out += bytes([len(cookie)]) + cookie + + cipher_suites = b''.join(int(suite).to_bytes(2, 'big') for suite in data.cipher_suites) + out += len(cipher_suites).to_bytes(2, 'big') + cipher_suites + + compression_methods = bytes(int(method) for method in data.compression_methods) + out += bytes([len(compression_methods)]) + compression_methods + + if data.extensions is not None: + extensions_raw = self._make_tls_extensions(data.extensions) + out += len(extensions_raw).to_bytes(2, 'big') + extensions_raw + return bytes(out) + + def _read_tls_server_hello(self, body: 'bytes') -> 'Data_ServerHello': + """Decode a ``ServerHello`` handshake message body. + + Structure of ``ServerHello`` [:rfc:`9846#section-4.2.3`]: + + .. code-block:: text + + struct { + ProtocolVersion legacy_version = 0x0303; /* TLS v1.2 */ + Random random; + opaque legacy_session_id_echo<0..32>; + CipherSuite cipher_suite; + uint8 legacy_compression_method = 0; + Extension extensions<6..2^16-1>; + } ServerHello; + + Args: + body: Message body. + + Returns: + Decoded ``ServerHello``. + + Raises: + ProtocolError: If ``body`` is malformed. + + """ + alias = self.alias + version = int.from_bytes(_take(body, 0, 2, what='server_hello.version', alias=alias), 'big') + random = _take(body, 2, 32, what='server_hello.random', alias=alias) + pos = 34 + + session_id_len = _take(body, pos, 1, what='server_hello.session_id length', alias=alias)[0] + pos += 1 + session_id = _take(body, pos, session_id_len, what='server_hello.session_id', alias=alias) + pos += session_id_len + + cipher_suite = int.from_bytes(_take(body, pos, 2, what='server_hello.cipher_suite', alias=alias), 'big') + pos += 2 + compression_method = _take(body, pos, 1, what='server_hello.compression_method', alias=alias)[0] + pos += 1 + + extensions = None # type: Optional[tuple[Data_Extension, ...]] + if pos < len(body): + ext_total_len = int.from_bytes( + _take(body, pos, 2, what='server_hello.extensions length', alias=alias), 'big') + pos += 2 + extensions_raw = _take(body, pos, ext_total_len, what='server_hello.extensions', alias=alias) + pos += ext_total_len + extensions = self._read_tls_extensions(extensions_raw) + + if pos != len(body): + raise ProtocolError(f'{alias}: invalid format: server_hello carries excess octets') + + return Data_ServerHello( + version=version, random=random, session_id=session_id, cipher_suite=cipher_suite, + compression_method=compression_method, extensions=extensions, + ) + + def _make_tls_server_hello(self, data: 'Data_ServerHello') -> 'bytes': + """Encode a ``ServerHello`` handshake message body -- the inverse of + :meth:`_read_tls_server_hello`. + + Args: + data: Decoded ``ServerHello``. + + Returns: + Encoded message body. + + """ + out = bytearray() + out += int(data.version).to_bytes(2, 'big') + out += data.random + out += bytes([len(data.session_id)]) + data.session_id + out += int(data.cipher_suite).to_bytes(2, 'big') + out += bytes([int(data.compression_method)]) + + if data.extensions is not None: + extensions_raw = self._make_tls_extensions(data.extensions) + out += len(extensions_raw).to_bytes(2, 'big') + extensions_raw + return bytes(out) + + def _read_tls_extensions(self, data: 'bytes') -> 'tuple[Data_Extension, ...]': + """Decode an extension list -- TYPE and LENGTH framing only. + + Structure of one extension [:rfc:`9846#section-4.3`]: + + .. code-block:: text + + struct { + ExtensionType extension_type; + opaque extension_data<0..2^16-1>; + } Extension; + + An extension's body is kept undecoded (:issue:`2437`). + + Args: + data: The extension list, i.e. one or more concatenated + extensions, with no further length prefix of its own. + + Returns: + The decoded extensions, in order. + + Raises: + ProtocolError: If an extension's header or body runs past the + end of ``data``. + + """ + extensions = [] # type: list[Data_Extension] + pos = 0 + total = len(data) + while pos < total: + header = _take(data, pos, 4, what='extension header', alias=self.alias) + ext_type = int.from_bytes(header[0:2], 'big') + ext_len = int.from_bytes(header[2:4], 'big') + pos += 4 + ext_data = _take(data, pos, ext_len, what='extension data', alias=self.alias) + pos += ext_len + extensions.append(Data_Extension(type=ext_type, data=ext_data)) + return tuple(extensions) + + def _make_tls_extensions(self, extensions: 'tuple[Data_Extension, ...]') -> 'bytes': + """Encode an extension list -- the inverse of :meth:`_read_tls_extensions`. + + Args: + extensions: Decoded extensions. + + Returns: + Encoded extension list, with no further length prefix of its own. + + """ + out = bytearray() + for extension in extensions: + out += int(extension.type).to_bytes(2, 'big') + out += len(extension.data).to_bytes(2, 'big') + out += extension.data + return bytes(out) diff --git a/pcapkit/protocols/application/tls.py b/pcapkit/protocols/application/tls.py new file mode 100644 index 0000000000..70ca0ea43d --- /dev/null +++ b/pcapkit/protocols/application/tls.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +"""TLS/SSL - Transport Layer Security +======================================== + +.. module:: pcapkit.protocols.application.tls + +:mod:`pcapkit.protocols.application.tls` contains +:class:`~pcapkit.protocols.application.tls.TLS` and +:class:`~pcapkit.protocols.application.tls.SSLv3`, +which implement an extractor for the TLS/SSLv3+ record-layer family -- +SSLv3, TLS 1.0, TLS 1.1, TLS 1.2 (:rfc:`5246`) and TLS 1.3 (:rfc:`9846`, +which obsoletes :rfc:`5077`, :rfc:`5246`, :rfc:`6961`, :rfc:`7627`, +:rfc:`8422` and :rfc:`8446`). :attr:`TCP.__proto__ +` binds :class:`TLS` at port +443. Every tier shares one record header, so :meth:`~TLS.read` sets +:attr:`~TLS.alias` (and, for SSLv3, :attr:`~TLS.name`) from the record's +legacy version, as :class:`~pcapkit.protocols.link.arp.ARP` does by +operation code, rather than dispatching to per-version classes. + +Decoded Fields +-------------- + +* The record header, always: it is never encrypted. +* A **cleartext** ``handshake``, ``alert`` or ``change_cipher_spec`` record + (:meth:`SSL._tls_content_is_cleartext ` + decides which are): + + * ``ClientHello``/``ServerHello`` down to their cipher suites, compression + methods and extensions (type and length only). + * Every other handshake message at type and length only. + * ``Alert`` and ``ChangeCipherSpec`` in full. + +* Everything else -- the same types once encrypted, ``application_data`` + and every other content type -- as opaque :obj:`bytes`. + +Known Limitation +---------------- + +**A record split across two TCP segments is not reassembled**, and decodes as +:class:`~pcapkit.protocols.misc.raw.Raw`. A record may be up to 16 KiB +against a typical 1460-octet MSS, so this is routine. + +.. versionadded:: 1.6.0 + +""" +from typing import TYPE_CHECKING, cast + +from pcapkit.const.tls.content_type import ContentType as Enum_ContentType +from pcapkit.protocols.application.ssl import SSL, TLS13_VERSION +from pcapkit.protocols.data.application.tls import TLS as Data_TLS +from pcapkit.protocols.schema.application.tls import TLS as Schema_TLS + +if TYPE_CHECKING: + from enum import IntEnum as StdlibEnum + from typing import Any, Optional + from typing import Type as _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__ = ['TLS', 'SSLv3'] + +#: Legacy record version SSLv3 carries, the one tier with its own :attr:`~TLS.name`. +_SSLV3_VERSION = 0x0300 + +#: Legacy record version to alias. TLS 1.3 reuses ``0x0303``; see +#: :data:`_TLS13_LEGACY_VERSIONS`. +_TLS_VERSION_ALIASES = { + _SSLV3_VERSION: 'SSLv3', + 0x0301: 'TLS 1.0', + 0x0302: 'TLS 1.1', + 0x0303: 'TLS 1.2', +} # type: dict[int, Literal["SSLv3", "TLS 1.0", "TLS 1.1", "TLS 1.2"]] + +#: Legacy record versions a TLS 1.3 record may carry: ``0x0303`` always, and +#: ``0x0301`` on an initial ``ClientHello`` (:rfc:`9846#section-5.1`). +_TLS13_LEGACY_VERSIONS = frozenset({0x0301, 0x0303}) + + +class TLS(SSL[Data_TLS, Schema_TLS], schema=Schema_TLS, data=Data_TLS): + """This class implements all protocols in the TLS/SSLv3+ record-layer family. + + - Secure Sockets Layer version 3 (:class:`SSLv3`) + - Transport Layer Security 1.0 [:rfc:`2246`] + - Transport Layer Security 1.1 [:rfc:`4346`] + - Transport Layer Security 1.2 [:rfc:`5246`] + - Transport Layer Security 1.3 [:rfc:`9846`] + + Note: + The subpackage does not track the dispatch tier: TLS is dispatched + from :attr:`TCP.__proto__ ` + at port 443, the ``https`` service name IANA assigns there. See + :func:`pcapkit.foundation.registry.protocols.register_tcp`. + + """ + + #: Name of corresponding protocol; only SSLv3 has a different one. + _name = 'Transport Layer Security' # type: Literal["Transport Layer Security", "Secure Sockets Layer version 3"] # pylint: disable=line-too-long + + #: 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"] + + ########################################################################## + # Properties. + ########################################################################## + + @property + def name(self) -> 'Literal["Transport Layer Security", "Secure Sockets Layer version 3"]': + """Name of current protocol.""" + return self._name + + @property + def alias(self) -> 'Literal["TLS", "SSLv3", "TLS 1.0", "TLS 1.1", "TLS 1.2", "TLS 1.3"]': + """Acronym of current protocol -- the concrete tier :meth:`read` identified.""" + return self._alias + + @property + def length(self) -> 'Literal[5]': + """Header length of current protocol -- the fixed record header, + :attr:`~pcapkit.protocols.data.application.tls.TLS.length`'s fragment + excluded. + + """ + return 5 + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["TLS"]]': # type: ignore[override] + """Index ID of the protocol. + + Returns: + Index ID of the protocol. Fixed whatever tier :meth:`read` + identifies, so a lookup by ID matches every record; inspect + :attr:`alias` for the tier. + + """ + return ('TLS',) + + def read(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': + """Read Transport Layer Security (TLS). + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If the record cannot be a genuine TLS/SSLv3+ + record -- see :meth:`_read_tls_record`. + + """ + return self._read_tls_record(length, **kwargs) + + def make(self, + content_type: 'Enum_ContentType | StdlibEnum | AenumEnum | str | int' = Enum_ContentType.application_data, # pylint: disable=line-too-long + content_type_default: 'Optional[int]' = None, + content_type_namespace: 'Optional[dict[str, int] | dict[int, str] | _Type[StdlibEnum] | _Type[AenumEnum]]' = None, # pylint: disable=line-too-long + content_type_reversed: 'bool' = False, + version: 'int' = 0x0303, + content: 'Any' = b'', + data: 'Optional[bytes]' = None, + payload: 'bytes | ProtocolBase | Schema' = b'', + **kwargs: 'Any') -> 'Schema_TLS': + """Make (construct) packet data, for every tier: ``version`` is written as is. + + Args: + content_type: Record content type. + content_type_default: Default value for ``content_type``. + content_type_namespace: Namespace for ``content_type``. + content_type_reversed: Reverse namespace for ``content_type``. + version: Legacy record version (``major << 8 | minor``), e.g. + ``0x0303`` for TLS 1.2 and (the legacy value TLS 1.3 keeps + here) TLS 1.3. + content: Record content: a :obj:`tuple` of + :class:`~pcapkit.protocols.data.application.tls.Handshake` + for ``handshake``, an + :class:`~pcapkit.protocols.data.application.tls.Alert`, a + :class:`~pcapkit.protocols.data.application.tls.ChangeCipherSpec`, + or raw :obj:`bytes`, as :meth:`read` would decode it. + data: Pre-encoded record fragment. When given, it is used + verbatim in place of ``content`` and ``length`` is computed + from it. + payload: Octets after the record, e.g. a further record + coalesced in the same buffer. + **kwargs: Arbitrary keyword arguments. + + Returns: + Constructed packet data. + + """ + content_type_ = cast('Enum_ContentType', self._make_index( + content_type, content_type_default, namespace=content_type_namespace, + reversed=content_type_reversed, pack=False)) + + fragment = data if data is not None else self._make_tls_content(content_type_, content, dtls=False) + return Schema_TLS( + content_type=content_type_, + version=version, + length=len(fragment), + fragment=fragment, + payload=payload, + ) + + ########################################################################## + # Data models. + ########################################################################## + + def __length_hint__(self) -> 'Literal[5]': + """Return an estimated length for the object.""" + return 5 + + ########################################################################## + # Utilities. + ########################################################################## + + @classmethod + def _make_data(cls, data: 'Data_TLS') -> 'dict[str, Any]': # type: ignore[override] + """Create key-value pairs from ``data`` for protocol construction. + + Args: + data: protocol data + + Returns: + Key-value pairs for protocol construction. + + """ + return { + 'content_type': data.content_type, + 'version': data.version, + 'content': data.content, + 'payload': cls._make_payload(data), + } + + def _read_tls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_TLS': # pylint: disable=unused-argument + """Read a TLS/SSLv3+ record, identifying its concrete tier as it goes. + + Structure of TLS record layer [:rfc:`9846#section-5.1`]: + + .. code-block:: text + + struct { + ContentType type; + ProtocolVersion legacy_record_version; + uint16 length; + opaque fragment[TLSPlaintext.length]; + } TLSPlaintext; + + The legacy version names the tier, except that TLS 1.3 reuses + ``0x0303`` (or ``0x0301`` on an initial ``ClientHello``) and is found + by peeking ``supported_versions`` (:meth:`SSL._tls_negotiates + `). An unknown + ``0x03xx`` keeps the generic ``'TLS'`` alias. + + Args: + length: Length of packet data. + **kwargs: Arbitrary keyword arguments. + + Returns: + Parsed packet data. + + Raises: + ProtocolError: If the record header cannot be a genuine TLS + record -- an unrecognised content type, a legacy version + outside TLS's range, or a declared ``length`` that runs past + the end of the buffer -- or if the record fragment is + malformed for its content type. + + """ + schema = self.__header__ + available = length if length is not None else len(self) + self._validate_tls_record(schema, available=available, dtls=False) + + # NOTE: TLS 1.3 is checked first, at 0x0301 too: an initial ClientHello may carry + # 0x0301 (RFC 9846, section 5.1). Unknown versions keep the generic alias. + self._name = 'Transport Layer Security' + if schema.version in _TLS13_LEGACY_VERSIONS and self._tls_negotiates(schema, TLS13_VERSION, dtls=False): + self._alias = 'TLS 1.3' + elif schema.version in _TLS_VERSION_ALIASES: + self._alias = _TLS_VERSION_ALIASES[schema.version] + if schema.version == _SSLV3_VERSION: + self._name = 'Secure Sockets Layer version 3' + else: + self._alias = 'TLS' + + content = self._read_tls_content(schema.content_type, schema.fragment, dtls=False) + tls = Data_TLS( + content_type=schema.content_type, + version=schema.version, + length=schema.length, + content=content, + ) + + remaining = available - (self.length + schema.length) + return self._decode_next_layer(tls, -1, remaining) + + +class SSLv3(TLS, schema=Schema_TLS, data=Data_TLS): + """This class implements Secure Sockets Layer version 3. + + Dispatch never constructs it: an SSLv3 record parses as :class:`TLS` with + :attr:`~TLS.alias` ``'SSLv3'``. Like :class:`~pcapkit.protocols.link.arp.InARP` + to :class:`~pcapkit.protocols.link.arp.ARP`, it narrows :meth:`id` only. + + """ + + ########################################################################## + # Methods. + ########################################################################## + + @classmethod + def id(cls) -> 'tuple[Literal["SSLv3"]]': # type: ignore[override] + """Index ID of the protocol.""" + return ('SSLv3',) diff --git a/pcapkit/protocols/data/__init__.py b/pcapkit/protocols/data/__init__.py index f818d81927..86a855c6f5 100644 --- a/pcapkit/protocols/data/__init__.py +++ b/pcapkit/protocols/data/__init__.py @@ -239,6 +239,9 @@ # User Datagram Protocol 'UDP', + # Datagram Transport Layer Security + 'DTLS', 'DTLS_Handshake', + # File Transfer Protocol 'FTP', 'FTP_Request', 'FTP_Response', @@ -264,6 +267,11 @@ 'HTTPv2_RSTStreamFrame', 'HTTPv2_SettingsFrame', 'HTTPv2_PushPromiseFrame', 'HTTPv2_PingFrame', 'HTTPv2_GoawayFrame', 'HTTPv2_WindowUpdateFrame', 'HTTPv2_ContinuationFrame', + # Transport Layer Security + 'TLS', + 'TLS_Handshake', 'TLS_ClientHello', 'TLS_ServerHello', 'TLS_Extension', + 'TLS_Alert', 'TLS_ChangeCipherSpec', + # No Payload 'NoPayload', diff --git a/pcapkit/protocols/data/application/__init__.py b/pcapkit/protocols/data/application/__init__.py index 88d41724c2..5ce80b3e29 100644 --- a/pcapkit/protocols/data/application/__init__.py +++ b/pcapkit/protocols/data/application/__init__.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- """data models for application layer protocols""" +# Datagram Transport Layer Security +from pcapkit.protocols.data.application.dtls import DTLS +from pcapkit.protocols.data.application.dtls import DTLSHandshake as DTLS_Handshake + # File Transfer Protocol from pcapkit.protocols.data.application.ftp import FTP from pcapkit.protocols.data.application.ftp import Request as FTP_Request @@ -49,7 +53,19 @@ from pcapkit.protocols.data.application.ospf import \ CryptographicAuthentication as OSPF_CryptographicAuthentication +# Transport Layer Security +from pcapkit.protocols.data.application.tls import TLS +from pcapkit.protocols.data.application.tls import Alert as TLS_Alert +from pcapkit.protocols.data.application.tls import ChangeCipherSpec as TLS_ChangeCipherSpec +from pcapkit.protocols.data.application.tls import ClientHello as TLS_ClientHello +from pcapkit.protocols.data.application.tls import Extension as TLS_Extension +from pcapkit.protocols.data.application.tls import Handshake as TLS_Handshake +from pcapkit.protocols.data.application.tls import ServerHello as TLS_ServerHello + __all__ = [ + # Datagram Transport Layer Security + 'DTLS', 'DTLS_Handshake', + # File Transfer Protocol 'FTP', 'FTP_Request', 'FTP_Response', @@ -74,4 +90,9 @@ # Open Shortest Path First 'OSPF', 'OSPF_CryptographicAuthentication', + + # Transport Layer Security + 'TLS', + 'TLS_Handshake', 'TLS_ClientHello', 'TLS_ServerHello', 'TLS_Extension', + 'TLS_Alert', 'TLS_ChangeCipherSpec', ] diff --git a/pcapkit/protocols/data/application/dtls.py b/pcapkit/protocols/data/application/dtls.py new file mode 100644 index 0000000000..664fd3b4ee --- /dev/null +++ b/pcapkit/protocols/data/application/dtls.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""data models for DTLS protocol""" + +from typing import TYPE_CHECKING + +from pcapkit.corekit.infoclass import info_final +from pcapkit.protocols.data.application.tls import Handshake +from pcapkit.protocols.data.protocol import Protocol + +if TYPE_CHECKING: + from typing import Any, Union + + from pcapkit.const.tls.content_type import ContentType as Enum_ContentType + from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType + from pcapkit.protocols.data.application.tls import ClientHello, ServerHello + +__all__ = [ + 'DTLS', + + 'DTLSHandshake', +] + + +@info_final +class DTLSHandshake(Handshake): + """Data model for a DTLS handshake message. + + DTLS adds a fragment header -- ``message_seq``, ``fragment_offset`` and + ``fragment_length`` -- on top of the TLS handshake header shared with + :class:`~pcapkit.protocols.data.application.tls.Handshake` + (:rfc:`6347#section-4.2.2`), so that a handshake message that does not fit + in one datagram can be split across several. + + :attr:`body` decodes to a :class:`~pcapkit.protocols.data.application.tls.ClientHello` + or :class:`~pcapkit.protocols.data.application.tls.ServerHello` only when + :attr:`is_fragmented` is :data:`False`; a genuinely fragmented message is + not reassembled (:issue:`2440`; see + :class:`~pcapkit.protocols.application.dtls.DTLS` for why), so its + :attr:`body` is the single fragment's raw, undecoded octets regardless of + :attr:`msg_type`. + + """ + + #: Handshake message type. + msg_type: 'Enum_HandshakeType' + #: Total message length (24-bit), fragment headers and bodies excluded. + length: 'int' + #: Message body -- see the class docstring for when this decodes. + body: 'Union[ClientHello, ServerHello, bytes]' + #: Message sequence number. + message_seq: 'int' + #: Fragment offset (24-bit) into the reassembled message. + fragment_offset: 'int' + #: Length (24-bit) of this fragment. + fragment_length: 'int' + #: Whether this fragment is a strict subset of the message, i.e. + #: ``fragment_offset != 0`` or ``fragment_length != length``. + is_fragmented: 'bool' + + if TYPE_CHECKING: + def __init__(self, msg_type: 'Enum_HandshakeType', length: 'int', body: 'Union[ClientHello, ServerHello, bytes]', message_seq: 'int', fragment_offset: 'int', fragment_length: 'int', is_fragmented: 'bool') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + + +@info_final +class DTLS(Protocol): + """Data model for DTLS protocol.""" + + #: Content type. + content_type: 'Enum_ContentType' + #: Legacy record version (``major << 8 | minor``). + version: 'int' + #: Epoch -- incremented on every change of cipher state. + epoch: 'int' + #: Sequence number within :attr:`epoch` (48-bit). + sequence_number: 'int' + #: Length of the record fragment. + length: 'int' + #: Decoded record content. For ``handshake``, ``alert``, and + #: ``change_cipher_spec``, this is a tuple of :class:`DTLSHandshake`, an + #: :class:`~pcapkit.protocols.data.application.tls.Alert`, or a + #: :class:`~pcapkit.protocols.data.application.tls.ChangeCipherSpec` + #: respectively *only when the record is cleartext*, i.e. ``epoch == 0`` + #: (as decided by :meth:`SSL._tls_content_is_cleartext + #: `) -- + #: an encrypted record of any of those three types, which is any record + #: at a later epoch, stays raw :obj:`bytes` instead, same as every other + #: content type. + content: 'Any' + + if TYPE_CHECKING: + def __init__(self, content_type: 'Enum_ContentType', version: 'int', epoch: 'int', sequence_number: 'int', length: 'int', content: 'Any') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long diff --git a/pcapkit/protocols/data/application/tls.py b/pcapkit/protocols/data/application/tls.py new file mode 100644 index 0000000000..68f6648e09 --- /dev/null +++ b/pcapkit/protocols/data/application/tls.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +"""data models for TLS 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 typing import Any, Optional, Union + + from pcapkit.const.tls.content_type import ContentType as Enum_ContentType + from pcapkit.const.tls.handshake_type import HandshakeType as Enum_HandshakeType + +__all__ = [ + 'TLS', + + 'Handshake', 'ClientHello', 'ServerHello', 'Extension', + 'Alert', 'ChangeCipherSpec', +] + + +@info_final +class Extension(Data): + """Data model for a TLS/DTLS extension list entry. + + Only the TYPE and LENGTH framing is decoded (:issue:`2437`); an + extension's own body is kept as :attr:`data`, undecoded. + + """ + + #: Extension type. + type: 'int' + #: Extension data, undecoded. + data: 'bytes' + + if TYPE_CHECKING: + def __init__(self, type: 'int', data: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,redefined-builtin + + +@info_final +class ClientHello(Data): + """Data model for a TLS/DTLS ``ClientHello`` handshake message body.""" + + #: Client (legacy) version. + version: 'int' + #: Client random (32 octets). + random: 'bytes' + #: Session ID. + session_id: 'bytes' + #: DTLS's ``cookie`` (:rfc:`6347#section-4.2.1`; carried forward as + #: ``legacy_cookie`` by :rfc:`9147#section-5.3`), or :data:`None` for + #: TLS, whose ``ClientHello`` carries no cookie field at all -- distinct + #: from DTLS's *empty* cookie, which is ``b''``. + cookie: 'Optional[bytes]' + #: Cipher suites offered. + cipher_suites: 'tuple[int, ...]' + #: Compression methods offered. + compression_methods: 'tuple[int, ...]' + #: Extensions, or :data:`None` when the message carries no extensions + #: field at all -- distinct from an *empty* extension list, which is + #: ``()``. + extensions: 'Optional[tuple[Extension, ...]]' + + if TYPE_CHECKING: + def __init__(self, version: 'int', random: 'bytes', session_id: 'bytes', cookie: 'Optional[bytes]', cipher_suites: 'tuple[int, ...]', compression_methods: 'tuple[int, ...]', extensions: 'Optional[tuple[Extension, ...]]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + + +@info_final +class ServerHello(Data): + """Data model for a TLS/DTLS ``ServerHello`` handshake message body.""" + + #: Server (legacy) version. + version: 'int' + #: Server random (32 octets). + random: 'bytes' + #: Session ID. + session_id: 'bytes' + #: Cipher suite selected. + cipher_suite: 'int' + #: Compression method selected. + compression_method: 'int' + #: Extensions, or :data:`None` when the message carries no extensions + #: field at all -- distinct from an *empty* extension list, which is + #: ``()``. + extensions: 'Optional[tuple[Extension, ...]]' + + if TYPE_CHECKING: + def __init__(self, version: 'int', random: 'bytes', session_id: 'bytes', cipher_suite: 'int', compression_method: 'int', extensions: 'Optional[tuple[Extension, ...]]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + + +class Handshake(Data): + """Data model for a TLS/DTLS handshake message. + + :attr:`body` holds a :class:`ClientHello` or :class:`ServerHello` for + those two message types, and the raw, undecoded message body for every + other :class:`~pcapkit.const.tls.handshake_type.HandshakeType` -- + message-TYPE-level framing only (:issue:`2437`). + + """ + + #: Handshake message type. + msg_type: 'Enum_HandshakeType' + #: Message length (24-bit), the 4-octet handshake header excluded. + length: 'int' + #: Message body. + body: 'Union[ClientHello, ServerHello, bytes]' + + if TYPE_CHECKING: + def __init__(self, msg_type: 'Enum_HandshakeType', length: 'int', body: 'Union[ClientHello, ServerHello, bytes]') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + + +@info_final +class Alert(Data): + """Data model for a TLS/DTLS ``Alert`` record.""" + + #: Alert level. + level: 'int' + #: Alert description. + description: 'int' + + if TYPE_CHECKING: + def __init__(self, level: 'int', description: 'int') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class ChangeCipherSpec(Data): + """Data model for a TLS/DTLS ``ChangeCipherSpec`` record.""" + + #: ``change_cipher_spec`` message (always ``1`` on the wire). + msg: 'int' + + if TYPE_CHECKING: + def __init__(self, msg: 'int') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements + + +@info_final +class TLS(Protocol): + """Data model for TLS protocol.""" + + #: Content type. + content_type: 'Enum_ContentType' + #: Legacy record version (``major << 8 | minor``). + version: 'int' + #: Length of the record fragment. + length: 'int' + #: Decoded record content. For ``handshake``, ``alert``, and + #: ``change_cipher_spec``, this is a tuple of :class:`Handshake`, an + #: :class:`Alert`, or a :class:`ChangeCipherSpec` respectively *only when + #: the record is cleartext* (as decided by + #: :meth:`SSL._tls_content_is_cleartext + #: `) -- + #: an encrypted record of any of those three types, which is the common + #: case for TLS 1.2 post-handshake traffic, stays raw :obj:`bytes` instead, + #: same as every other content type. + content: 'Any' + + if TYPE_CHECKING: + def __init__(self, content_type: 'Enum_ContentType', version: 'int', length: 'int', content: 'Any') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long diff --git a/pcapkit/protocols/schema/__init__.py b/pcapkit/protocols/schema/__init__.py index 601d84cc57..7dffb2cae6 100644 --- a/pcapkit/protocols/schema/__init__.py +++ b/pcapkit/protocols/schema/__init__.py @@ -172,6 +172,7 @@ 'SCTP_ProtocolViolationCause', # Application Layer Protocols + 'DTLS', 'FTP', 'NGAP', 'OSPF', 'OSPF_CryptographicAuthentication', @@ -181,6 +182,7 @@ 'HTTPv2_UnassignedFrame', 'HTTPv2_DataFrame', 'HTTPv2_HeadersFrame', 'HTTPv2_PriorityFrame', 'HTTPv2_RSTStreamFrame', 'HTTPv2_SettingsFrame', 'HTTPv2_PushPromiseFrame', 'HTTPv2_PingFrame', 'HTTPv2_GoawayFrame', 'HTTPv2_WindowUpdateFrame', 'HTTPv2_ContinuationFrame', + 'TLS', # PCAP file format 'Header', diff --git a/pcapkit/protocols/schema/application/__init__.py b/pcapkit/protocols/schema/application/__init__.py index b786af1ab5..1ace1f1a17 100644 --- a/pcapkit/protocols/schema/application/__init__.py +++ b/pcapkit/protocols/schema/application/__init__.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """header schema for application layer protocols""" +# Datagram Transport Layer Security +from pcapkit.protocols.schema.application.dtls import DTLS + # File Transfer Protocol from pcapkit.protocols.schema.application.ftp import FTP @@ -32,7 +35,13 @@ from pcapkit.protocols.schema.application.ospf import \ CryptographicAuthentication as OSPF_CryptographicAuthentication +# Transport Layer Security +from pcapkit.protocols.schema.application.tls import TLS + __all__ = [ + # Datagram Transport Layer Security + 'DTLS', + # File Transfer Protocol 'FTP', @@ -51,4 +60,7 @@ # Open Shortest Path First 'OSPF', 'OSPF_CryptographicAuthentication', + + # Transport Layer Security + 'TLS', ] diff --git a/pcapkit/protocols/schema/application/dtls.py b/pcapkit/protocols/schema/application/dtls.py new file mode 100644 index 0000000000..ae6bfba1c1 --- /dev/null +++ b/pcapkit/protocols/schema/application/dtls.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for DTLS protocol""" + +from typing import TYPE_CHECKING + +from pcapkit.const.tls.content_type import ContentType as Enum_ContentType +from pcapkit.corekit.fields.misc import PayloadField +from pcapkit.corekit.fields.numbers import EnumField, NumberField, UInt16Field +from pcapkit.corekit.fields.strings import BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['DTLS'] + +if TYPE_CHECKING: + from pcapkit.protocols.protocol import ProtocolBase + + +@schema_final +class DTLS(Schema): + """Header schema for DTLS record layer packet. + + Same :class:`ContentType `, + legacy-version and length fields as :class:`TLS + `'s record header, with the + explicit ``epoch``/``sequence_number`` pair DTLS inserts between the + version and the length (:rfc:`9147#section-4`) -- the divergence a + reliable TLS stream never needs, since DTLS runs over an unreliable, + unordered datagram transport. + + """ + + #: Content type. + content_type: 'Enum_ContentType' = EnumField(length=1, namespace=Enum_ContentType) + #: Legacy record version (``major << 8 | minor``). + version: 'int' = UInt16Field() + #: Epoch -- incremented on every change of cipher state. + epoch: 'int' = UInt16Field() + #: Sequence number within :attr:`epoch` (48-bit). + sequence_number: 'int' = NumberField(length=6, signed=False) + #: Length of :attr:`fragment`. + length: 'int' = UInt16Field() + #: Record fragment, undecoded. + fragment: 'bytes' = BytesField(length=lambda pkt: pkt['length']) + #: Octets after the record, e.g. a further record coalesced in the same + #: buffer. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, content_type: 'Enum_ContentType', version: 'int', epoch: 'int', + sequence_number: 'int', length: 'int', fragment: 'bytes', + payload: 'bytes | ProtocolBase | Schema') -> 'None': ... diff --git a/pcapkit/protocols/schema/application/tls.py b/pcapkit/protocols/schema/application/tls.py new file mode 100644 index 0000000000..61feeb04a6 --- /dev/null +++ b/pcapkit/protocols/schema/application/tls.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# mypy: disable-error-code=assignment +"""header schema for TLS protocol""" + +from typing import TYPE_CHECKING + +from pcapkit.const.tls.content_type import ContentType as Enum_ContentType +from pcapkit.corekit.fields.misc import PayloadField +from pcapkit.corekit.fields.numbers import EnumField, UInt16Field +from pcapkit.corekit.fields.strings import BytesField +from pcapkit.protocols.schema.schema import Schema, schema_final + +__all__ = ['TLS'] + +if TYPE_CHECKING: + from pcapkit.protocols.protocol import ProtocolBase + + +@schema_final +class TLS(Schema): + """Header schema for TLS record layer packet. + + The record header has :class:`DTLS + `'s fields minus the epoch + and sequence number (:rfc:`9846#section-5.1`). :attr:`fragment` is decoded + by :class:`~pcapkit.protocols.application.ssl.SSL`, which DTLS shares. + + """ + + #: Content type. + content_type: 'Enum_ContentType' = EnumField(length=1, namespace=Enum_ContentType) + #: Legacy record version (``major << 8 | minor``). + version: 'int' = UInt16Field() + #: Length of :attr:`fragment`. + length: 'int' = UInt16Field() + #: Record fragment, undecoded. + fragment: 'bytes' = BytesField(length=lambda pkt: pkt['length']) + #: Octets after the record, e.g. a further record coalesced in the same + #: buffer. + payload: 'bytes' = PayloadField() + + if TYPE_CHECKING: + def __init__(self, content_type: 'Enum_ContentType', version: 'int', length: 'int', + fragment: 'bytes', payload: 'bytes | ProtocolBase | Schema') -> 'None': ... diff --git a/pcapkit/protocols/transport/sctp.py b/pcapkit/protocols/transport/sctp.py index 49906abb9d..94f725c253 100644 --- a/pcapkit/protocols/transport/sctp.py +++ b/pcapkit/protocols/transport/sctp.py @@ -333,10 +333,13 @@ class SCTP(Transport[Data_SCTP, Schema_SCTP], resolves to :class:`~pcapkit.protocols.misc.raw.Raw`. Only PPID 60 actually decodes. A PPID 66 payload is an NGAP PDU wrapped in - a DTLS record, and :mod:`pcapkit` has no DTLS implementation, so the bytes - are not aligned PER and the parse always degrades to - :class:`~pcapkit.protocols.misc.raw.Raw`. It is registered so that the PPID - is *named* in the protochain rather than reported as an unassigned number. + a DTLS record, and this registry maps it straight to + :class:`~pcapkit.protocols.application.ngap.NGAP` rather than through + :class:`~pcapkit.protocols.application.dtls.DTLS`, so the bytes are not + aligned PER and the parse always degrades to + :class:`~pcapkit.protocols.misc.raw.Raw`. It is registered so that the + PPID is *named* in the protochain rather than reported as an unassigned + number. This class supports parsing of the following SCTP chunks, which are directly mapped to the :class:`pcapkit.const.sctp.chunk.Chunk` @@ -464,9 +467,10 @@ class SCTP(Transport[Data_SCTP, Schema_SCTP], lambda: ModuleDescriptor('pcapkit.protocols.misc.raw', 'Raw'), { # PPID 66 is NGAP wrapped in a DTLS record rather than a bare - # NGAP-PDU, and pcapkit implements no DTLS. It is registered so - # that the PPID is *named*: the payload fails in NGAP's own decoder - # and `beholder` degrades it to Raw, as an unregistered PPID would. + # NGAP-PDU, and this table maps it to NGAP rather than through + # DTLS. It is registered so that the PPID is + # *named*: the payload fails in NGAP's own decoder and + # `beholder` degrades it to Raw, as an unregistered PPID would. Enum_PayloadProtocolIdentifier.PayloadProtocolIdentifier_3GPP_NG_Application_Protocol: ModuleDescriptor('pcapkit.protocols.application.ngap', 'NGAP'), # NGAP Enum_PayloadProtocolIdentifier.PayloadProtocolIdentifier_3GPP_NGAP_over_DTLS_over_SCTP: ModuleDescriptor('pcapkit.protocols.application.ngap', 'NGAP'), # NGAP over DTLS }, diff --git a/pcapkit/protocols/transport/tcp.py b/pcapkit/protocols/transport/tcp.py index 82304c9359..98fb687a32 100644 --- a/pcapkit/protocols/transport/tcp.py +++ b/pcapkit/protocols/transport/tcp.py @@ -348,11 +348,12 @@ class TCP(Transport[Data_TCP, Schema_TCP], # 20 ftp-data File Transfer [Default Data] # 21 ftp File Transfer Protocol [Control] # 80 http World Wide Web HTTP + # 443 https http protocol over TLS/SSL # 8080 http-alt HTTP Alternate (see port 80) # # 8443 is deliberately absent: IANA registers it as ``pcsync-https`` - # rather than as an HTTP alternate, and traffic there is TLS-wrapped, - # which pcapkit does not parse. + # rather than as an HTTP alternate or a TLS one, so nothing here + # names it. # # The HTTP ports bind the version-dispatching # :class:`pcapkit.protocols.application.http.HTTP` rather than @@ -366,6 +367,7 @@ class TCP(Transport[Data_TCP, Schema_TCP], 20: ModuleDescriptor('pcapkit.protocols.application.ftp_data', 'FTP_DATA'), 21: ModuleDescriptor('pcapkit.protocols.application.ftp', 'FTP'), 80: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), + 443: ModuleDescriptor('pcapkit.protocols.application.tls', 'TLS'), 8080: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), }, ) diff --git a/pcapkit/protocols/transport/udp.py b/pcapkit/protocols/transport/udp.py index 9314200893..744d32537b 100644 --- a/pcapkit/protocols/transport/udp.py +++ b/pcapkit/protocols/transport/udp.py @@ -99,6 +99,7 @@ class UDP(Transport[Data_UDP, Schema_UDP], # # 80 http World Wide Web HTTP # 1701 l2tp l2tp + # 5684 coaps CoAP over DTLS # 8080 http-alt HTTP Alternate (see port 80) # # The HTTP entries bind the version-dispatching @@ -107,6 +108,15 @@ class UDP(Transport[Data_UDP, Schema_UDP], 80: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), 8080: ModuleDescriptor('pcapkit.protocols.application.http', 'HTTP'), + # CoAP over DTLS (RFC 7252) is bound here, but it is not the only + # fixed port a DTLS-borne protocol is registered at -- IANA also + # registers DTLS at 853 (domain-s), 4740 (ipfixs), 5349 + # (stuns/turns), 6514 (syslog-tls) and 10161/10162 (snmpdtls), + # among others -- and plenty of deployments negotiate DTLS on a + # dynamic port no registry can name. See + # pcapkit.protocols.application.dtls. + 5684: ModuleDescriptor('pcapkit.protocols.application.dtls', 'DTLS'), + # L2TPv2 (RFC 2661) is UDP-borne, and v2 is the only version # implemented, so the concrete class is bound rather than the # abstract L2TP base. IANA protocol number 115 (L2TPv3, RFC 3931) diff --git a/tests/const/test_const_enum_builtin_parity.py b/tests/const/test_const_enum_builtin_parity.py index 733cfa8965..8b87715511 100644 --- a/tests/const/test_const_enum_builtin_parity.py +++ b/tests/const/test_const_enum_builtin_parity.py @@ -377,11 +377,11 @@ 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. - self.assertEqual(len(ints), 114) + self.assertEqual(len(ints), 116) self.assertEqual(len(flags), 6) self.assertEqual(len(strs), 10) - self.assertEqual(len(self.enums), 130) - self.assertEqual(len({obj.__module__ for obj in self.enums}), 124) + self.assertEqual(len(self.enums), 132) + self.assertEqual(len({obj.__module__ for obj in self.enums}), 126) 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 c33ae20cd4..8a936a873d 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), 120) + self.assertEqual(len(self.enums), 122) 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, 117) + self.assertEqual(covered, 119) 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 c95477d8c7..749f95e6e1 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), 114) + self.assertEqual(len(self.enums), 116) 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 44eaccac05..c8af9846a5 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), 124) - self.assertEqual(len(generated), 107) + self.assertEqual(len(all_files), 126) + self.assertEqual(len(generated), 109) for relpath in generated: with self.subTest(module=relpath): 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 856c90fd36..dbbf8353c0 100644 --- a/tests/project/test_rfc_constants_one_home_1739_unit.py +++ b/tests/project/test_rfc_constants_one_home_1739_unit.py @@ -205,6 +205,12 @@ #: the module, the expression (see :func:`_context`) and why. A hit here is in a #: module whose own protocol owns a constant of that value, or that reads one. NOT_A_COPY = { + ('pcapkit/protocols/application/ssl.py', '_take(32)'): + "a TLS/DTLS ClientHello/ServerHello Random is 32 octets (:rfc:`9846#section-4.2.2`), " + 'not an Interface Description Block minimum', + ('pcapkit/protocols/schema/application/dtls.py', 'NumberField(length=6)'): + "DTLS's 48-bit sequence_number (:rfc:`9147#section-4`), not the IPv6 Next " + 'Header offset', ('pcapkit/protocols/internet/ipv6.py', "schema.hextet['version'] != 6"): 'the IP version number, not the Next Header offset', ('pcapkit/protocols/internet/ipv6.py', 'self._import_next_layer(version=6)'): diff --git a/tests/protocols/application/test_ssl_tls_dtls_unit.py b/tests/protocols/application/test_ssl_tls_dtls_unit.py new file mode 100644 index 0000000000..3614adfbe7 --- /dev/null +++ b/tests/protocols/application/test_ssl_tls_dtls_unit.py @@ -0,0 +1,990 @@ +# -*- coding: utf-8 -*- +"""Unit tests for :mod:`pcapkit.protocols.application.ssl`, +:mod:`pcapkit.protocols.application.tls` and +:mod:`pcapkit.protocols.application.dtls`. + +""" +from __future__ import annotations + +import unittest +import warnings + +from tests._support import reimport_once_per_class + +#: A 20-octet TCP header, data offset 5, ACK set, from port 50000 to port 443, +#: i.e. the port :attr:`TCP.__proto__ ` +#: binds TLS at. Shaped exactly as +#: :mod:`tests.protocols.transport.test_tcp_http_dispatch_unit`'s port-80 header. +_TCP_TO_443 = bytes.fromhex('c35001bb00000001000000015010ffff00000000') + + +def _udp_to_5684(payload: bytes) -> bytes: + """An 8-octet UDP header to port 5684 -- where DTLS is bound -- plus + ``payload``. The checksum is left zero, which UDP permits over IPv4 and + which nothing here is testing. + + """ + return ((50000).to_bytes(2, 'big') + (5684).to_bytes(2, 'big') + + (8 + len(payload)).to_bytes(2, 'big') + b'\x00\x00' + payload) + + +class SSLFamilyUnitTests(unittest.TestCase): + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_ssl_id_names_both_concrete_protocols(self) -> None: + from pcapkit.protocols.application.ssl import SSL + + self.assertEqual(SSL.id(), ('TLS', 'DTLS')) + + def test_tls_properties_and_make_data(self) -> None: + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import TLS as Data_TLS + + proto = object.__new__(TLS) + self.assertEqual(proto.name, 'Transport Layer Security') + self.assertEqual(proto.length, 5) + self.assertEqual(proto.__length_hint__(), 5) + + data = Data_TLS(content_type=ContentType.application_data, version=0x0303, + length=5, content=b'hello') + values = TLS._make_data(data) + self.assertEqual(values['content_type'], ContentType.application_data) + self.assertEqual(values['version'], 0x0303) + self.assertEqual(values['content'], b'hello') + self.assertIn('payload', values) + + def test_dtls_properties_and_make_data(self) -> None: + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.data.application.dtls import DTLS as Data_DTLS + + proto = object.__new__(DTLS) + self.assertEqual(proto.name, 'Datagram Transport Layer Security') + self.assertEqual(proto.length, 13) + self.assertEqual(proto.__length_hint__(), 13) + + data = Data_DTLS(content_type=ContentType.change_cipher_spec, version=0xFEFD, + epoch=1, sequence_number=42, length=1, content=b'\x01') + values = DTLS._make_data(data) + self.assertEqual(values['epoch'], 1) + self.assertEqual(values['sequence_number'], 42) + self.assertEqual(values['content_type'], ContentType.change_cipher_spec) + + def test_tcp_and_udp_dispatch_tables_name_tls_and_dtls(self) -> None: + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.protocols.transport.udp import UDP + + self.assertEqual(TCP.__proto__[443].klass.__name__, 'TLS') + self.assertEqual(UDP.__proto__[5684].klass.__name__, 'DTLS') + # 8443 stays unbound -- IANA names it ``pcsync-https``, not an HTTP + # alternate or a TLS one. + self.assertEqual(TCP.__proto__[8443].klass.__name__, 'Raw') + + def test_change_cipher_spec_wrong_length_keeps_its_octets(self) -> None: + """A ``change_cipher_spec`` that is not exactly 1 octet is not a bug. + + A cleartext CCS is always 1 octet, but a CCS sent mid-renegotiation + (after keys exist) is ciphertext under the same content type, and + ciphertext is never exactly 1 octet, given MAC/tag overhead. As for + content types 21 and 22, the fragment is kept opaque rather than + raising, so the record (and its cleartext header) survives instead + of degrading to :class:`Raw`. + + """ + from pcapkit.protocols.application.tls import TLS + + wire = b'\x14\x03\x03\x00\x02\x01\x01' + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.info.content, b'\x01\x01') + self.assertEqual(bytes(TLS.from_data(parsed.info)), wire) + + def test_dtls_truncated_fragment_header_raises(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.utilities.exceptions import ProtocolError + + # A handshake header claiming a body, but too few octets follow for + # even the 8-octet DTLS fragment header. + fragment = b'\x01\x00\x00\x05\x00\x00' + wire = (b'\x16\xfe\xfd\x00\x00\x00\x00\x00\x00\x00\x01' + + len(fragment).to_bytes(2, 'big') + fragment) + with self.assertRaises(ProtocolError): + DTLS(wire, len(wire)) + + def test_malformed_extension_list_raises(self) -> None: + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + version = b'\x03\x03' + random = bytes(32) + session_id = b'\x00' + cipher_suites = b'\x00\x02\x13\x01' + compression = b'\x01\x00' + # Extensions length says 4 octets follow, but the extension header + # alone claims a body longer than that. + bad_extensions = b'\x00\x04' + b'\x00\x0d\x00\xff' + body = version + random + session_id + cipher_suites + compression + bad_extensions + handshake = b'\x01' + len(body).to_bytes(3, 'big') + body + wire = b'\x16\x03\x03' + len(handshake).to_bytes(2, 'big') + handshake + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + def test_client_hello_with_an_odd_cipher_suites_length_raises(self) -> None: + """An odd ``cipher_suites`` length byte count has no RFC-compliant decoding. + + Without this check the trailing, incomplete pair decoded as one lone + octet rather than failing, and rebuilding padded it back out to 2 + octets -- a round-trip that silently lost byte-exactness instead of + refusing to parse. + + """ + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + version = b'\x03\x03' + random = bytes(32) + session_id = b'\x00' + # 3 octets of cipher suites -- not a multiple of 2. + cipher_suites = b'\x00\x03\x13\x01\x00' + compression = b'\x01\x00' + body = version + random + session_id + cipher_suites + compression + handshake = b'\x01' + len(body).to_bytes(3, 'big') + body + wire = b'\x16\x03\x03' + len(handshake).to_bytes(2, 'big') + handshake + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + def test_client_hello_with_excess_octets_raises(self) -> None: + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + version = b'\x03\x03' + random = bytes(32) + body = version + random + b'\x00' + b'\x00\x02\x13\x01' + b'\x01\x00' + b'\x00\x00' + b'\xff' + handshake = b'\x01' + len(body).to_bytes(3, 'big') + body + wire = b'\x16\x03\x03' + len(handshake).to_bytes(2, 'big') + handshake + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + def test_server_hello_with_excess_octets_raises(self) -> None: + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + version = b'\x03\x03' + random = bytes(32) + body = version + random + b'\x00' + b'\x13\x01' + b'\x00' + b'\x00\x00' + b'\xff' + handshake = b'\x02' + len(body).to_bytes(3, 'big') + body + wire = b'\x16\x03\x03' + len(handshake).to_bytes(2, 'big') + handshake + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + def test_make_accepts_an_already_encoded_handshake_message(self) -> None: + """A list of handshake messages may mix decoded and raw entries.""" + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.tls import TLS + + raw_message = b'\x0e\x00\x00\x00' # server_hello_done, zero-length + maker = object.__new__(TLS) + schema = maker.make(content_type=ContentType.handshake, content=(raw_message,)) + self.assertEqual(bytes(schema)[5:], raw_message) + + def test_make_content_accepts_a_bytearray_for_an_unknown_content_type(self) -> None: + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.tls import TLS + + maker = object.__new__(TLS) + schema = maker.make(content_type=ContentType.heartbeat, content=bytearray(b'\x01\x02')) + self.assertEqual(bytes(schema)[5:], b'\x01\x02') + + +class RecordValidationTests(unittest.TestCase): + """Every branch of ``SSL._validate_tls_record``, and what dispatch does with it. + + The content-type check is an allowlist of the assigned 20-27, not a test + of whether :class:`~pcapkit.const.tls.content_type.ContentType` *names* + the value: ``invalid(0)`` is named -- a sentinel :rfc:`9846#section-5.1` + defines so a record carrying it can be rejected -- so a name-based check + accepts ``00 03 03 00 04 deadbeef`` on TCP:443 and decodes its fragment. + + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_content_type_zero_is_rejected(self) -> None: + """The exploit, verbatim, for both protocols.""" + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + tls_wire = b'\x00\x03\x03\x00\x04\xde\xad\xbe\xef' + with self.assertRaises(ProtocolError): + TLS(tls_wire, len(tls_wire)) + + dtls_wire = (b'\x00\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + + (4).to_bytes(2, 'big') + b'\xde\xad\xbe\xef') + with self.assertRaises(ProtocolError): + DTLS(dtls_wire, len(dtls_wire)) + + def test_content_types_outside_the_assigned_range_are_rejected(self) -> None: + """One from each band IANA does not assign a content type in. + + 19 is the top of the Unassigned 0-19 band, 28 the bottom of 28-31, 32 + and 63 the ends of the 32-63 band :rfc:`9147` reserves, and 64 and 255 + the ends of 64-255. 27, :rfc:`9853`'s + ``return_routability_check``, is on the other side of the boundary and + must *not* be rejected, so it is asserted here rather than left to the + round-trip module, where a regression would read as an unrelated + failure. + + """ + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + for content_type in (19, 28, 32, 63, 64, 255): + with self.subTest(content_type=content_type): + wire = bytes([content_type]) + b'\x03\x03\x00\x04\xde\xad\xbe\xef' + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + wire = b'\x1b\x03\x03\x00\x04\xde\xad\xbe\xef' + parsed = TLS(wire, len(wire)) + self.assertIs(parsed.info.content_type, ContentType.return_routability_check) + self.assertEqual(parsed.info.content, b'\xde\xad\xbe\xef') + + def test_tls12_cid_is_excluded(self) -> None: + """Content type 25 (RFC 9146 Connection ID) is assigned by IANA, but + is deliberately left out of the wire allowlist. + + Its record format puts the connection ID *before* ``length``, at an + offset only the out-of-band negotiated CID length can resolve -- + something this dissector's stateless, per-packet schema read has no + way to track. Reading ``length`` at the fixed offset every other + type uses instead fabricates a fragment from the CID's own tail, so + 25 is excluded here and degrades to Raw, same as an unassigned type, + rather than being decoded wrong. Guards a coverage gap the full suite + would otherwise pass with 25 silently dropped from the allowlist. + + """ + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.utilities.exceptions import ProtocolError + + wire = b'\x19\x03\x03\x00\x08' + b'\xde\xad\xbe\xef\xde\xad\xbe\xef' + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + raw = _TCP_TO_443 + wire + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:Raw') + + def test_ack_content_type_is_accepted(self) -> None: + """Content type 26 (``ack``) stays in the wire allowlist. + + Guards the other half of the coverage gap above: a regression that + dropped 26 instead of (or alongside) 25 would otherwise pass the + full suite undetected, since nothing else exercises it directly. + + """ + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.tls import TLS + + wire = b'\x1a\x03\x03\x00\x04\xde\xad\xbe\xef' + parsed = TLS(wire, len(wire)) + self.assertIs(parsed.info.content_type, ContentType.ack) + self.assertEqual(parsed.info.content, b'\xde\xad\xbe\xef') + + def test_a_legacy_record_version_outside_the_protocol_range_is_rejected(self) -> None: + """TLS records are ``0x03xx`` and DTLS records ``0xFExx``, never each + other's. Each protocol is handed the other's version as well as a + value neither uses, so neither half can pass by accepting everything. + + """ + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + for version in (b'\x02\x03', b'\xfe\xfd'): + with self.subTest(protocol='TLS', version=version.hex()): + wire = b'\x17' + version + b'\x00\x05hello' + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + for version in (b'\x02\x03', b'\x03\x03'): + with self.subTest(protocol='DTLS', version=version.hex()): + wire = (b'\x17' + version + bytes(2) + (1).to_bytes(6, 'big') + + (5).to_bytes(2, 'big') + b'hello') + with self.assertRaises(ProtocolError): + DTLS(wire, len(wire)) + + def test_a_declared_length_past_the_end_of_the_buffer_is_rejected(self) -> None: + """A record header is not made genuine by claiming more than arrived. + + ``FieldBase.unpack``'s truncated-capture tolerance zero-pads a short + read out to the declared length, so without this check the five octets + of ``hello`` would decode as a 65535-octet record whose remaining + content was fabricated here rather than received. + + """ + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + with warnings.catch_warnings(): + # The over-declared length makes the schema's own residual-length + # arithmetic go negative, which it is entitled to warn about; the + # refusal is what is under test. + warnings.simplefilter('ignore') + + tls_wire = b'\x17\x03\x03\xff\xffhello' + with self.assertRaises(ProtocolError): + TLS(tls_wire, len(tls_wire)) + + # One octet over, rather than wildly over: the check is ``>``, so + # an off-by-one in it would survive the extreme case above. + tls_wire = b'\x17\x03\x03\x00\x06hello' + with self.assertRaises(ProtocolError): + TLS(tls_wire, len(tls_wire)) + + dtls_wire = (b'\x17\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + + (0xFFFF).to_bytes(2, 'big') + b'hello') + with self.assertRaises(ProtocolError): + DTLS(dtls_wire, len(dtls_wire)) + + def test_a_rejected_record_degrades_to_raw_rather_than_propagating(self) -> None: + """What the refusals above are *for*, measured end to end. + + ``_validate_tls_record`` raises so that + :func:`~pcapkit.corekit.decorators.beholder` replaces the layer with + :class:`~pcapkit.protocols.misc.raw.Raw`; asserting the exception alone + would not show that dispatch survives it. The good payload is asserted + alongside, so the test cannot be satisfied by a binding that resolves + to ``Raw`` unconditionally. + + """ + from pcapkit.protocols.transport.tcp import TCP + from pcapkit.protocols.transport.udp import UDP + + tls_good = b'\x16\x03\x03\x00\x04\x0e\x00\x00\x00' + tls_cases = ( + # The identified tier's own alias, not the dispatcher's generic + # name -- same convention as ``HTTP``'s own dispatch (see + # ``tests.protocols.transport.test_tcp_http_dispatch_unit``). + ('a genuine record', tls_good, 'TCP:TLS 1.2'), + ('content type 0', b'\x00\x03\x03\x00\x04\xde\xad\xbe\xef', 'TCP:Raw'), + ('a DTLS legacy version', b'\x17\xfe\xfd\x00\x05hello', 'TCP:Raw'), + ('a length past the buffer', b'\x17\x03\x03\xff\xffhello', 'TCP:Raw'), + ) + for name, payload, expected in tls_cases: + with self.subTest(transport='TCP:443', payload=name): + raw = _TCP_TO_443 + payload + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), expected) + + dtls_header = b'\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + dtls_cases = ( + ('a genuine record', + b'\x16' + dtls_header + (12).to_bytes(2, 'big') + b'\x0e\x00\x00\x00' + bytes(8), + 'UDP:DTLS 1.2'), + ('content type 0', + b'\x00' + dtls_header + (4).to_bytes(2, 'big') + b'\xde\xad\xbe\xef', + 'UDP:Raw'), + ) + for name, payload, expected in dtls_cases: + with self.subTest(transport='UDP:5684', payload=name): + raw = _udp_to_5684(payload) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = UDP(raw, len(raw)) + self.assertEqual(str(proto.protochain), expected) + + +class EncryptedHandshakeRecordTests(unittest.TestCase): + """A ``handshake`` record is not always a *cleartext* handshake. + + Once keys are derived, a ``Finished`` and every post-handshake message + travel as ciphertext under content type 22 -- the same type the cleartext + flights used, with nothing in the cleartext header to say which it is. + Decoding the second kind as the first either fabricates messages from + pseudorandom octets or, as it did here, raises and drops the whole record + to :class:`~pcapkit.protocols.misc.raw.Raw`, discarding a record header + that *was* in the clear. + + DTLS answers this exactly, from the epoch in its record header; TLS has no + such field, so self-consistency stands in -- whether the 4-octet message + headers tile the fragment exactly. + + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_an_encrypted_tls_handshake_record_keeps_its_octets(self) -> None: + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.tls import TLS + + # 40 octets, the size of a TLS 1.2 encrypted ``Finished``. Its first + # octet would read as a ``msg_type`` and the next three as a length, + # neither of which tiles the fragment. + ciphertext = (b'\x9f\x2b\x71\xc4' * 10)[:40] + wire = b'\x16\x03\x03' + len(ciphertext).to_bytes(2, 'big') + ciphertext + + parsed = TLS(wire, len(wire)) + self.assertIs(parsed.info.content_type, ContentType.handshake) + self.assertEqual(parsed.info.content, ciphertext) + self.assertEqual(bytes(TLS.from_data(parsed.info)), wire) + + def test_a_fragment_that_nearly_tiles_keeps_its_octets(self) -> None: + """The two ways the walk can fail, which a single ciphertext sample + would not separate: running out of room for the next header, and a + declared length that overshoots the end. + + """ + from pcapkit.protocols.application.tls import TLS + + # Two octets left over after one whole message: too few for a header. + short_tail = b'\x0e\x00\x00\x00' + b'\xab\xcd' + # One message declaring one octet more than the fragment holds. + overshoot = b'\x0e\x00\x00\x05' + b'\xab\xcd\xef\x01' + + for name, fragment in (('too short for a header', short_tail), + ('declared length overshoots', overshoot)): + with self.subTest(fragment=name): + wire = b'\x16\x03\x03' + len(fragment).to_bytes(2, 'big') + fragment + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.info.content, fragment) + self.assertEqual(bytes(TLS.from_data(parsed.info)), wire) + + def test_a_dtls_handshake_record_past_epoch_zero_keeps_its_octets(self) -> None: + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.dtls import DTLS + + # 48 octets, the size of a DTLS encrypted ``Finished``. + ciphertext = (b'\xa3\x5d\x12\xee' * 12)[:48] + wire = (b'\x16\xfe\xfd' + (1).to_bytes(2, 'big') + (7).to_bytes(6, 'big') + + len(ciphertext).to_bytes(2, 'big') + ciphertext) + + parsed = DTLS(wire, len(wire)) + self.assertIs(parsed.info.content_type, ContentType.handshake) + self.assertEqual(parsed.info.epoch, 1) + self.assertEqual(parsed.info.content, ciphertext) + self.assertEqual(bytes(DTLS.from_data(parsed.info)), wire) + + def test_a_dtls_handshake_record_at_epoch_zero_still_decodes(self) -> None: + """The complement, so the epoch test cannot pass by refusing everything. + + An epoch-0 record whose fragment is byte-for-byte what the epoch-1 case + above keeps opaque would be ciphertext in reality, but epoch 0 is by + definition the cleartext handshake, so the decision is the epoch's to + make and this asserts it is actually consulted. + + """ + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.data.application.dtls import DTLSHandshake + + fragment = b'\x0e\x00\x00\x00' + bytes(8) # server_hello_done, unfragmented + wire = (b'\x16\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + + len(fragment).to_bytes(2, 'big') + fragment) + + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.info.epoch, 0) + self.assertIsInstance(parsed.info.content[0], DTLSHandshake) + self.assertEqual(bytes(DTLS.from_data(parsed.info)), wire) + + +class EncryptedAlertRecordTests(unittest.TestCase): + """The same bug class as ``EncryptedHandshakeRecordTests`` above, for + content type 21 (``alert``). + + An encrypted alert is not an edge case: it is any TLS 1.2 alert sent + after ``ChangeCipherSpec``, including the ``close_notify`` that ends + nearly every TLS 1.2 connection. Before this fix, any alert fragment + that was not exactly 2 octets raised and dropped the whole record -- + cleartext header included -- to :class:`~pcapkit.protocols.misc.raw.Raw`. + + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_an_encrypted_tls12_close_notify_keeps_its_octets(self) -> None: + """A 26-octet AES-GCM-shaped ``close_notify``, the common case.""" + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.tls import TLS + + ciphertext = (b'\xaa\xbb\xcc\xdd' * 7)[:26] + wire = b'\x15\x03\x03' + len(ciphertext).to_bytes(2, 'big') + ciphertext + + parsed = TLS(wire, len(wire)) + self.assertIs(parsed.info.content_type, ContentType.alert) + self.assertEqual(parsed.info.content, ciphertext) + self.assertEqual(bytes(TLS.from_data(parsed.info)), wire) + + def test_an_encrypted_cbc_alert_keeps_its_octets(self) -> None: + """A 48-octet CBC-shaped alert -- a different cipher, same shape bug.""" + from pcapkit.protocols.application.tls import TLS + + ciphertext = (b'\x9a\x1b\x2c\x3d' * 12)[:48] + wire = b'\x15\x03\x03' + len(ciphertext).to_bytes(2, 'big') + ciphertext + + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.info.content, ciphertext) + self.assertEqual(bytes(TLS.from_data(parsed.info)), wire) + + def test_a_dtls_alert_past_epoch_zero_keeps_its_octets(self) -> None: + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.dtls import DTLS + + ciphertext = (b'\x11\x22\x33\x44' * 2)[:7] + wire = (b'\x15\xfe\xfd' + (1).to_bytes(2, 'big') + (9).to_bytes(6, 'big') + + len(ciphertext).to_bytes(2, 'big') + ciphertext) + + parsed = DTLS(wire, len(wire)) + self.assertIs(parsed.info.content_type, ContentType.alert) + self.assertEqual(parsed.info.epoch, 1) + self.assertEqual(parsed.info.content, ciphertext) + self.assertEqual(bytes(DTLS.from_data(parsed.info)), wire) + + def test_a_dtls_alert_at_epoch_zero_still_decodes(self) -> None: + """The complement, so the epoch test cannot pass by refusing everything.""" + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.data.application.tls import Alert + + fragment = b'\x01\x00' # level=warning, description=close_notify + wire = (b'\x15\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + + len(fragment).to_bytes(2, 'big') + fragment) + + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.info.epoch, 0) + self.assertIsInstance(parsed.info.content, Alert) + self.assertEqual(bytes(DTLS.from_data(parsed.info)), wire) + + def test_a_dtls_alert_at_epoch_zero_with_the_wrong_length_raises(self) -> None: + """Epoch 0 means cleartext, not "any length is fine". + + Unlike TLS -- where a non-2-octet fragment is read as ciphertext by + construction -- DTLS already knows from the epoch that this fragment + is supposed to be a genuine cleartext alert, so a wrong length here + is a malformed record, not a disguised ciphertext one, and still + has to raise. + + """ + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.utilities.exceptions import ProtocolError + + fragment = b'\x01\x00\xff' # one octet too many for a cleartext alert + wire = (b'\x15\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + + len(fragment).to_bytes(2, 'big') + fragment) + with self.assertRaises(ProtocolError): + DTLS(wire, len(wire)) + + def test_a_dtls_change_cipher_spec_at_epoch_zero_with_the_wrong_length_raises(self) -> None: + """The same malformed-length case, for ``change_cipher_spec``.""" + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.utilities.exceptions import ProtocolError + + fragment = b'\x01\x02' # a genuine cleartext CCS is exactly 1 octet + wire = (b'\x14\xfe\xfd' + bytes(2) + (1).to_bytes(6, 'big') + + len(fragment).to_bytes(2, 'big') + fragment) + with self.assertRaises(ProtocolError): + DTLS(wire, len(wire)) + + +def _tls_client_hello_wire(*, supported_versions: 'bytes | None' = None, + record_version: bytes = b'\x03\x03') -> bytes: + """A minimal, well-formed TLS ``ClientHello`` record. + + Args: + supported_versions: When given, the raw ``supported_versions`` + extension body (length-prefixed version list) to append as an + extension; otherwise the message carries no extensions at all. + record_version: The record header's legacy version octets. + + """ + version = b'\x03\x03' + random = bytes(32) + session_id = b'\x00' + cipher_suites = b'\x00\x02\x13\x01' + compression = b'\x01\x00' + body = version + random + session_id + cipher_suites + compression + if supported_versions is not None: + extension = (43).to_bytes(2, 'big') + len(supported_versions).to_bytes(2, 'big') + supported_versions + body += len(extension).to_bytes(2, 'big') + extension + handshake = b'\x01' + len(body).to_bytes(3, 'big') + body # msg_type=1, client_hello + return b'\x16' + record_version + len(handshake).to_bytes(2, 'big') + handshake + + +def _dtls_client_hello_wire(*, supported_versions: 'bytes | None' = None, + record_version: bytes = b'\xfe\xfd') -> bytes: + """A minimal, well-formed DTLS ``ClientHello`` record, unfragmented.""" + version = b'\xfe\xfd' + random = bytes(32) + session_id = b'\x00' + cookie = b'\x00' + cipher_suites = b'\x00\x02\x13\x01' + compression = b'\x01\x00' + body = version + random + session_id + cookie + cipher_suites + compression + if supported_versions is not None: + extension = (43).to_bytes(2, 'big') + len(supported_versions).to_bytes(2, 'big') + supported_versions + body += len(extension).to_bytes(2, 'big') + extension + frag_header = (0).to_bytes(2, 'big') + (0).to_bytes(3, 'big') + len(body).to_bytes(3, 'big') + handshake = b'\x01' + len(body).to_bytes(3, 'big') + frag_header + body + record_header = b'\x16' + record_version + bytes(2) + (1).to_bytes(6, 'big') + return record_header + len(handshake).to_bytes(2, 'big') + handshake + + +class VersionDispatchTests(unittest.TestCase): + """:meth:`TLS.read`/:meth:`DTLS.read` (:issue:`2437`): identifying the + concrete tier and setting + :attr:`~pcapkit.protocols.application.tls.TLS.alias` dynamically, + mirroring :class:`~pcapkit.protocols.link.arp.ARP`'s own dynamic dispatch + over RARP/DRARP/InARP by operation code, rather than + :class:`~pcapkit.protocols.application.http.HTTP`'s dispatch over + distinct per-version classes: a record identified as, say, SSLv3 never + becomes a different *class*, only a different :attr:`alias`, on the same + :class:`~pcapkit.protocols.application.tls.TLS` instance throughout. + + """ + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_tls_dispatches_sslv3_tlsv1_0_and_tlsv1_1_by_legacy_version(self) -> None: + from pcapkit.protocols.application.tls import TLS + + cases = ( + (0x0300, 'SSLv3'), + (0x0301, 'TLS 1.0'), + (0x0302, 'TLS 1.1'), + ) + for legacy_version, alias in cases: + with self.subTest(legacy_version=hex(legacy_version)): + wire = bytes([0x17]) + legacy_version.to_bytes(2, 'big') + b'\x00\x05hello' + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, alias) + self.assertEqual(parsed.info.content, b'hello') + + def test_tls_0303_without_1_3_negotiation_dispatches_tls_1_2(self) -> None: + from pcapkit.protocols.application.tls import TLS + + wire = _tls_client_hello_wire() + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'TLS 1.2') + + def test_tls_0303_negotiating_1_3_dispatches_tls_1_3(self) -> None: + from pcapkit.protocols.application.tls import TLS + + supported_versions = b'\x02' + (0x0304).to_bytes(2, 'big') + wire = _tls_client_hello_wire(supported_versions=supported_versions) + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'TLS 1.3') + + def test_tls_supported_versions_naming_only_1_2_stays_tls_1_2(self) -> None: + """The negotiation really is consulted, not just "an extension exists".""" + from pcapkit.protocols.application.tls import TLS + + supported_versions = b'\x02' + (0x0303).to_bytes(2, 'big') + wire = _tls_client_hello_wire(supported_versions=supported_versions) + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'TLS 1.2') + + def test_dtls_dispatches_dtlsv1_0_by_legacy_version(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + wire = (b'\x17\xfe\xff' + bytes(2) + (1).to_bytes(6, 'big') + + (5).to_bytes(2, 'big') + b'hello') + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'DTLS 1.0') + + def test_dtls_0xfefd_without_1_3_negotiation_dispatches_dtls_1_2(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + wire = _dtls_client_hello_wire() + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'DTLS 1.2') + + def test_dtls_0xfefd_negotiating_1_3_dispatches_dtls_1_3(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + supported_versions = b'\x02' + (0xFEFC).to_bytes(2, 'big') + wire = _dtls_client_hello_wire(supported_versions=supported_versions) + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'DTLS 1.3') + + def test_tls_unknown_record_version_keeps_the_generic_alias(self) -> None: + """A ``0x03xx`` legacy version naming no known tier claims none. + + TLS 1.3 never puts ``0x0304`` on the record layer + (:rfc:`9846#section-5.1`), and nothing assigns a later value, so + neither may be branded TLS 1.2 -- or any other specific version. + + """ + from pcapkit.protocols.application.tls import TLS + + for legacy_version in (0x0304, 0x0305, 0x03FF): + with self.subTest(legacy_version=hex(legacy_version)): + wire = bytes([0x17]) + legacy_version.to_bytes(2, 'big') + b'\x00\x05hello' + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'TLS') + self.assertEqual(parsed.name, 'Transport Layer Security') + self.assertEqual(parsed.info.content, b'hello') + + def test_tls_unknown_record_version_stays_generic_even_offering_1_3(self) -> None: + """The ``supported_versions`` peek only applies where TLS 1.3 may + legitimately sit on the record layer, not to an unknown version.""" + from pcapkit.protocols.application.tls import TLS + + supported_versions = b'\x02' + (0x0304).to_bytes(2, 'big') + wire = _tls_client_hello_wire(supported_versions=supported_versions, + record_version=b'\x03\x04') + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'TLS') + + def test_tls_0301_client_hello_negotiating_1_3_dispatches_tls_1_3(self) -> None: + """An initial ``ClientHello`` may carry ``0x0301`` on the record layer + "for compatibility purposes" (:rfc:`9846#section-5.1`).""" + from pcapkit.protocols.application.tls import TLS + + supported_versions = b'\x02' + (0x0304).to_bytes(2, 'big') + wire = _tls_client_hello_wire(supported_versions=supported_versions, + record_version=b'\x03\x01') + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'TLS 1.3') + + plain = _tls_client_hello_wire(record_version=b'\x03\x01') + self.assertEqual(TLS(plain, len(plain)).alias, 'TLS 1.0') + + def test_dtls_unknown_record_version_keeps_the_generic_alias(self) -> None: + """A ``0xFExx`` legacy version naming no known tier claims none -- + DTLS 1.3's own ``0xFEFC`` included, which :rfc:`9147#section-4` + never puts on the record layer.""" + from pcapkit.protocols.application.dtls import DTLS + + for legacy_version in (0xFEFC, 0xFEFE, 0xFE00): + with self.subTest(legacy_version=hex(legacy_version)): + wire = (bytes([0x17]) + legacy_version.to_bytes(2, 'big') + bytes(2) + + (1).to_bytes(6, 'big') + (5).to_bytes(2, 'big') + b'hello') + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'DTLS') + self.assertEqual(parsed.info.content, b'hello') + + def test_dtls_0xfeff_client_hello_negotiating_1_3_dispatches_dtls_1_3(self) -> None: + """An initial ``ClientHello`` may carry DTLS 1.0's ``{254, 255}`` + on the record layer (:rfc:`9147#section-4`).""" + from pcapkit.protocols.application.dtls import DTLS + + supported_versions = b'\x02' + (0xFEFC).to_bytes(2, 'big') + wire = _dtls_client_hello_wire(supported_versions=supported_versions, + record_version=b'\xfe\xff') + parsed = DTLS(wire, len(wire)) + self.assertEqual(parsed.alias, 'DTLS 1.3') + + plain = _dtls_client_hello_wire(record_version=b'\xfe\xff') + self.assertEqual(DTLS(plain, len(plain)).alias, 'DTLS 1.0') + + def test_sslv3_is_constructible_directly_and_narrows_id(self) -> None: + """:class:`SSLv3` -- explicit/manual construction only, mirroring + :class:`~pcapkit.protocols.link.arp.InARP`'s relationship to + :class:`~pcapkit.protocols.link.arp.ARP`: it narrows :meth:`id` only + and inherits :meth:`~pcapkit.protocols.application.tls.TLS.read` + unchanged, so parsing SSLv3-shaped data through it sets + :attr:`alias` to ``'SSLv3'`` exactly as parsing it through + :class:`~pcapkit.protocols.application.tls.TLS` would. + + """ + from pcapkit.protocols.application.tls import SSLv3 + + tls_wire = b'\x17\x03\x00\x00\x05hello' + parsed = SSLv3(tls_wire, len(tls_wire)) + self.assertEqual(parsed.alias, 'SSLv3') + self.assertEqual(parsed.info.content, b'hello') + self.assertEqual(SSLv3.id(), ('SSLv3',)) + + def test_sslv3_class_does_not_force_its_own_alias(self) -> None: + """The same nuance :class:`~pcapkit.protocols.link.arp.InARP` has: + constructing :class:`SSLv3` directly over data that is not actually + SSLv3-shaped still reports the *real* tier, since :meth:`read` is + inherited unmodified and dispatches off the wire, not off the class + it happened to be instantiated as. + + """ + from pcapkit.protocols.application.tls import SSLv3 + + tls_1_0_wire = b'\x17\x03\x01\x00\x05hello' + parsed = SSLv3(tls_1_0_wire, len(tls_1_0_wire)) + self.assertEqual(parsed.alias, 'TLS 1.0') + + def test_tls_and_dtls_id_are_fixed_regardless_of_dispatched_tier(self) -> None: + """:meth:`TLS.id`/:meth:`DTLS.id` do not vary with the identified + tier: a genuine TLS 1.2 record must not stop matching a lookup keyed + on ``('TLS',)`` just because *some* instance of the class happened to + parse as SSLv3. + + """ + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.application.tls import TLS + + self.assertEqual(TLS.id(), ('TLS',)) + self.assertEqual(DTLS.id(), ('DTLS',)) + + sslv3_wire = b'\x17\x03\x00\x00\x05hello' + parsed = TLS(sslv3_wire, len(sslv3_wire)) + self.assertEqual(parsed.alias, 'SSLv3') + self.assertEqual(TLS.id(), ('TLS',)) + + dtls_1_0_wire = (b'\x17\xfe\xff' + bytes(2) + (1).to_bytes(6, 'big') + + (5).to_bytes(2, 'big') + b'hello') + parsed_dtls = DTLS(dtls_1_0_wire, len(dtls_1_0_wire)) + self.assertEqual(parsed_dtls.alias, 'DTLS 1.0') + self.assertEqual(DTLS.id(), ('DTLS',)) + + def test_sslv3_record_reports_its_own_name(self) -> None: + """SSLv3 is the one tier in the family with a genuinely different + full :attr:`name`, set dynamically the same way :attr:`alias` is -- + mirroring :attr:`ARP._name ` + tracking :attr:`ARP._acnm `. + + """ + from pcapkit.protocols.application.tls import TLS + + sslv3_wire = b'\x17\x03\x00\x00\x05hello' + parsed = TLS(sslv3_wire, len(sslv3_wire)) + self.assertEqual(parsed.name, 'Secure Sockets Layer version 3') + + tls_1_2_wire = _tls_client_hello_wire() + parsed_12 = TLS(tls_1_2_wire, len(tls_1_2_wire)) + self.assertEqual(parsed_12.name, 'Transport Layer Security') + + def test_malformed_client_hello_raises_through_dispatch_same_as_before(self) -> None: + """Dispatch's own sniff must not swallow a genuine parse failure. + + The sniff that decides SSLv3+ vs. TLS 1.3 decodes the same + ``ClientHello`` the real parse goes on to decode; this pins that a + malformed one still raises from the dispatcher's :meth:`read`, not + just from a concrete tier constructed directly (already covered by + ``RecordValidationTests`` et al. above). + + """ + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + version = b'\x03\x03' + random = bytes(32) + session_id = b'\x00' + cipher_suites = b'\x00\x03\x13\x01\x00' # odd length -- malformed + compression = b'\x01\x00' + body = version + random + session_id + cipher_suites + compression + handshake = b'\x01' + len(body).to_bytes(3, 'big') + body + wire = b'\x16\x03\x03' + len(handshake).to_bytes(2, 'big') + handshake + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + +class NonRecordPayloadTests(unittest.TestCase): + """A port-443 payload that is not a TLS record degrades to + :class:`~pcapkit.protocols.misc.raw.Raw`.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_every_first_octet_parses_only_as_an_accepted_content_type(self) -> None: + from pcapkit.protocols.application.ssl import _WIRE_CONTENT_TYPES + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + for content_type in range(0x100): + with self.subTest(content_type=content_type): + wire = bytes([content_type]) + b'\x03\x03\x00\x04\xde\xad\xbe\xef' + if content_type in _WIRE_CONTENT_TYPES: + self.assertEqual(int(TLS(wire, len(wire)).info.content_type), content_type) + else: + with self.assertRaises(ProtocolError): + TLS(wire, len(wire)) + + def test_an_sslv2_client_hello_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 + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:Raw') + + def test_random_segments_are_raw(self) -> None: + import random + + from pcapkit.protocols.transport.tcp import TCP + + rng = random.Random(2441) + for _ in range(200): + raw = _TCP_TO_443 + rng.getrandbits(1460 * 8).to_bytes(1460, 'big') + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + proto = TCP(raw, len(raw)) + self.assertEqual(str(proto.protochain), 'TCP:Raw', raw[20:25].hex()) + + +class ExportTests(unittest.TestCase): + """The family is exported, and registered, the way every sibling + application protocol is -- :class:`~pcapkit.protocols.application.ngap.NGAP` + for one.""" + + NAMES = ('SSL', 'SSLv3', 'TLS', 'DTLS') + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_exported_from_every_namespace(self) -> None: + import pcapkit + import pcapkit.all + import pcapkit.protocols + import pcapkit.protocols.application + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.application.ssl import SSL + from pcapkit.protocols.application.tls import TLS, SSLv3 + + classes = {'SSL': SSL, 'SSLv3': SSLv3, 'TLS': TLS, 'DTLS': DTLS} + for module in (pcapkit, pcapkit.all, pcapkit.protocols, pcapkit.protocols.application): + for name in self.NAMES: + with self.subTest(module=module.__name__, name=name): + self.assertIn(name, module.__all__) + self.assertIs(getattr(module, name), classes[name]) + + def test_registered_in_the_protocol_registry(self) -> None: + import pcapkit.protocols + + for name in self.NAMES: + with self.subTest(name=name): + self.assertIs(pcapkit.protocols.__proto__[name.upper()], + getattr(pcapkit.protocols, name)) + + def test_string_lookup_matches_a_parsed_record(self) -> None: + """``'SSL' in pkt`` agrees with ``SSL in pkt``, the name being registered.""" + from pcapkit.protocols.application.ssl import SSL + from pcapkit.protocols.transport.tcp import TCP + + raw = _TCP_TO_443 + b'\x17\x03\x03\x00\x05hello' + proto = TCP(raw, len(raw)) + self.assertIn(SSL, proto) + self.assertIn('SSL', proto) + self.assertIn('TLS', proto) + + def test_const_package_star_imports_tls(self) -> None: + import pcapkit.const + from pcapkit.const.tls.content_type import ContentType + from pcapkit.const.tls.handshake_type import HandshakeType + + self.assertIn('TLS_ContentType', pcapkit.const.__all__) + self.assertIn('TLS_HandshakeType', pcapkit.const.__all__) + self.assertIs(pcapkit.const.TLS_ContentType, ContentType) + self.assertIs(pcapkit.const.TLS_HandshakeType, HandshakeType) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_tls_dtls_roundtrip_unit.py b/tests/protocols/application/test_tls_dtls_roundtrip_unit.py new file mode 100644 index 0000000000..1c76d67907 --- /dev/null +++ b/tests/protocols/application/test_tls_dtls_roundtrip_unit.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +"""TLS and DTLS records rebuild byte for byte. + +Hand-crafted wire octets for every content type and handshake-message shape +the TLS and DTLS dissectors implement (:issue:`2437`): ``handshake`` carrying a ``ClientHello``, +a ``ServerHello``, a message-TYPE-level-only type, and several messages +coalesced in one record; ``alert``; ``change_cipher_spec``; +``application_data``; and, for DTLS, both an unfragmented handshake message +(which decodes exactly as TLS's does) and a message split across two +fragments (which does not, since one is not reassembled -- see +:class:`~pcapkit.protocols.application.dtls.DTLS`). + +Every case is checked two ways: parsing the wire octets and rebuilding them +through :meth:`~pcapkit.protocols.application.application.Application.from_data` +(the parse-then-rebuild direction), and building the same octets through +:meth:`~pcapkit.protocols.application.tls.TLS.make`/ +:meth:`~pcapkit.protocols.application.dtls.DTLS.make` and reading them back +(the make-then-parse direction) -- so neither direction is a tautology of +the other. + +""" +from __future__ import annotations + +import unittest + +from tests._support import reimport_once_per_class + +#: Test tier (#1958): hand-picked round-trip cases, not a sweep. +__tier__ = 'ALL' + + +def _tls_record(content_type: int, fragment: bytes, version: bytes = b'\x03\x03') -> bytes: + return bytes([content_type]) + version + len(fragment).to_bytes(2, 'big') + fragment + + +def _handshake(msg_type: int, body: bytes) -> bytes: + return bytes([msg_type]) + len(body).to_bytes(3, 'big') + body + + +def _client_hello_body(cipher_suites: bytes = b'\x13\x01', extensions: 'bytes | None' = b'') -> bytes: + version = b'\x03\x03' + random = bytes(range(32)) + session_id = b'' + compression = b'\x00' + tail = b'' if extensions is None else len(extensions).to_bytes(2, 'big') + extensions + return (version + random + bytes([len(session_id)]) + session_id + + len(cipher_suites).to_bytes(2, 'big') + cipher_suites + + bytes([len(compression)]) + compression + + tail) + + +def _server_hello_body(extensions: 'bytes | None' = b'') -> bytes: + version = b'\x03\x03' + random = bytes(range(32, 64)) + session_id = b'' + cipher_suite = b'\x13\x01' + compression = b'\x00' + tail = b'' if extensions is None else len(extensions).to_bytes(2, 'big') + extensions + return (version + random + bytes([len(session_id)]) + session_id + + cipher_suite + compression + tail) + + +#: Wire octets that must rebuild unchanged, by case name. +TLS_RECORDS = { + 'handshake: ClientHello': _tls_record(22, _handshake(1, _client_hello_body())), + 'handshake: ClientHello with an extension': _tls_record( + 22, _handshake(1, _client_hello_body(extensions=b'\x00\x0d\x00\x02\xab\xcd'))), + 'handshake: ClientHello with no extensions field at all': _tls_record( + 22, _handshake(1, _client_hello_body(extensions=None))), + 'handshake: ServerHello': _tls_record(22, _handshake(2, _server_hello_body())), + 'handshake: ServerHello with no extensions field at all': _tls_record( + 22, _handshake(2, _server_hello_body(extensions=None))), + 'handshake: Finished (message-type-level only)': _tls_record(22, _handshake(20, b'\x01\x02\x03\x04')), + 'handshake: HelloRequest + ServerHelloDone coalesced': _tls_record( + 22, _handshake(0, b'') + _handshake(14, b'')), + # Content type 22 after the handshake has derived keys: ciphertext, which + # the message headers do not tile, so it stays opaque rather than being + # decoded into fabricated messages. 40 octets, as a TLS 1.2 encrypted + # ``Finished`` is. + 'handshake: encrypted, kept as octets': _tls_record(22, (b'\x9f\x2b\x71\xc4' * 10)[:40]), + 'alert': _tls_record(21, b'\x02\x0a'), + 'change_cipher_spec': _tls_record(20, b'\x01'), + 'application_data': _tls_record(23, b'hello'), + # DTLS connection-ID path validation (:rfc:`9853`). + 'return_routability_check': _tls_record(27, b'\xde\xad\xbe\xef'), + # The other legacy record versions dispatch brands: SSLv3, TLS 1.0, TLS 1.1. + 'handshake: ClientHello at record version 0x0300': _tls_record( + 22, _handshake(1, _client_hello_body()), version=b'\x03\x00'), + 'handshake: ClientHello at record version 0x0301': _tls_record( + 22, _handshake(1, _client_hello_body()), version=b'\x03\x01'), + 'alert at record version 0x0302': _tls_record(21, b'\x02\x0a', version=b'\x03\x02'), +} + + +def _dtls_client_hello_body(cipher_suites: bytes = b'\x13\x01', extensions: 'bytes | None' = b'', + cookie: bytes = b'') -> bytes: + """A genuine DTLS ``ClientHello`` body -- TLS's shape plus the + length-prefixed ``cookie`` DTLS inserts between ``session_id`` and + ``cipher_suites`` (:rfc:`6347#section-4.2.1`). ``_client_hello_body`` + above is deliberately TLS-only and must not be reused for DTLS: it has no + cookie field at all, which is exactly the shape bug this fixture exists + to catch. + + """ + version = b'\xfe\xfd' + random = bytes(range(32)) + session_id = b'' + compression = b'\x00' + tail = b'' if extensions is None else len(extensions).to_bytes(2, 'big') + extensions + return (version + random + bytes([len(session_id)]) + session_id + + bytes([len(cookie)]) + cookie + + len(cipher_suites).to_bytes(2, 'big') + cipher_suites + + bytes([len(compression)]) + compression + + tail) + + +def _dtls_record(content_type: int, fragment: bytes, version: bytes = b'\xfe\xfd', + epoch: int = 0, seq: int = 1) -> bytes: + return (bytes([content_type]) + version + epoch.to_bytes(2, 'big') + seq.to_bytes(6, 'big') + + len(fragment).to_bytes(2, 'big') + fragment) + + +def _dtls_handshake(msg_type: int, body: bytes, *, message_seq: int, fragment_offset: int, + fragment_length: int, total_length: 'int | None' = None) -> bytes: + length = total_length if total_length is not None else len(body) + header = bytes([msg_type]) + length.to_bytes(3, 'big') + frag_header = (message_seq.to_bytes(2, 'big') + fragment_offset.to_bytes(3, 'big') + + fragment_length.to_bytes(3, 'big')) + return header + frag_header + body + + +#: Non-empty, so the round-trip actually exercises ``cookie``'s bytes rather +#: than merely its (zero) length. +_CH_COOKIE = b'\xab\xcd\xef\x01' +_CH_BODY = _dtls_client_hello_body(cookie=_CH_COOKIE) +_CH_HALF = len(_CH_BODY) // 2 + +DTLS_RECORDS = { + 'handshake: unfragmented ClientHello': _dtls_record( + 22, _dtls_handshake(1, _CH_BODY, message_seq=0, fragment_offset=0, fragment_length=len(_CH_BODY))), + 'handshake: unfragmented ClientHello with an empty cookie': _dtls_record( + 22, _dtls_handshake(1, _dtls_client_hello_body(cookie=b''), message_seq=0, + fragment_offset=0, fragment_length=len(_dtls_client_hello_body(cookie=b'')))), + 'handshake: fragment 1 of 2': _dtls_record( + 22, _dtls_handshake(1, _CH_BODY[:_CH_HALF], message_seq=5, fragment_offset=0, + fragment_length=_CH_HALF, total_length=len(_CH_BODY)), seq=2), + 'handshake: fragment 2 of 2': _dtls_record( + 22, _dtls_handshake(1, _CH_BODY[_CH_HALF:], message_seq=5, fragment_offset=_CH_HALF, + fragment_length=len(_CH_BODY) - _CH_HALF, total_length=len(_CH_BODY)), seq=3), + # Content type 22 at a non-zero epoch: encrypted by definition, since the + # epoch counts cipher changes, so the fragment stays opaque. 48 octets, as + # a DTLS encrypted ``Finished`` is. + 'handshake: post-handshake epoch, kept as octets': _dtls_record( + 22, (b'\xa3\x5d\x12\xee' * 12)[:48], epoch=1, seq=7), + 'alert': _dtls_record(21, b'\x01\x00'), + 'change_cipher_spec': _dtls_record(20, b'\x01'), + 'return_routability_check': _dtls_record(27, b'\xde\xad\xbe\xef'), + # DTLS 1.0's legacy record version. + 'handshake: unfragmented ClientHello at record version 0xFEFF': _dtls_record( + 22, _dtls_handshake(1, _CH_BODY, message_seq=0, fragment_offset=0, + fragment_length=len(_CH_BODY)), version=b'\xfe\xff'), +} + + +class TestTLSRoundTrip(unittest.TestCase): + """Pin the rebuild of parsed TLS records, both directions.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_parse_then_rebuild_is_byte_exact(self) -> None: + from pcapkit.protocols.application.tls import TLS + + for name, wire in TLS_RECORDS.items(): + with self.subTest(record=name): + parsed = TLS(wire, len(wire)) + self.assertEqual(bytes(TLS.from_data(parsed.info)).hex(), wire.hex()) + + def test_make_then_parse_is_byte_exact(self) -> None: + """``make`` fed the just-parsed content reproduces the same octets. + + This is the other direction: it would not catch a ``make`` that + merely replays whatever ``from_data`` already proved, since ``make`` + is driven here from the parsed *content* -- not from ``info`` -- and + the schema it returns is packed directly, without going through + ``from_data`` at all. + + """ + from pcapkit.protocols.application.tls import TLS + + for name, wire in TLS_RECORDS.items(): + with self.subTest(record=name): + parsed = TLS(wire, len(wire)) + maker = object.__new__(TLS) + schema = maker.make(content_type=parsed.info.content_type, + version=parsed.info.version, + content=parsed.info.content) + self.assertEqual(bytes(schema).hex(), wire.hex()) + + def test_coalesced_handshake_decodes_both_messages(self) -> None: + """Byte-exact rebuild alone does not prove the walk decoded anything. + + Opaque bytes rebuild exactly too, so a handshake-tiling walk that + were broken to only check the first message's header (rather than + walking all of them) would still pass + ``test_parse_then_rebuild_is_byte_exact`` on this very fixture -- + the coalesced-record case is exactly where that gap hides, since + the record happens to rebuild byte-for-byte regardless of whether + the second message was actually decoded. This asserts the decoded + message *count* and *types*, which that broken walk would get wrong. + + """ + from pcapkit.const.tls.handshake_type import HandshakeType + from pcapkit.protocols.application.tls import TLS + from pcapkit.protocols.data.application.tls import Handshake + + wire = TLS_RECORDS['handshake: HelloRequest + ServerHelloDone coalesced'] + parsed = TLS(wire, len(wire)) + + self.assertEqual(len(parsed.info.content), 2) + self.assertTrue(all(isinstance(message, Handshake) for message in parsed.info.content)) + self.assertEqual( + [message.msg_type for message in parsed.info.content], + [HandshakeType.hello_request, HandshakeType.server_hello_done], + ) + + def test_malformed_client_hello_raises_protocol_error(self) -> None: + from pcapkit.protocols.application.tls import TLS + from pcapkit.utilities.exceptions import ProtocolError + + # A ClientHello whose declared body is shorter than its fixed fields + # (version + random alone need 34 octets). + truncated = _tls_record(22, _handshake(1, b'\x03\x03' + b'\x00' * 10)) + with self.assertRaises(ProtocolError): + TLS(truncated, len(truncated)) + + def test_alert_record_with_excess_octets_keeps_its_octets(self) -> None: + """A 3-octet TLS ``alert`` fragment is not a malformed cleartext alert. + + An encrypted alert is never exactly 2 octets, so a non-2-octet + fragment is kept opaque rather than raising, the same discipline + applied to the encrypted close_notify case below. + + """ + from pcapkit.protocols.application.tls import TLS + + wire = _tls_record(21, b'\x02\x0a\x00') + parsed = TLS(wire, len(wire)) + self.assertEqual(parsed.info.content, b'\x02\x0a\x00') + self.assertEqual(bytes(TLS.from_data(parsed.info)), wire) + + +class TestDTLSRoundTrip(unittest.TestCase): + """Pin the rebuild of parsed DTLS records, both directions.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_parse_then_rebuild_is_byte_exact(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + for name, wire in DTLS_RECORDS.items(): + with self.subTest(record=name): + parsed = DTLS(wire, len(wire)) + self.assertEqual(bytes(DTLS.from_data(parsed.info)).hex(), wire.hex()) + + def test_make_then_parse_is_byte_exact(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + for name, wire in DTLS_RECORDS.items(): + with self.subTest(record=name): + parsed = DTLS(wire, len(wire)) + maker = object.__new__(DTLS) + schema = maker.make(content_type=parsed.info.content_type, + version=parsed.info.version, + epoch=parsed.info.epoch, + sequence_number=parsed.info.sequence_number, + content=parsed.info.content) + self.assertEqual(bytes(schema).hex(), wire.hex()) + + def test_fragment_is_flagged_and_body_kept_undecoded(self) -> None: + """The documented limitation: a fragmented message is not reassembled.""" + from pcapkit.protocols.application.dtls import DTLS + + wire = DTLS_RECORDS['handshake: fragment 1 of 2'] + parsed = DTLS(wire, len(wire)) + message = parsed.info.content[0] + + self.assertTrue(message.is_fragmented) + self.assertIsInstance(message.body, bytes) + self.assertEqual(message.body, _CH_BODY[:_CH_HALF]) + + def test_unfragmented_message_decodes_its_client_hello(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + wire = DTLS_RECORDS['handshake: unfragmented ClientHello'] + parsed = DTLS(wire, len(wire)) + message = parsed.info.content[0] + + self.assertFalse(message.is_fragmented) + self.assertEqual(message.body.cipher_suites, (0x1301,)) + + def test_unfragmented_client_hello_decodes_its_cookie(self) -> None: + """RFC 6347 Sec.4.2.1 / RFC 9147 Sec.5.3's ``cookie`` field. + + The fixture this guards replaced one that was TLS-shaped -- no + cookie field at all -- and so could not have caught a DTLS + ``ClientHello`` reader that never looked for one. + + """ + from pcapkit.protocols.application.dtls import DTLS + + wire = DTLS_RECORDS['handshake: unfragmented ClientHello'] + parsed = DTLS(wire, len(wire)) + message = parsed.info.content[0] + + self.assertFalse(message.is_fragmented) + self.assertEqual(message.body.cookie, _CH_COOKIE) + + def test_unfragmented_client_hello_with_an_empty_cookie(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + wire = DTLS_RECORDS['handshake: unfragmented ClientHello with an empty cookie'] + parsed = DTLS(wire, len(wire)) + message = parsed.info.content[0] + + self.assertFalse(message.is_fragmented) + self.assertEqual(message.body.cookie, b'') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/test_dispatch_bindings_unit.py b/tests/protocols/test_dispatch_bindings_unit.py index 55610eeff0..30f555ab31 100644 --- a/tests/protocols/test_dispatch_bindings_unit.py +++ b/tests/protocols/test_dispatch_bindings_unit.py @@ -278,11 +278,11 @@ def test_registered_ports_are_exactly_those_intended(self) -> None: from pcapkit.protocols.transport.tcp import TCP from pcapkit.protocols.transport.udp import UDP - self.assertEqual(sorted(TCP.__proto__), [20, 21, 80, 8080]) - self.assertEqual(sorted(UDP.__proto__), [80, 1701, 8080]) + self.assertEqual(sorted(TCP.__proto__), [20, 21, 80, 443, 8080]) + self.assertEqual(sorted(UDP.__proto__), [80, 1701, 5684, 8080]) def test_port_8443_is_deliberately_not_bound(self) -> None: - """8443 is IANA's ``pcsync-https``, and pcapkit implements no TLS. + """8443 is IANA's ``pcsync-https``, not an alternate HTTPS port. Binding HTTP there would hand a TLS record to an HTTP parser. diff --git a/tests/protocols/test_dispatch_registry_unit.py b/tests/protocols/test_dispatch_registry_unit.py index 2ddfdc901e..e39e279b04 100644 --- a/tests/protocols/test_dispatch_registry_unit.py +++ b/tests/protocols/test_dispatch_registry_unit.py @@ -121,8 +121,8 @@ def setUpClass(cls) -> None: 'library defect'), 'sctp/PayloadProtocolIdentifier_3GPP_NGAP_over_DTLS_over_SCTP': ( ('malformed NGAP-PDU', 'needs the optional "pycrate" dependency'), - 'pcapkit implements no DTLS, and the same placeholder payload ' - 'is not a DTLS record either'), + 'PPID 66 is not routed through DTLS, and the same placeholder ' + 'payload is not a DTLS record either'), }.items() } @@ -227,8 +227,8 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: # registered at IPv6_Ext rather than defaulting to Raw, plus #1574's # LinkType.NULL and LinkType.LOOP in both link-type tables and the four # address families of Loopback.__proto__. - self.assertEqual(len(cases), 47, - 'expected exactly 47 entries across the seven __proto__ ' + self.assertEqual(len(cases), 49, + 'expected exactly 49 entries across the seven __proto__ ' 'tables named in GitHub issue #496 and the loopback table ' 'of #1574; a different count means a registry changed ' 'shape and this module was not updated to match') @@ -237,7 +237,7 @@ def test_cases_cover_every_table_named_in_the_issue(self) -> None: for case in cases: by_family[case.family] = by_family.get(case.family, 0) + 1 self.assertEqual(by_family, { - 'link': 7, 'internet': 17, 'tcp': 4, 'udp': 3, 'sctp': 2, + 'link': 7, 'internet': 17, 'tcp': 5, 'udp': 4, 'sctp': 2, 'pcap-frame': 5, 'pcapng-frame': 5, 'loopback': 4, }) diff --git a/tests/protocols/transport/test_sctp_unit.py b/tests/protocols/transport/test_sctp_unit.py index d893424173..26d6fac7cc 100644 --- a/tests/protocols/transport/test_sctp_unit.py +++ b/tests/protocols/transport/test_sctp_unit.py @@ -893,9 +893,10 @@ def test_ppid_dispatch_hook(self) -> None: Dispatching is all the two have in common, and the assertion is deliberately no stronger than that. Only PPID 60 can decode: a PPID 66 - payload is an NGAP PDU inside a DTLS record, and with no DTLS - implementation those bytes are never aligned PER, so 66 degrades to - :class:`Raw` on every well-formed capture as well as on this junk one. + payload is an NGAP PDU inside a DTLS record, and the registry maps it + to NGAP rather than through DTLS, so those bytes are + never aligned PER and 66 degrades to :class:`Raw` on every + well-formed capture as well as on this junk one. ``b'ngap-pdu'`` is not an aligned PER ``NGAP-PDU``, which is the point: the failure has to reach :class:`Raw` through